From c7c279fc1766aa073a50140c57a863fe666739a0 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:01:14 +0000 Subject: [PATCH 001/181] fix: Resource.List/ListAll/Iter use collection path, not item path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Resource path like /repos/{owner}/{repo}/issues/{index} is the item path. List operations need the collection path (without the trailing /{placeholder}). Fixed by deriving the collection path at construction time — only strips the last segment if it's a pure placeholder like /{index}, not mixed segments like /repos. Co-Authored-By: Virgil --- resource.go | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/resource.go b/resource.go index 04f073c..e322973 100644 --- a/resource.go +++ b/resource.go @@ -3,34 +3,46 @@ package forge import ( "context" "iter" + "strings" ) // 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. 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 } // 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. 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 := strings.LastIndex(path, "/"); i >= 0 { + lastSeg := path[i+1:] + if strings.HasPrefix(lastSeg, "{") && strings.HasSuffix(lastSeg, "}") { + collection = path[:i] + } + } + return &Resource[T, C, U]{client: c, path: path, collection: collection} } // List returns a single page of resources. 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. 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. 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. From 33eb5bc91a45052816d48b995363418e769938f8 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:07:10 +0000 Subject: [PATCH 002/181] fix: StateType and TimeStamp are strings, not empty structs Swagger spec didn't define these properly. StateType is "open"|"closed", TimeStamp is a date string. Both were generated as struct{} which fails to unmarshal JSON. Also change from pointer to value type in Issue, PR, Milestone, Notification structs. Co-Authored-By: Virgil --- types/common.go | 10 ++++------ types/issue.go | 2 +- types/milestone.go | 2 +- types/notification.go | 2 +- types/pr.go | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/types/common.go b/types/common.go index df9223c..f25b84f 100644 --- a/types/common.go +++ b/types/common.go @@ -30,11 +30,9 @@ type Permission struct { Push bool `json:"push,omitempty"` } -// StateType — StateType issue state type -// StateType has no fields in the swagger spec. -type StateType struct{} +// StateType is the state of an issue or PR: "open", "closed". +type StateType string -// TimeStamp — TimeStamp defines a timestamp -// TimeStamp has no fields in the swagger spec. -type TimeStamp struct{} +// TimeStamp is a Forgejo timestamp string. +type TimeStamp string diff --git a/types/issue.go b/types/issue.go index 841f6ae..f411c8e 100644 --- a/types/issue.go +++ b/types/issue.go @@ -71,7 +71,7 @@ type Issue struct { PullRequest *PullRequestMeta `json:"pull_request,omitempty"` Ref string `json:"ref,omitempty"` Repository *RepositoryMeta `json:"repository,omitempty"` - State *StateType `json:"state,omitempty"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` Updated time.Time `json:"updated_at,omitempty"` diff --git a/types/milestone.go b/types/milestone.go index 6d294d5..fab5844 100644 --- a/types/milestone.go +++ b/types/milestone.go @@ -30,7 +30,7 @@ type Milestone struct { Description string `json:"description,omitempty"` ID int64 `json:"id,omitempty"` OpenIssues int64 `json:"open_issues,omitempty"` - State *StateType `json:"state,omitempty"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` Updated time.Time `json:"updated_at,omitempty"` } diff --git a/types/notification.go b/types/notification.go index 3d84aa7..dccc380 100644 --- a/types/notification.go +++ b/types/notification.go @@ -15,7 +15,7 @@ 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"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` Type *NotifySubjectType `json:"type,omitempty"` URL string `json:"url,omitempty"` diff --git a/types/pr.go b/types/pr.go index 6aa28ed..274d649 100644 --- a/types/pr.go +++ b/types/pr.go @@ -87,7 +87,7 @@ type PullRequest struct { 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"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` Updated time.Time `json:"updated_at,omitempty"` From 107c78dc0750585340238c93a1f563d39853b629 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:13:40 +0000 Subject: [PATCH 003/181] test(services): road-test resource CRUD paths Co-Authored-By: Virgil --- forge_test.go | 82 ++++++++++++++++++++++++++++++-- issues_test.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++ pulls_test.go | 23 +++++++++ resource.go | 2 +- resource_test.go | 5 ++ 5 files changed, 227 insertions(+), 5 deletions(-) diff --git a/forge_test.go b/forge_test.go index c13feaa..747b37a 100644 --- a/forge_test.go +++ b/forge_test.go @@ -31,25 +31,38 @@ func TestForge_Good_Client(t *testing.T) { } } -func TestRepoService_Good_List(t *testing.T) { +func TestRepoService_Good_ListOrgRepos(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) { 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,6 +77,67 @@ func TestRepoService_Good_Get(t *testing.T) { } } +func TestRepoService_Good_Update(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_Good_Delete(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_Bad_Get(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_Good_Fork(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/issues_test.go b/issues_test.go index 8ad5fbf..b9d7ed1 100644 --- a/issues_test.go +++ b/issues_test.go @@ -15,6 +15,11 @@ func TestIssueService_Good_List(t *testing.T) { 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"}, @@ -63,6 +68,11 @@ func TestIssueService_Good_Create(t *testing.T) { 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,6 +93,81 @@ func TestIssueService_Good_Create(t *testing.T) { } } +func TestIssueService_Good_Update(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_Good_Delete(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_Good_CreateComment(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/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_Good_Pin(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -101,3 +186,38 @@ func TestIssueService_Good_Pin(t *testing.T) { t.Fatal(err) } } + +func TestIssueService_Bad_List(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_Ugly_ListIgnoresIndexParam(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/pulls_test.go b/pulls_test.go index cdc9512..b88ba22 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -15,6 +15,11 @@ func TestPullService_Good_List(t *testing.T) { 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"}, @@ -63,6 +68,11 @@ func TestPullService_Good_Create(t *testing.T) { 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) @@ -107,3 +117,16 @@ func TestPullService_Good_Merge(t *testing.T) { t.Fatal(err) } } + +func TestPullService_Bad_Merge(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) + } +} diff --git a/resource.go b/resource.go index e322973..3ee5d45 100644 --- a/resource.go +++ b/resource.go @@ -57,7 +57,7 @@ func (r *Resource[T, C, U]) Get(ctx context.Context, params Params) (*T, error) // Create creates a new resource. 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 diff --git a/resource_test.go b/resource_test.go index 0b00b81..6d0d560 100644 --- a/resource_test.go +++ b/resource_test.go @@ -70,6 +70,11 @@ func TestResource_Good_Create(t *testing.T) { 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) From 206749eb8a0c1bd7ae0b85dc9f0f4990c462cdb0 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:19:28 +0000 Subject: [PATCH 004/181] fix: Create uses collection path + road-test suite (Codex) Bug: Resource.Create was POSTing to item path (/issues/{index}) instead of collection path (/issues). Same class as the List fix. Tests: path validation on all service methods, Update tests for issues/repos, CreateComment test, ListComments test, PR merge error case (conflict handling). 227 lines of test coverage added by Codex agent. Co-Authored-By: Virgil --- forge_test.go | 82 ++++++++++++++++++++++++++++++-- issues_test.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++ pulls_test.go | 23 +++++++++ resource.go | 2 +- resource_test.go | 5 ++ 5 files changed, 227 insertions(+), 5 deletions(-) diff --git a/forge_test.go b/forge_test.go index c13feaa..747b37a 100644 --- a/forge_test.go +++ b/forge_test.go @@ -31,25 +31,38 @@ func TestForge_Good_Client(t *testing.T) { } } -func TestRepoService_Good_List(t *testing.T) { +func TestRepoService_Good_ListOrgRepos(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) { 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,6 +77,67 @@ func TestRepoService_Good_Get(t *testing.T) { } } +func TestRepoService_Good_Update(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_Good_Delete(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_Bad_Get(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_Good_Fork(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/issues_test.go b/issues_test.go index 8ad5fbf..b9d7ed1 100644 --- a/issues_test.go +++ b/issues_test.go @@ -15,6 +15,11 @@ func TestIssueService_Good_List(t *testing.T) { 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"}, @@ -63,6 +68,11 @@ func TestIssueService_Good_Create(t *testing.T) { 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,6 +93,81 @@ func TestIssueService_Good_Create(t *testing.T) { } } +func TestIssueService_Good_Update(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_Good_Delete(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_Good_CreateComment(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/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_Good_Pin(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -101,3 +186,38 @@ func TestIssueService_Good_Pin(t *testing.T) { t.Fatal(err) } } + +func TestIssueService_Bad_List(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_Ugly_ListIgnoresIndexParam(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/pulls_test.go b/pulls_test.go index cdc9512..b88ba22 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -15,6 +15,11 @@ func TestPullService_Good_List(t *testing.T) { 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"}, @@ -63,6 +68,11 @@ func TestPullService_Good_Create(t *testing.T) { 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) @@ -107,3 +117,16 @@ func TestPullService_Good_Merge(t *testing.T) { t.Fatal(err) } } + +func TestPullService_Bad_Merge(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) + } +} diff --git a/resource.go b/resource.go index e322973..3ee5d45 100644 --- a/resource.go +++ b/resource.go @@ -57,7 +57,7 @@ func (r *Resource[T, C, U]) Get(ctx context.Context, params Params) (*T, error) // Create creates a new resource. 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 diff --git a/resource_test.go b/resource_test.go index 0b00b81..6d0d560 100644 --- a/resource_test.go +++ b/resource_test.go @@ -70,6 +70,11 @@ func TestResource_Good_Create(t *testing.T) { 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) From 244ff651c3ad2b2a9766a4e9e224d13d9774333c Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:53:03 +0000 Subject: [PATCH 005/181] feat(commits): add List/Get methods + tests (Codex) CommitService now has ListRepoCommits and GetCommit methods with full httptest coverage. Tests verify correct paths, response parsing, and error handling. Co-Authored-By: Virgil --- commits.go | 33 ++++++++++++++++++- commits_test.go | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/commits.go b/commits.go index 1c38c82..27b501e 100644 --- a/commits.go +++ b/commits.go @@ -3,21 +3,52 @@ package forge import ( "context" "fmt" + "iter" "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. type CommitService struct { client *Client } +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) (*PagedResult[types.Commit], error) { + return ListPage[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil, opts) +} + +// ListAll returns all commits for a repository. +func (s *CommitService) ListAll(ctx context.Context, params Params) ([]types.Commit, error) { + return ListAll[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil) +} + +// Iter returns an iterator over all commits for a repository. +func (s *CommitService) Iter(ctx context.Context, params Params) iter.Seq2[types.Commit, error] { + return ListIter[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil) +} + +// 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 +} + // 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) diff --git a/commits_test.go b/commits_test.go index 4c44a0f..82043f8 100644 --- a/commits_test.go +++ b/commits_test.go @@ -10,6 +10,91 @@ import ( "dappco.re/go/core/forge/types" ) +func TestCommitService_Good_List(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_Good_Get(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_Good_ListStatuses(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From ecad738da9069300088640f39dec2487985e1bd6 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 23 Mar 2026 12:53:10 +0000 Subject: [PATCH 006/181] feat(forge): add MilestoneService, fix comment creation - Add MilestoneService with ListAll, Get, Create - Fix CreateIssueCommentOption Updated field to *time.Time (was serialising zero value) - Register Milestones in Forge client Co-Authored-By: Virgil --- forge.go | 2 ++ milestones.go | 43 +++++++++++++++++++++++++++++++++++++++++++ types/issue.go | 2 +- 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 milestones.go diff --git a/forge.go b/forge.go index c65689a..ecb5c17 100644 --- a/forge.go +++ b/forge.go @@ -22,6 +22,7 @@ type Forge struct { Wiki *WikiService Misc *MiscService Commits *CommitService + Milestones *MilestoneService } // NewForge creates a new Forge client. @@ -46,6 +47,7 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Wiki = newWikiService(c) f.Misc = newMiscService(c) f.Commits = newCommitService(c) + f.Milestones = newMilestoneService(c) return f } diff --git a/milestones.go b/milestones.go new file mode 100644 index 0000000..fa2dfd8 --- /dev/null +++ b/milestones.go @@ -0,0 +1,43 @@ +package forge + +import ( + "context" + "fmt" + + "dappco.re/go/core/forge/types" +) + +// MilestoneService handles repository milestones. +type MilestoneService struct { + client *Client +} + +func newMilestoneService(c *Client) *MilestoneService { + return &MilestoneService{client: c} +} + +// ListAll returns all milestones for a repository. +func (s *MilestoneService) ListAll(ctx context.Context, params Params) ([]types.Milestone, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/milestones", params["owner"], params["repo"]) + return ListAll[types.Milestone](ctx, s.client, path, nil) +} + +// Get returns a single milestone by ID. +func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, 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 := fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo) + var out types.Milestone + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/types/issue.go b/types/issue.go index f411c8e..c7157e5 100644 --- a/types/issue.go +++ b/types/issue.go @@ -8,7 +8,7 @@ import "time" // CreateIssueCommentOption — CreateIssueCommentOption options for creating a comment on an issue type CreateIssueCommentOption struct { Body string `json:"body"` - Updated time.Time `json:"updated_at,omitempty"` + Updated *time.Time `json:"updated_at,omitempty"` } // CreateIssueOption — CreateIssueOption options to create one issue From 1cee642101f1072667b85325ab5e8810173a29c3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 13:22:56 +0000 Subject: [PATCH 007/181] docs: add security attack vector mapping Co-Authored-By: Virgil --- docs/security-attack-vector-mapping.md | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/security-attack-vector-mapping.md diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md new file mode 100644 index 0000000..dcb5b39 --- /dev/null +++ b/docs/security-attack-vector-mapping.md @@ -0,0 +1,67 @@ +# Security Attack Vector Mapping + +`CODEX.md` was not present in `/workspace`, so this review follows [`CLAUDE.md`](../CLAUDE.md) plus the repository conventions already in force. No fixes were made. + +## Scope + +- Target: all non-test external input entry points in the library and `cmd/forgegen` +- Output format: `function`, `file:line`, `input source`, `what it flows into`, `current validation`, `potential attack vector` +- Line ranges below are grouped when a row covers multiple adjacent methods with the same behaviour + +## Cross-Cutting Sinks + +These internal paths are reached by most public API methods and are the main places where untrusted data actually crosses the trust boundary. + +| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector | +|---|---|---|---|---|---| +| `Client.doJSON` | `client.go:216-260` | Caller-supplied `path` and `body`; remote Forgejo JSON/body/headers | `baseURL + path`, `json.Marshal(body)`, `http.NewRequestWithContext`, `http.Client.Do`, `json.Decoder.Decode(out)` | Request method is fixed by caller; JSON marshal failure stops request; `http.NewRequestWithContext` validates URL syntax; no body-size cap; no response-size cap; no `DisallowUnknownFields` | Arbitrary request path within configured host; query/fragment injection if upstream callers pass unescaped path segments; request/response memory exhaustion; malicious Forgejo can return oversized or schema-confusing JSON | +| `Client.parseError` | `client.go:263-285` | Remote error body | Up to 1024 bytes copied into `APIError.Message` | Size capped at 1 KiB; best-effort JSON parse only | Server-controlled error text can propagate to logs/UI and support log-forging or misleading diagnostics | +| `Client.updateRateLimit` | `client.go:287-297` | Remote rate-limit headers | `Client.rateLimit` cache | Parses with `Atoi`/`ParseInt`; conversion errors ignored | Malicious or buggy server can poison rate-limit telemetry and mislead callers about throttling state | +| `Client.PostRaw` | `client.go:137-176` | Caller-supplied `path` and `body`; remote raw response body | `http.Client.Do` then `io.ReadAll(resp.Body)` | JSON marshal only; no response-size cap; no content-type check | Memory exhaustion on large responses; raw HTML/text returned to caller can become XSS/content-injection if rendered downstream | +| `Client.GetRaw` | `client.go:180-208` | Caller-supplied `path`; remote raw response body | `http.Client.Do` then `io.ReadAll(resp.Body)` | No response-size cap; no content-type check | Memory exhaustion on large file responses; untrusted bytes flow straight to caller | +| `ListPage` | `pagination.go:32-70` | Caller-supplied `path`, `query`, `opts`; remote `X-Total-Count` and JSON list body | `url.Parse(path)`, query encoding, `Client.doJSON` | `Page`/`Limit` clamped to minimum `1`; query values encoded with `url.Values`; no max page/limit cap | Arbitrary in-host endpoint access if caller controls `path`; malicious server can lie in `X-Total-Count` and increase work or confuse pagination state | +| `ListAll`, `ListIter` | `pagination.go:73-113` | Remote pagination metadata and page contents | Repeated `ListPage` calls until `HasMore == false` | No max page bound; loop stops only when server indicates completion or returns a short page | Request amplification / unbounded pagination against a malicious or buggy server | +| `ResolvePath` | `params.go:13-17` | Caller-supplied path template and placeholder values | `strings.ReplaceAll` with `url.PathEscape(v)` | Placeholder values are path-escaped | Prevents slash/query breakout from placeholder values, but does not verify required placeholders are present or that the template itself is trusted | + +## Entry Points + +| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector | +|---|---|---|---|---|---| +| `ResolveConfig` | `config.go:21-35` | Environment variables `FORGE_URL`, `FORGE_TOKEN`; caller-supplied flag values | Returned `url`/`token` consumed by `NewForgeFromConfig`/`NewClient` | Priority only: flags override env, empty URL falls back to `http://localhost:3000` | If env/flags are attacker-controlled, requests and auth token can be redirected to an attacker-chosen Forgejo instance, including cleartext `http://` | +| `NewForgeFromConfig`, `NewForge`, `NewClient` | `config.go:39-48`; `forge.go:29-51`; `client.go:81-95` | Caller-supplied `url`, `token`, `opts` | `Client.baseURL`, `Authorization` header, service wiring | `NewForgeFromConfig` only checks token is non-empty; `NewClient` trims trailing `/`; redirects disabled by default | SSRF/token exfiltration to attacker-controlled host; accidental cleartext transport; malformed base URL causes runtime request failures instead of early rejection | +| `WithHTTPClient`, `WithUserAgent` | `client.go:50-56` | Caller-supplied `*http.Client` and User-Agent string | Overrides transport/TLS/proxy behaviour and `User-Agent` header | No validation | Untrusted caller can supply insecure transport settings or proxy all traffic; arbitrary UA content is forwarded to downstream logs/telemetry | +| `Client.Get`, `Client.Post`, `Client.Patch`, `Client.Put`, `Client.Delete`, `Client.DeleteWithBody` | `client.go:99-130` | Caller-supplied `path` and optional request body; remote JSON/error responses | Thin wrappers around `Client.doJSON` | No local path validation; body only has JSON-marshalling checks | Direct low-level escape hatch to any in-host API path; if upstream code passes unescaped path segments, this layer will forward them verbatim | +| `NewResource` | `resource.go:20-30` | Caller-supplied item path template | Stored `path`/`collection` later used by CRUD methods | Only strips a trailing pure placeholder segment when deriving `collection` | If a library consumer builds `Resource` from untrusted templates, later CRUD calls can target arbitrary API paths | +| `Resource.List`, `Resource.ListAll`, `Resource.Iter`, `Resource.Get`, `Resource.Create`, `Resource.Update`, `Resource.Delete` as surfaced by `RepoService`, `IssueService`, `PullService`, `OrgService`, `UserService`, `TeamService`, `BranchService`, `ReleaseService`, `WebhookService` | `resource.go:34-77`; `repos.go:17-19`; `issues.go:18-20`; `pulls.go:18-20`; `orgs.go:18-20`; `users.go:18-20`; `teams.go:18-20`; `branches.go:18-20`; `releases.go:18-20`; `webhooks.go:19-20` | Caller-supplied `Params` placeholder values and typed request bodies; remote JSON responses | `ResolvePath` then `ListPage`/`ListAll`/`ListIter` or `Client.Get/Post/Patch/Delete` | Placeholder values path-escaped; request bodies are type-shaped only; no required-param check; no semantic validation | Lower path-injection risk than the `fmt.Sprintf` call sites, but missing placeholders can still hit wrong paths; oversized/malformed bodies depend entirely on server-side validation; all response-size/pagination risks from the shared sinks still apply | +| `CommitService.List`, `CommitService.ListAll`, `CommitService.Iter`, `CommitService.Get` | `commits.go:29-48` | Caller-supplied `Params` (`owner`, `repo`, `sha`/`ref`) | `ResolvePath` into `ListPage`/`ListAll`/`ListIter`/`Client.Get` | Uses `ResolvePath`; no semantic validation of `sha`/`ref` | Same as generic `Resource` methods: path breakout is mitigated, but placeholder omissions and unbounded remote responses remain possible | +| `AdminService.EditUser`, `AdminService.DeleteUser`, `AdminService.RenameUser`, `AdminService.RunCron`, `AdminService.AdoptRepo` | `admin.go:41-57`; `admin.go:69-86` | Caller-supplied `username`, `newName`, `task`, `owner`, `repo`, `opts` | `ResolvePath` then `Client.Patch/Post/Delete` | Path values escaped by `ResolvePath`; `opts` for `EditUser` is an unrestricted `map[string]any` | Path breakout is mitigated, but `EditUser` forwards arbitrary JSON fields and all methods rely on server-side validation for semantics and size | +| Fixed-path network fetch/update methods: `AdminService.ListUsers`, `AdminService.IterUsers`, `AdminService.CreateUser`, `AdminService.ListOrgs`, `AdminService.IterOrgs`, `AdminService.ListCron`, `AdminService.IterCron`, `AdminService.GenerateRunnerToken`, `RepoService.ListUserRepos`, `RepoService.IterUserRepos`, `OrgService.ListMyOrgs`, `OrgService.IterMyOrgs`, `UserService.GetCurrent`, `NotificationService.List`, `NotificationService.Iter`, `NotificationService.MarkRead`, `NotificationService.GetThread`, `NotificationService.MarkThreadRead`, `MiscService.ListLicenses`, `MiscService.ListGitignoreTemplates`, `MiscService.GetNodeInfo`, `MiscService.GetVersion` | `admin.go:22-39`; `admin.go:59-91`; `repos.go:34-39`; `orgs.go:61-66`; `users.go:25-31`; `notifications.go:22-59`; `misc.go:34-81` | Remote Forgejo responses; for `CreateUser`, caller-supplied typed body; for thread methods, numeric IDs | Fixed API paths or `%d` path formatting into `Client` or pagination helpers | No local validation beyond Go types and integer formatting | Main risk is trust in remote server responses plus unbounded pagination/JSON decode; `CreateUser` also forwards request body without field-level validation | +| `MiscService.RenderMarkdown` | `misc.go:24-31` | Caller-supplied markdown `text` and `mode`; remote raw HTML response | `types.MarkdownOption` -> `Client.PostRaw` -> raw string return | Struct-shaped body only; no output sanitisation | If caller renders the returned HTML directly, untrusted markdown or compromised server output can become XSS/content injection; large responses can exhaust memory | +| `ActionsService.ListRepoSecrets`, `ActionsService.IterRepoSecrets`, `ActionsService.CreateRepoSecret`, `ActionsService.DeleteRepoSecret`, `ActionsService.ListRepoVariables`, `ActionsService.IterRepoVariables`, `ActionsService.CreateRepoVariable`, `ActionsService.DeleteRepoVariable`, `ActionsService.ListOrgSecrets`, `ActionsService.IterOrgSecrets`, `ActionsService.ListOrgVariables`, `ActionsService.IterOrgVariables`, `ActionsService.DispatchWorkflow` | `actions.go:23-99` | Caller-supplied `owner`, `repo`, `org`, `name`, `workflow`, `data`, `value`, `opts`; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Put/Post/Delete` or pagination helpers | No path escaping; bodies are either simple structs/maps or raw strings | Path/endpoint confusion via `/`, `?`, `#`, or encoded separators in names/orgs/repos/workflow IDs; `DispatchWorkflow` forwards arbitrary JSON fields via `map[string]any`; pagination/response risks still apply | +| `BranchService.ListBranchProtections`, `BranchService.IterBranchProtections`, `BranchService.GetBranchProtection`, `BranchService.CreateBranchProtection`, `BranchService.EditBranchProtection`, `BranchService.DeleteBranchProtection` | `branches.go:25-67` | Caller-supplied `owner`, `repo`, `name`, typed option structs; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Post/Patch/Delete` or pagination helpers | No path escaping; body shape only | Unescaped branch-protection names, owners, or repos can alter the targeted API path; body content is unchecked locally | +| `CommitService.GetCombinedStatus`, `CommitService.ListStatuses`, `CommitService.CreateStatus`, `CommitService.GetNote` | `commits.go:53-83` | Caller-supplied `owner`, `repo`, `ref`/`sha`, typed status body; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Post` | No path escaping; body shape only | Branch names/refs containing `/`, `?`, or `#` can retarget requests or make legitimate refs unreachable; body fields rely on server validation | +| `ContentService.GetFile`, `ContentService.CreateFile`, `ContentService.UpdateFile`, `ContentService.DeleteFile`, `ContentService.GetRawFile` | `contents.go:21-57` | Caller-supplied `owner`, `repo`, `filepath`, typed file option structs; remote JSON/raw file responses | `fmt.Sprintf` path building -> `Client.Get/Post/Put/DeleteWithBody/GetRaw` | No path escaping; nested `filepath` is passed through verbatim | `filepath` is intentionally free-form but reserved characters such as `?`, `#`, and ambiguous percent-encoding can still alter routing/query semantics; raw file reads have no size cap | +| `IssueService.Pin`, `IssueService.Unpin`, `IssueService.SetDeadline`, `IssueService.AddReaction`, `IssueService.DeleteReaction`, `IssueService.StartStopwatch`, `IssueService.StopStopwatch`, `IssueService.AddLabels`, `IssueService.RemoveLabel`, `IssueService.ListComments`, `IssueService.IterComments`, `IssueService.CreateComment` | `issues.go:25-102` | Caller-supplied `owner`, `repo`, numeric IDs, deadline/reaction/body/label list; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for `owner`/`repo`; request bodies are raw strings, slices, or simple structs | Owner/repo path breakout; comment/reaction/deadline values go straight upstream with no local validation or size limits | +| `LabelService.ListRepoLabels`, `LabelService.IterRepoLabels`, `LabelService.GetRepoLabel`, `LabelService.CreateRepoLabel`, `LabelService.EditRepoLabel`, `LabelService.DeleteRepoLabel`, `LabelService.ListOrgLabels`, `LabelService.IterOrgLabels`, `LabelService.CreateOrgLabel` | `labels.go:22-89` | Caller-supplied `owner`, `repo`, `org`, numeric IDs, typed option structs; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for string path segments; body shape only | Path/endpoint confusion through unescaped owner/repo/org values; no local validation on label metadata | +| `MilestoneService.ListAll`, `MilestoneService.Get`, `MilestoneService.Create` | `milestones.go:20-44` | Caller-supplied `params["owner"]`, `params["repo"]`, `owner`, `repo`, typed create body; remote JSON responses | `fmt.Sprintf` path building -> pagination helpers or `Client.Get/Post` | No path escaping; missing map keys become empty path segments | Path manipulation and silent empty-segment requests if `owner`/`repo` are absent or attacker-controlled; no local validation on milestone body | +| `MiscService.GetLicense`, `MiscService.GetGitignoreTemplate` | `misc.go:43-69` | Caller-supplied template name; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get` | No path escaping | Template names with reserved characters can alter the request path/query | +| `NotificationService.ListRepo`, `NotificationService.IterRepo` | `notifications.go:32-40` | Caller-supplied `owner`, `repo`; remote JSON responses | `fmt.Sprintf` path building -> pagination helpers | No path escaping | Owner/repo path breakout or query injection against repo-scoped notification endpoints | +| `OrgService.ListMembers`, `OrgService.IterMembers`, `OrgService.AddMember`, `OrgService.RemoveMember`, `OrgService.ListUserOrgs`, `OrgService.IterUserOrgs` | `orgs.go:25-55` | Caller-supplied `org`, `username`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping | Unescaped org/user names can alter the intended endpoint path | +| `PackageService.List`, `PackageService.Iter`, `PackageService.Get`, `PackageService.Delete`, `PackageService.ListFiles`, `PackageService.IterFiles` | `packages.go:22-61` | Caller-supplied `owner`, `pkgType`, `name`, `version`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping | Package coordinates with `/`, `?`, `#`, or crafted percent-encoding can retarget the request or make legitimate versions unreachable | +| `PullService.Merge`, `PullService.Update`, `PullService.ListReviews`, `PullService.IterReviews`, `PullService.SubmitReview`, `PullService.DismissReview`, `PullService.UndismissReview` | `pulls.go:25-69` | Caller-supplied `owner`, `repo`, numeric IDs, `method`, `review`, `msg`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for string path segments; `SubmitReview` forwards unrestricted `map[string]any` | Owner/repo path breakout; `Merge` method value is unchecked locally; `SubmitReview` can forward arbitrary JSON fields to the API | +| `RepoService.ListOrgRepos`, `RepoService.IterOrgRepos`, `RepoService.Fork`, `RepoService.Transfer`, `RepoService.AcceptTransfer`, `RepoService.RejectTransfer`, `RepoService.MirrorSync` | `repos.go:24-73` | Caller-supplied `org`, `owner`, `repo`, transfer `opts`; remote JSON responses | String concatenation / `map[string]string` / `map[string]any` -> `Client.*` and pagination helpers | No path escaping; `Transfer` body unrestricted; `Fork` only conditionally adds `organization` body field | Owner/repo/org path breakout; `Transfer` is a direct arbitrary-JSON passthrough; body size/field semantics rely on server enforcement | +| `ReleaseService.GetByTag`, `ReleaseService.DeleteByTag`, `ReleaseService.ListAssets`, `ReleaseService.IterAssets`, `ReleaseService.GetAsset`, `ReleaseService.DeleteAsset` | `releases.go:25-68` | Caller-supplied `owner`, `repo`, `tag`, numeric IDs; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for string segments | Tag names or repo identifiers with reserved characters can alter endpoint targeting | +| `TeamService.ListMembers`, `TeamService.IterMembers`, `TeamService.ListRepos`, `TeamService.IterRepos`, `TeamService.AddMember`, `TeamService.RemoveMember`, `TeamService.AddRepo`, `TeamService.RemoveRepo`, `TeamService.ListOrgTeams`, `TeamService.IterOrgTeams` | `teams.go:25-79` | Caller-supplied `teamID`, `username`, `org`, `repo`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | Numeric IDs are formatted safely; string path segments are unescaped | Numeric-only methods are lower risk, but the member/repo/org methods still allow path confusion through unescaped string segments | +| `UserService.ListFollowers`, `UserService.IterFollowers`, `UserService.ListFollowing`, `UserService.IterFollowing`, `UserService.Follow`, `UserService.Unfollow`, `UserService.ListStarred`, `UserService.IterStarred`, `UserService.Star`, `UserService.Unstar` | `users.go:34-88` | Caller-supplied `username`, `owner`, `repo`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping | Username/owner/repo values can alter targeted endpoints or inject query/fragment data | +| `WebhookService.TestHook`, `WebhookService.ListOrgHooks`, `WebhookService.IterOrgHooks` | `webhooks.go:26-40` | Caller-supplied `owner`, `repo`, `org`, numeric `id`; remote JSON responses | `fmt.Sprintf` path building -> `Client.Post` and pagination helpers | Numeric ID formatting only for `id`; no path escaping for string segments | Owner/repo/org values can alter endpoint targeting; organisation hook enumeration inherits pagination risks | +| `WikiService.ListPages`, `WikiService.GetPage`, `WikiService.CreatePage`, `WikiService.EditPage`, `WikiService.DeletePage` | `wiki.go:21-61` | Caller-supplied `owner`, `repo`, `pageName`, typed page body; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Post/Patch/Delete` | No path escaping | `pageName` is free-form and may legitimately contain slashes, so reserved characters can change routing or query semantics; page content/body is forwarded without local validation | +| `main` | `cmd/forgegen/main.go:9-30` | CLI flags `-spec`, `-out` | `LoadSpec`, `ExtractTypes`, `DetectCRUDPairs`, `Generate` | Standard `flag` parsing only | Untrusted operator input can point the generator at sensitive local files or arbitrary output directories | +| `LoadSpec` | `cmd/forgegen/parser.go:73-84` | Caller/CLI-supplied file path and file contents | `coreio.Local.Read(path)` then `json.Unmarshal` into `Spec` | Parse failure only; entire file read into memory | Arbitrary local file read if path is attacker-controlled; large or malformed JSON can exhaust memory or CPU | +| `ExtractTypes`, `DetectCRUDPairs` | `cmd/forgegen/parser.go:86-147` | Parsed, potentially untrusted Swagger definitions and metadata | In-memory `GoType`/`CRUDPair` model used by `Generate` | No schema hardening beyond basic JSON types; names/comments/enum values are accepted as-is | Malicious spec can create huge intermediate structures or feed unsafe identifiers/strings into code generation | +| `Generate` | `cmd/forgegen/generator.go:206-263` | Caller-supplied `outDir`; type names/comments/enum values derived from the spec | `EnsureDir(outDir)`, `filepath.Join(outDir, file+".go")`, Go source template, `coreio.Local.Write` | Output file names are limited by `classifyType`; generated source content is mostly unsanitised spec data | Arbitrary directory creation/file overwrite if `outDir` is attacker-controlled; malicious spec strings can break generated Go syntax or inject unexpected source content into generated files | + +## Highest-Risk Patterns + +- Unescaped `fmt.Sprintf`/string-concatenated paths are the dominant library issue pattern. Most custom service methods trust raw `owner`, `repo`, `org`, `username`, `name`, `tag`, `workflow`, `pageName`, and similar values. +- The `Resource`/`ResolvePath` path family is materially safer because it uses `url.PathEscape`, but it still performs no semantic validation and still inherits unbounded response/pagination behaviour. +- `cmd/forgegen` assumes the spec file is trusted. If the swagger input is attacker-controlled, the generator can read arbitrary files, write arbitrary directories, and emit attacker-influenced Go source. From 55c5aa46c7ae8dcd61c563d1d2e8b013b30da14a Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 14:29:24 +0000 Subject: [PATCH 008/181] docs: add convention drift audit Co-Authored-By: Virgil --- docs/convention-drift-2026-03-23.md | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/convention-drift-2026-03-23.md diff --git a/docs/convention-drift-2026-03-23.md b/docs/convention-drift-2026-03-23.md new file mode 100644 index 0000000..1664812 --- /dev/null +++ b/docs/convention-drift-2026-03-23.md @@ -0,0 +1,79 @@ +# Convention Drift Check (2026-03-23) + +`CODEX.md` was not present anywhere under `/workspace`, so this review is based on `CLAUDE.md`, `docs/development.md`, and current coverage data from `go test -coverprofile=/tmp/cover.out ./...`. + +No fixes were applied as part of this pass. + +## stdlib -> core.* + +Confirmed drift against the documented `CLAUDE.md` rules: none. + +- No uses found of `fmt.Errorf`, `errors.New`, `os.ReadFile`, `os.WriteFile`, or `os.MkdirAll`. + +Broader-scope candidates only if the missing `CODEX.md` was meant to forbid more stdlib usage than `CLAUDE.md` currently documents: + +- `config.go:22`, `config.go:23` use `os.Getenv(...)`. +- `cmd/forgegen/main.go:16`, `cmd/forgegen/main.go:17`, `cmd/forgegen/main.go:27`, `cmd/forgegen/main.go:28` use `fmt.Fprintf(os.Stderr, ...)` and `os.Exit(1)`. +- `cmd/forgegen/generator_test.go:26`, `cmd/forgegen/generator_test.go:52`, `cmd/forgegen/generator_test.go:88`, `cmd/forgegen/generator_test.go:130` use `os.ReadDir(...)` in tests. + +## UK English + +Drift found in hand-written docs: + +- `README.md:2` badge text uses `License`. +- `README.md:9` heading uses `License`. +- `CONTRIBUTING.md:34` heading uses `License`. + +## Missing Tests + +Coverage basis: `go test -coverprofile=/tmp/cover.out ./...` on 2026-03-23. Line references below are function start lines from `go tool cover -func=/tmp/cover.out`. + +No dedicated service test file: + +- `milestones.go:10`, `milestones.go:20`, `milestones.go:26`, `milestones.go:36` (`MilestoneService`; no `milestones_test.go` present). + +Zero-coverage functions and methods: + +- `actions.go:29` `IterRepoSecrets`; `actions.go:55` `IterRepoVariables`; `actions.go:81` `IterOrgSecrets`; `actions.go:93` `IterOrgVariables` +- `admin.go:27` `IterUsers`; `admin.go:64` `IterOrgs`; `admin.go:80` `IterCron` +- `branches.go:25` `ListBranchProtections`; `branches.go:31` `IterBranchProtections`; `branches.go:37` `GetBranchProtection`; `branches.go:57` `EditBranchProtection`; `branches.go:67` `DeleteBranchProtection` +- `client.go:211` `do` +- `cmd/forgegen/generator.go:158` `enumConstName` +- `cmd/forgegen/main.go:9` `main` +- `commits.go:34` `ListAll`; `commits.go:39` `Iter` +- `issues.go:31` `Unpin`; `issues.go:37` `SetDeadline`; `issues.go:44` `AddReaction`; `issues.go:51` `DeleteReaction`; `issues.go:58` `StartStopwatch`; `issues.go:64` `StopStopwatch`; `issues.go:70` `AddLabels`; `issues.go:77` `RemoveLabel`; `issues.go:83` `ListComments`; `issues.go:89` `IterComments`; `issues.go:106` `toAnySlice` +- `labels.go:28` `IterRepoLabels`; `labels.go:76` `IterOrgLabels` +- `milestones.go:20` `ListAll`; `milestones.go:26` `Get`; `milestones.go:36` `Create` +- `notifications.go:27` `Iter`; `notifications.go:38` `IterRepo` +- `orgs.go:31` `IterMembers`; `orgs.go:37` `AddMember`; `orgs.go:43` `RemoveMember`; `orgs.go:49` `ListUserOrgs`; `orgs.go:55` `IterUserOrgs`; `orgs.go:61` `ListMyOrgs`; `orgs.go:66` `IterMyOrgs` +- `packages.go:28` `Iter`; `packages.go:56` `IterFiles` +- `pulls.go:32` `Update`; `pulls.go:38` `ListReviews`; `pulls.go:44` `IterReviews`; `pulls.go:50` `SubmitReview`; `pulls.go:60` `DismissReview`; `pulls.go:67` `UndismissReview` +- `releases.go:35` `DeleteByTag`; `releases.go:41` `ListAssets`; `releases.go:47` `IterAssets`; `releases.go:53` `GetAsset`; `releases.go:63` `DeleteAsset` +- `repos.go:29` `IterOrgRepos`; `repos.go:34` `ListUserRepos`; `repos.go:39` `IterUserRepos`; `repos.go:58` `Transfer`; `repos.go:63` `AcceptTransfer`; `repos.go:68` `RejectTransfer`; `repos.go:73` `MirrorSync` +- `teams.go:31` `IterMembers`; `teams.go:43` `RemoveMember`; `teams.go:49` `ListRepos`; `teams.go:55` `IterRepos`; `teams.go:61` `AddRepo`; `teams.go:67` `RemoveRepo`; `teams.go:73` `ListOrgTeams`; `teams.go:79` `IterOrgTeams` +- `users.go:40` `IterFollowers`; `users.go:46` `ListFollowing`; `users.go:52` `IterFollowing`; `users.go:58` `Follow`; `users.go:64` `Unfollow`; `users.go:70` `ListStarred`; `users.go:76` `IterStarred`; `users.go:82` `Star`; `users.go:88` `Unstar` +- `webhooks.go:38` `IterOrgHooks` + +## SPDX Headers + +No tracked `.go`, `.md`, `go.mod`, or `.gitignore` files currently start with an SPDX header. + +Repo meta and docs: + +- `.gitignore:1`, `go.mod:1`, `CLAUDE.md:1`, `CONTRIBUTING.md:1`, `README.md:1`, `docs/architecture.md:1`, `docs/development.md:1`, `docs/index.md:1` + +Hand-written package files: + +- `actions.go:1`, `admin.go:1`, `branches.go:1`, `client.go:1`, `commits.go:1`, `config.go:1`, `contents.go:1`, `doc.go:1`, `forge.go:1`, `issues.go:1`, `labels.go:1`, `milestones.go:1`, `misc.go:1`, `notifications.go:1`, `orgs.go:1`, `packages.go:1`, `pagination.go:1`, `params.go:1`, `pulls.go:1`, `releases.go:1`, `repos.go:1`, `resource.go:1`, `teams.go:1`, `users.go:1`, `webhooks.go:1`, `wiki.go:1` + +Hand-written test files: + +- `actions_test.go:1`, `admin_test.go:1`, `branches_test.go:1`, `client_test.go:1`, `commits_test.go:1`, `config_test.go:1`, `contents_test.go:1`, `forge_test.go:1`, `issues_test.go:1`, `labels_test.go:1`, `misc_test.go:1`, `notifications_test.go:1`, `orgs_test.go:1`, `packages_test.go:1`, `pagination_test.go:1`, `params_test.go:1`, `pulls_test.go:1`, `releases_test.go:1`, `resource_test.go:1`, `teams_test.go:1`, `users_test.go:1`, `webhooks_test.go:1`, `wiki_test.go:1` + +Generator and tooling: + +- `cmd/forgegen/generator.go:1`, `cmd/forgegen/generator_test.go:1`, `cmd/forgegen/main.go:1`, `cmd/forgegen/parser.go:1`, `cmd/forgegen/parser_test.go:1` + +Generated `types/` files (would need generator-owned header handling rather than manual edits): + +- `types/action.go:1`, `types/activity.go:1`, `types/admin.go:1`, `types/branch.go:1`, `types/comment.go:1`, `types/commit.go:1`, `types/common.go:1`, `types/content.go:1`, `types/error.go:1`, `types/federation.go:1`, `types/generate.go:1`, `types/git.go:1`, `types/hook.go:1`, `types/issue.go:1`, `types/key.go:1`, `types/label.go:1`, `types/milestone.go:1`, `types/misc.go:1`, `types/notification.go:1`, `types/oauth.go:1`, `types/org.go:1`, `types/package.go:1`, `types/pr.go:1`, `types/quota.go:1`, `types/reaction.go:1`, `types/release.go:1`, `types/repo.go:1`, `types/review.go:1`, `types/settings.go:1`, `types/status.go:1`, `types/tag.go:1`, `types/team.go:1`, `types/time_tracking.go:1`, `types/topic.go:1`, `types/user.go:1`, `types/wiki.go:1` From ce3108b01eda860a99f4d375e9dd5d67d0f8b39f Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 14:35:01 +0000 Subject: [PATCH 009/181] docs: add API contract inventory Co-Authored-By: Virgil --- docs/api-contract.md | 468 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/api-contract.md diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..fb0f51e --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,468 @@ +# 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, and workflow dispatches. No Resource embedding — heterogeneous endpoints across repo and org levels. | `TestActionsService_Bad_NotFound`, `TestActionsService_Good_CreateRepoSecret`, `TestActionsService_Good_CreateRepoVariable` (+7 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 within a repository. Embeds Resource for standard CRUD on /api/v1/repos/{owner}/{repo}/hooks/{id}. | `TestWebhookService_Bad_NotFound`, `TestWebhookService_Good_Create`, `TestWebhookService_Good_Get` (+3 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.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.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.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.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.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.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.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 | 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 | 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.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.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.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) ([]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.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.GetVersion | `func (s *MiscService) GetVersion(ctx context.Context) (*types.ServerVersion, error)` | GetVersion returns the server version. | `TestMiscService_Good_GetVersion` | +| 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 | 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.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.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.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.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.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.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.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.GetCurrent | `func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error)` | GetCurrent returns the authenticated user. | `TestUserService_Good_GetCurrent` | +| 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.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) | From aa839b07b05fe8434b3ababde67e29bc3d497ad9 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 23 Mar 2026 20:39:09 +0000 Subject: [PATCH 010/181] Delete docs/security-attack-vector-mapping.md --- docs/security-attack-vector-mapping.md | 67 -------------------------- 1 file changed, 67 deletions(-) delete mode 100644 docs/security-attack-vector-mapping.md diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md deleted file mode 100644 index dcb5b39..0000000 --- a/docs/security-attack-vector-mapping.md +++ /dev/null @@ -1,67 +0,0 @@ -# Security Attack Vector Mapping - -`CODEX.md` was not present in `/workspace`, so this review follows [`CLAUDE.md`](../CLAUDE.md) plus the repository conventions already in force. No fixes were made. - -## Scope - -- Target: all non-test external input entry points in the library and `cmd/forgegen` -- Output format: `function`, `file:line`, `input source`, `what it flows into`, `current validation`, `potential attack vector` -- Line ranges below are grouped when a row covers multiple adjacent methods with the same behaviour - -## Cross-Cutting Sinks - -These internal paths are reached by most public API methods and are the main places where untrusted data actually crosses the trust boundary. - -| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector | -|---|---|---|---|---|---| -| `Client.doJSON` | `client.go:216-260` | Caller-supplied `path` and `body`; remote Forgejo JSON/body/headers | `baseURL + path`, `json.Marshal(body)`, `http.NewRequestWithContext`, `http.Client.Do`, `json.Decoder.Decode(out)` | Request method is fixed by caller; JSON marshal failure stops request; `http.NewRequestWithContext` validates URL syntax; no body-size cap; no response-size cap; no `DisallowUnknownFields` | Arbitrary request path within configured host; query/fragment injection if upstream callers pass unescaped path segments; request/response memory exhaustion; malicious Forgejo can return oversized or schema-confusing JSON | -| `Client.parseError` | `client.go:263-285` | Remote error body | Up to 1024 bytes copied into `APIError.Message` | Size capped at 1 KiB; best-effort JSON parse only | Server-controlled error text can propagate to logs/UI and support log-forging or misleading diagnostics | -| `Client.updateRateLimit` | `client.go:287-297` | Remote rate-limit headers | `Client.rateLimit` cache | Parses with `Atoi`/`ParseInt`; conversion errors ignored | Malicious or buggy server can poison rate-limit telemetry and mislead callers about throttling state | -| `Client.PostRaw` | `client.go:137-176` | Caller-supplied `path` and `body`; remote raw response body | `http.Client.Do` then `io.ReadAll(resp.Body)` | JSON marshal only; no response-size cap; no content-type check | Memory exhaustion on large responses; raw HTML/text returned to caller can become XSS/content-injection if rendered downstream | -| `Client.GetRaw` | `client.go:180-208` | Caller-supplied `path`; remote raw response body | `http.Client.Do` then `io.ReadAll(resp.Body)` | No response-size cap; no content-type check | Memory exhaustion on large file responses; untrusted bytes flow straight to caller | -| `ListPage` | `pagination.go:32-70` | Caller-supplied `path`, `query`, `opts`; remote `X-Total-Count` and JSON list body | `url.Parse(path)`, query encoding, `Client.doJSON` | `Page`/`Limit` clamped to minimum `1`; query values encoded with `url.Values`; no max page/limit cap | Arbitrary in-host endpoint access if caller controls `path`; malicious server can lie in `X-Total-Count` and increase work or confuse pagination state | -| `ListAll`, `ListIter` | `pagination.go:73-113` | Remote pagination metadata and page contents | Repeated `ListPage` calls until `HasMore == false` | No max page bound; loop stops only when server indicates completion or returns a short page | Request amplification / unbounded pagination against a malicious or buggy server | -| `ResolvePath` | `params.go:13-17` | Caller-supplied path template and placeholder values | `strings.ReplaceAll` with `url.PathEscape(v)` | Placeholder values are path-escaped | Prevents slash/query breakout from placeholder values, but does not verify required placeholders are present or that the template itself is trusted | - -## Entry Points - -| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector | -|---|---|---|---|---|---| -| `ResolveConfig` | `config.go:21-35` | Environment variables `FORGE_URL`, `FORGE_TOKEN`; caller-supplied flag values | Returned `url`/`token` consumed by `NewForgeFromConfig`/`NewClient` | Priority only: flags override env, empty URL falls back to `http://localhost:3000` | If env/flags are attacker-controlled, requests and auth token can be redirected to an attacker-chosen Forgejo instance, including cleartext `http://` | -| `NewForgeFromConfig`, `NewForge`, `NewClient` | `config.go:39-48`; `forge.go:29-51`; `client.go:81-95` | Caller-supplied `url`, `token`, `opts` | `Client.baseURL`, `Authorization` header, service wiring | `NewForgeFromConfig` only checks token is non-empty; `NewClient` trims trailing `/`; redirects disabled by default | SSRF/token exfiltration to attacker-controlled host; accidental cleartext transport; malformed base URL causes runtime request failures instead of early rejection | -| `WithHTTPClient`, `WithUserAgent` | `client.go:50-56` | Caller-supplied `*http.Client` and User-Agent string | Overrides transport/TLS/proxy behaviour and `User-Agent` header | No validation | Untrusted caller can supply insecure transport settings or proxy all traffic; arbitrary UA content is forwarded to downstream logs/telemetry | -| `Client.Get`, `Client.Post`, `Client.Patch`, `Client.Put`, `Client.Delete`, `Client.DeleteWithBody` | `client.go:99-130` | Caller-supplied `path` and optional request body; remote JSON/error responses | Thin wrappers around `Client.doJSON` | No local path validation; body only has JSON-marshalling checks | Direct low-level escape hatch to any in-host API path; if upstream code passes unescaped path segments, this layer will forward them verbatim | -| `NewResource` | `resource.go:20-30` | Caller-supplied item path template | Stored `path`/`collection` later used by CRUD methods | Only strips a trailing pure placeholder segment when deriving `collection` | If a library consumer builds `Resource` from untrusted templates, later CRUD calls can target arbitrary API paths | -| `Resource.List`, `Resource.ListAll`, `Resource.Iter`, `Resource.Get`, `Resource.Create`, `Resource.Update`, `Resource.Delete` as surfaced by `RepoService`, `IssueService`, `PullService`, `OrgService`, `UserService`, `TeamService`, `BranchService`, `ReleaseService`, `WebhookService` | `resource.go:34-77`; `repos.go:17-19`; `issues.go:18-20`; `pulls.go:18-20`; `orgs.go:18-20`; `users.go:18-20`; `teams.go:18-20`; `branches.go:18-20`; `releases.go:18-20`; `webhooks.go:19-20` | Caller-supplied `Params` placeholder values and typed request bodies; remote JSON responses | `ResolvePath` then `ListPage`/`ListAll`/`ListIter` or `Client.Get/Post/Patch/Delete` | Placeholder values path-escaped; request bodies are type-shaped only; no required-param check; no semantic validation | Lower path-injection risk than the `fmt.Sprintf` call sites, but missing placeholders can still hit wrong paths; oversized/malformed bodies depend entirely on server-side validation; all response-size/pagination risks from the shared sinks still apply | -| `CommitService.List`, `CommitService.ListAll`, `CommitService.Iter`, `CommitService.Get` | `commits.go:29-48` | Caller-supplied `Params` (`owner`, `repo`, `sha`/`ref`) | `ResolvePath` into `ListPage`/`ListAll`/`ListIter`/`Client.Get` | Uses `ResolvePath`; no semantic validation of `sha`/`ref` | Same as generic `Resource` methods: path breakout is mitigated, but placeholder omissions and unbounded remote responses remain possible | -| `AdminService.EditUser`, `AdminService.DeleteUser`, `AdminService.RenameUser`, `AdminService.RunCron`, `AdminService.AdoptRepo` | `admin.go:41-57`; `admin.go:69-86` | Caller-supplied `username`, `newName`, `task`, `owner`, `repo`, `opts` | `ResolvePath` then `Client.Patch/Post/Delete` | Path values escaped by `ResolvePath`; `opts` for `EditUser` is an unrestricted `map[string]any` | Path breakout is mitigated, but `EditUser` forwards arbitrary JSON fields and all methods rely on server-side validation for semantics and size | -| Fixed-path network fetch/update methods: `AdminService.ListUsers`, `AdminService.IterUsers`, `AdminService.CreateUser`, `AdminService.ListOrgs`, `AdminService.IterOrgs`, `AdminService.ListCron`, `AdminService.IterCron`, `AdminService.GenerateRunnerToken`, `RepoService.ListUserRepos`, `RepoService.IterUserRepos`, `OrgService.ListMyOrgs`, `OrgService.IterMyOrgs`, `UserService.GetCurrent`, `NotificationService.List`, `NotificationService.Iter`, `NotificationService.MarkRead`, `NotificationService.GetThread`, `NotificationService.MarkThreadRead`, `MiscService.ListLicenses`, `MiscService.ListGitignoreTemplates`, `MiscService.GetNodeInfo`, `MiscService.GetVersion` | `admin.go:22-39`; `admin.go:59-91`; `repos.go:34-39`; `orgs.go:61-66`; `users.go:25-31`; `notifications.go:22-59`; `misc.go:34-81` | Remote Forgejo responses; for `CreateUser`, caller-supplied typed body; for thread methods, numeric IDs | Fixed API paths or `%d` path formatting into `Client` or pagination helpers | No local validation beyond Go types and integer formatting | Main risk is trust in remote server responses plus unbounded pagination/JSON decode; `CreateUser` also forwards request body without field-level validation | -| `MiscService.RenderMarkdown` | `misc.go:24-31` | Caller-supplied markdown `text` and `mode`; remote raw HTML response | `types.MarkdownOption` -> `Client.PostRaw` -> raw string return | Struct-shaped body only; no output sanitisation | If caller renders the returned HTML directly, untrusted markdown or compromised server output can become XSS/content injection; large responses can exhaust memory | -| `ActionsService.ListRepoSecrets`, `ActionsService.IterRepoSecrets`, `ActionsService.CreateRepoSecret`, `ActionsService.DeleteRepoSecret`, `ActionsService.ListRepoVariables`, `ActionsService.IterRepoVariables`, `ActionsService.CreateRepoVariable`, `ActionsService.DeleteRepoVariable`, `ActionsService.ListOrgSecrets`, `ActionsService.IterOrgSecrets`, `ActionsService.ListOrgVariables`, `ActionsService.IterOrgVariables`, `ActionsService.DispatchWorkflow` | `actions.go:23-99` | Caller-supplied `owner`, `repo`, `org`, `name`, `workflow`, `data`, `value`, `opts`; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Put/Post/Delete` or pagination helpers | No path escaping; bodies are either simple structs/maps or raw strings | Path/endpoint confusion via `/`, `?`, `#`, or encoded separators in names/orgs/repos/workflow IDs; `DispatchWorkflow` forwards arbitrary JSON fields via `map[string]any`; pagination/response risks still apply | -| `BranchService.ListBranchProtections`, `BranchService.IterBranchProtections`, `BranchService.GetBranchProtection`, `BranchService.CreateBranchProtection`, `BranchService.EditBranchProtection`, `BranchService.DeleteBranchProtection` | `branches.go:25-67` | Caller-supplied `owner`, `repo`, `name`, typed option structs; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Post/Patch/Delete` or pagination helpers | No path escaping; body shape only | Unescaped branch-protection names, owners, or repos can alter the targeted API path; body content is unchecked locally | -| `CommitService.GetCombinedStatus`, `CommitService.ListStatuses`, `CommitService.CreateStatus`, `CommitService.GetNote` | `commits.go:53-83` | Caller-supplied `owner`, `repo`, `ref`/`sha`, typed status body; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Post` | No path escaping; body shape only | Branch names/refs containing `/`, `?`, or `#` can retarget requests or make legitimate refs unreachable; body fields rely on server validation | -| `ContentService.GetFile`, `ContentService.CreateFile`, `ContentService.UpdateFile`, `ContentService.DeleteFile`, `ContentService.GetRawFile` | `contents.go:21-57` | Caller-supplied `owner`, `repo`, `filepath`, typed file option structs; remote JSON/raw file responses | `fmt.Sprintf` path building -> `Client.Get/Post/Put/DeleteWithBody/GetRaw` | No path escaping; nested `filepath` is passed through verbatim | `filepath` is intentionally free-form but reserved characters such as `?`, `#`, and ambiguous percent-encoding can still alter routing/query semantics; raw file reads have no size cap | -| `IssueService.Pin`, `IssueService.Unpin`, `IssueService.SetDeadline`, `IssueService.AddReaction`, `IssueService.DeleteReaction`, `IssueService.StartStopwatch`, `IssueService.StopStopwatch`, `IssueService.AddLabels`, `IssueService.RemoveLabel`, `IssueService.ListComments`, `IssueService.IterComments`, `IssueService.CreateComment` | `issues.go:25-102` | Caller-supplied `owner`, `repo`, numeric IDs, deadline/reaction/body/label list; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for `owner`/`repo`; request bodies are raw strings, slices, or simple structs | Owner/repo path breakout; comment/reaction/deadline values go straight upstream with no local validation or size limits | -| `LabelService.ListRepoLabels`, `LabelService.IterRepoLabels`, `LabelService.GetRepoLabel`, `LabelService.CreateRepoLabel`, `LabelService.EditRepoLabel`, `LabelService.DeleteRepoLabel`, `LabelService.ListOrgLabels`, `LabelService.IterOrgLabels`, `LabelService.CreateOrgLabel` | `labels.go:22-89` | Caller-supplied `owner`, `repo`, `org`, numeric IDs, typed option structs; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for string path segments; body shape only | Path/endpoint confusion through unescaped owner/repo/org values; no local validation on label metadata | -| `MilestoneService.ListAll`, `MilestoneService.Get`, `MilestoneService.Create` | `milestones.go:20-44` | Caller-supplied `params["owner"]`, `params["repo"]`, `owner`, `repo`, typed create body; remote JSON responses | `fmt.Sprintf` path building -> pagination helpers or `Client.Get/Post` | No path escaping; missing map keys become empty path segments | Path manipulation and silent empty-segment requests if `owner`/`repo` are absent or attacker-controlled; no local validation on milestone body | -| `MiscService.GetLicense`, `MiscService.GetGitignoreTemplate` | `misc.go:43-69` | Caller-supplied template name; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get` | No path escaping | Template names with reserved characters can alter the request path/query | -| `NotificationService.ListRepo`, `NotificationService.IterRepo` | `notifications.go:32-40` | Caller-supplied `owner`, `repo`; remote JSON responses | `fmt.Sprintf` path building -> pagination helpers | No path escaping | Owner/repo path breakout or query injection against repo-scoped notification endpoints | -| `OrgService.ListMembers`, `OrgService.IterMembers`, `OrgService.AddMember`, `OrgService.RemoveMember`, `OrgService.ListUserOrgs`, `OrgService.IterUserOrgs` | `orgs.go:25-55` | Caller-supplied `org`, `username`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping | Unescaped org/user names can alter the intended endpoint path | -| `PackageService.List`, `PackageService.Iter`, `PackageService.Get`, `PackageService.Delete`, `PackageService.ListFiles`, `PackageService.IterFiles` | `packages.go:22-61` | Caller-supplied `owner`, `pkgType`, `name`, `version`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping | Package coordinates with `/`, `?`, `#`, or crafted percent-encoding can retarget the request or make legitimate versions unreachable | -| `PullService.Merge`, `PullService.Update`, `PullService.ListReviews`, `PullService.IterReviews`, `PullService.SubmitReview`, `PullService.DismissReview`, `PullService.UndismissReview` | `pulls.go:25-69` | Caller-supplied `owner`, `repo`, numeric IDs, `method`, `review`, `msg`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for string path segments; `SubmitReview` forwards unrestricted `map[string]any` | Owner/repo path breakout; `Merge` method value is unchecked locally; `SubmitReview` can forward arbitrary JSON fields to the API | -| `RepoService.ListOrgRepos`, `RepoService.IterOrgRepos`, `RepoService.Fork`, `RepoService.Transfer`, `RepoService.AcceptTransfer`, `RepoService.RejectTransfer`, `RepoService.MirrorSync` | `repos.go:24-73` | Caller-supplied `org`, `owner`, `repo`, transfer `opts`; remote JSON responses | String concatenation / `map[string]string` / `map[string]any` -> `Client.*` and pagination helpers | No path escaping; `Transfer` body unrestricted; `Fork` only conditionally adds `organization` body field | Owner/repo/org path breakout; `Transfer` is a direct arbitrary-JSON passthrough; body size/field semantics rely on server enforcement | -| `ReleaseService.GetByTag`, `ReleaseService.DeleteByTag`, `ReleaseService.ListAssets`, `ReleaseService.IterAssets`, `ReleaseService.GetAsset`, `ReleaseService.DeleteAsset` | `releases.go:25-68` | Caller-supplied `owner`, `repo`, `tag`, numeric IDs; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping for string segments | Tag names or repo identifiers with reserved characters can alter endpoint targeting | -| `TeamService.ListMembers`, `TeamService.IterMembers`, `TeamService.ListRepos`, `TeamService.IterRepos`, `TeamService.AddMember`, `TeamService.RemoveMember`, `TeamService.AddRepo`, `TeamService.RemoveRepo`, `TeamService.ListOrgTeams`, `TeamService.IterOrgTeams` | `teams.go:25-79` | Caller-supplied `teamID`, `username`, `org`, `repo`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | Numeric IDs are formatted safely; string path segments are unescaped | Numeric-only methods are lower risk, but the member/repo/org methods still allow path confusion through unescaped string segments | -| `UserService.ListFollowers`, `UserService.IterFollowers`, `UserService.ListFollowing`, `UserService.IterFollowing`, `UserService.Follow`, `UserService.Unfollow`, `UserService.ListStarred`, `UserService.IterStarred`, `UserService.Star`, `UserService.Unstar` | `users.go:34-88` | Caller-supplied `username`, `owner`, `repo`; remote JSON responses | `fmt.Sprintf` path building -> `Client.*` and pagination helpers | No path escaping | Username/owner/repo values can alter targeted endpoints or inject query/fragment data | -| `WebhookService.TestHook`, `WebhookService.ListOrgHooks`, `WebhookService.IterOrgHooks` | `webhooks.go:26-40` | Caller-supplied `owner`, `repo`, `org`, numeric `id`; remote JSON responses | `fmt.Sprintf` path building -> `Client.Post` and pagination helpers | Numeric ID formatting only for `id`; no path escaping for string segments | Owner/repo/org values can alter endpoint targeting; organisation hook enumeration inherits pagination risks | -| `WikiService.ListPages`, `WikiService.GetPage`, `WikiService.CreatePage`, `WikiService.EditPage`, `WikiService.DeletePage` | `wiki.go:21-61` | Caller-supplied `owner`, `repo`, `pageName`, typed page body; remote JSON responses | `fmt.Sprintf` path building -> `Client.Get/Post/Patch/Delete` | No path escaping | `pageName` is free-form and may legitimately contain slashes, so reserved characters can change routing or query semantics; page content/body is forwarded without local validation | -| `main` | `cmd/forgegen/main.go:9-30` | CLI flags `-spec`, `-out` | `LoadSpec`, `ExtractTypes`, `DetectCRUDPairs`, `Generate` | Standard `flag` parsing only | Untrusted operator input can point the generator at sensitive local files or arbitrary output directories | -| `LoadSpec` | `cmd/forgegen/parser.go:73-84` | Caller/CLI-supplied file path and file contents | `coreio.Local.Read(path)` then `json.Unmarshal` into `Spec` | Parse failure only; entire file read into memory | Arbitrary local file read if path is attacker-controlled; large or malformed JSON can exhaust memory or CPU | -| `ExtractTypes`, `DetectCRUDPairs` | `cmd/forgegen/parser.go:86-147` | Parsed, potentially untrusted Swagger definitions and metadata | In-memory `GoType`/`CRUDPair` model used by `Generate` | No schema hardening beyond basic JSON types; names/comments/enum values are accepted as-is | Malicious spec can create huge intermediate structures or feed unsafe identifiers/strings into code generation | -| `Generate` | `cmd/forgegen/generator.go:206-263` | Caller-supplied `outDir`; type names/comments/enum values derived from the spec | `EnsureDir(outDir)`, `filepath.Join(outDir, file+".go")`, Go source template, `coreio.Local.Write` | Output file names are limited by `classifyType`; generated source content is mostly unsanitised spec data | Arbitrary directory creation/file overwrite if `outDir` is attacker-controlled; malicious spec strings can break generated Go syntax or inject unexpected source content into generated files | - -## Highest-Risk Patterns - -- Unescaped `fmt.Sprintf`/string-concatenated paths are the dominant library issue pattern. Most custom service methods trust raw `owner`, `repo`, `org`, `username`, `name`, `tag`, `workflow`, `pageName`, and similar values. -- The `Resource`/`ResolvePath` path family is materially safer because it uses `url.PathEscape`, but it still performs no semantic validation and still inherits unbounded response/pagination behaviour. -- `cmd/forgegen` assumes the spec file is trusted. If the swagger input is attacker-controlled, the generator can read arbitrary files, write arbitrary directories, and emit attacker-influenced Go source. From f99a00c2435c079cd44ee13200396aa6b9c77700 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 23 Mar 2026 20:39:15 +0000 Subject: [PATCH 011/181] Delete docs/convention-drift-2026-03-23.md --- docs/convention-drift-2026-03-23.md | 79 ----------------------------- 1 file changed, 79 deletions(-) delete mode 100644 docs/convention-drift-2026-03-23.md diff --git a/docs/convention-drift-2026-03-23.md b/docs/convention-drift-2026-03-23.md deleted file mode 100644 index 1664812..0000000 --- a/docs/convention-drift-2026-03-23.md +++ /dev/null @@ -1,79 +0,0 @@ -# Convention Drift Check (2026-03-23) - -`CODEX.md` was not present anywhere under `/workspace`, so this review is based on `CLAUDE.md`, `docs/development.md`, and current coverage data from `go test -coverprofile=/tmp/cover.out ./...`. - -No fixes were applied as part of this pass. - -## stdlib -> core.* - -Confirmed drift against the documented `CLAUDE.md` rules: none. - -- No uses found of `fmt.Errorf`, `errors.New`, `os.ReadFile`, `os.WriteFile`, or `os.MkdirAll`. - -Broader-scope candidates only if the missing `CODEX.md` was meant to forbid more stdlib usage than `CLAUDE.md` currently documents: - -- `config.go:22`, `config.go:23` use `os.Getenv(...)`. -- `cmd/forgegen/main.go:16`, `cmd/forgegen/main.go:17`, `cmd/forgegen/main.go:27`, `cmd/forgegen/main.go:28` use `fmt.Fprintf(os.Stderr, ...)` and `os.Exit(1)`. -- `cmd/forgegen/generator_test.go:26`, `cmd/forgegen/generator_test.go:52`, `cmd/forgegen/generator_test.go:88`, `cmd/forgegen/generator_test.go:130` use `os.ReadDir(...)` in tests. - -## UK English - -Drift found in hand-written docs: - -- `README.md:2` badge text uses `License`. -- `README.md:9` heading uses `License`. -- `CONTRIBUTING.md:34` heading uses `License`. - -## Missing Tests - -Coverage basis: `go test -coverprofile=/tmp/cover.out ./...` on 2026-03-23. Line references below are function start lines from `go tool cover -func=/tmp/cover.out`. - -No dedicated service test file: - -- `milestones.go:10`, `milestones.go:20`, `milestones.go:26`, `milestones.go:36` (`MilestoneService`; no `milestones_test.go` present). - -Zero-coverage functions and methods: - -- `actions.go:29` `IterRepoSecrets`; `actions.go:55` `IterRepoVariables`; `actions.go:81` `IterOrgSecrets`; `actions.go:93` `IterOrgVariables` -- `admin.go:27` `IterUsers`; `admin.go:64` `IterOrgs`; `admin.go:80` `IterCron` -- `branches.go:25` `ListBranchProtections`; `branches.go:31` `IterBranchProtections`; `branches.go:37` `GetBranchProtection`; `branches.go:57` `EditBranchProtection`; `branches.go:67` `DeleteBranchProtection` -- `client.go:211` `do` -- `cmd/forgegen/generator.go:158` `enumConstName` -- `cmd/forgegen/main.go:9` `main` -- `commits.go:34` `ListAll`; `commits.go:39` `Iter` -- `issues.go:31` `Unpin`; `issues.go:37` `SetDeadline`; `issues.go:44` `AddReaction`; `issues.go:51` `DeleteReaction`; `issues.go:58` `StartStopwatch`; `issues.go:64` `StopStopwatch`; `issues.go:70` `AddLabels`; `issues.go:77` `RemoveLabel`; `issues.go:83` `ListComments`; `issues.go:89` `IterComments`; `issues.go:106` `toAnySlice` -- `labels.go:28` `IterRepoLabels`; `labels.go:76` `IterOrgLabels` -- `milestones.go:20` `ListAll`; `milestones.go:26` `Get`; `milestones.go:36` `Create` -- `notifications.go:27` `Iter`; `notifications.go:38` `IterRepo` -- `orgs.go:31` `IterMembers`; `orgs.go:37` `AddMember`; `orgs.go:43` `RemoveMember`; `orgs.go:49` `ListUserOrgs`; `orgs.go:55` `IterUserOrgs`; `orgs.go:61` `ListMyOrgs`; `orgs.go:66` `IterMyOrgs` -- `packages.go:28` `Iter`; `packages.go:56` `IterFiles` -- `pulls.go:32` `Update`; `pulls.go:38` `ListReviews`; `pulls.go:44` `IterReviews`; `pulls.go:50` `SubmitReview`; `pulls.go:60` `DismissReview`; `pulls.go:67` `UndismissReview` -- `releases.go:35` `DeleteByTag`; `releases.go:41` `ListAssets`; `releases.go:47` `IterAssets`; `releases.go:53` `GetAsset`; `releases.go:63` `DeleteAsset` -- `repos.go:29` `IterOrgRepos`; `repos.go:34` `ListUserRepos`; `repos.go:39` `IterUserRepos`; `repos.go:58` `Transfer`; `repos.go:63` `AcceptTransfer`; `repos.go:68` `RejectTransfer`; `repos.go:73` `MirrorSync` -- `teams.go:31` `IterMembers`; `teams.go:43` `RemoveMember`; `teams.go:49` `ListRepos`; `teams.go:55` `IterRepos`; `teams.go:61` `AddRepo`; `teams.go:67` `RemoveRepo`; `teams.go:73` `ListOrgTeams`; `teams.go:79` `IterOrgTeams` -- `users.go:40` `IterFollowers`; `users.go:46` `ListFollowing`; `users.go:52` `IterFollowing`; `users.go:58` `Follow`; `users.go:64` `Unfollow`; `users.go:70` `ListStarred`; `users.go:76` `IterStarred`; `users.go:82` `Star`; `users.go:88` `Unstar` -- `webhooks.go:38` `IterOrgHooks` - -## SPDX Headers - -No tracked `.go`, `.md`, `go.mod`, or `.gitignore` files currently start with an SPDX header. - -Repo meta and docs: - -- `.gitignore:1`, `go.mod:1`, `CLAUDE.md:1`, `CONTRIBUTING.md:1`, `README.md:1`, `docs/architecture.md:1`, `docs/development.md:1`, `docs/index.md:1` - -Hand-written package files: - -- `actions.go:1`, `admin.go:1`, `branches.go:1`, `client.go:1`, `commits.go:1`, `config.go:1`, `contents.go:1`, `doc.go:1`, `forge.go:1`, `issues.go:1`, `labels.go:1`, `milestones.go:1`, `misc.go:1`, `notifications.go:1`, `orgs.go:1`, `packages.go:1`, `pagination.go:1`, `params.go:1`, `pulls.go:1`, `releases.go:1`, `repos.go:1`, `resource.go:1`, `teams.go:1`, `users.go:1`, `webhooks.go:1`, `wiki.go:1` - -Hand-written test files: - -- `actions_test.go:1`, `admin_test.go:1`, `branches_test.go:1`, `client_test.go:1`, `commits_test.go:1`, `config_test.go:1`, `contents_test.go:1`, `forge_test.go:1`, `issues_test.go:1`, `labels_test.go:1`, `misc_test.go:1`, `notifications_test.go:1`, `orgs_test.go:1`, `packages_test.go:1`, `pagination_test.go:1`, `params_test.go:1`, `pulls_test.go:1`, `releases_test.go:1`, `resource_test.go:1`, `teams_test.go:1`, `users_test.go:1`, `webhooks_test.go:1`, `wiki_test.go:1` - -Generator and tooling: - -- `cmd/forgegen/generator.go:1`, `cmd/forgegen/generator_test.go:1`, `cmd/forgegen/main.go:1`, `cmd/forgegen/parser.go:1`, `cmd/forgegen/parser_test.go:1` - -Generated `types/` files (would need generator-owned header handling rather than manual edits): - -- `types/action.go:1`, `types/activity.go:1`, `types/admin.go:1`, `types/branch.go:1`, `types/comment.go:1`, `types/commit.go:1`, `types/common.go:1`, `types/content.go:1`, `types/error.go:1`, `types/federation.go:1`, `types/generate.go:1`, `types/git.go:1`, `types/hook.go:1`, `types/issue.go:1`, `types/key.go:1`, `types/label.go:1`, `types/milestone.go:1`, `types/misc.go:1`, `types/notification.go:1`, `types/oauth.go:1`, `types/org.go:1`, `types/package.go:1`, `types/pr.go:1`, `types/quota.go:1`, `types/reaction.go:1`, `types/release.go:1`, `types/repo.go:1`, `types/review.go:1`, `types/settings.go:1`, `types/status.go:1`, `types/tag.go:1`, `types/team.go:1`, `types/time_tracking.go:1`, `types/topic.go:1`, `types/user.go:1`, `types/wiki.go:1` From 1ffb4bee5a48d2a6344a8e7f18b0ab08103b72d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 13:27:06 +0000 Subject: [PATCH 012/181] feat: upgrade to core v0.8.0-alpha.1, replace banned stdlib imports Replace fmt, errors, strings, path/filepath, encoding/json with Core primitives across 23 files. Keep encoding/json for streaming NewDecoder/NewEncoder, strings for Fields/FieldsFuncSeq. Co-Authored-By: Claude Opus 4.6 (1M context) --- actions.go | 28 ++++++++++++------------ branches.go | 14 ++++++------ client.go | 32 +++++++++++++-------------- client_test.go | 5 +++-- cmd/forgegen/generator.go | 26 +++++++++++++--------- cmd/forgegen/generator_test.go | 23 ++++++++++--------- cmd/forgegen/main.go | 11 +++++----- cmd/forgegen/parser.go | 40 ++++++++++++++++++++++------------ commits.go | 10 ++++----- contents.go | 12 +++++----- go.mod | 1 + go.sum | 2 ++ issues.go | 26 +++++++++++----------- labels.go | 20 ++++++++--------- milestones.go | 8 +++---- misc.go | 6 ++--- notifications.go | 10 ++++----- orgs.go | 14 ++++++------ packages.go | 14 ++++++------ params.go | 5 +++-- pulls.go | 16 +++++++------- releases.go | 14 ++++++------ resource.go | 12 +++++----- teams.go | 22 +++++++++---------- users.go | 22 +++++++++---------- webhooks.go | 8 +++---- wiki.go | 12 +++++----- 27 files changed, 218 insertions(+), 195 deletions(-) diff --git a/actions.go b/actions.go index ba110b8..c1bbb65 100644 --- a/actions.go +++ b/actions.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -21,82 +21,82 @@ 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/secrets", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/variables", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) body := types.CreateVariableOption{Value: value} return s.client.Post(ctx, path, body, 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, 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 := core.Sprintf("/api/v1/orgs/%s/actions/secrets", 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 := core.Sprintf("/api/v1/orgs/%s/actions/secrets", 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 := core.Sprintf("/api/v1/orgs/%s/actions/variables", 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 := core.Sprintf("/api/v1/orgs/%s/actions/variables", org) return ListIter[types.ActionVariable](ctx, s.client, path, nil) } // 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow) return s.client.Post(ctx, path, opts, nil) } diff --git a/branches.go b/branches.go index 66af9a4..010b5e8 100644 --- a/branches.go +++ b/branches.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -23,19 +23,19 @@ func newBranchService(c *Client) *BranchService { // 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 := core.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) var out types.BranchProtection if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -45,7 +45,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 := core.Sprintf("/api/v1/repos/%s/%s/branch_protections", owner, repo) var out types.BranchProtection if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -55,7 +55,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 := core.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) var out types.BranchProtection if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -65,6 +65,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 := core.Sprintf("/api/v1/repos/%s/%s/branch_protections/%s", owner, repo, name) return s.client.Delete(ctx, path) } diff --git a/client.go b/client.go index cb10233..6a56f6d 100644 --- a/client.go +++ b/client.go @@ -4,13 +4,11 @@ import ( "bytes" "context" "encoding/json" - "errors" - "fmt" "io" "net/http" "strconv" - "strings" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" ) @@ -22,25 +20,25 @@ type APIError struct { } func (e *APIError) Error() string { - return fmt.Sprintf("forge: %s %d: %s", e.URL, e.StatusCode, e.Message) + return core.Sprintf("forge: %s %d: %s", e.URL, e.StatusCode, e.Message) } // IsNotFound returns true if the error is a 404 response. 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. 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. 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. @@ -80,7 +78,7 @@ func (c *Client) RateLimit() RateLimit { // NewClient creates a new Forgejo API client. func NewClient(url, token string, opts ...Option) *Client { c := &Client{ - baseURL: strings.TrimRight(url, "/"), + baseURL: core.TrimSuffix(url, "/"), token: token, httpClient: &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -139,11 +137,11 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er var bodyReader io.Reader if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, coreerr.E("Client.PostRaw", "forge: marshal body", err) + r := core.JSONMarshal(body) + if !r.OK { + return nil, coreerr.E("Client.PostRaw", "forge: marshal body", nil) } - bodyReader = bytes.NewReader(data) + bodyReader = bytes.NewReader(r.Value.([]byte)) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader) @@ -218,11 +216,11 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) var bodyReader io.Reader if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, coreerr.E("Client.doJSON", "forge: marshal body", err) + r := core.JSONMarshal(body) + if !r.OK { + return nil, coreerr.E("Client.doJSON", "forge: marshal body", nil) } - bodyReader = bytes.NewReader(data) + bodyReader = bytes.NewReader(r.Value.([]byte)) } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) @@ -267,7 +265,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)) - _ = json.Unmarshal(data, &errBody) + core.JSONUnmarshal(data, &errBody) msg := errBody.Message if msg == "" && len(data) > 0 { diff --git a/client_test.go b/client_test.go index 1c67351..fb7707a 100644 --- a/client_test.go +++ b/client_test.go @@ -3,10 +3,11 @@ package forge import ( "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "testing" + + core "dappco.re/go/core" ) func TestClient_Good_Get(t *testing.T) { @@ -91,7 +92,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 { diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index aea2da5..08fc51e 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -3,11 +3,11 @@ package main import ( "bytes" "maps" - "path/filepath" "slices" "strings" "text/template" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) @@ -110,7 +110,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 +122,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 +135,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 +151,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(" ", strings.Fields(s)...) } // enumConstName generates a Go constant name for an enum value. @@ -219,7 +219,13 @@ 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) + if a.Name < b.Name { + return -1 + } + if a.Name > b.Name { + return 1 + } + return 0 }) } @@ -228,7 +234,7 @@ 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) } @@ -241,7 +247,7 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { func writeFile(path string, types []*GoType) error { needTime := slices.ContainsFunc(types, func(gt *GoType) bool { return slices.ContainsFunc(gt.Fields, func(f GoField) bool { - return strings.Contains(f.GoType, "time.Time") + return core.Contains(f.GoType, "time.Time") }) }) diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 9f60e45..3d63a60 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -2,10 +2,9 @@ package main import ( "os" - "path/filepath" - "strings" "testing" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" ) @@ -26,7 +25,7 @@ func TestGenerate_Good_CreatesFiles(t *testing.T) { entries, _ := os.ReadDir(outDir) goFiles := 0 for _, e := range entries { - if strings.HasSuffix(e.Name(), ".go") { + if core.HasSuffix(e.Name(), ".go") { goFiles++ } } @@ -52,8 +51,8 @@ func TestGenerate_Good_ValidGoSyntax(t *testing.T) { entries, _ := os.ReadDir(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,10 +61,10 @@ 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") } } @@ -87,8 +86,8 @@ func TestGenerate_Good_RepositoryType(t *testing.T) { var content string entries, _ := os.ReadDir(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,7 +106,7 @@ 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) } } @@ -129,8 +128,8 @@ func TestGenerate_Good_TimeImport(t *testing.T) { entries, _ := os.ReadDir(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()) } } diff --git a/cmd/forgegen/main.go b/cmd/forgegen/main.go index 856331d..b34c874 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() { @@ -13,18 +14,18 @@ func main() { spec, err := LoadSpec(*specPath) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + core.Print(os.Stderr, "Error: %v\n", err) os.Exit(1) } 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(os.Stdout, "Loaded %d types, %d CRUD pairs\n", len(types), len(pairs)) + core.Print(os.Stdout, "Output dir: %s\n", *outDir) if err := Generate(types, pairs, *outDir); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + core.Print(os.Stderr, "Error: %v\n", err) os.Exit(1) } } diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index f32bb8e..d56de7e 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -1,11 +1,10 @@ package main import ( - "encoding/json" - "fmt" "slices" "strings" + core "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) @@ -77,8 +76,9 @@ func LoadSpec(path string) (*Spec, error) { return nil, coreerr.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) + r := core.JSONUnmarshal([]byte(content), &spec) + if !r.OK { + return nil, coreerr.E("LoadSpec", "parse spec", nil) } return &spec, nil } @@ -91,7 +91,7 @@ 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.Sprintf("%v", v)) } slices.Sort(gt.EnumValues) result[name] = gt @@ -116,7 +116,13 @@ 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) + if a.GoName < b.GoName { + return -1 + } + if a.GoName > b.GoName { + return 1 + } + return 0 }) result[name] = gt } @@ -128,11 +134,11 @@ func ExtractTypes(spec *Spec) map[string]*GoType { 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") + inner := core.TrimPrefix(name, "Create") + inner = core.TrimSuffix(inner, "Option") editName := "Edit" + inner + "Option" pair := CRUDPair{Base: inner, Create: name} if _, ok := spec.Definitions[editName]; ok { @@ -141,7 +147,13 @@ func DetectCRUDPairs(spec *Spec) []CRUDPair { pairs = append(pairs, pair) } slices.SortFunc(pairs, func(a, b CRUDPair) int { - return strings.Compare(a.Base, b.Base) + if a.Base < b.Base { + return -1 + } + if a.Base > b.Base { + return 1 + } + return 0 }) return pairs } @@ -149,7 +161,7 @@ func DetectCRUDPairs(spec *Spec) []CRUDPair { // resolveGoType maps a swagger schema property to a Go type string. func resolveGoType(prop SchemaProperty) string { if prop.Ref != "" { - parts := strings.Split(prop.Ref, "/") + parts := core.Split(prop.Ref, "/") return "*" + parts[len(parts)-1] } switch prop.Type { @@ -202,13 +214,13 @@ func pascalCase(s string) string { 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.Upper(p[:1])+p[1:]) } } - return strings.Join(parts, "") + return core.Join("", parts...) } diff --git a/commits.go b/commits.go index 27b501e..f6540b9 100644 --- a/commits.go +++ b/commits.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -51,7 +51,7 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, // 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 := core.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, ref) var out types.CombinedStatus if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -61,7 +61,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 := core.Sprintf("/api/v1/repos/%s/%s/commits/%s/statuses", owner, repo, ref) var out []types.CommitStatus if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -71,7 +71,7 @@ func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref strin // 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 := core.Sprintf("/api/v1/repos/%s/%s/statuses/%s", owner, repo, sha) var out types.CommitStatus if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -81,7 +81,7 @@ 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 := core.Sprintf("/api/v1/repos/%s/%s/git/notes/%s", owner, repo, sha) var out types.Note if err := s.client.Get(ctx, path, &out); err != nil { return nil, err diff --git a/contents.go b/contents.go index 8a6f48e..a4b3775 100644 --- a/contents.go +++ b/contents.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -19,7 +19,7 @@ func newContentService(c *Client) *ContentService { // 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 := core.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) var out types.ContentsResponse if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -29,7 +29,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 := core.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) var out types.FileResponse if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -39,7 +39,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 := core.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, filepath) var out types.FileResponse if err := s.client.Put(ctx, path, opts, &out); err != nil { return nil, err @@ -49,12 +49,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 := core.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/raw/%s", owner, repo, filepath) return s.client.GetRaw(ctx, path) } diff --git a/go.mod b/go.mod index f973b82..bf0625a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module dappco.re/go/core/forge go 1.26.0 require ( + dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 ) diff --git a/go.sum b/go.sum index 76d01ec..5a2e71c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/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= diff --git a/issues.go b/issues.go index a6ab01d..8779495 100644 --- a/issues.go +++ b/issues.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -23,77 +23,77 @@ func newIssueService(c *Client) *IssueService { // 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", owner, repo, index) return s.client.Post(ctx, path, nil, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/deadline", owner, repo, 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) + path := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) body := map[string]string{"content": reaction} return s.client.Post(ctx, path, body, 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) + path := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) body := map[string]string{"content": 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/stopwatch/start", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/stopwatch/stop", owner, repo, index) return s.client.Post(ctx, path, nil, nil) } // 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels/%d", owner, repo, index, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index) opts := types.CreateIssueCommentOption{Body: body} var out types.Comment if err := s.client.Post(ctx, path, opts, &out); err != nil { diff --git a/labels.go b/labels.go index acb1146..d4efe7f 100644 --- a/labels.go +++ b/labels.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -20,19 +20,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 := core.Sprintf("/api/v1/repos/%s/%s/labels", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/labels", owner, 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 := core.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) var out types.Label if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -42,7 +42,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 := core.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) var out types.Label if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -52,7 +52,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 := core.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, id) var out types.Label if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -62,25 +62,25 @@ 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 := core.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner, repo, 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 := core.Sprintf("/api/v1/orgs/%s/labels", 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 := core.Sprintf("/api/v1/orgs/%s/labels", 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 := core.Sprintf("/api/v1/orgs/%s/labels", org) var out types.Label if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err diff --git a/milestones.go b/milestones.go index fa2dfd8..767cb2f 100644 --- a/milestones.go +++ b/milestones.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -18,13 +18,13 @@ func newMilestoneService(c *Client) *MilestoneService { // ListAll returns all milestones for a repository. func (s *MilestoneService) ListAll(ctx context.Context, params Params) ([]types.Milestone, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/milestones", params["owner"], params["repo"]) + path := core.Sprintf("/api/v1/repos/%s/%s/milestones", params["owner"], params["repo"]) return ListAll[types.Milestone](ctx, s.client, path, nil) } // Get returns a single milestone by ID. func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, id) + path := core.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, id) var out types.Milestone if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -34,7 +34,7 @@ func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64 // Create creates a new milestone. func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { - path := fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo) + path := core.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo) var out types.Milestone if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err diff --git a/misc.go b/misc.go index b05526a..70a1770 100644 --- a/misc.go +++ b/misc.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -41,7 +41,7 @@ func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplat // 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 := core.Sprintf("/api/v1/licenses/%s", name) var out types.LicenseTemplateInfo if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -60,7 +60,7 @@ func (s *MiscService) ListGitignoreTemplates(ctx context.Context) ([]string, err // 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 := core.Sprintf("/api/v1/gitignore/templates/%s", name) var out types.GitignoreTemplateInfo if err := s.client.Get(ctx, path, &out); err != nil { return nil, err diff --git a/notifications.go b/notifications.go index e3b8af2..465fc3b 100644 --- a/notifications.go +++ b/notifications.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -30,13 +30,13 @@ func (s *NotificationService) Iter(ctx context.Context) iter.Seq2[types.Notifica // 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) + path := core.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) return ListAll[types.NotificationThread](ctx, s.client, path, nil) } // 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) + path := core.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) return ListIter[types.NotificationThread](ctx, s.client, path, nil) } @@ -47,7 +47,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 := core.Sprintf("/api/v1/notifications/threads/%d", id) var out types.NotificationThread if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -57,6 +57,6 @@ 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 := core.Sprintf("/api/v1/notifications/threads/%d", id) return s.client.Patch(ctx, path, nil, nil) } diff --git a/orgs.go b/orgs.go index 36d4dc2..18fa762 100644 --- a/orgs.go +++ b/orgs.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -23,37 +23,37 @@ func newOrgService(c *Client) *OrgService { // 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 := core.Sprintf("/api/v1/orgs/%s/members", 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 := core.Sprintf("/api/v1/orgs/%s/members", 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 := core.Sprintf("/api/v1/orgs/%s/members/%s", org, 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 := core.Sprintf("/api/v1/orgs/%s/members/%s", org, username) return s.client.Delete(ctx, path) } // 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 := core.Sprintf("/api/v1/users/%s/orgs", 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 := core.Sprintf("/api/v1/users/%s/orgs", username) return ListIter[types.Organization](ctx, s.client, path, nil) } diff --git a/packages.go b/packages.go index ee724ea..bade2e3 100644 --- a/packages.go +++ b/packages.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -20,19 +20,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 := core.Sprintf("/api/v1/packages/%s", 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 := core.Sprintf("/api/v1/packages/%s", 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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) var out types.Package if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -42,18 +42,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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, 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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, 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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) return ListIter[types.PackageFile](ctx, s.client, path, nil) } diff --git a/params.go b/params.go index ce20aaf..a6306d5 100644 --- a/params.go +++ b/params.go @@ -2,7 +2,8 @@ package forge import ( "net/url" - "strings" + + core "dappco.re/go/core" ) // Params maps path variable names to values. @@ -12,7 +13,7 @@ type Params map[string]string // ResolvePath substitutes {placeholders} in path with values from params. 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/pulls.go b/pulls.go index 408f438..3530884 100644 --- a/pulls.go +++ b/pulls.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -23,32 +23,32 @@ func newPullService(c *Client) *PullService { // 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 := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) body := map[string]string{"Do": method} return s.client.Post(ctx, path, body, nil) } // 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 := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/update", owner, repo, index) return s.client.Post(ctx, path, nil, 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 := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) return ListIter[types.PullReview](ctx, s.client, path, nil) } // 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) + path := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index) var out types.PullReview if err := s.client.Post(ctx, path, review, &out); err != nil { return nil, err @@ -58,13 +58,13 @@ func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, inde // 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 := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, 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 := core.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, reviewID) return s.client.Post(ctx, path, nil, nil) } diff --git a/releases.go b/releases.go index ae32d49..3e8a227 100644 --- a/releases.go +++ b/releases.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -23,7 +23,7 @@ func newReleaseService(c *Client) *ReleaseService { // 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) var out types.Release if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -33,25 +33,25 @@ 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) return ListAll[types.Attachment](ctx, s.client, path, nil) } // 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) var out types.Attachment if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -61,6 +61,6 @@ 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets/%d", owner, repo, releaseID, assetID) return s.client.Delete(ctx, path) } diff --git a/resource.go b/resource.go index 3ee5d45..cfd68b4 100644 --- a/resource.go +++ b/resource.go @@ -3,7 +3,8 @@ package forge import ( "context" "iter" - "strings" + + core "dappco.re/go/core" ) // Resource provides generic CRUD operations for a Forgejo API resource. @@ -21,10 +22,11 @@ func NewResource[T any, C any, U any](c *Client, path string) *Resource[T, C, U] 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 := strings.LastIndex(path, "/"); i >= 0 { - lastSeg := path[i+1:] - if strings.HasPrefix(lastSeg, "{") && strings.HasSuffix(lastSeg, "}") { - collection = path[:i] + parts := core.Split(path, "/") + if len(parts) > 0 { + lastSeg := parts[len(parts)-1] + if core.HasPrefix(lastSeg, "{") && core.HasSuffix(lastSeg, "}") { + collection = path[:len(path)-len(lastSeg)-1] } } return &Resource[T, C, U]{client: c, path: path, collection: collection} diff --git a/teams.go b/teams.go index 32470ec..b1e1bf3 100644 --- a/teams.go +++ b/teams.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -23,60 +23,60 @@ func newTeamService(c *Client) *TeamService { // 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 := core.Sprintf("/api/v1/teams/%d/members", 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 := core.Sprintf("/api/v1/teams/%d/members", 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 := core.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) return s.client.Put(ctx, path, nil, 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 := core.Sprintf("/api/v1/teams/%d/members/%s", teamID, 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 := core.Sprintf("/api/v1/teams/%d/repos", 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 := core.Sprintf("/api/v1/teams/%d/repos", 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 := core.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, 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 := core.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) return s.client.Delete(ctx, path) } // 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 := core.Sprintf("/api/v1/orgs/%s/teams", 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 := core.Sprintf("/api/v1/orgs/%s/teams", org) return ListIter[types.Team](ctx, s.client, path, nil) } diff --git a/users.go b/users.go index 2aba489..bc55a2a 100644 --- a/users.go +++ b/users.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -32,60 +32,60 @@ func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { // 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 := core.Sprintf("/api/v1/users/%s/followers", 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 := core.Sprintf("/api/v1/users/%s/followers", username) return ListIter[types.User](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 := core.Sprintf("/api/v1/users/%s/following", 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 := core.Sprintf("/api/v1/users/%s/following", username) return ListIter[types.User](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 := core.Sprintf("/api/v1/user/following/%s", username) return s.client.Put(ctx, path, nil, 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 := core.Sprintf("/api/v1/user/following/%s", 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 := core.Sprintf("/api/v1/users/%s/starred", 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 := core.Sprintf("/api/v1/users/%s/starred", username) return ListIter[types.Repository](ctx, s.client, path, 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 := core.Sprintf("/api/v1/user/starred/%s/%s", owner, 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 := core.Sprintf("/api/v1/user/starred/%s/%s", owner, repo) return s.client.Delete(ctx, path) } diff --git a/webhooks.go b/webhooks.go index b814b28..eaa51d0 100644 --- a/webhooks.go +++ b/webhooks.go @@ -2,9 +2,9 @@ package forge import ( "context" - "fmt" "iter" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -24,18 +24,18 @@ func newWebhookService(c *Client) *WebhookService { // 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 := core.Sprintf("/api/v1/repos/%s/%s/hooks/%d/tests", owner, repo, id) return s.client.Post(ctx, path, nil, nil) } // 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 := core.Sprintf("/api/v1/orgs/%s/hooks", 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 := core.Sprintf("/api/v1/orgs/%s/hooks", org) return ListIter[types.Hook](ctx, s.client, path, nil) } diff --git a/wiki.go b/wiki.go index 898c5ca..7681ada 100644 --- a/wiki.go +++ b/wiki.go @@ -2,8 +2,8 @@ package forge import ( "context" - "fmt" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -19,7 +19,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 := core.Sprintf("/api/v1/repos/%s/%s/wiki/pages", owner, repo) var out []types.WikiPageMetaData if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -29,7 +29,7 @@ func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]type // 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 := core.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) var out types.WikiPage if err := s.client.Get(ctx, path, &out); err != nil { return nil, err @@ -39,7 +39,7 @@ func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) // 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 := core.Sprintf("/api/v1/repos/%s/%s/wiki/new", owner, repo) var out types.WikiPage if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err @@ -49,7 +49,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 := core.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) var out types.WikiPage if err := s.client.Patch(ctx, path, opts, &out); err != nil { return nil, err @@ -59,6 +59,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 := core.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", owner, repo, pageName) return s.client.Delete(ctx, path) } From c147b5650c63236d02f538d9eebcd0a51adb5f5f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 10:38:45 +0000 Subject: [PATCH 013/181] docs(package): fix stale usage example Co-Authored-By: Virgil --- doc.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 551a964fdb62cc573bf73a0d32f4ddc0efa4a05c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 18:00:20 +0000 Subject: [PATCH 014/181] refactor(ax): enforce v0.8.0 polish rules Co-Authored-By: Virgil --- actions.go | 32 +++++++----- actions_test.go | 22 ++++---- admin.go | 5 ++ admin_test.go | 26 +++++----- branches.go | 18 ++++--- branches_test.go | 8 +-- client.go | 94 ++++++++++++++++++++++++++-------- client_test.go | 32 ++++++------ cmd/forgegen/generator.go | 26 +++++----- cmd/forgegen/generator_test.go | 17 +++--- cmd/forgegen/helpers.go | 41 +++++++++++++++ cmd/forgegen/main.go | 11 ++-- cmd/forgegen/parser.go | 83 ++++++++++++++++++++---------- cmd/forgegen/parser_test.go | 8 +-- commits.go | 14 +++-- commits_test.go | 16 +++--- config.go | 25 +++++++-- config_test.go | 17 +++--- contents.go | 16 +++--- contents_test.go | 16 +++--- forge.go | 10 ++++ forge_test.go | 18 +++---- go.mod | 4 +- go.sum | 8 +-- helpers.go | 35 +++++++++++++ issues.go | 30 ++++++----- issues_test.go | 20 ++++---- labels.go | 24 +++++---- labels_test.go | 18 +++---- milestones.go | 12 +++-- misc.go | 10 ++-- misc_test.go | 18 +++---- notifications.go | 14 +++-- notifications_test.go | 14 ++--- orgs.go | 18 ++++--- orgs_test.go | 8 +-- packages.go | 18 ++++--- packages_test.go | 12 ++--- pagination.go | 35 ++++++++++++- pagination_test.go | 14 ++--- params.go | 10 ++++ params_test.go | 8 +-- pulls.go | 20 +++++--- pulls_test.go | 12 ++--- releases.go | 18 ++++--- releases_test.go | 8 +-- repos.go | 5 ++ resource.go | 17 ++++-- resource_test.go | 20 ++++---- teams.go | 26 ++++++---- teams_test.go | 8 +-- users.go | 26 ++++++---- users_test.go | 8 +-- webhooks.go | 12 +++-- webhooks_test.go | 14 ++--- wiki.go | 16 +++--- wiki_test.go | 14 ++--- 57 files changed, 705 insertions(+), 404 deletions(-) create mode 100644 cmd/forgegen/helpers.go create mode 100644 helpers.go diff --git a/actions.go b/actions.go index c1bbb65..0e9a39f 100644 --- a/actions.go +++ b/actions.go @@ -4,13 +4,17 @@ import ( "context" "iter" - 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. // 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 +25,82 @@ 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 := core.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 := core.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 := core.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", pathParams("owner", owner, "repo", repo, "name", 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 := core.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", pathParams("owner", owner, "repo", repo, "name", 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 := core.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 := core.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 := core.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", pathParams("owner", owner, "repo", repo, "name", name)) body := types.CreateVariableOption{Value: value} return s.client.Post(ctx, path, body, nil) } // DeleteRepoVariable removes an action variable from a repository. func (s *ActionsService) DeleteRepoVariable(ctx context.Context, owner, repo, name string) error { - path := core.Sprintf("/api/v1/repos/%s/%s/actions/variables/%s", owner, repo, name) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", pathParams("owner", owner, "repo", repo, "name", 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 := core.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 := core.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 := core.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 := core.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) } // DispatchWorkflow triggers a workflow run. func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, opts map[string]any) error { - path := core.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", pathParams("owner", owner, "repo", repo, "workflow", workflow)) return s.client.Post(ctx, path, opts, nil) } diff --git a/actions_test.go b/actions_test.go index 9304440..17b826b 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,7 @@ func TestActionsService_Good_CreateRepoVariable(t *testing.T) { } } -func TestActionsService_Good_DeleteRepoVariable(t *testing.T) { +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 +160,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 +188,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 +216,7 @@ func TestActionsService_Good_ListOrgVariables(t *testing.T) { } } -func TestActionsService_Good_DispatchWorkflow(t *testing.T) { +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 +244,7 @@ func TestActionsService_Good_DispatchWorkflow(t *testing.T) { } } -func TestActionsService_Bad_NotFound(t *testing.T) { +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/admin.go b/admin.go index a887316..825abef 100644 --- a/admin.go +++ b/admin.go @@ -10,6 +10,11 @@ import ( // 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 } diff --git a/admin_test.go b/admin_test.go index 8901d7f..0f21d9f 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,7 @@ func TestAdminService_Good_ListOrgs(t *testing.T) { } } -func TestAdminService_Good_ListCron(t *testing.T) { +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 +223,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 +241,7 @@ func TestAdminService_Good_AdoptRepo(t *testing.T) { } } -func TestAdminService_Good_GenerateRunnerToken(t *testing.T) { +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 +263,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 +277,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/branches.go b/branches.go index 010b5e8..b7d4fd1 100644 --- a/branches.go +++ b/branches.go @@ -4,11 +4,15 @@ import ( "context" "iter" - core "dappco.re/go/core" "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{}] } @@ -23,19 +27,19 @@ func newBranchService(c *Client) *BranchService { // ListBranchProtections returns all branch protections for a repository. func (s *BranchService) ListBranchProtections(ctx context.Context, owner, repo string) ([]types.BranchProtection, error) { - path := core.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 := core.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 := core.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 +49,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 := core.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 +59,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 := core.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 +69,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 := core.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_test.go b/branches_test.go index 22d1302..ca1f99e 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,7 @@ func TestBranchService_Good_Get(t *testing.T) { } } -func TestBranchService_Good_CreateProtection(t *testing.T) { +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 6a56f6d..8b4ca90 100644 --- a/client.go +++ b/client.go @@ -3,16 +3,21 @@ package forge import ( "bytes" "context" - "encoding/json" + json "github.com/goccy/go-json" "io" "net/http" "strconv" core "dappco.re/go/core" - coreerr "dappco.re/go/core/log" ) // 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 @@ -20,41 +25,76 @@ type APIError struct { } func (e *APIError) Error() string { - return core.Sprintf("forge: %s %d: %s", e.URL, e.StatusCode, e.Message) + return core.Concat("forge: ", e.URL, " ", strconv.Itoa(e.StatusCode), ": ", e.Message) } // 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 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 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 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 @@ -62,6 +102,11 @@ type RateLimit struct { } // 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 @@ -76,9 +121,14 @@ func (c *Client) RateLimit() RateLimit { } // 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: core.TrimSuffix(url, "/"), + baseURL: trimTrailingSlashes(url), token: token, httpClient: &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -137,16 +187,16 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er var bodyReader io.Reader if body != nil { - r := core.JSONMarshal(body) - if !r.OK { - return nil, coreerr.E("Client.PostRaw", "forge: marshal body", nil) + data, err := json.Marshal(body) + if err != nil { + return nil, core.E("Client.PostRaw", "forge: marshal body", err) } - bodyReader = bytes.NewReader(r.Value.([]byte)) + 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) @@ -157,7 +207,7 @@ 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() @@ -167,7 +217,7 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er data, err := io.ReadAll(resp.Body) if err != nil { - return nil, coreerr.E("Client.PostRaw", "forge: read response body", err) + return nil, core.E("Client.PostRaw", "forge: read response body", err) } return data, nil @@ -180,7 +230,7 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { 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) @@ -190,7 +240,7 @@ 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() @@ -200,7 +250,7 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { data, err := io.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,16 +266,16 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) var bodyReader io.Reader if body != nil { - r := core.JSONMarshal(body) - if !r.OK { - return nil, coreerr.E("Client.doJSON", "forge: marshal body", nil) + data, err := json.Marshal(body) + if err != nil { + return nil, core.E("Client.doJSON", "forge: marshal body", err) } - bodyReader = bytes.NewReader(r.Value.([]byte)) + 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) @@ -239,7 +289,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() @@ -251,7 +301,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) } } @@ -265,7 +315,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)) - core.JSONUnmarshal(data, &errBody) + _ = json.Unmarshal(data, &errBody) msg := errBody.Message if msg == "" && len(data) > 0 { diff --git a/client_test.go b/client_test.go index fb7707a..57fb5c6 100644 --- a/client_test.go +++ b/client_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 ( 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) @@ -36,7 +36,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) @@ -63,7 +63,7 @@ func TestClient_Good_Post(t *testing.T) { } } -func TestClient_Good_Delete(t *testing.T) { +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) @@ -79,7 +79,7 @@ func TestClient_Good_Delete(t *testing.T) { } } -func TestClient_Bad_ServerError(t *testing.T) { +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"}) @@ -100,7 +100,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"}) @@ -114,7 +114,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() })) @@ -129,7 +129,7 @@ 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"), ) @@ -138,7 +138,7 @@ func TestClient_Good_Options(t *testing.T) { } } -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 { @@ -146,7 +146,7 @@ func TestClient_Good_WithHTTPClient(t *testing.T) { } } -func TestAPIError_Good_Error(t *testing.T) { +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" @@ -155,28 +155,28 @@ func TestAPIError_Good_Error(t *testing.T) { } } -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") @@ -203,7 +203,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"}) @@ -217,7 +217,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 08fc51e..3e0a3fb 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -2,14 +2,13 @@ package main import ( "bytes" + "cmp" "maps" "slices" - "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. @@ -151,7 +150,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 core.Join(" ", strings.Fields(s)...) + return core.Join(" ", splitFields(s)...) } // enumConstName generates a Go constant name for an enum value. @@ -204,9 +203,14 @@ 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) } // Group types by output file. @@ -219,13 +223,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 { - if a.Name < b.Name { - return -1 - } - if a.Name > b.Name { - return 1 - } - return 0 + return cmp.Compare(a.Name, b.Name) }) } @@ -236,7 +234,7 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { for _, file := range fileNames { 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) } } @@ -258,11 +256,11 @@ 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) + return core.E("writeFile", "write file", err) } return nil diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 3d63a60..ae3b059 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -1,14 +1,13 @@ package main import ( - "os" "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) @@ -22,7 +21,7 @@ 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 core.HasSuffix(e.Name(), ".go") { @@ -34,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) @@ -48,7 +47,7 @@ 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 core.HasSuffix(e.Name(), ".go") { @@ -69,7 +68,7 @@ func TestGenerate_Good_ValidGoSyntax(t *testing.T) { } } -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) @@ -84,7 +83,7 @@ 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(core.JoinPath(outDir, e.Name())) if core.Contains(data, "type Repository struct") { @@ -112,7 +111,7 @@ func TestGenerate_Good_RepositoryType(t *testing.T) { } } -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) @@ -126,7 +125,7 @@ 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(core.JoinPath(outDir, e.Name())) if core.Contains(content, "time.Time") && !core.Contains(content, "\"time\"") { 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 b34c874..469f626 100644 --- a/cmd/forgegen/main.go +++ b/cmd/forgegen/main.go @@ -2,7 +2,6 @@ package main import ( "flag" - "os" core "dappco.re/go/core" ) @@ -14,18 +13,16 @@ func main() { spec, err := LoadSpec(*specPath) if err != nil { - core.Print(os.Stderr, "Error: %v\n", err) - os.Exit(1) + panic(core.E("forgegen.main", "load spec", err)) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) - core.Print(os.Stdout, "Loaded %d types, %d CRUD pairs\n", len(types), len(pairs)) - core.Print(os.Stdout, "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 { - core.Print(os.Stderr, "Error: %v\n", err) - os.Exit(1) + panic(core.E("forgegen.main", "generate types", err)) } } diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index d56de7e..f42e77b 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -1,15 +1,20 @@ package main import ( + "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"` @@ -18,12 +23,20 @@ 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"` @@ -34,6 +47,10 @@ type SchemaDefinition struct { } // SchemaProperty represents a single property within a schema definition. +// +// Usage: +// +// _ = SchemaProperty{Type: "string"} type SchemaProperty struct { Type string `json:"type"` Format string `json:"format"` @@ -45,6 +62,10 @@ type SchemaProperty struct { } // GoType is the intermediate representation for a Go type to be generated. +// +// Usage: +// +// _ = GoType{Name: "Repository"} type GoType struct { Name string Description string @@ -54,6 +75,10 @@ type GoType struct { } // GoField is the intermediate representation for a single struct field. +// +// Usage: +// +// _ = GoField{GoName: "ID", GoType: "int64"} type GoField struct { GoName string GoType string @@ -63,6 +88,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 @@ -70,20 +99,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 - r := core.JSONUnmarshal([]byte(content), &spec) - if !r.OK { - return nil, coreerr.E("LoadSpec", "parse spec", nil) + if err := json.Unmarshal([]byte(content), &spec); err != nil { + 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,7 +129,7 @@ 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, core.Sprintf("%v", v)) + gt.EnumValues = append(gt.EnumValues, core.Sprint(v)) } slices.Sort(gt.EnumValues) result[name] = gt @@ -116,13 +154,7 @@ func ExtractTypes(spec *Spec) map[string]*GoType { gt.Fields = append(gt.Fields, gf) } slices.SortFunc(gt.Fields, func(a, b GoField) int { - if a.GoName < b.GoName { - return -1 - } - if a.GoName > b.GoName { - return 1 - } - return 0 + return cmp.Compare(a.GoName, b.GoName) }) result[name] = gt } @@ -131,6 +163,11 @@ func ExtractTypes(spec *Spec) map[string]*GoType { // 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 { @@ -139,7 +176,7 @@ func DetectCRUDPairs(spec *Spec) []CRUDPair { } inner := core.TrimPrefix(name, "Create") inner = core.TrimSuffix(inner, "Option") - editName := "Edit" + inner + "Option" + editName := core.Concat("Edit", inner, "Option") pair := CRUDPair{Base: inner, Create: name} if _, ok := spec.Definitions[editName]; ok { pair.Edit = editName @@ -147,13 +184,7 @@ func DetectCRUDPairs(spec *Spec) []CRUDPair { pairs = append(pairs, pair) } slices.SortFunc(pairs, func(a, b CRUDPair) int { - if a.Base < b.Base { - return -1 - } - if a.Base > b.Base { - return 1 - } - return 0 + return cmp.Compare(a.Base, b.Base) }) return pairs } @@ -208,9 +239,7 @@ func resolveGoType(prop SchemaProperty) string { // 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 } @@ -219,8 +248,8 @@ func pascalCase(s string) string { case "ID", "URL", "HTML", "SSH", "HTTP", "HTTPS", "API", "URI", "GPG", "IP", "CSS", "JS": parts = append(parts, upper) default: - parts = append(parts, core.Upper(p[:1])+p[1:]) + parts = append(parts, core.Concat(core.Upper(p[:1]), p[1:])) } } - return core.Join("", parts...) + return core.Concat(parts...) } diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go index 2607268..b8808c6 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) @@ -74,7 +74,7 @@ func TestParser_Good_FieldTypes(t *testing.T) { } } -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) diff --git a/commits.go b/commits.go index f6540b9..8db96cc 100644 --- a/commits.go +++ b/commits.go @@ -4,7 +4,6 @@ import ( "context" "iter" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -12,6 +11,11 @@ import ( // and git notes. // 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 } @@ -51,7 +55,7 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, // 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 := core.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 @@ -61,7 +65,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 := core.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 @@ -71,7 +75,7 @@ func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref strin // 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 := core.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 @@ -81,7 +85,7 @@ 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 := core.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 diff --git a/commits_test.go b/commits_test.go index 82043f8..d75a20a 100644 --- a/commits_test.go +++ b/commits_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 TestCommitService_Good_List(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) @@ -61,7 +61,7 @@ func TestCommitService_Good_List(t *testing.T) { } } -func TestCommitService_Good_Get(t *testing.T) { +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) @@ -95,7 +95,7 @@ func TestCommitService_Good_Get(t *testing.T) { } } -func TestCommitService_Good_ListStatuses(t *testing.T) { +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) @@ -126,7 +126,7 @@ func TestCommitService_Good_ListStatuses(t *testing.T) { } } -func TestCommitService_Good_CreateStatus(t *testing.T) { +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) @@ -169,7 +169,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) @@ -199,7 +199,7 @@ func TestCommitService_Good_GetNote(t *testing.T) { } } -func TestCommitService_Good_GetCombinedStatus(t *testing.T) { +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) @@ -234,7 +234,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..d1e442f 100644 --- a/config.go +++ b/config.go @@ -1,14 +1,18 @@ package forge import ( - "os" + "syscall" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" ) 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" ) @@ -18,9 +22,15 @@ const ( // 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") + url, _ = syscall.Getenv("FORGE_URL") + token, _ = syscall.Getenv("FORGE_TOKEN") if flagURL != "" { url = flagURL @@ -36,13 +46,18 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { // NewForgeFromConfig creates a new Forge client using resolved configuration. // It returns an error if no API token is available from flags or environment. +// +// 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..009789e 100644 --- a/config_test.go +++ b/config_test.go @@ -1,11 +1,10 @@ package forge import ( - "os" "testing" ) -func TestResolveConfig_Good_EnvOverrides(t *testing.T) { +func TestResolveConfig_EnvOverrides_Good(t *testing.T) { t.Setenv("FORGE_URL", "https://forge.example.com") t.Setenv("FORGE_TOKEN", "env-token") @@ -21,7 +20,7 @@ func TestResolveConfig_Good_EnvOverrides(t *testing.T) { } } -func TestResolveConfig_Good_FlagOverridesEnv(t *testing.T) { +func TestResolveConfig_FlagOverridesEnv_Good(t *testing.T) { t.Setenv("FORGE_URL", "https://env.example.com") t.Setenv("FORGE_TOKEN", "env-token") @@ -37,9 +36,9 @@ 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("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") url, _, err := ResolveConfig("", "") if err != nil { @@ -50,9 +49,9 @@ func TestResolveConfig_Good_DefaultURL(t *testing.T) { } } -func TestNewForgeFromConfig_Bad_NoToken(t *testing.T) { - os.Unsetenv("FORGE_URL") - os.Unsetenv("FORGE_TOKEN") +func TestNewForgeFromConfig_NoToken_Bad(t *testing.T) { + t.Setenv("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") _, err := NewForgeFromConfig("", "") if err == nil { diff --git a/contents.go b/contents.go index a4b3775..0fdbfc0 100644 --- a/contents.go +++ b/contents.go @@ -3,12 +3,16 @@ package forge import ( "context" - 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 } @@ -19,7 +23,7 @@ func newContentService(c *Client) *ContentService { // 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 := core.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 +33,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 := core.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 +43,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 := core.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 +53,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 := core.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 := core.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..7e03ca7 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,7 @@ import ( "dappco.re/go/core/forge/types" ) -func TestContentService_Good_GetFile(t *testing.T) { +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 +49,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 +98,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 +138,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 +168,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 +192,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 +209,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/forge.go b/forge.go index ecb5c17..becab42 100644 --- a/forge.go +++ b/forge.go @@ -1,6 +1,11 @@ package forge // Forge is the top-level client for the Forgejo API. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _ = f.Repos type Forge struct { client *Client @@ -26,6 +31,11 @@ type Forge struct { } // NewForge creates a new Forge client. +// +// Usage: +// +// f := forge.NewForge("https://forge.lthn.ai", "token") +// _ = f func NewForge(url, token string, opts ...Option) *Forge { c := NewClient(url, token, opts...) f := &Forge{client: c} diff --git a/forge_test.go b/forge_test.go index 747b37a..2ed0b2e 100644 --- a/forge_test.go +++ b/forge_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 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") @@ -20,7 +20,7 @@ func TestForge_Good_NewForge(t *testing.T) { } } -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 { @@ -31,7 +31,7 @@ func TestForge_Good_Client(t *testing.T) { } } -func TestRepoService_Good_ListOrgRepos(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) @@ -56,7 +56,7 @@ func TestRepoService_Good_ListOrgRepos(t *testing.T) { } } -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) @@ -77,7 +77,7 @@ func TestRepoService_Good_Get(t *testing.T) { } } -func TestRepoService_Good_Update(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) @@ -105,7 +105,7 @@ func TestRepoService_Good_Update(t *testing.T) { } } -func TestRepoService_Good_Delete(t *testing.T) { +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) @@ -125,7 +125,7 @@ func TestRepoService_Good_Delete(t *testing.T) { } } -func TestRepoService_Bad_Get(t *testing.T) { +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"}) @@ -138,7 +138,7 @@ func TestRepoService_Bad_Get(t *testing.T) { } } -func TestRepoService_Good_Fork(t *testing.T) { +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) diff --git a/go.mod b/go.mod index bf0625a..0111cc8 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module dappco.re/go/core/forge go 1.26.0 require ( - dappco.re/go/core v0.8.0-alpha.1 + 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 diff --git a/go.sum b/go.sum index 5a2e71c..8169f53 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= -dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +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..936af77 --- /dev/null +++ b/helpers.go @@ -0,0 +1,35 @@ +package forge + +import ( + "strconv" + + 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 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 8779495..15306a4 100644 --- a/issues.go +++ b/issues.go @@ -4,11 +4,15 @@ import ( "context" "iter" - core "dappco.re/go/core" "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] } @@ -23,77 +27,77 @@ func newIssueService(c *Client) *IssueService { // Pin pins an issue. func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error { - path := core.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) } // Unpin unpins an issue. func (s *IssueService) Unpin(ctx context.Context, owner, repo string, index int64) error { - path := core.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 := core.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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) body := map[string]string{"content": reaction} return s.client.Post(ctx, path, body, nil) } // DeleteReaction removes a reaction from an issue. func (s *IssueService) DeleteReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { - path := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) body := map[string]string{"content": 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 := core.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 := core.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) } // AddLabels adds labels to an issue. func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index int64, labelIDs []int64) error { - path := core.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 := core.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels/%d", owner, repo, index, labelID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels/{labelID}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "labelID", 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 := core.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 := core.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 := core.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 { diff --git a/issues_test.go b/issues_test.go index b9d7ed1..a00fff0 100644 --- a/issues_test.go +++ b/issues_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 TestIssueService_Good_List(t *testing.T) { +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) @@ -41,7 +41,7 @@ func TestIssueService_Good_List(t *testing.T) { } } -func TestIssueService_Good_Get(t *testing.T) { +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) @@ -63,7 +63,7 @@ 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) @@ -93,7 +93,7 @@ func TestIssueService_Good_Create(t *testing.T) { } } -func TestIssueService_Good_Update(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) @@ -121,7 +121,7 @@ func TestIssueService_Good_Update(t *testing.T) { } } -func TestIssueService_Good_Delete(t *testing.T) { +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) @@ -141,7 +141,7 @@ func TestIssueService_Good_Delete(t *testing.T) { } } -func TestIssueService_Good_CreateComment(t *testing.T) { +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) @@ -168,7 +168,7 @@ func TestIssueService_Good_CreateComment(t *testing.T) { } } -func TestIssueService_Good_Pin(t *testing.T) { +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) @@ -187,7 +187,7 @@ func TestIssueService_Good_Pin(t *testing.T) { } } -func TestIssueService_Bad_List(t *testing.T) { +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"}) @@ -200,7 +200,7 @@ func TestIssueService_Bad_List(t *testing.T) { } } -func TestIssueService_Ugly_ListIgnoresIndexParam(t *testing.T) { +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) diff --git a/labels.go b/labels.go index d4efe7f..341e7d3 100644 --- a/labels.go +++ b/labels.go @@ -4,12 +4,16 @@ import ( "context" "iter" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // 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 := core.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 := core.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 := core.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 := core.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 := core.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,25 +66,25 @@ 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 := core.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 := core.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 := core.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 := core.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 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 index 767cb2f..84c3365 100644 --- a/milestones.go +++ b/milestones.go @@ -3,11 +3,15 @@ package forge import ( "context" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // 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 } @@ -18,13 +22,13 @@ func newMilestoneService(c *Client) *MilestoneService { // ListAll returns all milestones for a repository. func (s *MilestoneService) ListAll(ctx context.Context, params Params) ([]types.Milestone, error) { - path := core.Sprintf("/api/v1/repos/%s/%s/milestones", params["owner"], params["repo"]) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) return ListAll[types.Milestone](ctx, s.client, path, nil) } // Get returns a single milestone by ID. func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error) { - path := core.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, id) + 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 @@ -34,7 +38,7 @@ func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64 // Create creates a new milestone. func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { - path := core.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo) + 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 diff --git a/misc.go b/misc.go index 70a1770..77243c2 100644 --- a/misc.go +++ b/misc.go @@ -3,7 +3,6 @@ package forge import ( "context" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -11,6 +10,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 } @@ -41,7 +45,7 @@ func (s *MiscService) ListLicenses(ctx context.Context) ([]types.LicensesTemplat // GetLicense returns a single licence template by name. func (s *MiscService) GetLicense(ctx context.Context, name string) (*types.LicenseTemplateInfo, error) { - path := core.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 @@ -60,7 +64,7 @@ func (s *MiscService) ListGitignoreTemplates(ctx context.Context) ([]string, err // GetGitignoreTemplate returns a single gitignore template by name. func (s *MiscService) GetGitignoreTemplate(ctx context.Context, name string) (*types.GitignoreTemplateInfo, error) { - path := core.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 diff --git a/misc_test.go b/misc_test.go index 25cf7f4..c0e845a 100644 --- a/misc_test.go +++ b/misc_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 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 +44,7 @@ func TestMiscService_Good_RenderMarkdown(t *testing.T) { } } -func TestMiscService_Good_GetVersion(t *testing.T) { +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 +68,7 @@ func TestMiscService_Good_GetVersion(t *testing.T) { } } -func TestMiscService_Good_ListLicenses(t *testing.T) { +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 +99,7 @@ func TestMiscService_Good_ListLicenses(t *testing.T) { } } -func TestMiscService_Good_GetLicense(t *testing.T) { +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 +128,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 +153,7 @@ func TestMiscService_Good_ListGitignoreTemplates(t *testing.T) { } } -func TestMiscService_Good_GetGitignoreTemplate(t *testing.T) { +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 +178,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 +209,7 @@ func TestMiscService_Good_GetNodeInfo(t *testing.T) { } } -func TestMiscService_Bad_NotFound(t *testing.T) { +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 465fc3b..d053bbc 100644 --- a/notifications.go +++ b/notifications.go @@ -4,12 +4,16 @@ import ( "context" "iter" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // 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 } @@ -30,13 +34,13 @@ func (s *NotificationService) Iter(ctx context.Context) iter.Seq2[types.Notifica // ListRepo returns all notifications for a specific repository. func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error) { - path := core.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) return ListAll[types.NotificationThread](ctx, s.client, path, nil) } // 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 := core.Sprintf("/api/v1/repos/%s/%s/notifications", owner, repo) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) return ListIter[types.NotificationThread](ctx, s.client, path, nil) } @@ -47,7 +51,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 := core.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 +61,6 @@ 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 := core.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) } diff --git a/notifications_test.go b/notifications_test.go index a497451..69f1fd2 100644 --- a/notifications_test.go +++ b/notifications_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 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 +45,7 @@ func TestNotificationService_Good_List(t *testing.T) { } } -func TestNotificationService_Good_ListRepo(t *testing.T) { +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 +73,7 @@ func TestNotificationService_Good_ListRepo(t *testing.T) { } } -func TestNotificationService_Good_GetThread(t *testing.T) { +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 +107,7 @@ func TestNotificationService_Good_GetThread(t *testing.T) { } } -func TestNotificationService_Good_MarkRead(t *testing.T) { +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 +126,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 +145,7 @@ func TestNotificationService_Good_MarkThreadRead(t *testing.T) { } } -func TestNotificationService_Bad_NotFound(t *testing.T) { +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 18fa762..8001e86 100644 --- a/orgs.go +++ b/orgs.go @@ -4,11 +4,15 @@ import ( "context" "iter" - core "dappco.re/go/core" "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] } @@ -23,37 +27,37 @@ func newOrgService(c *Client) *OrgService { // ListMembers returns all members of an organisation. func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error) { - path := core.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 := core.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 := core.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 := core.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) } // ListUserOrgs returns all organisations for a user. func (s *OrgService) ListUserOrgs(ctx context.Context, username string) ([]types.Organization, error) { - path := core.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 := core.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) } diff --git a/orgs_test.go b/orgs_test.go index bec5198..04dbdb3 100644 --- a/orgs_test.go +++ b/orgs_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 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 +36,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 +58,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) diff --git a/packages.go b/packages.go index bade2e3..2553349 100644 --- a/packages.go +++ b/packages.go @@ -4,12 +4,16 @@ import ( "context" "iter" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // 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 := core.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 := core.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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}", pathParams("owner", owner, "pkgType", 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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}", pathParams("owner", owner, "pkgType", 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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}/files", pathParams("owner", owner, "pkgType", 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 := core.Sprintf("/api/v1/packages/%s/%s/%s/%s/files", owner, pkgType, name, version) + path := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}/files", pathParams("owner", owner, "pkgType", 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..3c2c468 100644 --- a/pagination.go +++ b/pagination.go @@ -7,19 +7,34 @@ import ( "net/url" "strconv" - coreerr "dappco.re/go/core/log" + core "dappco.re/go/core" ) // 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. +// +// Usage: +// +// page, err := forge.ListPage[types.Repository](ctx, client, path, nil, forge.DefaultList) +// _ = page var DefaultList = ListOptions{Page: 1, Limit: 50} // 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 @@ -29,6 +44,11 @@ type PagedResult[T any] struct { // 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 @@ -39,7 +59,7 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri 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,6 +90,11 @@ 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 @@ -90,6 +115,12 @@ 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 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 a6306d5..9646e97 100644 --- a/params.go +++ b/params.go @@ -8,9 +8,19 @@ import ( // 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 // 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 = core.Replace(path, "{"+k+"}", url.PathEscape(v)) 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 3530884..e9d52ee 100644 --- a/pulls.go +++ b/pulls.go @@ -4,11 +4,15 @@ import ( "context" "iter" - 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] } @@ -23,32 +27,32 @@ func newPullService(c *Client) *PullService { // 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 := core.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) } // Update updates a pull request branch with the base branch. func (s *PullService) Update(ctx context.Context, owner, repo string, index int64) error { - path := core.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) } // ListReviews returns all reviews on a pull request. func (s *PullService) ListReviews(ctx context.Context, owner, repo string, index int64) ([]types.PullReview, error) { - path := core.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 := core.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) } // 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 := core.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))) var out types.PullReview if err := s.client.Post(ctx, path, review, &out); err != nil { return nil, err @@ -58,13 +62,13 @@ func (s *PullService) SubmitReview(ctx context.Context, owner, repo string, inde // DismissReview dismisses a pull request review. func (s *PullService) DismissReview(ctx context.Context, owner, repo string, index, reviewID int64, msg string) error { - path := core.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/{reviewID}/dismissals", pathParams("owner", owner, "repo", repo, "index", int64String(index), "reviewID", 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 := core.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/{reviewID}/undismissals", pathParams("owner", owner, "repo", repo, "index", int64String(index), "reviewID", int64String(reviewID))) return s.client.Post(ctx, path, nil, nil) } diff --git a/pulls_test.go b/pulls_test.go index b88ba22..6249eb0 100644 --- a/pulls_test.go +++ b/pulls_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 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) @@ -41,7 +41,7 @@ func TestPullService_Good_List(t *testing.T) { } } -func TestPullService_Good_Get(t *testing.T) { +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) @@ -63,7 +63,7 @@ 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) @@ -94,7 +94,7 @@ func TestPullService_Good_Create(t *testing.T) { } } -func TestPullService_Good_Merge(t *testing.T) { +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) @@ -118,7 +118,7 @@ func TestPullService_Good_Merge(t *testing.T) { } } -func TestPullService_Bad_Merge(t *testing.T) { +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"}) diff --git a/releases.go b/releases.go index 3e8a227..ed3a841 100644 --- a/releases.go +++ b/releases.go @@ -4,11 +4,15 @@ import ( "context" "iter" - core "dappco.re/go/core" "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] } @@ -23,7 +27,7 @@ func newReleaseService(c *Client) *ReleaseService { // GetByTag returns a release by its tag name. func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) (*types.Release, error) { - path := core.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 @@ -33,25 +37,25 @@ 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 := core.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 := core.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID))) return ListAll[types.Attachment](ctx, s.client, path, nil) } // 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 := core.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", 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 := core.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 +65,6 @@ 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 := core.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) } diff --git a/releases_test.go b/releases_test.go index 1520fd2..c0b22c3 100644 --- a/releases_test.go +++ b/releases_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 TestReleaseService_Good_List(t *testing.T) { +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 +36,7 @@ func TestReleaseService_Good_List(t *testing.T) { } } -func TestReleaseService_Good_Get(t *testing.T) { +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 +61,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) diff --git a/repos.go b/repos.go index 13a9f0b..1f31917 100644 --- a/repos.go +++ b/repos.go @@ -8,6 +8,11 @@ import ( ) // 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] } diff --git a/resource.go b/resource.go index cfd68b4..f6fb521 100644 --- a/resource.go +++ b/resource.go @@ -9,6 +9,11 @@ import ( // 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 // item path: /api/v1/repos/{owner}/{repo}/issues/{index} @@ -18,15 +23,19 @@ type Resource[T any, C any, U any] struct { // 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. +// +// 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] { collection := path // Strip last segment if it's a pure placeholder like /{index} // Don't strip if mixed like /repos or /{org}/repos - parts := core.Split(path, "/") - if len(parts) > 0 { - lastSeg := parts[len(parts)-1] + if i := lastIndexByte(path, '/'); i >= 0 { + lastSeg := path[i+1:] if core.HasPrefix(lastSeg, "{") && core.HasSuffix(lastSeg, "}") { - collection = path[:len(path)-len(lastSeg)-1] + collection = path[:i] } } return &Resource[T, C, U]{client: c, path: path, collection: collection} diff --git a/resource_test.go b/resource_test.go index 6d0d560..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,7 +65,7 @@ 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) @@ -94,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) @@ -116,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) @@ -134,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++ @@ -159,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++ @@ -190,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"}) @@ -212,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/teams.go b/teams.go index b1e1bf3..862584b 100644 --- a/teams.go +++ b/teams.go @@ -4,11 +4,15 @@ import ( "context" "iter" - core "dappco.re/go/core" "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] } @@ -23,60 +27,60 @@ func newTeamService(c *Client) *TeamService { // ListMembers returns all members of a team. func (s *TeamService) ListMembers(ctx context.Context, teamID int64) ([]types.User, error) { - path := core.Sprintf("/api/v1/teams/%d/members", teamID) + path := ResolvePath("/api/v1/teams/{teamID}/members", pathParams("teamID", 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 := core.Sprintf("/api/v1/teams/%d/members", teamID) + path := ResolvePath("/api/v1/teams/{teamID}/members", pathParams("teamID", 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 := core.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + path := ResolvePath("/api/v1/teams/{teamID}/members/{username}", pathParams("teamID", int64String(teamID), "username", username)) return s.client.Put(ctx, path, nil, nil) } // RemoveMember removes a user from a team. func (s *TeamService) RemoveMember(ctx context.Context, teamID int64, username string) error { - path := core.Sprintf("/api/v1/teams/%d/members/%s", teamID, username) + path := ResolvePath("/api/v1/teams/{teamID}/members/{username}", pathParams("teamID", 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 := core.Sprintf("/api/v1/teams/%d/repos", teamID) + path := ResolvePath("/api/v1/teams/{teamID}/repos", pathParams("teamID", 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 := core.Sprintf("/api/v1/teams/%d/repos", teamID) + path := ResolvePath("/api/v1/teams/{teamID}/repos", pathParams("teamID", 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 := core.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + path := ResolvePath("/api/v1/teams/{teamID}/repos/{org}/{repo}", pathParams("teamID", 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 := core.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, org, repo) + path := ResolvePath("/api/v1/teams/{teamID}/repos/{org}/{repo}", pathParams("teamID", int64String(teamID), "org", org, "repo", repo)) return s.client.Delete(ctx, path) } // ListOrgTeams returns all teams in an organisation. func (s *TeamService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { - path := core.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 := core.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) } diff --git a/teams_test.go b/teams_test.go index dc2d9f0..4844b3f 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,7 @@ func TestTeamService_Good_Get(t *testing.T) { } } -func TestTeamService_Good_ListMembers(t *testing.T) { +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 +61,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) diff --git a/users.go b/users.go index bc55a2a..0918d04 100644 --- a/users.go +++ b/users.go @@ -4,11 +4,15 @@ import ( "context" "iter" - 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{}] } @@ -32,60 +36,60 @@ func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { - path := core.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 := core.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) } // ListFollowing returns all users that a user is following. func (s *UserService) ListFollowing(ctx context.Context, username string) ([]types.User, error) { - path := core.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 := core.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) } // Follow follows a user as the authenticated user. func (s *UserService) Follow(ctx context.Context, username string) error { - path := core.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) } // Unfollow unfollows a user as the authenticated user. func (s *UserService) Unfollow(ctx context.Context, username string) error { - path := core.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 := core.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 := core.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) } // Star stars a repository as the authenticated user. func (s *UserService) Star(ctx context.Context, owner, repo string) error { - path := core.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 := core.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) } diff --git a/users_test.go b/users_test.go index 015f7f8..bfda464 100644 --- a/users_test.go +++ b/users_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 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 +32,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 +54,7 @@ func TestUserService_Good_GetCurrent(t *testing.T) { } } -func TestUserService_Good_ListFollowers(t *testing.T) { +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) diff --git a/webhooks.go b/webhooks.go index eaa51d0..1cc8894 100644 --- a/webhooks.go +++ b/webhooks.go @@ -4,12 +4,16 @@ import ( "context" "iter" - core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) // 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] } @@ -24,18 +28,18 @@ func newWebhookService(c *Client) *WebhookService { // TestHook triggers a test delivery for a webhook. func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error { - path := core.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) } // ListOrgHooks returns all webhooks for an organisation. func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types.Hook, error) { - path := core.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 := core.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) } diff --git a/webhooks_test.go b/webhooks_test.go index 4098061..b6a973e 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,7 @@ func TestWebhookService_Good_TestHook(t *testing.T) { } } -func TestWebhookService_Good_ListOrgHooks(t *testing.T) { +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 +155,7 @@ func TestWebhookService_Good_ListOrgHooks(t *testing.T) { } } -func TestWebhookService_Bad_NotFound(t *testing.T) { +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 7681ada..5a5e039 100644 --- a/wiki.go +++ b/wiki.go @@ -3,12 +3,16 @@ package forge import ( "context" - core "dappco.re/go/core" "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 +23,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 := core.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 @@ -29,7 +33,7 @@ func (s *WikiService) ListPages(ctx context.Context, owner, repo string) ([]type // GetPage returns a single wiki page by name. func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) (*types.WikiPage, error) { - path := core.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 @@ -39,7 +43,7 @@ func (s *WikiService) GetPage(ctx context.Context, owner, repo, pageName string) // CreatePage creates a new wiki page. func (s *WikiService) CreatePage(ctx context.Context, owner, repo string, opts *types.CreateWikiPageOptions) (*types.WikiPage, error) { - path := core.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 +53,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 := core.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 +63,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 := core.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..2a35728 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,7 @@ func TestWikiService_Good_ListPages(t *testing.T) { } } -func TestWikiService_Good_GetPage(t *testing.T) { +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 +74,7 @@ func TestWikiService_Good_GetPage(t *testing.T) { } } -func TestWikiService_Good_CreatePage(t *testing.T) { +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 +118,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 +151,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 +170,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"}) From 2708eef3596f69feb2ada2cc095ed3e663512317 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 00:03:05 +0000 Subject: [PATCH 015/181] docs: correct dependency overview Co-Authored-By: Virgil --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 18e5d58..d2f6ae3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -142,7 +142,7 @@ Services that embed `Resource[T, C, U]` inherit `List`, `ListAll`, `Iter`, `Get` ## 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 From bfb8cf60349da775c4d109ec01c0ee7aac4facef Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 01:07:12 +0000 Subject: [PATCH 016/181] refactor(ax): harden forgegen error flow and sync dev guide Co-Authored-By: Virgil --- cmd/forgegen/main.go | 19 ++++++++++++++----- cmd/forgegen/main_test.go | 40 +++++++++++++++++++++++++++++++++++++++ docs/development.md | 16 ++++++++-------- 3 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 cmd/forgegen/main_test.go diff --git a/cmd/forgegen/main.go b/cmd/forgegen/main.go index 469f626..08f95a3 100644 --- a/cmd/forgegen/main.go +++ b/cmd/forgegen/main.go @@ -2,6 +2,7 @@ package main import ( "flag" + "os" core "dappco.re/go/core" ) @@ -11,18 +12,26 @@ func main() { outDir := flag.String("out", "types", "output directory for generated types") flag.Parse() - spec, err := LoadSpec(*specPath) + 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 { - panic(core.E("forgegen.main", "load spec", err)) + return core.E("forgegen.main", "load spec", err) } types := ExtractTypes(spec) pairs := DetectCRUDPairs(spec) core.Print(nil, "Loaded %d types, %d CRUD pairs", len(types), len(pairs)) - core.Print(nil, "Output dir: %s", *outDir) + core.Print(nil, "Output dir: %s", outDir) - if err := Generate(types, pairs, *outDir); err != nil { - panic(core.E("forgegen.main", "generate types", err)) + 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/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) } ``` From 725fce93e7474187002e445dbdbccb88e9ba41df Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 06:41:21 +0000 Subject: [PATCH 017/181] test: add milestone service coverage Co-Authored-By: Virgil --- milestones_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 milestones_test.go diff --git a/milestones_test.go b/milestones_test.go new file mode 100644 index 0000000..f5e5a21 --- /dev/null +++ b/milestones_test.go @@ -0,0 +1,99 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +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") + } +} From 85fde21694a192d0b8a8d0511755ae876cf5d02c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 05:21:24 +0000 Subject: [PATCH 018/181] Add authenticated user email management Co-Authored-By: Virgil --- users.go | 24 ++++++++++++++ users_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/users.go b/users.go index 0918d04..ba655c2 100644 --- a/users.go +++ b/users.go @@ -34,6 +34,30 @@ func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { return &out, 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}) +} + // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { path := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) diff --git a/users_test.go b/users_test.go index bfda464..d5a5f0f 100644 --- a/users_test.go +++ b/users_test.go @@ -54,6 +54,96 @@ func TestUserService_GetCurrent_Good(t *testing.T) { } } +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_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_ListFollowers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From ef44e7ada2498b5fdbc1ffa8dc152482c8dcf5fc Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 05:37:02 +0000 Subject: [PATCH 019/181] Add milestone edit and delete APIs Co-Authored-By: Virgil --- milestones.go | 16 ++++++++++++++ milestones_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/milestones.go b/milestones.go index 84c3365..d46ea3e 100644 --- a/milestones.go +++ b/milestones.go @@ -45,3 +45,19 @@ func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts } 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) +} diff --git a/milestones_test.go b/milestones_test.go index f5e5a21..04f0b9e 100644 --- a/milestones_test.go +++ b/milestones_test.go @@ -97,3 +97,56 @@ func TestMilestoneService_Create_Good(t *testing.T) { 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) + } +} From 7106861c3bf685ab7ddf633741769e3afbe656a8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:00:14 +0000 Subject: [PATCH 020/181] feat: add raw markdown rendering Co-Authored-By: Virgil --- client.go | 37 +++++++++++++++++++++++++++++++++++++ docs/api-contract.md | 1 + misc.go | 10 ++++++++++ misc_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/client.go b/client.go index 8b4ca90..fc1109c 100644 --- a/client.go +++ b/client.go @@ -183,6 +183,10 @@ func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) erro // response body as bytes instead of JSON-decoding. Useful for endpoints // such as /markdown that return raw HTML text. 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 @@ -223,6 +227,39 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er 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() + + if resp.StatusCode >= 400 { + return nil, c.parseError(resp, path) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, core.E("Client.PostText", "forge: read response body", err) + } + + return data, 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. func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { diff --git a/docs/api-contract.md b/docs/api-contract.md index fb0f51e..c31acad 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -137,6 +137,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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. | diff --git a/misc.go b/misc.go index 77243c2..fc264d2 100644 --- a/misc.go +++ b/misc.go @@ -34,6 +34,16 @@ func (s *MiscService) RenderMarkdown(ctx context.Context, text, mode string) (st 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 diff --git a/misc_test.go b/misc_test.go index c0e845a..521ab82 100644 --- a/misc_test.go +++ b/misc_test.go @@ -3,8 +3,10 @@ package forge import ( "context" json "github.com/goccy/go-json" + "io" "net/http" "net/http/httptest" + "strings" "testing" "dappco.re/go/core/forge/types" @@ -44,6 +46,43 @@ func TestMiscService_RenderMarkdown_Good(t *testing.T) { } } +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("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) + } +} + func TestMiscService_GetVersion_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 80f69121d6a50227fcd37a1d82c0a43882927903 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:12:51 +0000 Subject: [PATCH 021/181] Add user stopwatch listing Co-Authored-By: Virgil --- docs/api-contract.md | 2 ++ users.go | 10 ++++++++ users_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index c31acad..9670fef 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -200,6 +200,8 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.GetCurrent | `func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error)` | GetCurrent returns the authenticated user. | `TestUserService_Good_GetCurrent` | +| 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. | diff --git a/users.go b/users.go index ba655c2..f616ff5 100644 --- a/users.go +++ b/users.go @@ -58,6 +58,16 @@ func (s *UserService) DeleteEmails(ctx context.Context, emails ...string) error return s.client.DeleteWithBody(ctx, "/api/v1/user/emails", types.DeleteEmailOption{Emails: emails}) } +// 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) +} + // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { path := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) diff --git a/users_test.go b/users_test.go index d5a5f0f..18eed62 100644 --- a/users_test.go +++ b/users_test.go @@ -83,6 +83,66 @@ func TestUserService_ListEmails_Good(t *testing.T) { } } +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_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 { From 060de1796c079e4c616090d026f54ee0e0c71e56 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:25:32 +0000 Subject: [PATCH 022/181] Add issue stopwatch delete API Co-Authored-By: Virgil --- docs/api-contract.md | 1 + issues.go | 6 ++++++ issues_test.go | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 9670fef..45f2332 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -109,6 +109,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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. | diff --git a/issues.go b/issues.go index 15306a4..c9d81de 100644 --- a/issues.go +++ b/issues.go @@ -70,6 +70,12 @@ func (s *IssueService) StopStopwatch(ctx context.Context, owner, repo string, in 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) +} + // AddLabels adds labels to an issue. func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index int64, labelIDs []int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index a00fff0..7178160 100644 --- a/issues_test.go +++ b/issues_test.go @@ -187,6 +187,24 @@ func TestIssueService_Pin_Good(t *testing.T) { } } +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_List_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) From 891e82237f24645b3fbba762493a3ce50435313b Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:33:51 +0000 Subject: [PATCH 023/181] Add repo archive download helper Co-Authored-By: Virgil --- docs/api-contract.md | 1 + forge_test.go | 27 +++++++++++++++++++++++++++ repos.go | 6 ++++++ 3 files changed, 34 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 45f2332..f44c770 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -175,6 +175,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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` | diff --git a/forge_test.go b/forge_test.go index 2ed0b2e..640874b 100644 --- a/forge_test.go +++ b/forge_test.go @@ -1,6 +1,7 @@ package forge import ( + "bytes" "context" json "github.com/goccy/go-json" "net/http" @@ -157,3 +158,29 @@ func TestRepoService_Fork_Good(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) + } +} diff --git a/repos.go b/repos.go index 1f31917..fc0af38 100644 --- a/repos.go +++ b/repos.go @@ -45,6 +45,12 @@ func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Reposit return ListIter[types.Repository](ctx, s.client, "/api/v1/user/repos", 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) +} + // Fork forks a repository. If org is non-empty, forks into that organisation. func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error) { body := map[string]string{} From b9e8b22d41d9d524b3a959e94a6b9732af2a2dd9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:42:16 +0000 Subject: [PATCH 024/181] Add issue pin repositioning Co-Authored-By: Virgil --- issues.go | 6 ++++++ issues_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/issues.go b/issues.go index c9d81de..daacbf4 100644 --- a/issues.go +++ b/issues.go @@ -31,6 +31,12 @@ func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) 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) +} + // Unpin unpins an issue. func (s *IssueService) Unpin(ctx context.Context, owner, repo string, index int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index 7178160..42955a3 100644 --- a/issues_test.go +++ b/issues_test.go @@ -187,6 +187,24 @@ func TestIssueService_Pin_Good(t *testing.T) { } } +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_DeleteStopwatch_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { From 5326ecefb610b8584618e17c56dfdd29d2da91a1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:47:52 +0000 Subject: [PATCH 025/181] Add repository tag listing Co-Authored-By: Virgil --- forge_test.go | 25 +++++++++++++++++++++++++ repos.go | 12 ++++++++++++ 2 files changed, 37 insertions(+) diff --git a/forge_test.go b/forge_test.go index 640874b..8a4421e 100644 --- a/forge_test.go +++ b/forge_test.go @@ -184,3 +184,28 @@ func TestRepoService_GetArchive_Good(t *testing.T) { 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/repos.go b/repos.go index fc0af38..a3223b1 100644 --- a/repos.go +++ b/repos.go @@ -45,6 +45,18 @@ func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Reposit return ListIter[types.Repository](ctx, s.client, "/api/v1/user/repos", 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) +} + // 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)) From 6ce4d5ede39960457f5476e5d27fe789c4f5142c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 06:55:15 +0000 Subject: [PATCH 026/181] feat: add misc iterators Co-Authored-By: Virgil --- misc.go | 33 +++++++++++++++++++++++++++++ misc_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/misc.go b/misc.go index fc264d2..df307c9 100644 --- a/misc.go +++ b/misc.go @@ -2,6 +2,7 @@ package forge import ( "context" + "iter" "dappco.re/go/core/forge/types" ) @@ -53,6 +54,22 @@ 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 := ResolvePath("/api/v1/licenses/{name}", pathParams("name", name)) @@ -72,6 +89,22 @@ 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 := ResolvePath("/api/v1/gitignore/templates/{name}", pathParams("name", name)) diff --git a/misc_test.go b/misc_test.go index 521ab82..791860d 100644 --- a/misc_test.go +++ b/misc_test.go @@ -138,6 +138,37 @@ func TestMiscService_ListLicenses_Good(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 { @@ -192,6 +223,34 @@ func TestMiscService_ListGitignoreTemplates_Good(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 { From 042e886b2f211f628d65b268bee68d70f995d2a6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:01:59 +0000 Subject: [PATCH 027/181] feat: add wiki iter pages Co-Authored-By: Virgil --- wiki.go | 17 +++++++++++++++++ wiki_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/wiki.go b/wiki.go index 5a5e039..ce9ce3a 100644 --- a/wiki.go +++ b/wiki.go @@ -2,6 +2,7 @@ package forge import ( "context" + "iter" "dappco.re/go/core/forge/types" ) @@ -31,6 +32,22 @@ 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/page/{pageName}", pathParams("owner", owner, "repo", repo, "pageName", pageName)) diff --git a/wiki_test.go b/wiki_test.go index 2a35728..2de0e29 100644 --- a/wiki_test.go +++ b/wiki_test.go @@ -41,6 +41,37 @@ func TestWikiService_ListPages_Good(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 { From 2ad117dcc05c4a8dd6b1ccd705a2355bdd7de8b4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:09:12 +0000 Subject: [PATCH 028/181] feat: add org webhook CRUD Co-Authored-By: Virgil --- webhooks.go | 36 +++++++++++++ webhooks_test.go | 128 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/webhooks.go b/webhooks.go index 1cc8894..f057229 100644 --- a/webhooks.go +++ b/webhooks.go @@ -43,3 +43,39 @@ func (s *WebhookService) IterOrgHooks(ctx context.Context, org string) iter.Seq2 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_test.go b/webhooks_test.go index b6a973e..4e0ce05 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -155,6 +155,134 @@ func TestWebhookService_ListOrgHooks_Good(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) From 2f1809818591f4934cf10c165d1f358611a686d4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:16:48 +0000 Subject: [PATCH 029/181] feat: add markup rendering endpoint Co-Authored-By: Virgil --- docs/api-contract.md | 1 + misc.go | 11 +++++++++++ misc_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index f44c770..487d9c8 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -138,6 +138,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/misc.go b/misc.go index df307c9..a2ebda4 100644 --- a/misc.go +++ b/misc.go @@ -35,6 +35,17 @@ 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) { diff --git a/misc_test.go b/misc_test.go index 791860d..bf2fe2b 100644 --- a/misc_test.go +++ b/misc_test.go @@ -46,6 +46,40 @@ func TestMiscService_RenderMarkdown_Good(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 { From fd621a4517edbf383d9a87fe550f182912682b7d Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:25:29 +0000 Subject: [PATCH 030/181] Add repository topics support Co-Authored-By: Virgil --- repos.go | 16 +++++++++++++ repos_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 repos_test.go diff --git a/repos.go b/repos.go index a3223b1..a7abbc5 100644 --- a/repos.go +++ b/repos.go @@ -63,6 +63,22 @@ func (s *RepoService) GetArchive(ctx context.Context, owner, repo, archive strin return s.client.GetRaw(ctx, path) } +// 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 +} + +// 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) +} + // Fork forks a repository. If org is non-empty, forks into that organisation. func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error) { body := map[string]string{} diff --git a/repos_test.go b/repos_test.go new file mode 100644 index 0000000..b2d1d89 --- /dev/null +++ b/repos_test.go @@ -0,0 +1,65 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "dappco.re/go/core/forge/types" +) + +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_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) + } +} From 419e622659d3daf76c16753c4efdf9fce329381c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:15:33 +0000 Subject: [PATCH 031/181] fix: URL-escape repository path params --- repos.go | 21 ++++++++++++++------- repos_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/repos.go b/repos.go index a7abbc5..12672c7 100644 --- a/repos.go +++ b/repos.go @@ -27,12 +27,14 @@ func newRepoService(c *Client) *RepoService { // 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. @@ -86,7 +88,8 @@ func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types body["organization"] = org } var out types.Repository - err := s.client.Post(ctx, "/api/v1/repos/"+owner+"/"+repo+"/forks", body, &out) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/forks", pathParams("owner", owner, "repo", repo)) + err := s.client.Post(ctx, path, body, &out) if err != nil { return nil, err } @@ -95,20 +98,24 @@ func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types // 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) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer", pathParams("owner", owner, "repo", repo)) + return s.client.Post(ctx, path, opts, 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) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer/accept", pathParams("owner", owner, "repo", repo)) + return s.client.Post(ctx, path, nil, 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) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer/reject", pathParams("owner", owner, "repo", repo)) + return s.client.Post(ctx, path, nil, 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) } diff --git a/repos_test.go b/repos_test.go index b2d1d89..3ba3b8e 100644 --- a/repos_test.go +++ b/repos_test.go @@ -63,3 +63,45 @@ func TestRepoService_UpdateTopics_Good(t *testing.T) { t.Fatal(err) } } + +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.Path != "/api/v1/orgs/team%20alpha/repos" { + t.Errorf("got path %q, want %q", r.URL.Path, "/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("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.Path != want { + t.Errorf("got path %q, want %q", r.URL.Path, want) + http.NotFound(w, r) + return + } + 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) + } + }) +} From 4ef2c273e537121d146cd8e696831f8c54ce6155 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:47:49 +0000 Subject: [PATCH 032/181] feat(repo): add new pin allowance getter Co-Authored-By: Virgil --- docs/api-contract.md | 1 + repos.go | 10 ++++++++++ repos_test.go | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index 487d9c8..12006ef 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -177,6 +177,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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` | diff --git a/repos.go b/repos.go index 12672c7..d78f407 100644 --- a/repos.go +++ b/repos.go @@ -81,6 +81,16 @@ func (s *RepoService) UpdateTopics(ctx context.Context, owner, repo string, topi return s.client.Put(ctx, path, types.RepoTopicOptions{Topics: topics}, nil) } +// 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 +} + // Fork forks a repository. If org is non-empty, forks into that organisation. func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error) { body := map[string]string{} diff --git a/repos_test.go b/repos_test.go index 3ba3b8e..cb37136 100644 --- a/repos_test.go +++ b/repos_test.go @@ -64,6 +64,33 @@ func TestRepoService_UpdateTopics_Good(t *testing.T) { } } +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_PathParamsAreEscaped_Good(t *testing.T) { owner := "acme org" repo := "my/repo" @@ -71,8 +98,8 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { t.Run("ListOrgRepos", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/orgs/team%20alpha/repos" { - t.Errorf("got path %q, want %q", r.URL.Path, "/api/v1/orgs/team%20alpha/repos") + 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 } @@ -90,8 +117,8 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { 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.Path != want { - t.Errorf("got path %q, want %q", r.URL.Path, want) + if r.URL.EscapedPath() != want { + t.Errorf("got path %q, want %q", r.URL.EscapedPath(), want) http.NotFound(w, r) return } From 8e238c79dedbc5331ecb36c1b579b4739b659cd9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:53:33 +0000 Subject: [PATCH 033/181] feat(repo): add subscription watch helpers Co-Authored-By: Virgil --- repos.go | 26 ++++++++++++++++++ repos_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/repos.go b/repos.go index d78f407..a3e39bf 100644 --- a/repos.go +++ b/repos.go @@ -91,6 +91,32 @@ func (s *RepoService) GetNewPinAllowed(ctx context.Context, owner, repo string) return &out, nil } +// 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. If org is non-empty, forks into that organisation. func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types.Repository, error) { body := map[string]string{} diff --git a/repos_test.go b/repos_test.go index cb37136..5119c78 100644 --- a/repos_test.go +++ b/repos_test.go @@ -91,6 +91,80 @@ func TestRepoService_GetNewPinAllowed_Good(t *testing.T) { } } +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_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_PathParamsAreEscaped_Good(t *testing.T) { owner := "acme org" repo := "my/repo" From c1f3ff5bca89899112433432d6f995b64eea1393 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 09:10:05 +0000 Subject: [PATCH 034/181] feat(milestones): add list and iterator helpers Co-Authored-By: Virgil --- milestones.go | 13 ++++++++ milestones_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/milestones.go b/milestones.go index d46ea3e..f5df222 100644 --- a/milestones.go +++ b/milestones.go @@ -2,6 +2,7 @@ package forge import ( "context" + "iter" "dappco.re/go/core/forge/types" ) @@ -20,6 +21,18 @@ 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) (*PagedResult[types.Milestone], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) + return ListPage[types.Milestone](ctx, s.client, path, nil, opts) +} + +// Iter returns an iterator over all milestones for a repository. +func (s *MilestoneService) Iter(ctx context.Context, params Params) iter.Seq2[types.Milestone, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) + return ListIter[types.Milestone](ctx, s.client, path, nil) +} + // ListAll returns all milestones for a repository. func (s *MilestoneService) ListAll(ctx context.Context, params Params) ([]types.Milestone, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) diff --git a/milestones_test.go b/milestones_test.go index 04f0b9e..f225d57 100644 --- a/milestones_test.go +++ b/milestones_test.go @@ -5,11 +5,92 @@ import ( 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_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 { From 5257002ec13cc9b960d1ec700452b8b0dc5dc0cb Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 09:29:29 +0000 Subject: [PATCH 035/181] feat(issue): add issue subscription endpoints Co-Authored-By: Virgil --- issues.go | 34 ++++++++++++++ issues_test.go | 121 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/issues.go b/issues.go index daacbf4..098c565 100644 --- a/issues.go +++ b/issues.go @@ -118,6 +118,40 @@ func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, in return &out, nil } +// 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) +} + // toAnySlice converts a slice of int64 to a slice of any for IssueLabelsOption. func toAnySlice(ids []int64) []any { out := make([]any, len(ids)) diff --git a/issues_test.go b/issues_test.go index 42955a3..b7ada8b 100644 --- a/issues_test.go +++ b/issues_test.go @@ -5,6 +5,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "reflect" "testing" "dappco.re/go/core/forge/types" @@ -168,6 +169,126 @@ func TestIssueService_CreateComment_Good(t *testing.T) { } } +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_Pin_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { From 60a7c9420d675b9c11c821fd15e4de1474dc389e Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 09:47:43 +0000 Subject: [PATCH 036/181] Add repo stargazer and subscriber listing Co-Authored-By: Virgil --- repos.go | 24 ++++++++++++++++++++++++ repos_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/repos.go b/repos.go index a3e39bf..ba3a711 100644 --- a/repos.go +++ b/repos.go @@ -59,6 +59,30 @@ func (s *RepoService) IterTags(ctx context.Context, owner, repo string) iter.Seq return ListIter[types.Tag](ctx, s.client, path, nil) } +// 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) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index 5119c78..da73252 100644 --- a/repos_test.go +++ b/repos_test.go @@ -118,6 +118,54 @@ func TestRepoService_GetSubscription_Good(t *testing.T) { } } +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_Watch_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { From 221c5fa94f1693904f2c19f207540c1f3746c392 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:18:01 +0000 Subject: [PATCH 037/181] Add repo topic add/delete helpers Co-Authored-By: Virgil --- repos.go | 12 ++++++++++++ repos_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/repos.go b/repos.go index ba3a711..5c08fe1 100644 --- a/repos.go +++ b/repos.go @@ -105,6 +105,18 @@ func (s *RepoService) UpdateTopics(ctx context.Context, owner, repo string, topi 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) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index da73252..d8bd358 100644 --- a/repos_test.go +++ b/repos_test.go @@ -64,6 +64,46 @@ func TestRepoService_UpdateTopics_Good(t *testing.T) { } } +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_GetNewPinAllowed_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 3b1ed341e2c92697859cababe3ce199687ac99ee Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:20:28 +0000 Subject: [PATCH 038/181] feat(repo): add collaborator management helpers Co-Authored-By: Virgil --- repos.go | 47 +++++++++++++++++++ repos_test.go | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/repos.go b/repos.go index 5c08fe1..65a82e0 100644 --- a/repos.go +++ b/repos.go @@ -83,6 +83,53 @@ func (s *RepoService) IterSubscribers(ctx context.Context, owner, repo string) i 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) +} + +// 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 +} + // 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)) diff --git a/repos_test.go b/repos_test.go index d8bd358..575d35b 100644 --- a/repos_test.go +++ b/repos_test.go @@ -206,6 +206,129 @@ func TestRepoService_ListSubscribers_Good(t *testing.T) { } } +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) + } +} + func TestRepoService_Watch_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { From 3a00e9febf10fb6c573c1e043ed1e50b261a0be8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:22:57 +0000 Subject: [PATCH 039/181] feat(repo): add assignee helpers Co-Authored-By: Virgil --- repos.go | 12 ++++++++++++ repos_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/repos.go b/repos.go index 65a82e0..0c1629c 100644 --- a/repos.go +++ b/repos.go @@ -83,6 +83,18 @@ func (s *RepoService) IterSubscribers(ctx context.Context, owner, repo string) i 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)) diff --git a/repos_test.go b/repos_test.go index 575d35b..cd63ad5 100644 --- a/repos_test.go +++ b/repos_test.go @@ -206,6 +206,58 @@ func TestRepoService_ListSubscribers_Good(t *testing.T) { } } +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 { From 8ecc090a0f7de6a85d62b728fe73d9adea3c6305 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:25:41 +0000 Subject: [PATCH 040/181] Add user webhook API support Co-Authored-By: Virgil --- docs/api-contract.md | 8 ++- webhooks.go | 45 +++++++++++++ webhooks_test.go | 155 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index 12006ef..7388d0b 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -34,7 +34,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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 within a repository. Embeds Resource for standard CRUD on /api/v1/repos/{owner}/{repo}/hooks/{id}. | `TestWebhookService_Bad_NotFound`, `TestWebhookService_Good_Create`, `TestWebhookService_Good_Get` (+3 more) | +| 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` | @@ -217,6 +217,12 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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` | diff --git a/webhooks.go b/webhooks.go index f057229..0479e37 100644 --- a/webhooks.go +++ b/webhooks.go @@ -32,6 +32,51 @@ func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id in return s.client.Post(ctx, path, nil, nil) } +// 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 := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) diff --git a/webhooks_test.go b/webhooks_test.go index 4e0ce05..e36522b 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -127,6 +127,161 @@ func TestWebhookService_TestHook_Good(t *testing.T) { } } +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 { From 79bb4277b383aea061d133cf27ce7fc05bb2249e Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:27:49 +0000 Subject: [PATCH 041/181] feat(webhooks): add git hook helpers Co-Authored-By: Virgil --- docs/api-contract.md | 4 ++ webhooks.go | 32 +++++++++++++ webhooks_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 7388d0b..ae87f6d 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -217,6 +217,10 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | diff --git a/webhooks.go b/webhooks.go index 0479e37..a700ea3 100644 --- a/webhooks.go +++ b/webhooks.go @@ -32,6 +32,38 @@ func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id in 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) +} + +// 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) diff --git a/webhooks_test.go b/webhooks_test.go index e36522b..84965d0 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -127,6 +127,116 @@ func TestWebhookService_TestHook_Good(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_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 { From 99867c9d4bf60237193c3f732ea95d87a6768505 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:30:00 +0000 Subject: [PATCH 042/181] feat(repo): add avatar helpers Co-Authored-By: Virgil --- docs/api-contract.md | 2 ++ repos.go | 12 +++++++++++ repos_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index ae87f6d..13bb8dc 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -184,6 +184,8 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | diff --git a/repos.go b/repos.go index 0c1629c..105198f 100644 --- a/repos.go +++ b/repos.go @@ -186,6 +186,18 @@ func (s *RepoService) GetNewPinAllowed(ctx context.Context, owner, repo string) return &out, 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) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index cd63ad5..df59491 100644 --- a/repos_test.go +++ b/repos_test.go @@ -131,6 +131,55 @@ func TestRepoService_GetNewPinAllowed_Good(t *testing.T) { } } +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_GetSubscription_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 4af621e58080b8eb0395bb48cef142dee352c643 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:32:51 +0000 Subject: [PATCH 043/181] feat(repo): add push mirror helpers Co-Authored-By: Virgil --- repos.go | 44 ++++++++++++++++ repos_test.go | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/repos.go b/repos.go index 105198f..0081581 100644 --- a/repos.go +++ b/repos.go @@ -198,6 +198,44 @@ func (s *RepoService) DeleteAvatar(ctx context.Context, owner, repo string) erro 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)) @@ -262,3 +300,9 @@ func (s *RepoService) MirrorSync(ctx context.Context, owner, repo string) error path := ResolvePath("/api/v1/repos/{owner}/{repo}/mirror-sync", pathParams("owner", owner, "repo", repo)) return s.client.Post(ctx, path, nil, 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) +} diff --git a/repos_test.go b/repos_test.go index df59491..1eca550 100644 --- a/repos_test.go +++ b/repos_test.go @@ -180,6 +180,143 @@ func TestRepoService_DeleteAvatar_Good(t *testing.T) { } } +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 { From d97b9ba48024a30a71a7f74534bae07ad6b23994 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:35:26 +0000 Subject: [PATCH 044/181] feat(repos): add issue template listing Co-Authored-By: Virgil --- repos.go | 6 ++++++ repos_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/repos.go b/repos.go index 0081581..7850746 100644 --- a/repos.go +++ b/repos.go @@ -148,6 +148,12 @@ func (s *RepoService) GetArchive(ctx context.Context, owner, repo, archive strin return s.client.GetRaw(ctx, path) } +// 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) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index 1eca550..65aae91 100644 --- a/repos_test.go +++ b/repos_test.go @@ -104,6 +104,40 @@ func TestRepoService_DeleteTopic_Good(t *testing.T) { } } +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_GetNewPinAllowed_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 717de80cac975b4e244cf8e900ff5b64cffe3009 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:37:27 +0000 Subject: [PATCH 045/181] Add user subscription listing Co-Authored-By: Virgil --- users.go | 22 ++++++++++ users_test.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/users.go b/users.go index f616ff5..9cb36f3 100644 --- a/users.go +++ b/users.go @@ -68,6 +68,16 @@ func (s *UserService) IterStopwatches(ctx context.Context) iter.Seq2[types.StopW return ListIter[types.StopWatch](ctx, s.client, "/api/v1/user/stopwatches", 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) +} + // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { path := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) @@ -80,6 +90,18 @@ func (s *UserService) IterFollowers(ctx context.Context, username string) iter.S 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 := ResolvePath("/api/v1/users/{username}/following", pathParams("username", username)) diff --git a/users_test.go b/users_test.go index 18eed62..29dc4de 100644 --- a/users_test.go +++ b/users_test.go @@ -112,6 +112,65 @@ func TestUserService_ListStopwatches_Good(t *testing.T) { } } +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 { @@ -232,3 +291,62 @@ func TestUserService_ListFollowers_Good(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) + } +} From 36181a73355da6dcd6774ee9169e10591e28e92f Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:40:32 +0000 Subject: [PATCH 046/181] Add issue timeline listing Co-Authored-By: Virgil --- docs/api-contract.md | 2 ++ issues.go | 33 +++++++++++++++++++++ issues_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 13bb8dc..a61ea72 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -113,6 +113,8 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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. | diff --git a/issues.go b/issues.go index 098c565..88d674d 100644 --- a/issues.go +++ b/issues.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "time" "dappco.re/go/core/forge/types" ) @@ -118,6 +119,38 @@ func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, in return &out, nil } +// 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))) diff --git a/issues_test.go b/issues_test.go index b7ada8b..96470ca 100644 --- a/issues_test.go +++ b/issues_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "reflect" "testing" + "time" "dappco.re/go/core/forge/types" ) @@ -169,6 +170,74 @@ func TestIssueService_CreateComment_Good(t *testing.T) { } } +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 { From 26ea87556b4cc90813be2debabc1b74887f388c9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:43:54 +0000 Subject: [PATCH 047/181] Add issue time tracking helpers Co-Authored-By: Virgil --- docs/api-contract.md | 3 ++ issues.go | 35 ++++++++++++++++ issues_test.go | 96 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index a61ea72..4a11a3e 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -109,17 +109,20 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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` | diff --git a/issues.go b/issues.go index 88d674d..e8b3d16 100644 --- a/issues.go +++ b/issues.go @@ -83,6 +83,41 @@ func (s *IssueService) DeleteStopwatch(ctx context.Context, owner, repo string, 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))) + 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 { + query = nil + } + return ListAll[types.TrackedTime](ctx, s.client, path, query) +} + +// 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) +} + // AddLabels adds labels to an issue. func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index int64, labelIDs []int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index 96470ca..39aa611 100644 --- a/issues_test.go +++ b/issues_test.go @@ -413,6 +413,102 @@ func TestIssueService_DeleteStopwatch_Good(t *testing.T) { } } +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_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_List_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) From 1569bfea615f84ba4fc4e0417a6c995fc3b44f34 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:46:14 +0000 Subject: [PATCH 048/181] Add repo raw file helper Co-Authored-By: Virgil --- forge_test.go | 26 ++++++++++++++++++++++++++ repos.go | 6 ++++++ 2 files changed, 32 insertions(+) diff --git a/forge_test.go b/forge_test.go index 8a4421e..5ed95f5 100644 --- a/forge_test.go +++ b/forge_test.go @@ -185,6 +185,32 @@ func TestRepoService_GetArchive_Good(t *testing.T) { } } +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 { diff --git a/repos.go b/repos.go index 7850746..be8d8e3 100644 --- a/repos.go +++ b/repos.go @@ -148,6 +148,12 @@ func (s *RepoService) GetArchive(ctx context.Context, owner, repo, archive strin return s.client.GetRaw(ctx, path) } +// 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) +} + // 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)) From 43e99eb0d6d7f50fa4a5e1a8fb31ef1af47e4dd7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:54:06 +0000 Subject: [PATCH 049/181] Add repo signing key lookup Co-Authored-By: Virgil --- docs/api-contract.md | 1 + repos.go | 10 ++++++++++ repos_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 4a11a3e..ecf1098 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -182,6 +182,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/repos.go b/repos.go index be8d8e3..12ba612 100644 --- a/repos.go +++ b/repos.go @@ -154,6 +154,16 @@ func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath stri return s.client.GetRaw(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)) diff --git a/repos_test.go b/repos_test.go index 65aae91..fceffda 100644 --- a/repos_test.go +++ b/repos_test.go @@ -426,6 +426,32 @@ func TestRepoService_ListSubscribers_Good(t *testing.T) { } } +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_ListAssignees_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 67aad89cc42bc119a2714dec329f091c8f54d3cb Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:56:54 +0000 Subject: [PATCH 050/181] feat: expose repository pull reviewers Co-Authored-By: Virgil --- pulls.go | 12 +++++++++ pulls_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/pulls.go b/pulls.go index e9d52ee..879b15c 100644 --- a/pulls.go +++ b/pulls.go @@ -50,6 +50,18 @@ func (s *PullService) IterReviews(ctx context.Context, owner, repo string, index return ListIter[types.PullReview](ctx, s.client, path, 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) +} + // 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/pulls_test.go b/pulls_test.go index 6249eb0..230c5d7 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -94,6 +94,77 @@ func TestPullService_Create_Good(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_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_Merge_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { From de54fa8da281cd6393ffbe28bd1380ce58056db4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:59:42 +0000 Subject: [PATCH 051/181] feat: add pull review request endpoints Co-Authored-By: Virgil --- docs/api-contract.md | 2 ++ pulls.go | 16 ++++++++++ pulls_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index ecf1098..74b50a0 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -167,9 +167,11 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/pulls.go b/pulls.go index 879b15c..b5fac00 100644 --- a/pulls.go +++ b/pulls.go @@ -62,6 +62,22 @@ func (s *PullService) IterReviewers(ctx context.Context, owner, repo string) ite 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/pulls_test.go b/pulls_test.go index 230c5d7..eadd3d8 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -165,6 +165,78 @@ func TestPullService_IterReviewers_Good(t *testing.T) { } } +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 { From 93708fd0ecf49b5276a9d42f2810110918cbee22 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:01:34 +0000 Subject: [PATCH 052/181] Add repo tag endpoints Co-Authored-By: Virgil --- repos.go | 16 ++++++++++++ repos_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/repos.go b/repos.go index 12ba612..7f53487 100644 --- a/repos.go +++ b/repos.go @@ -59,6 +59,22 @@ func (s *RepoService) IterTags(ctx context.Context, owner, repo string) iter.Seq 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) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index fceffda..5410e88 100644 --- a/repos_test.go +++ b/repos_test.go @@ -104,6 +104,77 @@ func TestRepoService_DeleteTopic_Good(t *testing.T) { } } +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_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 { From 85d4ae6cc89005d448112efe4a658d7aa557acf2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:03:31 +0000 Subject: [PATCH 053/181] feat(pulls): add scheduled auto-merge cancellation Co-Authored-By: Virgil --- pulls.go | 6 ++++++ pulls_test.go | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/pulls.go b/pulls.go index b5fac00..9d69a96 100644 --- a/pulls.go +++ b/pulls.go @@ -32,6 +32,12 @@ func (s *PullService) Merge(ctx context.Context, owner, repo string, index int64 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/update", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/pulls_test.go b/pulls_test.go index eadd3d8..762eae4 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -273,3 +273,23 @@ func TestPullService_Merge_Bad(t *testing.T) { 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) + } +} From 0a592fab7bf260c7041e3753b5f59ef2e0182557 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:05:43 +0000 Subject: [PATCH 054/181] Add repository contents listing Co-Authored-By: Virgil --- contents.go | 23 +++++++++++++++++++++++ contents_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/contents.go b/contents.go index 0fdbfc0..01d1ea2 100644 --- a/contents.go +++ b/contents.go @@ -2,6 +2,7 @@ package forge import ( "context" + "net/url" "dappco.re/go/core/forge/types" ) @@ -21,6 +22,28 @@ 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, 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 +} + // 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) diff --git a/contents_test.go b/contents_test.go index 7e03ca7..2a7966c 100644 --- a/contents_test.go +++ b/contents_test.go @@ -10,6 +10,37 @@ import ( "dappco.re/go/core/forge/types" ) +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_GetFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From e1f67f6da1ceb85bf16be5d4b309253c35835832 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:07:59 +0000 Subject: [PATCH 055/181] feat(user): add avatar endpoints --- users.go | 10 ++++++++++ users_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/users.go b/users.go index 9cb36f3..9410985 100644 --- a/users.go +++ b/users.go @@ -58,6 +58,16 @@ 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") +} + // 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) diff --git a/users_test.go b/users_test.go index 29dc4de..3ad2e90 100644 --- a/users_test.go +++ b/users_test.go @@ -263,6 +263,49 @@ func TestUserService_DeleteEmails_Good(t *testing.T) { } } +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_ListFollowers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 129c03199a06925a8ddb75ac406b1f08865f578b Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:10:21 +0000 Subject: [PATCH 056/181] Add repo tag protection endpoints Co-Authored-By: Virgil --- repos.go | 48 ++++++++++++++++ repos_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) diff --git a/repos.go b/repos.go index 7f53487..bf92243 100644 --- a/repos.go +++ b/repos.go @@ -75,6 +75,54 @@ func (s *RepoService) DeleteTag(ctx context.Context, owner, repo, tag string) er 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) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index 5410e88..99709e6 100644 --- a/repos_test.go +++ b/repos_test.go @@ -151,6 +151,159 @@ func TestRepoService_DeleteTag_Good(t *testing.T) { } } +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_DeleteTag_Bad_NotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { From 36217311c2a33bf36ed988281fa651d9ba9e3a4a Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:12:31 +0000 Subject: [PATCH 057/181] feat(repos): add fork listing helpers Co-Authored-By: Virgil --- repos.go | 12 ++++++++++++ repos_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/repos.go b/repos.go index bf92243..df61664 100644 --- a/repos.go +++ b/repos.go @@ -363,6 +363,18 @@ func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types 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 { path := ResolvePath("/api/v1/repos/{owner}/{repo}/transfer", pathParams("owner", owner, "repo", repo)) diff --git a/repos_test.go b/repos_test.go index 99709e6..558da00 100644 --- a/repos_test.go +++ b/repos_test.go @@ -151,6 +151,34 @@ func TestRepoService_DeleteTag_Good(t *testing.T) { } } +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 { From 6116316bead8986422f5fa1021f98b9dc10c3820 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:15:13 +0000 Subject: [PATCH 058/181] feat(users): add blocked user listing helpers Co-Authored-By: Virgil --- users.go | 10 +++++++++ users_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/users.go b/users.go index 9410985..ed37a19 100644 --- a/users.go +++ b/users.go @@ -78,6 +78,16 @@ func (s *UserService) IterStopwatches(ctx context.Context) iter.Seq2[types.StopW 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) +} + // 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) diff --git a/users_test.go b/users_test.go index 3ad2e90..a0ebdc4 100644 --- a/users_test.go +++ b/users_test.go @@ -112,6 +112,66 @@ func TestUserService_ListStopwatches_Good(t *testing.T) { } } +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_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 { From 2dd66a02105b32999925b6c2ac41966073357bc4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:17:28 +0000 Subject: [PATCH 059/181] feat(users): add block user helper Co-Authored-By: Virgil --- users.go | 6 ++++++ users_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/users.go b/users.go index ed37a19..ab52195 100644 --- a/users.go +++ b/users.go @@ -88,6 +88,12 @@ func (s *UserService) IterBlockedUsers(ctx context.Context) iter.Seq2[types.Bloc 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) +} + // 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) diff --git a/users_test.go b/users_test.go index a0ebdc4..704e231 100644 --- a/users_test.go +++ b/users_test.go @@ -141,6 +141,24 @@ func TestUserService_ListBlockedUsers_Good(t *testing.T) { } } +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_IterBlockedUsers_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 02534e398c1dab1b77135fde73a5eb28e84956f6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:20:13 +0000 Subject: [PATCH 060/181] feat(repos): add topic search helpers Co-Authored-By: Virgil --- repos.go | 10 +++++++ repos_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/repos.go b/repos.go index df61664..97cf266 100644 --- a/repos.go +++ b/repos.go @@ -244,6 +244,16 @@ func (s *RepoService) ListTopics(ctx context.Context, owner, repo string) ([]str return out.TopicNames, nil } +// 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}) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index 558da00..4dceefc 100644 --- a/repos_test.go +++ b/repos_test.go @@ -35,6 +35,84 @@ func TestRepoService_ListTopics_Good(t *testing.T) { } } +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_UpdateTopics_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { From 0ab85b1b4b80823fa8f6ef3c8d5736693e377dfb Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:23:07 +0000 Subject: [PATCH 061/181] feat(repos): add repository flags helpers Co-Authored-By: Virgil --- repos.go | 22 ++++++++++++++++ repos_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/repos.go b/repos.go index 97cf266..8540f5d 100644 --- a/repos.go +++ b/repos.go @@ -218,6 +218,28 @@ func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath stri return s.client.GetRaw(ctx, path) } +// 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 +} + +// 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)) diff --git a/repos_test.go b/repos_test.go index 4dceefc..7dd2476 100644 --- a/repos_test.go +++ b/repos_test.go @@ -782,6 +782,79 @@ func TestRepoService_GetSigningKey_Good(t *testing.T) { } } +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_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 { From b5feca20a8393deaa51270a8b3ad224e9401189f Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:26:28 +0000 Subject: [PATCH 062/181] feat(actions): add repository action tasks listing Co-Authored-By: Virgil --- actions.go | 61 ++++++++++++++++++++++++++++++++- actions_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++++ docs/api-contract.md | 4 ++- docs/index.md | 4 +-- 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/actions.go b/actions.go index 0e9a39f..a980bab 100644 --- a/actions.go +++ b/actions.go @@ -3,12 +3,14 @@ package forge import ( "context" "iter" + "net/url" + "strconv" "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: @@ -104,3 +106,60 @@ func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, work path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", pathParams("owner", owner, "repo", repo, "workflow", 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, 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 17b826b..8b4112a 100644 --- a/actions_test.go +++ b/actions_test.go @@ -244,6 +244,87 @@ func TestActionsService_DispatchWorkflow_Good(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) diff --git a/docs/api-contract.md b/docs/api-contract.md index 74b50a0..fdca9e1 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -9,7 +9,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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, and workflow dispatches. No Resource embedding — heterogeneous endpoints across repo and org levels. | `TestActionsService_Bad_NotFound`, `TestActionsService_Good_CreateRepoSecret`, `TestActionsService_Good_CreateRepoVariable` (+7 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) | @@ -56,6 +56,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | @@ -63,6 +64,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | diff --git a/docs/index.md b/docs/index.md index d2f6ae3..2f23188 100644 --- a/docs/index.md +++ b/docs/index.md @@ -92,7 +92,7 @@ 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 @@ -131,7 +131,7 @@ 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 | From 4fc0484669991ed3f53cc4626fc25b9ed1c63d39 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:28:31 +0000 Subject: [PATCH 063/181] Add git note commit APIs Co-Authored-By: Virgil --- commits.go | 16 ++++++++++++++ commits_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/commits.go b/commits.go index 8db96cc..efb1a18 100644 --- a/commits.go +++ b/commits.go @@ -92,3 +92,19 @@ func (s *CommitService) GetNote(ctx context.Context, owner, repo, sha string) (* } 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) +} diff --git a/commits_test.go b/commits_test.go index d75a20a..5d57f6d 100644 --- a/commits_test.go +++ b/commits_test.go @@ -199,6 +199,61 @@ func TestCommitService_GetNote_Good(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 { From 23d879f235def93cf4b46250887c0f8131c5f712 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:31:21 +0000 Subject: [PATCH 064/181] feat(issues): add global issue search Add typed wrappers for Forgejo's global issue search endpoint, including paged, all-pages, and iterator variants. Co-Authored-By: Virgil --- issues.go | 88 +++++++++++++++++++++++++++++ issues_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/issues.go b/issues.go index e8b3d16..d7e8a52 100644 --- a/issues.go +++ b/issues.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" "time" "dappco.re/go/core/forge/types" @@ -26,6 +27,93 @@ func newIssueService(c *Client) *IssueService { } } +// SearchIssuesOptions controls filtering for the global issue search endpoint. +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 +} + +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()) +} + // Pin pins an issue. func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index 39aa611..ebd2967 100644 --- a/issues_test.go +++ b/issues_test.go @@ -143,6 +143,154 @@ func TestIssueService_Delete_Good(t *testing.T) { } } +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 { From dd8c6ba9989ac5fc98a44f1f302becb6a4c8125e Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:33:43 +0000 Subject: [PATCH 065/181] Add team member lookup Co-Authored-By: Virgil --- teams.go | 10 ++++++++++ teams_test.go | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/teams.go b/teams.go index 862584b..c37a94a 100644 --- a/teams.go +++ b/teams.go @@ -43,6 +43,16 @@ func (s *TeamService) AddMember(ctx context.Context, teamID int64, username stri 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/{teamID}/members/{username}", pathParams("teamID", 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 := ResolvePath("/api/v1/teams/{teamID}/members/{username}", pathParams("teamID", int64String(teamID), "username", username)) diff --git a/teams_test.go b/teams_test.go index 4844b3f..b18dbc8 100644 --- a/teams_test.go +++ b/teams_test.go @@ -79,3 +79,25 @@ func TestTeamService_AddMember_Good(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") + } +} From ed4a2c7b0cbe044af880fa33d95f201c108291fa Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:35:51 +0000 Subject: [PATCH 066/181] feat(issues): add dependency relation helpers Co-Authored-By: Virgil --- issues.go | 48 +++++++++++++++ issues_test.go | 154 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/issues.go b/issues.go index d7e8a52..06635b5 100644 --- a/issues.go +++ b/issues.go @@ -308,6 +308,54 @@ func (s *IssueService) UnsubscribeUser(ctx context.Context, owner, repo string, 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)) diff --git a/issues_test.go b/issues_test.go index ebd2967..1b8c8ef 100644 --- a/issues_test.go +++ b/issues_test.go @@ -506,6 +506,160 @@ func TestIssueService_UnsubscribeUser_Good(t *testing.T) { } } +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 { From 9f140e3f4fe63a87b67dcc449b99b022eb3e2db3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:39:46 +0000 Subject: [PATCH 067/181] feat(orgs): add organisation block helpers Co-Authored-By: Virgil --- orgs.go | 38 +++++++++++++++++++++++ orgs_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/orgs.go b/orgs.go index 8001e86..6649795 100644 --- a/orgs.go +++ b/orgs.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "net/http" "dappco.re/go/core/forge/types" ) @@ -49,6 +50,43 @@ func (s *OrgService) RemoveMember(ctx context.Context, org, username string) err return s.client.Delete(ctx, path) } +// ListBlockedUsers returns all users blocked by an organisation. +func (s *OrgService) ListBlockedUsers(ctx context.Context, org string) ([]types.User, error) { + path := ResolvePath("/api/v1/orgs/{org}/blocks", pathParams("org", org)) + return ListAll[types.User](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.User, error] { + path := ResolvePath("/api/v1/orgs/{org}/blocks", pathParams("org", org)) + return ListIter[types.User](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}/blocks/{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 +} + +// Block blocks a user within an organisation. +func (s *OrgService) Block(ctx context.Context, org, username string) error { + path := ResolvePath("/api/v1/orgs/{org}/blocks/{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}/blocks/{username}", pathParams("org", org, "username", username)) + return s.client.Delete(ctx, path) +} + // ListUserOrgs returns all organisations for a user. func (s *OrgService) ListUserOrgs(ctx context.Context, username string) ([]types.Organization, error) { path := ResolvePath("/api/v1/users/{username}/orgs", pathParams("username", username)) diff --git a/orgs_test.go b/orgs_test.go index 04dbdb3..eac1653 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -86,3 +86,90 @@ func TestOrgService_ListMembers_Good(t *testing.T) { 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/blocks" { + 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") + 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].UserName != "alice" { + t.Errorf("got username=%q, want %q", blocked[0].UserName, "alice") + } +} + +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/blocks/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/blocks/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_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/blocks/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") + } +} From c1b7e25bfdf41a482d49bcbcd65c2ee1cb7eb838 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:42:31 +0000 Subject: [PATCH 068/181] Add notification unread count endpoint Co-Authored-By: Virgil --- notifications.go | 9 +++++++++ notifications_test.go | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/notifications.go b/notifications.go index d053bbc..a272354 100644 --- a/notifications.go +++ b/notifications.go @@ -32,6 +32,15 @@ func (s *NotificationService) Iter(ctx context.Context) iter.Seq2[types.Notifica return ListIter[types.NotificationThread](ctx, s.client, "/api/v1/notifications", nil) } +// 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) diff --git a/notifications_test.go b/notifications_test.go index 69f1fd2..4d74b57 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -73,6 +73,28 @@ func TestNotificationService_ListRepo_Good(t *testing.T) { } } +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 { From 773713a088504d660c65d858587ac04c40a7c548 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:45:41 +0000 Subject: [PATCH 069/181] feat(repos): add repository search helpers --- repos.go | 89 ++++++++++++++++++++++++++++++++++ repos_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/repos.go b/repos.go index 8540f5d..fc6d503 100644 --- a/repos.go +++ b/repos.go @@ -3,6 +3,9 @@ package forge import ( "context" "iter" + "net/http" + "net/url" + "strconv" "dappco.re/go/core/forge/types" ) @@ -276,6 +279,92 @@ func (s *RepoService) IterSearchTopics(ctx context.Context, query string) iter.S 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, 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)) diff --git a/repos_test.go b/repos_test.go index 7dd2476..dd6b6a2 100644 --- a/repos_test.go +++ b/repos_test.go @@ -113,6 +113,135 @@ func TestRepoService_IterSearchTopics_Good(t *testing.T) { } } +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 { From 272de12bf004f2ce2c4d59f1438cd2f19921d710 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:47:34 +0000 Subject: [PATCH 070/181] feat(users): add unblock helper Co-Authored-By: Virgil --- users.go | 6 ++++++ users_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/users.go b/users.go index ab52195..1cda90a 100644 --- a/users.go +++ b/users.go @@ -94,6 +94,12 @@ func (s *UserService) Block(ctx context.Context, username string) error { 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) diff --git a/users_test.go b/users_test.go index 704e231..0c05aff 100644 --- a/users_test.go +++ b/users_test.go @@ -159,6 +159,24 @@ func TestUserService_Block_Good(t *testing.T) { } } +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 { From 15265e4599fcf8cecd01d9e8ab835534a6779cdb Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:50:04 +0000 Subject: [PATCH 071/181] feat: add issue time iterator Co-Authored-By: Virgil --- docs/api-contract.md | 1 + issues.go | 38 ++++++++++++++++++++------------- issues_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index fdca9e1..a09d8cc 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -119,6 +119,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/issues.go b/issues.go index 06635b5..968218e 100644 --- a/issues.go +++ b/issues.go @@ -174,20 +174,13 @@ func (s *IssueService) DeleteStopwatch(ctx context.Context, owner, repo string, // 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))) - 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 { - query = nil - } - return ListAll[types.TrackedTime](ctx, s.client, path, query) + 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. @@ -364,3 +357,20 @@ func toAnySlice(ids []int64) []any { } return out } + +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_test.go b/issues_test.go index 1b8c8ef..487864d 100644 --- a/issues_test.go +++ b/issues_test.go @@ -758,6 +758,56 @@ func TestIssueService_ListTimes_Good(t *testing.T) { } } +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 { From 3cb64ca7f9227462659c65007d3be19b4508e802 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:52:33 +0000 Subject: [PATCH 072/181] Add wiki page revision retrieval Co-Authored-By: Virgil --- wiki.go | 15 +++++++++++++++ wiki_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/wiki.go b/wiki.go index ce9ce3a..2243b3d 100644 --- a/wiki.go +++ b/wiki.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -58,6 +59,20 @@ 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/wiki/new", pathParams("owner", owner, "repo", repo)) diff --git a/wiki_test.go b/wiki_test.go index 2de0e29..ca2ab13 100644 --- a/wiki_test.go +++ b/wiki_test.go @@ -105,6 +105,43 @@ func TestWikiService_GetPage_Good(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 { From f7c8d7b7466b459ba906896ce9687b7d0fa9ba50 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:54:57 +0000 Subject: [PATCH 073/181] Add pull request changed files endpoint Co-Authored-By: Virgil --- pulls.go | 12 +++++++++ pulls_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/pulls.go b/pulls.go index 9d69a96..bdab33d 100644 --- a/pulls.go +++ b/pulls.go @@ -56,6 +56,18 @@ func (s *PullService) IterReviews(ctx context.Context, owner, repo string, 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) +} + // 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)) diff --git a/pulls_test.go b/pulls_test.go index 762eae4..65225cf 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -121,6 +121,80 @@ func TestPullService_ListReviewers_Good(t *testing.T) { } } +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_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) { From 484ecd1e1f4d9b9c92f9678fdb97d7d08212c923 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:58:11 +0000 Subject: [PATCH 074/181] feat(issues): add attachment helpers Co-Authored-By: Virgil --- issues.go | 38 +++++++++++++++ issues_test.go | 129 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/issues.go b/issues.go index 968218e..d0fa21e 100644 --- a/issues.go +++ b/issues.go @@ -235,6 +235,44 @@ func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, in return &out, nil } +// 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) +} + // 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))) diff --git a/issues_test.go b/issues_test.go index 487864d..429737d 100644 --- a/issues_test.go +++ b/issues_test.go @@ -318,6 +318,135 @@ func TestIssueService_CreateComment_Good(t *testing.T) { } } +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_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) From 584f3726228fea06bb24160b049c9c90e7e3e72b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:01:29 +0000 Subject: [PATCH 075/181] feat(issues): add attachment uploads Co-Authored-By: Virgil --- client.go | 60 ++++++++++++++++++++++ issues.go | 82 +++++++++++++++++++++++++++++++ issues_test.go | 131 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) diff --git a/client.go b/client.go index fc1109c..cf9214d 100644 --- a/client.go +++ b/client.go @@ -5,7 +5,9 @@ import ( "context" json "github.com/goccy/go-json" "io" + "mime/multipart" "net/http" + "net/url" "strconv" core "dappco.re/go/core" @@ -260,6 +262,64 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return data, nil } +func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fieldName, fileName string, content io.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) + 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 := io.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. func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { diff --git a/issues.go b/issues.go index d0fa21e..76e2f1e 100644 --- a/issues.go +++ b/issues.go @@ -2,6 +2,7 @@ package forge import ( "context" + "io" "iter" "strconv" "time" @@ -19,6 +20,12 @@ type IssueService struct { Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption] } +// AttachmentUploadOptions controls metadata sent when uploading an attachment. +type AttachmentUploadOptions struct { + Name string + UpdatedAt *time.Time +} + func newIssueService(c *Client) *IssueService { return &IssueService{ Resource: *NewResource[types.Issue, types.CreateIssueOption, types.EditIssueOption]( @@ -235,6 +242,37 @@ func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, in return &out, nil } +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 io.Reader) (*types.Attachment, error) { + var out types.Attachment + if err := s.client.postMultipartJSON(ctx, path, attachmentUploadQuery(opts), "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 io.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))) @@ -273,6 +311,50 @@ func (s *IssueService) DeleteAttachment(ctx context.Context, owner, repo string, 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 io.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))) diff --git a/issues_test.go b/issues_test.go index 429737d..1c90cb0 100644 --- a/issues_test.go +++ b/issues_test.go @@ -1,8 +1,12 @@ package forge import ( + "bytes" "context" json "github.com/goccy/go-json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" "reflect" @@ -12,6 +16,37 @@ import ( "dappco.re/go/core/forge/types" ) +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 { @@ -447,6 +482,102 @@ func TestIssueService_DeleteAttachment_Good(t *testing.T) { } } +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) From 5e12785d42d651f8db0f96639b73f23af163ea99 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:04:26 +0000 Subject: [PATCH 076/181] feat(users): add heatmap endpoint Co-Authored-By: Virgil --- users.go | 10 ++++++++++ users_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/users.go b/users.go index 1cda90a..d77d81d 100644 --- a/users.go +++ b/users.go @@ -170,6 +170,16 @@ func (s *UserService) IterStarred(ctx context.Context, username string) iter.Seq 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 := ResolvePath("/api/v1/user/starred/{owner}/{repo}", pathParams("owner", owner, "repo", repo)) diff --git a/users_test.go b/users_test.go index 0c05aff..9ea1dc1 100644 --- a/users_test.go +++ b/users_test.go @@ -489,3 +489,31 @@ func TestUserService_IterSubscriptions_Good(t *testing.T) { t.Fatalf("got %d repositories, want 1", count) } } + +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) + } +} From 4e251de2c3a18a347c3ef50bb597127e01d89507 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:07:29 +0000 Subject: [PATCH 077/181] feat(misc): add instance settings endpoints Co-Authored-By: Virgil --- docs/api-contract.md | 4 ++ misc.go | 36 ++++++++++++++ misc_test.go | 110 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index a09d8cc..7225ea1 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -140,9 +140,13 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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) ([]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` | diff --git a/misc.go b/misc.go index a2ebda4..85e23d3 100644 --- a/misc.go +++ b/misc.go @@ -135,6 +135,42 @@ func (s *MiscService) GetNodeInfo(ctx context.Context) (*types.NodeInfo, error) return &out, 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 bf2fe2b..14f5c98 100644 --- a/misc_test.go +++ b/misc_test.go @@ -141,6 +141,116 @@ func TestMiscService_GetVersion_Good(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 { From cb54bfeea24d867f4cbb42f37f76861f816a0416 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:09:49 +0000 Subject: [PATCH 078/181] feat(issues): add pinned issue listing Co-Authored-By: Virgil --- issues.go | 12 ++++++++ issues_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/issues.go b/issues.go index 76e2f1e..573c459 100644 --- a/issues.go +++ b/issues.go @@ -133,6 +133,18 @@ func (s *IssueService) MovePin(ctx context.Context, owner, repo string, index, p 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index 1c90cb0..8113e21 100644 --- a/issues_test.go +++ b/issues_test.go @@ -957,6 +957,81 @@ func TestIssueService_MovePin_Good(t *testing.T) { } } +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 { From 61eb6dedbb720bd68c6857ed361047fefea24179 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:12:11 +0000 Subject: [PATCH 079/181] feat(repos): add topic iterator Co-Authored-By: Virgil --- repos.go | 16 ++++++++++++++++ repos_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/repos.go b/repos.go index fc6d503..51c4263 100644 --- a/repos.go +++ b/repos.go @@ -269,6 +269,22 @@ func (s *RepoService) ListTopics(ctx context.Context, owner, repo string) ([]str 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}) diff --git a/repos_test.go b/repos_test.go index dd6b6a2..5755642 100644 --- a/repos_test.go +++ b/repos_test.go @@ -35,6 +35,38 @@ func TestRepoService_ListTopics_Good(t *testing.T) { } } +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) { From 6dff39c82d4029dfedc0f01f69cef2ec6c5ca9dd Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:15:15 +0000 Subject: [PATCH 080/181] feat(repos): add template repository generation Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/repos.go b/repos.go index 51c4263..4898965 100644 --- a/repos.go +++ b/repos.go @@ -500,6 +500,16 @@ func (s *RepoService) Fork(ctx context.Context, owner, repo, org string) (*types 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 + 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)) diff --git a/repos_test.go b/repos_test.go index 5755642..f3d0e4b 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1278,4 +1278,41 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { 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) + } + }) } From 3940f8f23ee1f0cc522836b80e8fb82e004286f2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:17:36 +0000 Subject: [PATCH 081/181] feat(repos): add repository languages endpoint Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/repos.go b/repos.go index 4898965..3652181 100644 --- a/repos.go +++ b/repos.go @@ -221,6 +221,16 @@ func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath stri return s.client.GetRaw(ctx, path) } +// 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)) diff --git a/repos_test.go b/repos_test.go index f3d0e4b..2b4b375 100644 --- a/repos_test.go +++ b/repos_test.go @@ -390,6 +390,33 @@ func TestRepoService_DeleteTag_Good(t *testing.T) { } } +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_ListForks_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 4489da434cf4dac387aa99ccf1d6c0c1e9e01a19 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:19:59 +0000 Subject: [PATCH 082/181] Add repo raw media endpoint Co-Authored-By: Virgil --- repos.go | 16 ++++++++++++++++ repos_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/repos.go b/repos.go index 3652181..334ed93 100644 --- a/repos.go +++ b/repos.go @@ -221,6 +221,22 @@ func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath stri 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, err + } + q := u.Query() + q.Set("ref", ref) + u.RawQuery = q.Encode() + path = u.String() + } + return s.client.GetRaw(ctx, path) +} + // 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)) diff --git a/repos_test.go b/repos_test.go index 2b4b375..af0d6a1 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1,6 +1,7 @@ package forge import ( + "bytes" "context" json "github.com/goccy/go-json" "net/http" @@ -417,6 +418,35 @@ func TestRepoService_GetLanguages_Good(t *testing.T) { } } +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_ListForks_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From c10ae6ddda975155798b35def393a7bafba0e937 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:22:30 +0000 Subject: [PATCH 083/181] feat(repos): add typed fork options Co-Authored-By: Virgil --- repos.go | 16 +++++++++------- repos_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/repos.go b/repos.go index 334ed93..2cc4b2a 100644 --- a/repos.go +++ b/repos.go @@ -511,16 +511,18 @@ func (s *RepoService) Unwatch(ctx context.Context, owner, repo string) error { return s.client.Delete(ctx, path) } -// Fork forks a repository. If org is non-empty, forks into that organisation. +// 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)) - err := s.client.Post(ctx, path, body, &out) - if err != nil { + if err := s.client.Post(ctx, path, opts, &out); err != nil { return nil, err } return &out, nil diff --git a/repos_test.go b/repos_test.go index af0d6a1..deefdbb 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1295,6 +1295,43 @@ func TestRepoService_Unwatch_Good(t *testing.T) { } } +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_PathParamsAreEscaped_Good(t *testing.T) { owner := "acme org" repo := "my/repo" @@ -1326,6 +1363,13 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { 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() From 1c03ea14a3b0fb6b49be1e8affc3a8aab4327710 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:25:15 +0000 Subject: [PATCH 084/181] feat(users): add user settings endpoints Co-Authored-By: Virgil --- users.go | 18 +++++++++++++ users_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/users.go b/users.go index d77d81d..8aa9ac0 100644 --- a/users.go +++ b/users.go @@ -34,6 +34,24 @@ 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 +} + // 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) diff --git a/users_test.go b/users_test.go index 9ea1dc1..7b2116b 100644 --- a/users_test.go +++ b/users_test.go @@ -54,6 +54,80 @@ func TestUserService_GetCurrent_Good(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_ListEmails_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From cb54c357e147968db1f2510f0fd6cdb7e94c82c2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:27:55 +0000 Subject: [PATCH 085/181] feat(users): add authenticated user quota getter Co-Authored-By: Virgil --- docs/api-contract.md | 1 + users.go | 9 +++++++++ users_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 7225ea1..231d64e 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -221,6 +221,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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.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. | diff --git a/users.go b/users.go index 8aa9ac0..3f676b1 100644 --- a/users.go +++ b/users.go @@ -52,6 +52,15 @@ func (s *UserService) UpdateSettings(ctx context.Context, opts *types.UserSettin 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 +} + // 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) diff --git a/users_test.go b/users_test.go index 7b2116b..5cb55a4 100644 --- a/users_test.go +++ b/users_test.go @@ -128,6 +128,41 @@ func TestUserService_UpdateSettings_Good(t *testing.T) { } } +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_ListEmails_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From b9962f2412d131a3ac268eddad21e1e99c8e504f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:30:36 +0000 Subject: [PATCH 086/181] feat: add milestone list filters Co-Authored-By: Virgil --- docs/api-contract.md | 2 +- milestones.go | 52 +++++++++++++++++++++++++++++++++++++++----- milestones_test.go | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index 231d64e..b02b74e 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -138,7 +138,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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) ([]types.Milestone, error)` | ListAll returns all milestones for a repository. | 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` | diff --git a/milestones.go b/milestones.go index f5df222..27658a8 100644 --- a/milestones.go +++ b/milestones.go @@ -7,6 +7,26 @@ import ( "dappco.re/go/core/forge/types" ) +// MilestoneListOptions controls filtering for repository milestone listings. +type MilestoneListOptions struct { + State string + Name 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: @@ -22,21 +42,21 @@ func newMilestoneService(c *Client) *MilestoneService { } // List returns a single page of milestones for a repository. -func (s *MilestoneService) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[types.Milestone], error) { +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, nil, opts) + 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) iter.Seq2[types.Milestone, error] { +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, nil) + 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) ([]types.Milestone, error) { +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, nil) + return ListAll[types.Milestone](ctx, s.client, path, milestoneQuery(filters...)) } // Get returns a single milestone by ID. @@ -74,3 +94,23 @@ func (s *MilestoneService) Delete(ctx context.Context, owner, repo string, id in 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 { + if filter.State != "" { + query["state"] = filter.State + } + if filter.Name != "" { + query["name"] = filter.Name + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/milestones_test.go b/milestones_test.go index f225d57..8def945 100644 --- a/milestones_test.go +++ b/milestones_test.go @@ -49,6 +49,46 @@ func TestMilestoneService_List_Good(t *testing.T) { } } +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) { From e9faaabc8ae3f7137b874f8f2181b2590a188952 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:33:59 +0000 Subject: [PATCH 087/181] feat(releases): add release attachment upload Co-Authored-By: Virgil --- client.go | 21 +++++--- issues.go | 2 +- releases.go | 44 +++++++++++++++ releases_test.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/client.go b/client.go index cf9214d..472354c 100644 --- a/client.go +++ b/client.go @@ -262,7 +262,7 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return data, nil } -func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fieldName, fileName string, content io.Reader, out any) error { +func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fields map[string]string, fieldName, fileName string, content io.Reader, out any) error { target, err := url.Parse(c.baseURL + path) if err != nil { return core.E("Client.PostMultipart", "forge: parse url", err) @@ -277,13 +277,20 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s var body bytes.Buffer writer := multipart.NewWriter(&body) - part, err := writer.CreateFormFile(fieldName, fileName) - if err != nil { - return core.E("Client.PostMultipart", "forge: create multipart form file", err) + 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 content != nil { - if _, err := io.Copy(part, content); err != nil { - return core.E("Client.PostMultipart", "forge: write multipart form file", 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 := io.Copy(part, content); err != nil { + return core.E("Client.PostMultipart", "forge: write multipart form file", err) + } } } if err := writer.Close(); err != nil { diff --git a/issues.go b/issues.go index 573c459..14a5f1e 100644 --- a/issues.go +++ b/issues.go @@ -273,7 +273,7 @@ func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { func (s *IssueService) createAttachment(ctx context.Context, path string, opts *AttachmentUploadOptions, filename string, content io.Reader) (*types.Attachment, error) { var out types.Attachment - if err := s.client.postMultipartJSON(ctx, path, attachmentUploadQuery(opts), "attachment", filename, content, &out); err != nil { + if err := s.client.postMultipartJSON(ctx, path, attachmentUploadQuery(opts), nil, "attachment", filename, content, &out); err != nil { return nil, err } return &out, nil diff --git a/releases.go b/releases.go index ed3a841..ebd5480 100644 --- a/releases.go +++ b/releases.go @@ -2,6 +2,7 @@ package forge import ( "context" + "io" "iter" "dappco.re/go/core/forge/types" @@ -17,6 +18,23 @@ type ReleaseService struct { Resource[types.Release, types.CreateReleaseOption, types.EditReleaseOption] } +// ReleaseAttachmentUploadOptions controls metadata sent when uploading a release attachment. +type ReleaseAttachmentUploadOptions struct { + Name string + ExternalURL 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]( @@ -47,6 +65,32 @@ func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, rel 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 io.Reader) (*types.Attachment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", 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 +} + +// 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 io.Reader) (*types.Attachment, error) { + return s.CreateAttachment(ctx, owner, repo, releaseID, opts, filename, content) +} + // 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID))) diff --git a/releases_test.go b/releases_test.go index c0b22c3..b0410d4 100644 --- a/releases_test.go +++ b/releases_test.go @@ -1,15 +1,63 @@ package forge import ( + "bytes" "context" json "github.com/goccy/go-json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" + "reflect" "testing" "dappco.re/go/core/forge/types" ) +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 { @@ -85,3 +133,92 @@ func TestReleaseService_GetByTag_Good(t *testing.T) { t.Errorf("got id=%d, want 1", release.ID) } } + +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) + } +} From 7862054999d1f7ae34d69ae66d7e603428c7a45b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:36:52 +0000 Subject: [PATCH 088/181] feat(users): add following check Co-Authored-By: Virgil --- docs/api-contract.md | 1 + users.go | 14 ++++++++++++++ users_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index b02b74e..c95f258 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -220,6 +220,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.ListStopwatches | `func (s *UserService) ListStopwatches(ctx context.Context) ([]types.StopWatch, error)` | ListStopwatches returns all existing stopwatches for the authenticated user. | `TestUserService_ListStopwatches_Good` | diff --git a/users.go b/users.go index 3f676b1..6e9c282 100644 --- a/users.go +++ b/users.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "net/http" "dappco.re/go/core/forge/types" ) @@ -179,6 +180,19 @@ func (s *UserService) Follow(ctx context.Context, username string) error { 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 := ResolvePath("/api/v1/user/following/{username}", pathParams("username", username)) diff --git a/users_test.go b/users_test.go index 5cb55a4..edcf601 100644 --- a/users_test.go +++ b/users_test.go @@ -268,6 +268,50 @@ func TestUserService_Block_Good(t *testing.T) { } } +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 { From 7c225360948ee12eca9f19c05bac1d5cc6d80b0b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:39:56 +0000 Subject: [PATCH 089/181] feat(repos): add compare endpoint Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/repos.go b/repos.go index 2cc4b2a..5bcd869 100644 --- a/repos.go +++ b/repos.go @@ -215,6 +215,16 @@ func (s *RepoService) GetArchive(ctx context.Context, owner, repo, archive strin 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)) diff --git a/repos_test.go b/repos_test.go index deefdbb..4666bc3 100644 --- a/repos_test.go +++ b/repos_test.go @@ -974,6 +974,41 @@ func TestRepoService_ListSubscribers_Good(t *testing.T) { } } +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 { From 20e038265f9a4df1366933785f8e989972fab827 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:41:50 +0000 Subject: [PATCH 090/181] feat(users): add starring check Co-Authored-By: Virgil --- users.go | 13 +++++++++++++ users_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/users.go b/users.go index 6e9c282..efb6d30 100644 --- a/users.go +++ b/users.go @@ -232,3 +232,16 @@ func (s *UserService) Unstar(ctx context.Context, owner, repo string) error { 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_test.go b/users_test.go index edcf601..48c3802 100644 --- a/users_test.go +++ b/users_test.go @@ -643,6 +643,50 @@ func TestUserService_IterSubscriptions_Good(t *testing.T) { } } +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 { From 1f6cfbfd8b00006de7f4313ae2b74a4711e7aa56 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:45:19 +0000 Subject: [PATCH 091/181] feat(repos): add repository deploy key endpoints Co-Authored-By: Virgil --- repos.go | 78 +++++++++++++++++++++++++++++++++ repos_test.go | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/repos.go b/repos.go index 5bcd869..6d4ae81 100644 --- a/repos.go +++ b/repos.go @@ -20,6 +20,26 @@ type RepoService struct { Resource[types.Repository, types.CreateRepoOption, types.EditRepoOption] } +// RepoKeyListOptions controls filtering for repository key listings. +type RepoKeyListOptions struct { + KeyID int64 + Fingerprint 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 +} + func newRepoService(c *Client) *RepoService { return &RepoService{ Resource: *NewResource[types.Repository, types.CreateRepoOption, types.EditRepoOption]( @@ -126,6 +146,44 @@ func (s *RepoService) DeleteTagProtection(ctx context.Context, owner, repo strin 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)) @@ -589,3 +647,23 @@ func (s *RepoService) SyncPushMirrors(ctx context.Context, owner, repo string) e path := ResolvePath("/api/v1/repos/{owner}/{repo}/push_mirrors-sync", pathParams("owner", owner, "repo", repo)) return s.client.Post(ctx, path, nil, 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 +} diff --git a/repos_test.go b/repos_test.go index 4666bc3..50e1f0f 100644 --- a/repos_test.go +++ b/repos_test.go @@ -628,6 +628,123 @@ func TestRepoService_DeleteTagProtection_Good(t *testing.T) { } } +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 { From 572a1f1ddc805039e8a2adf92b989ce429895e6f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:47:58 +0000 Subject: [PATCH 092/181] Add admin email endpoints Co-Authored-By: Virgil --- admin.go | 20 +++++++++++++++ admin_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ docs/api-contract.md | 4 +++ 3 files changed, 84 insertions(+) diff --git a/admin.go b/admin.go index 825abef..62b25ae 100644 --- a/admin.go +++ b/admin.go @@ -70,6 +70,26 @@ 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) +} + +// 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}) diff --git a/admin_test.go b/admin_test.go index 0f21d9f..b6e3dc0 100644 --- a/admin_test.go +++ b/admin_test.go @@ -195,6 +195,66 @@ func TestAdminService_ListOrgs_Good(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_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 { diff --git a/docs/api-contract.md b/docs/api-contract.md index c95f258..dce3f9a 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -72,11 +72,15 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | From b5792bae9bb879ca712b6a282b61b356089b2ad0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:50:00 +0000 Subject: [PATCH 093/181] Add latest release helper Co-Authored-By: Virgil --- docs/api-contract.md | 1 + releases.go | 10 ++++++++++ releases_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index dce3f9a..6b04c5e 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -190,6 +190,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/releases.go b/releases.go index ebd5480..4d833fd 100644 --- a/releases.go +++ b/releases.go @@ -53,6 +53,16 @@ func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) 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 + } + return &out, nil +} + // DeleteByTag deletes a release by its tag name. func (s *ReleaseService) DeleteByTag(ctx context.Context, owner, repo, tag string) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/tags/{tag}", pathParams("owner", owner, "repo", repo, "tag", tag)) diff --git a/releases_test.go b/releases_test.go index b0410d4..1779aa2 100644 --- a/releases_test.go +++ b/releases_test.go @@ -134,6 +134,31 @@ func TestReleaseService_GetByTag_Good(t *testing.T) { } } +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 { From 122b68c5fb0ac674a333feefea56566acc2f1edd Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:52:43 +0000 Subject: [PATCH 094/181] feat: add repo notification marking Co-Authored-By: Virgil --- docs/api-contract.md | 1 + notifications.go | 44 +++++++++++++++++++++++++++++++++++++ notifications_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 6b04c5e..5f5c64f 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -161,6 +161,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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. | diff --git a/notifications.go b/notifications.go index a272354..ff50476 100644 --- a/notifications.go +++ b/notifications.go @@ -3,6 +3,8 @@ package forge import ( "context" "iter" + "net/url" + "time" "dappco.re/go/core/forge/types" ) @@ -18,10 +20,37 @@ type NotificationService struct { client *Client } +// NotificationRepoMarkOptions controls how repository notifications are marked. +type NotificationRepoMarkOptions struct { + All bool + StatusTypes []string + ToStatus string + LastReadAt *time.Time +} + func newNotificationService(c *Client) *NotificationService { return &NotificationService{client: c} } +func (o NotificationRepoMarkOptions) queryString() string { + values := url.Values{} + if o.All { + values.Set("all", "true") + } + for _, status := range o.StatusTypes { + if status != "" { + values.Add("status-types", status) + } + } + if o.ToStatus != "" { + values.Set("to-status", o.ToStatus) + } + if o.LastReadAt != nil { + values.Set("last_read_at", o.LastReadAt.Format(time.RFC3339)) + } + return values.Encode() +} + // 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) @@ -53,6 +82,21 @@ func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string) return ListIter[types.NotificationThread](ctx, s.client, path, 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. func (s *NotificationService) MarkRead(ctx context.Context) error { return s.client.Put(ctx, "/api/v1/notifications", nil, nil) diff --git a/notifications_test.go b/notifications_test.go index 4d74b57..b9718b9 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "dappco.re/go/core/forge/types" ) @@ -167,6 +168,56 @@ func TestNotificationService_MarkThreadRead_Good(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) From de8a3c92149a8f1e5b6341bfeffe867b3e0e794f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:25:43 +0000 Subject: [PATCH 095/181] feat(notifications): add notification list filters Co-Authored-By: Virgil --- notifications.go | 127 +++++++++++++++++++++++++++++++++++++++--- notifications_test.go | 87 +++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 8 deletions(-) diff --git a/notifications.go b/notifications.go index ff50476..09cd4cb 100644 --- a/notifications.go +++ b/notifications.go @@ -3,12 +3,45 @@ package forge import ( "context" "iter" + "net/http" "net/url" + "strconv" "time" "dappco.re/go/core/forge/types" ) +// NotificationListOptions controls filtering for notification listings. +type NotificationListOptions struct { + All bool + StatusTypes []string + SubjectTypes []string + Since *time.Time + Before *time.Time +} + +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. // @@ -52,13 +85,13 @@ func (o NotificationRepoMarkOptions) queryString() string { } // 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. @@ -71,15 +104,15 @@ func (s *NotificationService) NewAvailable(ctx context.Context) (*types.Notifica } // ListRepo returns all notifications for a specific repository. -func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error) { +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 ListAll[types.NotificationThread](ctx, s.client, path, nil) + 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] { +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 ListIter[types.NotificationThread](ctx, s.client, path, nil) + return s.listIter(ctx, path, filters...) } // MarkRepoNotifications marks repository notification threads as read, unread, or pinned. @@ -117,3 +150,81 @@ func (s *NotificationService) MarkThreadRead(ctx context.Context, id int64) erro 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: 50}, 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: 50}, 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 = 50 + } + + u, err := url.Parse(path) + if err != nil { + return nil, 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 b9718b9..79ee76b 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -46,6 +46,54 @@ func TestNotificationService_List_Good(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 { @@ -74,6 +122,45 @@ func TestNotificationService_ListRepo_Good(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 { From 9deb4601304f70b57d3bf7c769e03167662d66ef Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:29:41 +0000 Subject: [PATCH 096/181] feat(admin): add quota group endpoints Co-Authored-By: Virgil --- admin.go | 14 ++++++++ admin_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/admin.go b/admin.go index 62b25ae..6e40914 100644 --- a/admin.go +++ b/admin.go @@ -80,6 +80,20 @@ func (s *AdminService) IterEmails(ctx context.Context) iter.Seq2[types.Email, er return ListIter[types.Email](ctx, s.client, "/api/v1/admin/emails", nil) } +// 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) +} + +// 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 +} + // 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}) diff --git a/admin_test.go b/admin_test.go index b6e3dc0..3ce6ec0 100644 --- a/admin_test.go +++ b/admin_test.go @@ -224,6 +224,98 @@ func TestAdminService_ListEmails_Good(t *testing.T) { } } +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_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_SearchEmails_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 35cf9664de48f76d3671e4b93af3a809323c33fe Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:33:35 +0000 Subject: [PATCH 097/181] feat(admin): add admin hook endpoints Co-Authored-By: Virgil --- admin.go | 45 +++++++++++++++ admin_test.go | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/admin.go b/admin.go index 6e40914..b913345 100644 --- a/admin.go +++ b/admin.go @@ -80,6 +80,51 @@ func (s *AdminService) IterEmails(ctx context.Context) iter.Seq2[types.Email, er 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) diff --git a/admin_test.go b/admin_test.go index 3ce6ec0..e487aac 100644 --- a/admin_test.go +++ b/admin_test.go @@ -224,6 +224,155 @@ func TestAdminService_ListEmails_Good(t *testing.T) { } } +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 { From a555c24a9fb68eee8127f1e9eadf6bccd8c0c347 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:35:55 +0000 Subject: [PATCH 098/181] feat(issues): add tracked time deletion Co-Authored-By: Virgil --- issues.go | 6 ++++++ issues_test.go | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/issues.go b/issues.go index 14a5f1e..e19858d 100644 --- a/issues.go +++ b/issues.go @@ -218,6 +218,12 @@ func (s *IssueService) ResetTime(ctx context.Context, owner, repo string, 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index 8113e21..c94fedf 100644 --- a/issues_test.go +++ b/issues_test.go @@ -1196,6 +1196,26 @@ func TestIssueService_ResetTime_Good(t *testing.T) { } } +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) From 5d4374847d96f1243f9b180d04d40cad228cc6b4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:38:14 +0000 Subject: [PATCH 099/181] Add admin quota group lookup Co-Authored-By: Virgil --- admin.go | 16 ++++++++++++++++ admin_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/admin.go b/admin.go index b913345..d15ec41 100644 --- a/admin.go +++ b/admin.go @@ -139,6 +139,22 @@ func (s *AdminService) CreateQuotaGroup(ctx context.Context, opts *types.CreateQ 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) +} + // 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}) diff --git a/admin_test.go b/admin_test.go index e487aac..ea0941e 100644 --- a/admin_test.go +++ b/admin_test.go @@ -465,6 +465,54 @@ func TestAdminService_CreateQuotaGroup_Good(t *testing.T) { } } +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_SearchEmails_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 02def0d8ad79b7b7931b41c22f5fd8a39eea5337 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:40:46 +0000 Subject: [PATCH 100/181] feat: add iterator for repository flags Co-Authored-By: Virgil --- repos.go | 6 ++++++ repos_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/repos.go b/repos.go index 6d4ae81..ffb3583 100644 --- a/repos.go +++ b/repos.go @@ -325,6 +325,12 @@ func (s *RepoService) ListFlags(ctx context.Context, owner, repo string) ([]stri 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)) diff --git a/repos_test.go b/repos_test.go index 50e1f0f..4c5ad06 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1176,6 +1176,45 @@ func TestRepoService_ListFlags_Good(t *testing.T) { } } +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 { From c7809c0dc708db071798cc97fedc9486310f9a4f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:43:11 +0000 Subject: [PATCH 101/181] feat(actions): add repo variable update Co-Authored-By: Virgil --- actions.go | 6 ++++++ actions_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/actions.go b/actions.go index a980bab..559fb28 100644 --- a/actions.go +++ b/actions.go @@ -71,6 +71,12 @@ func (s *ActionsService) CreateRepoVariable(ctx context.Context, owner, repo, na 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/{name}", pathParams("owner", owner, "repo", repo, "name", 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", pathParams("owner", owner, "repo", repo, "name", name)) diff --git a/actions_test.go b/actions_test.go index 8b4112a..82a4d09 100644 --- a/actions_test.go +++ b/actions_test.go @@ -141,6 +141,38 @@ func TestActionsService_CreateRepoVariable_Good(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 { From 3ed3ecaf3dd6ba74b53e1a54854c3b8e99eb7445 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:46:07 +0000 Subject: [PATCH 102/181] Add repository activity feed listing Co-Authored-By: Virgil --- repos.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ repos_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/repos.go b/repos.go index ffb3583..99333ac 100644 --- a/repos.go +++ b/repos.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strconv" + "time" "dappco.re/go/core/forge/types" ) @@ -40,6 +41,20 @@ func (o RepoKeyListOptions) queryParams() map[string]string { return query } +// ActivityFeedListOptions controls filtering for repository activity feeds. +type ActivityFeedListOptions struct { + Date *time.Time +} + +func (o ActivityFeedListOptions) queryParams() map[string]string { + if o.Date == nil { + return nil + } + return map[string]string{ + "date": o.Date.Format("2006-01-02"), + } +} + func newRepoService(c *Client) *RepoService { return &RepoService{ Resource: *NewResource[types.Repository, types.CreateRepoOption, types.EditRepoOption]( @@ -359,6 +374,18 @@ func (s *RepoService) ListIssueTemplates(ctx context.Context, owner, repo string return ListAll[types.IssueTemplate](ctx, s.client, path, 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)) @@ -673,3 +700,20 @@ func repoKeyQuery(filters ...RepoKeyListOptions) map[string]string { } 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 index 4c5ad06..81208bc 100644 --- a/repos_test.go +++ b/repos_test.go @@ -8,10 +8,44 @@ import ( "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_ListTopics_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From c7222155b4ca8e544dfa04fef7ba119f105ef7a2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:50:01 +0000 Subject: [PATCH 103/181] forgegen: preserve swagger additionalProperties Co-Authored-By: Virgil --- cmd/forgegen/generator.go | 5 + cmd/forgegen/generator_test.go | 37 +++++ cmd/forgegen/parser.go | 47 ++++-- cmd/forgegen/parser_test.go | 23 +++ types/action.go | 48 +++--- types/activity.go | 28 ++-- types/admin.go | 12 +- types/branch.go | 174 ++++++++++---------- types/comment.go | 24 ++- types/commit.go | 54 +++---- types/common.go | 24 ++- types/content.go | 118 +++++++------- types/error.go | 20 ++- types/federation.go | 34 ++-- types/git.go | 78 +++++---- types/hook.go | 79 +++++---- types/issue.go | 136 ++++++++-------- types/key.go | 76 +++++---- types/label.go | 36 ++--- types/milestone.go | 38 +++-- types/misc.go | 242 ++++++++++++++-------------- types/notification.go | 30 ++-- types/oauth.go | 34 ++-- types/org.go | 62 ++++---- types/package.go | 28 ++-- types/pr.go | 200 ++++++++++++----------- types/quota.go | 48 +++--- types/reaction.go | 8 +- types/release.go | 64 ++++---- types/repo.go | 282 ++++++++++++++++----------------- types/review.go | 2 - types/settings.go | 30 ++-- types/status.go | 24 ++- types/tag.go | 38 +++-- types/team.go | 48 +++--- types/time_tracking.go | 30 ++-- types/topic.go | 12 +- types/user.go | 166 ++++++++++--------- types/wiki.go | 36 ++--- 39 files changed, 1244 insertions(+), 1231 deletions(-) diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index 3e0a3fb..e339161 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -183,6 +183,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{} @@ -244,6 +246,9 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { // 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 core.Contains(f.GoType, "time.Time") }) diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index ae3b059..9fd1b72 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -133,3 +133,40 @@ func TestGenerate_TimeImport_Good(t *testing.T) { } } } + +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") + } +} diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index f42e77b..5c0acee 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -38,12 +38,13 @@ type SpecInfo struct { // // _ = 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"` + 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. @@ -52,13 +53,14 @@ type SchemaDefinition struct { // // _ = 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. @@ -72,6 +74,8 @@ type GoType struct { Fields []GoField IsEnum bool EnumValues []string + IsAlias bool + AliasType string } // GoField is the intermediate representation for a single struct field. @@ -135,6 +139,12 @@ func ExtractTypes(spec *Spec) map[string]*GoType { result[name] = gt continue } + if len(def.Properties) == 0 && def.AdditionalProperties != nil { + gt.IsAlias = true + gt.AliasType = resolveMapType(*def.AdditionalProperties) + result[name] = gt + continue + } required := make(map[string]bool) for _, r := range def.Required { required[r] = true @@ -229,12 +239,21 @@ func resolveGoType(prop SchemaProperty) string { } return "[]any" case "object": - return "map[string]any" + return resolveMapType(prop) default: return "any" } } +// resolveMapType maps a swagger object with additionalProperties to a Go map type. +func resolveMapType(prop SchemaProperty) string { + valueType := "any" + if prop.AdditionalProperties != nil { + valueType = resolveGoType(*prop.AdditionalProperties) + } + return "map[string]" + valueType +} + // pascalCase converts a snake_case or kebab-case string to PascalCase, // with common acronyms kept uppercase. func pascalCase(s string) string { diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go index b8808c6..a18f009 100644 --- a/cmd/forgegen/parser_test.go +++ b/cmd/forgegen/parser_test.go @@ -70,6 +70,10 @@ func TestParser_FieldTypes_Good(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) + } } } } @@ -101,3 +105,22 @@ func TestParser_DetectCreateEditPairs_Good(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) + } +} diff --git a/types/action.go b/types/action.go index 1d08904..ebd7fec 100644 --- a/types/action.go +++ b/types/action.go @@ -4,36 +4,35 @@ package types import "time" - // ActionTask — ActionTask represents a ActionTask 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 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 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 @@ -43,19 +42,18 @@ type CreateVariableOption struct { // DispatchWorkflowOption — DispatchWorkflowOption options when dispatching a workflow 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 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 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..d3793c8 100644 --- a/types/activity.go +++ b/types/activity.go @@ -4,25 +4,23 @@ package types import "time" - 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 type ActivityPub struct { Context string `json:"@context,omitempty"` } - diff --git a/types/admin.go b/types/admin.go index 5090ff2..f9418d2 100644 --- a/types/admin.go +++ b/types/admin.go @@ -4,18 +4,16 @@ package types import "time" - // Cron — Cron represents a Cron task 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 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..fd46ab5 100644 --- a/types/branch.go +++ b/types/branch.go @@ -4,116 +4,114 @@ package types import "time" - // Branch — Branch represents a repository branch 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 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 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 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 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 type UpdateBranchRepoOption struct { Name string `json:"name"` // New branch name } - diff --git a/types/comment.go b/types/comment.go index b951832..57867ca 100644 --- a/types/comment.go +++ b/types/comment.go @@ -4,19 +4,17 @@ package types import "time" - // Comment — Comment represents a comment on a commit or issue 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..5a654b6 100644 --- a/types/commit.go +++ b/types/commit.go @@ -4,56 +4,55 @@ package types import "time" - 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 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 type CommitDateOptions struct { - Author time.Time `json:"author,omitempty"` + Author time.Time `json:"author,omitempty"` Committer time.Time `json:"committer,omitempty"` } 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 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 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" @@ -61,8 +60,7 @@ type CommitStatus struct { type CommitStatusState struct{} 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 f25b84f..e5c2586 100644 --- a/types/common.go +++ b/types/common.go @@ -4,30 +4,29 @@ package types import "time" - // Attachment — Attachment a generic attachment 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 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 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 is the state of an issue or PR: "open", "closed". @@ -35,4 +34,3 @@ type StateType string // TimeStamp is a Forgejo timestamp string. type TimeStamp string - diff --git a/types/content.go b/types/content.go index 462eedc..057cf5f 100644 --- a/types/content.go +++ b/types/content.go @@ -4,101 +4,99 @@ package types import "time" - // ContentsResponse — ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content 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) 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) 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. } 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 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 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 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 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) 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..ba5c0b6 100644 --- a/types/error.go +++ b/types/error.go @@ -2,41 +2,39 @@ package types - // APIError — APIError is an api error with a message type APIError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } type APIForbiddenError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } type APIInvalidTopicsError struct { InvalidTopics []string `json:"invalidTopics,omitempty"` - Message string `json:"message,omitempty"` + Message string `json:"message,omitempty"` } 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"` } type APIRepoArchivedError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } type APIUnauthorizedError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } 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..f798101 100644 --- a/types/federation.go +++ b/types/federation.go @@ -2,43 +2,41 @@ package types - // NodeInfo — NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks 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 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 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 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 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..ec723f5 100644 --- a/types/git.go +++ b/types/git.go @@ -2,37 +2,36 @@ package types - // AnnotatedTag — AnnotatedTag represents an annotated tag 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 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 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 @@ -42,53 +41,52 @@ type EditGitHookOption struct { // GitBlobResponse — GitBlobResponse represents a git blob 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 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 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"` } 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 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 type Note struct { - Commit *Commit `json:"commit,omitempty"` - Message string `json:"message,omitempty"` + Commit *Commit `json:"commit,omitempty"` + Message string `json:"message,omitempty"` } type NoteOptions struct { Message string `json:"message,omitempty"` } - diff --git a/types/hook.go b/types/hook.go index 26ba1bd..2ecc2fe 100644 --- a/types/hook.go +++ b/types/hook.go @@ -4,66 +4,63 @@ package types import "time" - // CreateHookOption — CreateHookOption options when create a hook 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{} +type CreateHookOptionConfig map[string]any // EditHookOption — EditHookOption options when modify one hook 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 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 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 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 c7157e5..6aa51b4 100644 --- a/types/issue.go +++ b/types/issue.go @@ -4,24 +4,23 @@ package types import "time" - // CreateIssueCommentOption — CreateIssueCommentOption options for creating a comment on an issue 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 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 @@ -31,67 +30,67 @@ type EditDeadlineOption struct { // EditIssueCommentOption — EditIssueCommentOption options for editing a comment 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 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 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"` } 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"` } 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"` } type IssueConfigValidation struct { Message string `json:"message,omitempty"` - Valid bool `json:"valid,omitempty"` + Valid bool `json:"valid,omitempty"` } // IssueDeadline — IssueDeadline represents an issue deadline @@ -101,11 +100,11 @@ type IssueDeadline struct { // IssueFormField — IssueFormField represents a form field 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. @@ -117,29 +116,28 @@ type IssueFormFieldVisible struct{} // IssueLabelsOption — IssueLabelsOption a collection of labels 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 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 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{} - diff --git a/types/key.go b/types/key.go index 4415146..7b472c9 100644 --- a/types/key.go +++ b/types/key.go @@ -4,66 +4,64 @@ package types import "time" - // CreateGPGKeyOption — CreateGPGKeyOption options create user GPG key 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 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 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 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 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 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..117677c 100644 --- a/types/label.go +++ b/types/label.go @@ -4,14 +4,13 @@ package types import "time" - // CreateLabelOption — CreateLabelOption options for creating a label 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 @@ -21,29 +20,28 @@ type DeleteLabelsOption struct { // EditLabelOption — EditLabelOption options for editing a label 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 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 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 fab5844..974d826 100644 --- a/types/milestone.go +++ b/types/milestone.go @@ -4,34 +4,32 @@ package types import "time" - // CreateMilestoneOption — CreateMilestoneOption options for creating a milestone 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 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 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..a1eada1 100644 --- a/types/misc.go +++ b/types/misc.go @@ -4,7 +4,6 @@ package types import "time" - // AddCollaboratorOption — AddCollaboratorOption options when adding a user as a collaborator of a repository type AddCollaboratorOption struct { Permission string `json:"permission,omitempty"` @@ -13,39 +12,39 @@ type AddCollaboratorOption struct { // AddTimeOption — AddTimeOption options for adding time to an issue 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 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) 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. } 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 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 } @@ -57,7 +56,7 @@ type CreateOrUpdateSecretOption struct { // DismissPullReviewOptions — DismissPullReviewOptions are options to dismiss a pull review type DismissPullReviewOptions struct { Message string `json:"message,omitempty"` - Priors bool `json:"priors,omitempty"` + Priors bool `json:"priors,omitempty"` } // ForgeLike — ForgeLike activity data type @@ -66,105 +65,105 @@ type ForgeLike struct{} // GenerateRepoOption — GenerateRepoOption options when creating repository using a template 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 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 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 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 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 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 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 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 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 type NewIssuePinsAllowed struct { - Issues bool `json:"issues,omitempty"` + Issues bool `json:"issues,omitempty"` PullRequests bool `json:"pull_requests,omitempty"` } @@ -174,24 +173,24 @@ type NotifySubjectType struct{} // PRBranchInfo — PRBranchInfo information about a branch 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 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"` } 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 @@ -202,7 +201,7 @@ type ReplaceFlagsOption struct { // SearchResults — SearchResults results of a successful search 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 @@ -212,44 +211,43 @@ type ServerVersion struct { // TimelineComment — TimelineComment represents a timeline comment (comment of any type) on a commit or issue 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 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 dccc380..6e9fe0d 100644 --- a/types/notification.go +++ b/types/notification.go @@ -4,7 +4,6 @@ package types import "time" - // NotificationCount — NotificationCount number of unread notifications type NotificationCount struct { New int64 `json:"new,omitempty"` @@ -12,23 +11,22 @@ type NotificationCount struct { // NotificationSubject — NotificationSubject contains the notification subject (Issue/Pull/Commit) 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 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..b6d27d8 100644 --- a/types/oauth.go +++ b/types/oauth.go @@ -4,35 +4,33 @@ package types import "time" - 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 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 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"` } 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..bf084a0 100644 --- a/types/org.go +++ b/types/org.go @@ -2,51 +2,49 @@ package types - // CreateOrgOption — CreateOrgOption options for creating an organization 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 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 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 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..491c5a7 100644 --- a/types/package.go +++ b/types/package.go @@ -4,28 +4,26 @@ package types import "time" - // Package — Package represents a package 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 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 274d649..feb8bfd 100644 --- a/types/pr.go +++ b/types/pr.go @@ -4,26 +4,25 @@ package types import "time" - // CreatePullRequestOption — CreatePullRequestOption options when creating a pull request 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 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 @@ -32,122 +31,121 @@ type CreatePullReviewCommentOptions struct{} // CreatePullReviewOptions — CreatePullReviewOptions are options to create a pull review 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 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 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 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 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 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 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 type SubmitPullReviewOptions struct { - Body string `json:"body,omitempty"` + Body string `json:"body,omitempty"` Event *ReviewStateType `json:"event,omitempty"` } - diff --git a/types/quota.go b/types/quota.go index 7d9353c..62a0224 100644 --- a/types/quota.go +++ b/types/quota.go @@ -2,29 +2,28 @@ package types - // CreateQuotaGroupOptions — CreateQutaGroupOptions represents the options for creating a quota group 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 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 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 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 } @@ -35,13 +34,13 @@ type QuotaGroupList struct{} // QuotaInfo — QuotaInfo represents information about a user's quota type QuotaInfo struct { Groups *QuotaGroupList `json:"groups,omitempty"` - Used *QuotaUsed `json:"used,omitempty"` + Used *QuotaUsed `json:"used,omitempty"` } // QuotaRuleInfo — QuotaRuleInfo contains information about a quota rule 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 } @@ -53,8 +52,8 @@ type QuotaUsed struct { // QuotaUsedArtifact — QuotaUsedArtifact represents an artifact counting towards a user's quota 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 @@ -63,10 +62,10 @@ type QuotaUsedArtifactList struct{} // QuotaUsedAttachment — QuotaUsedAttachment represents an attachment counting towards a user's quota 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 @@ -76,10 +75,10 @@ type QuotaUsedAttachmentList struct{} // QuotaUsedPackage — QuotaUsedPackage represents a package counting towards a user's quota 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 @@ -89,20 +88,20 @@ type QuotaUsedPackageList struct{} // QuotaUsedSize — QuotaUsedSize represents the size-based quota usage of a user 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 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 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 } @@ -119,6 +118,5 @@ type QuotaUsedSizeGit struct { // QuotaUsedSizeRepos — QuotaUsedSizeRepos represents the size-based repository quota usage of a user 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..77cefd2 100644 --- a/types/reaction.go +++ b/types/reaction.go @@ -4,7 +4,6 @@ package types import "time" - // EditReactionOption — EditReactionOption contain the reaction type type EditReactionOption struct { Reaction string `json:"content,omitempty"` @@ -12,8 +11,7 @@ type EditReactionOption struct { // Reaction — Reaction contain one reaction 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..305eceb 100644 --- a/types/release.go +++ b/types/release.go @@ -4,48 +4,46 @@ package types import "time" - // CreateReleaseOption — CreateReleaseOption options when creating a release 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 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 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..b36f811 100644 --- a/types/repo.go +++ b/types/repo.go @@ -4,76 +4,75 @@ package types import "time" - 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 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 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 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 @@ -84,36 +83,36 @@ type ExternalWiki struct { // InternalTracker — InternalTracker represents settings for internal tracker 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 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 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"` } 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"` } @@ -124,94 +123,93 @@ type RepoTopicOptions struct { // RepoTransfer — RepoTransfer represents a pending repo transfer 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 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 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 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 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..acdb918 100644 --- a/types/review.go +++ b/types/review.go @@ -2,8 +2,6 @@ package types - // ReviewStateType — ReviewStateType review state type // ReviewStateType has no fields in the swagger spec. type ReviewStateType struct{} - diff --git a/types/settings.go b/types/settings.go index df31e57..0cc7bb6 100644 --- a/types/settings.go +++ b/types/settings.go @@ -2,38 +2,36 @@ package types - // GeneralAPISettings — GeneralAPISettings contains global api settings exposed by it 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 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 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 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..05f7c36 100644 --- a/types/status.go +++ b/types/status.go @@ -2,23 +2,21 @@ package types - // CombinedStatus — CombinedStatus holds the combined state of several statuses for a single commit 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 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..a4529d8 100644 --- a/types/tag.go +++ b/types/tag.go @@ -4,52 +4,50 @@ package types import "time" - // CreateTagOption — CreateTagOption options when creating a tag 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 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 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 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 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 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..c1aaf41 100644 --- a/types/team.go +++ b/types/team.go @@ -2,39 +2,37 @@ package types - // CreateTeamOption — CreateTeamOption options for creating a team 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 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 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..fe5216b 100644 --- a/types/time_tracking.go +++ b/types/time_tracking.go @@ -4,26 +4,24 @@ package types import "time" - // StopWatch — StopWatch represent a running stopwatch 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 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..50dc3f7 100644 --- a/types/topic.go +++ b/types/topic.go @@ -4,7 +4,6 @@ package types import "time" - // TopicName — TopicName a list of repo topic names type TopicName struct { TopicNames []string `json:"topics,omitempty"` @@ -12,10 +11,9 @@ type TopicName struct { // TopicResponse — TopicResponse for returning topics 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..7003c69 100644 --- a/types/user.go +++ b/types/user.go @@ -4,9 +4,8 @@ package types import "time" - type BlockedUser struct { - BlockID int64 `json:"block_id,omitempty"` + BlockID int64 `json:"block_id,omitempty"` Created time.Time `json:"created_at,omitempty"` } @@ -17,17 +16,17 @@ type CreateEmailOption struct { // CreateUserOption — CreateUserOption create user options 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 @@ -37,34 +36,34 @@ type DeleteEmailOption struct { // EditUserOption — EditUserOption edit user options 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 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 @@ -79,64 +78,63 @@ type UpdateUserAvatarOption struct { // User — User represents a user 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 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 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 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..cba56b9 100644 --- a/types/wiki.go +++ b/types/wiki.go @@ -2,45 +2,43 @@ package types - // CreateWikiPageOptions — CreateWikiPageOptions form for creating wiki 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 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 type WikiCommitList struct { - Count int64 `json:"count,omitempty"` + Count int64 `json:"count,omitempty"` WikiCommits []*WikiCommit `json:"commits,omitempty"` } // WikiPage — WikiPage a wiki page 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 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"` } - From 07241a502bb0beebbcd3df2b18a23f36763ab783 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:53:04 +0000 Subject: [PATCH 104/181] feat(orgs): add activity feed listings Co-Authored-By: Virgil --- orgs.go | 44 ++++++++++++++++++++++++++++++++++ orgs_test.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/orgs.go b/orgs.go index 6649795..9871529 100644 --- a/orgs.go +++ b/orgs.go @@ -4,6 +4,7 @@ import ( "context" "iter" "net/http" + "time" "dappco.re/go/core/forge/types" ) @@ -18,6 +19,20 @@ type OrgService struct { Resource[types.Organization, types.CreateOrgOption, types.EditOrgOption] } +// OrgActivityFeedListOptions controls filtering for organisation activity feeds. +type OrgActivityFeedListOptions struct { + Date *time.Time +} + +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]( @@ -87,6 +102,18 @@ func (s *OrgService) Unblock(ctx context.Context, org, username string) error { return s.client.Delete(ctx, path) } +// 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 := ResolvePath("/api/v1/users/{username}/orgs", pathParams("username", username)) @@ -108,3 +135,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_test.go b/orgs_test.go index eac1653..af100ca 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "dappco.re/go/core/forge/types" ) @@ -152,6 +153,72 @@ func TestOrgService_Unblock_Good(t *testing.T) { } } +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 { From 720e2f2e0a14d76ab4be2ebb5114ea7d2f3e26b2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:55:27 +0000 Subject: [PATCH 105/181] feat(commits): add commit pull request lookup Co-Authored-By: Virgil --- commits.go | 15 +++++++++++++++ commits_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/commits.go b/commits.go index efb1a18..d4efcec 100644 --- a/commits.go +++ b/commits.go @@ -53,6 +53,21 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, return &out, nil } +// 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/statuses/{ref}", pathParams("owner", owner, "repo", repo, "ref", ref)) diff --git a/commits_test.go b/commits_test.go index 5d57f6d..f9fbf2b 100644 --- a/commits_test.go +++ b/commits_test.go @@ -95,6 +95,41 @@ func TestCommitService_Get_Good(t *testing.T) { } } +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 { From c63e45d9e7f27706cfbe371b42a8a22b2ba1228e Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:57:55 +0000 Subject: [PATCH 106/181] Add admin quota rule endpoints Co-Authored-By: Virgil --- admin.go | 40 ++++++++++++++ admin_test.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/admin.go b/admin.go index d15ec41..d77eed3 100644 --- a/admin.go +++ b/admin.go @@ -155,6 +155,46 @@ func (s *AdminService) DeleteQuotaGroup(ctx context.Context, quotagroup string) 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) +} + +// 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) +} + // 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}) diff --git a/admin_test.go b/admin_test.go index ea0941e..31f4a26 100644 --- a/admin_test.go +++ b/admin_test.go @@ -513,6 +513,153 @@ func TestAdminService_DeleteQuotaGroup_Good(t *testing.T) { } } +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_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 { From a383ece924861a9850f2481c491276d19d249587 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 01:59:52 +0000 Subject: [PATCH 107/181] feat(users): add quota artifact listing Co-Authored-By: Virgil --- users.go | 10 +++++++++ users_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/users.go b/users.go index efb6d30..fac6f25 100644 --- a/users.go +++ b/users.go @@ -62,6 +62,16 @@ func (s *UserService) GetQuota(ctx context.Context) (*types.QuotaInfo, error) { return &out, nil } +// 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) +} + // 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) diff --git a/users_test.go b/users_test.go index 48c3802..6ea3c13 100644 --- a/users_test.go +++ b/users_test.go @@ -163,6 +163,66 @@ func TestUserService_GetQuota_Good(t *testing.T) { } } +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_ListEmails_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From d1c690ef3c73afa59c70fac55edd3b1b66f11a7d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:04:26 +0000 Subject: [PATCH 108/181] feat(activitypub): add ActivityPub actor service Co-Authored-By: Virgil --- activitypub.go | 67 +++++++++++++++++++++++++++++++++++++++++++++ activitypub_test.go | 59 +++++++++++++++++++++++++++++++++++++++ forge.go | 2 ++ forge_test.go | 3 ++ 4 files changed, 131 insertions(+) create mode 100644 activitypub.go create mode 100644 activitypub_test.go 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/forge.go b/forge.go index becab42..1e6a9ba 100644 --- a/forge.go +++ b/forge.go @@ -28,6 +28,7 @@ type Forge struct { Misc *MiscService Commits *CommitService Milestones *MilestoneService + ActivityPub *ActivityPubService } // NewForge creates a new Forge client. @@ -58,6 +59,7 @@ func NewForge(url, token string, opts ...Option) *Forge { f.Misc = newMiscService(c) f.Commits = newCommitService(c) f.Milestones = newMilestoneService(c) + f.ActivityPub = newActivityPubService(c) return f } diff --git a/forge_test.go b/forge_test.go index 5ed95f5..b77ad93 100644 --- a/forge_test.go +++ b/forge_test.go @@ -19,6 +19,9 @@ func TestForge_NewForge_Good(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_Client_Good(t *testing.T) { From 02382d137d621a6453cb01ede9ea8f30170d3741 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:06:57 +0000 Subject: [PATCH 109/181] feat(orgs): add public member endpoints Co-Authored-By: Virgil --- orgs.go | 37 ++++++++++++++++++++++ orgs_test.go | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/orgs.go b/orgs.go index 9871529..a128e4c 100644 --- a/orgs.go +++ b/orgs.go @@ -90,6 +90,43 @@ func (s *OrgService) IsBlocked(ctx context.Context, org, username string) (bool, 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}/blocks/{username}", pathParams("org", org, "username", username)) diff --git a/orgs_test.go b/orgs_test.go index af100ca..08a36ca 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -88,6 +88,35 @@ func TestOrgService_ListMembers_Good(t *testing.T) { } } +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 { @@ -117,6 +146,42 @@ func TestOrgService_ListBlockedUsers_Good(t *testing.T) { } } +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 { @@ -240,3 +305,25 @@ func TestOrgService_IsBlocked_Good(t *testing.T) { 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") + } +} From 9f263a77ecfdd3474d3f761b39f2bb8b8e03bc5a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:09:30 +0000 Subject: [PATCH 110/181] feat(repos): add issue config endpoints Co-Authored-By: Virgil --- repos.go | 20 +++++++++++++++++ repos_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/repos.go b/repos.go index 99333ac..6f7976e 100644 --- a/repos.go +++ b/repos.go @@ -374,6 +374,26 @@ func (s *RepoService) ListIssueTemplates(ctx context.Context, owner, repo string return ListAll[types.IssueTemplate](ctx, s.client, path, nil) } +// 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)) diff --git a/repos_test.go b/repos_test.go index 81208bc..c9aaa36 100644 --- a/repos_test.go +++ b/repos_test.go @@ -837,6 +837,67 @@ func TestRepoService_ListIssueTemplates_Good(t *testing.T) { } } +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 { From 204696915f551ed738676910f85aed23a4cf126c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:12:48 +0000 Subject: [PATCH 111/181] feat(orgs): add organisation membership check Co-Authored-By: Virgil --- orgs.go | 13 +++++++++++++ orgs_test.go | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/orgs.go b/orgs.go index a128e4c..aaf1e74 100644 --- a/orgs.go +++ b/orgs.go @@ -65,6 +65,19 @@ func (s *OrgService) RemoveMember(ctx context.Context, org, username string) err 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.User, error) { path := ResolvePath("/api/v1/orgs/{org}/blocks", pathParams("org", org)) diff --git a/orgs_test.go b/orgs_test.go index 08a36ca..94bb985 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -88,6 +88,28 @@ func TestOrgService_ListMembers_Good(t *testing.T) { } } +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 { From 232c1fa94343980a6730280dbb004cacffa144f9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:16:09 +0000 Subject: [PATCH 112/181] feat(repos): add pinned pull request listing Co-Authored-By: Virgil --- repos.go | 12 +++++++++ repos_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/repos.go b/repos.go index 6f7976e..1fc4e7b 100644 --- a/repos.go +++ b/repos.go @@ -556,6 +556,18 @@ func (s *RepoService) GetNewPinAllowed(ctx context.Context, owner, repo string) 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)) diff --git a/repos_test.go b/repos_test.go index c9aaa36..85a85a6 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1138,6 +1138,81 @@ func TestRepoService_GetSubscription_Good(t *testing.T) { } } +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 { From 48c6296d250e241c94ae5c49eee9093dade54e84 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:18:31 +0000 Subject: [PATCH 113/181] feat(repos): add repository permission lookup Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/repos.go b/repos.go index 1fc4e7b..447b11f 100644 --- a/repos.go +++ b/repos.go @@ -282,6 +282,16 @@ func (s *RepoService) GetCollaboratorPermission(ctx context.Context, owner, repo 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)) diff --git a/repos_test.go b/repos_test.go index 85a85a6..fb8d114 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1607,6 +1607,14 @@ func TestRepoService_GetCollaboratorPermission_Good(t *testing.T) { 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_Watch_Good(t *testing.T) { From dabfa7ee9d3153b60239351870508f9cc681b8c2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:23:01 +0000 Subject: [PATCH 114/181] fix(actions): align actions paths and add user variables Co-Authored-By: Virgil --- actions.go | 104 ++++++++++++++++++++++++++-- actions_test.go | 181 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 5 deletions(-) diff --git a/actions.go b/actions.go index 559fb28..68742f1 100644 --- a/actions.go +++ b/actions.go @@ -40,14 +40,14 @@ func (s *ActionsService) IterRepoSecrets(ctx context.Context, owner, repo string // 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", pathParams("owner", owner, "repo", repo, "name", 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{name}", pathParams("owner", owner, "repo", repo, "name", name)) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}", pathParams("owner", owner, "repo", repo, "secretname", name)) return s.client.Delete(ctx, path) } @@ -66,20 +66,20 @@ func (s *ActionsService) IterRepoVariables(ctx context.Context, owner, repo stri // 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", pathParams("owner", owner, "repo", repo, "name", 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/{name}", pathParams("owner", owner, "repo", repo, "name", name)) + 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{name}", pathParams("owner", owner, "repo", repo, "name", name)) + path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/variables/{variablename}", pathParams("owner", owner, "repo", repo, "variablename", name)) return s.client.Delete(ctx, path) } @@ -107,6 +107,100 @@ func (s *ActionsService) IterOrgVariables(ctx context.Context, org string) iter. 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", pathParams("owner", owner, "repo", repo, "workflow", workflow)) diff --git a/actions_test.go b/actions_test.go index 82a4d09..755ce7b 100644 --- a/actions_test.go +++ b/actions_test.go @@ -248,6 +248,187 @@ func TestActionsService_ListOrgVariables_Good(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 { From 7a591810ad91acc222850f8620ff96a32f10c0b8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:26:07 +0000 Subject: [PATCH 115/181] feat(admin): add quota group membership endpoints Co-Authored-By: Virgil --- admin.go | 24 +++++++++++++++++++ admin_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/admin.go b/admin.go index d77eed3..c3f0ae1 100644 --- a/admin.go +++ b/admin.go @@ -155,6 +155,30 @@ func (s *AdminService) DeleteQuotaGroup(ctx context.Context, quotagroup string) 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) diff --git a/admin_test.go b/admin_test.go index 31f4a26..900c818 100644 --- a/admin_test.go +++ b/admin_test.go @@ -513,6 +513,70 @@ func TestAdminService_DeleteQuotaGroup_Good(t *testing.T) { } } +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 { From 42286a1ee39c8d251187d3ceff80af8fc842041b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:28:59 +0000 Subject: [PATCH 116/181] feat(users): add quota attachment endpoints Co-Authored-By: Virgil --- users.go | 10 +++++++++ users_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/users.go b/users.go index fac6f25..51072c6 100644 --- a/users.go +++ b/users.go @@ -72,6 +72,16 @@ func (s *UserService) IterQuotaArtifacts(ctx context.Context) iter.Seq2[types.Qu 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) +} + // 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) diff --git a/users_test.go b/users_test.go index 6ea3c13..71413a7 100644 --- a/users_test.go +++ b/users_test.go @@ -223,6 +223,66 @@ func TestUserService_IterQuotaArtifacts_Good(t *testing.T) { } } +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_ListEmails_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 0a4b8a849a2c4e6964f32d599899888ef99e0a94 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:32:03 +0000 Subject: [PATCH 117/181] feat(users): add quota package endpoints Co-Authored-By: Virgil --- docs/api-contract.md | 6 +++++ users.go | 10 ++++++++ users_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 5f5c64f..1689e11 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -229,6 +229,12 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/users.go b/users.go index 51072c6..031e90a 100644 --- a/users.go +++ b/users.go @@ -82,6 +82,16 @@ func (s *UserService) IterQuotaAttachments(ctx context.Context) iter.Seq2[types. 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) diff --git a/users_test.go b/users_test.go index 71413a7..1668a1c 100644 --- a/users_test.go +++ b/users_test.go @@ -283,6 +283,66 @@ func TestUserService_IterQuotaAttachments_Good(t *testing.T) { } } +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 { From 39742d75c2cfb75c4f752a227499c4c11ddb7a83 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:34:41 +0000 Subject: [PATCH 118/181] feat(repos): add repository lookup by ID Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/repos.go b/repos.go index 447b11f..6379524 100644 --- a/repos.go +++ b/repos.go @@ -85,6 +85,16 @@ func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Reposit return ListIter[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } +// 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)) diff --git a/repos_test.go b/repos_test.go index fb8d114..b9121cb 100644 --- a/repos_test.go +++ b/repos_test.go @@ -46,6 +46,34 @@ func TestRepoService_ListActivityFeeds_Good(t *testing.T) { } } +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_ListTopics_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 4a7743a8acd47ae02f56bf2a17522f1627d4c77c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:37:16 +0000 Subject: [PATCH 119/181] feat(pulls): add pull lookup by base and head Co-Authored-By: Virgil --- pulls.go | 15 +++++++++++++++ pulls_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/pulls.go b/pulls.go index bdab33d..5f00e98 100644 --- a/pulls.go +++ b/pulls.go @@ -68,6 +68,21 @@ func (s *PullService) IterFiles(ctx context.Context, owner, repo string, index i 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)) diff --git a/pulls_test.go b/pulls_test.go index 65225cf..79571fc 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -151,6 +151,30 @@ func TestPullService_ListFiles_Good(t *testing.T) { } } +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) { From bb5c3c0e5be0dc41cc14be39eb91b398bce4760f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:40:45 +0000 Subject: [PATCH 120/181] Add repo runner registration token Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/repos.go b/repos.go index 6379524..a72132a 100644 --- a/repos.go +++ b/repos.go @@ -727,6 +727,16 @@ func (s *RepoService) MirrorSync(ctx context.Context, owner, repo string) error 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)) diff --git a/repos_test.go b/repos_test.go index b9121cb..234c647 100644 --- a/repos_test.go +++ b/repos_test.go @@ -74,6 +74,31 @@ func TestRepoService_GetByID_Good(t *testing.T) { } } +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_ListTopics_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From d2228a09355a196f775ddbfd5b8cf0ca5d778a42 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:43:19 +0000 Subject: [PATCH 121/181] feat: add repository team management Co-Authored-By: Virgil --- repos.go | 34 ++++++++++++++++++++ repos_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/repos.go b/repos.go index a72132a..5ed668e 100644 --- a/repos.go +++ b/repos.go @@ -257,6 +257,40 @@ func (s *RepoService) IterCollaborators(ctx context.Context, owner, repo string) 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)) diff --git a/repos_test.go b/repos_test.go index 234c647..e0497f4 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1670,6 +1670,94 @@ func TestRepoService_GetCollaboratorPermission_Good(t *testing.T) { } } +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 { From a0dee7c40ad8a888bc5f9feb93e8107a2e0830b6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:47:06 +0000 Subject: [PATCH 122/181] chore: expose client base url Co-Authored-By: Virgil --- client.go | 5 +++++ forge_test.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/client.go b/client.go index 472354c..9fac2db 100644 --- a/client.go +++ b/client.go @@ -117,6 +117,11 @@ type Client struct { rateLimit RateLimit } +// BaseURL returns the configured Forgejo base URL. +func (c *Client) BaseURL() string { + return c.baseURL +} + // RateLimit returns the last known rate limit information. func (c *Client) RateLimit() RateLimit { return c.rateLimit diff --git a/forge_test.go b/forge_test.go index b77ad93..af1ce57 100644 --- a/forge_test.go +++ b/forge_test.go @@ -33,6 +33,9 @@ func TestForge_Client_Good(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 TestRepoService_ListOrgRepos_Good(t *testing.T) { From de60dc08b51dbc9f863e9641d56f2f7cfa8fb87d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:49:47 +0000 Subject: [PATCH 123/181] feat(users): add authenticated starred listing Co-Authored-By: Virgil --- users.go | 10 +++++++++ users_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/users.go b/users.go index 031e90a..7340613 100644 --- a/users.go +++ b/users.go @@ -168,6 +168,16 @@ func (s *UserService) IterMySubscriptions(ctx context.Context) iter.Seq2[types.R 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) +} + // ListFollowers returns all followers of a user. func (s *UserService) ListFollowers(ctx context.Context, username string) ([]types.User, error) { path := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) diff --git a/users_test.go b/users_test.go index 1668a1c..e37b07f 100644 --- a/users_test.go +++ b/users_test.go @@ -823,6 +823,65 @@ func TestUserService_IterSubscriptions_Good(t *testing.T) { } } +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 { From 9ba98f9de3cc353009e52b45fdf2f93a5e11544d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:52:43 +0000 Subject: [PATCH 124/181] feat(issues): add reaction list endpoints Co-Authored-By: Virgil --- issues.go | 44 ++++++++++++++- issues_test.go | 145 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/issues.go b/issues.go index e19858d..6c78e7d 100644 --- a/issues.go +++ b/issues.go @@ -161,14 +161,26 @@ func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, inde // AddReaction adds a reaction to an issue. func (s *IssueService) AddReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) - body := map[string]string{"content": reaction} + 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/reactions", pathParams("owner", owner, "repo", repo, "index", int64String(index))) - body := map[string]string{"content": reaction} + body := types.EditReactionOption{Reaction: reaction} return s.client.DeleteWithBody(ctx, path, body) } @@ -260,6 +272,34 @@ func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, in return &out, nil } +// 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 attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { if opts == nil { return nil diff --git a/issues_test.go b/issues_test.go index c94fedf..e9d2ef5 100644 --- a/issues_test.go +++ b/issues_test.go @@ -353,6 +353,151 @@ func TestIssueService_CreateComment_Good(t *testing.T) { } } +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 { From 391083dbeeece63d4b0b90a9530eb36ad72e4e3d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:55:02 +0000 Subject: [PATCH 125/181] Add release attachment editing Co-Authored-By: Virgil --- docs/api-contract.md | 1 + releases.go | 15 +++++++++++++++ releases_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/docs/api-contract.md b/docs/api-contract.md index 1689e11..7d785f5 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -188,6 +188,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | diff --git a/releases.go b/releases.go index 4d833fd..66e5ef4 100644 --- a/releases.go +++ b/releases.go @@ -96,11 +96,26 @@ func (s *ReleaseService) CreateAttachment(ctx context.Context, owner, repo strin 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 io.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 := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID))) diff --git a/releases_test.go b/releases_test.go index 1779aa2..cfe8380 100644 --- a/releases_test.go +++ b/releases_test.go @@ -247,3 +247,32 @@ func TestReleaseService_CreateAttachmentExternalURL_Good(t *testing.T) { 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) + } +} From 8a7f969780aee8912a5518ea07f6bdfcb9225493 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 02:58:00 +0000 Subject: [PATCH 126/181] feat(repos): add repository migration endpoint Co-Authored-By: Virgil --- repos.go | 9 +++++++++ repos_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/repos.go b/repos.go index 5ed668e..92499d9 100644 --- a/repos.go +++ b/repos.go @@ -63,6 +63,15 @@ 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 +} + // ListOrgRepos returns all repositories for an organisation. func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error) { path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) diff --git a/repos_test.go b/repos_test.go index e0497f4..7c0343e 100644 --- a/repos_test.go +++ b/repos_test.go @@ -99,6 +99,45 @@ func TestRepoService_GetRunnerRegistrationToken_Good(t *testing.T) { } } +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 { From d09236ea2ab9252b09f342c8d7f3f16210f6b18c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:00:17 +0000 Subject: [PATCH 127/181] Add commit diff download helper Co-Authored-By: Virgil --- commits.go | 6 ++++++ commits_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/commits.go b/commits.go index d4efcec..499d745 100644 --- a/commits.go +++ b/commits.go @@ -53,6 +53,12 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, 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: diff --git a/commits_test.go b/commits_test.go index f9fbf2b..96c54ef 100644 --- a/commits_test.go +++ b/commits_test.go @@ -3,6 +3,7 @@ package forge import ( "context" json "github.com/goccy/go-json" + "io" "net/http" "net/http/httptest" "testing" @@ -95,6 +96,29 @@ func TestCommitService_Get_Good(t *testing.T) { } } +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 { From a26af42cfc4af8fe65dcd925759ff10a32923c90 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:03:01 +0000 Subject: [PATCH 128/181] feat(notifications): add bulk mark endpoint --- docs/api-contract.md | 1 + notifications.go | 45 ++++++++++++++++++++++++++++++++------ notifications_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index 7d785f5..434d392 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -161,6 +161,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | diff --git a/notifications.go b/notifications.go index 09cd4cb..77166a0 100644 --- a/notifications.go +++ b/notifications.go @@ -61,29 +61,45 @@ type NotificationRepoMarkOptions struct { LastReadAt *time.Time } +// NotificationMarkOptions controls how authenticated-user notifications are marked. +type NotificationMarkOptions struct { + All bool + StatusTypes []string + ToStatus string + LastReadAt *time.Time +} + func newNotificationService(c *Client) *NotificationService { return &NotificationService{client: c} } -func (o NotificationRepoMarkOptions) queryString() string { +func notificationMarkQueryString(all bool, statusTypes []string, toStatus string, lastReadAt *time.Time) string { values := url.Values{} - if o.All { + if all { values.Set("all", "true") } - for _, status := range o.StatusTypes { + for _, status := range statusTypes { if status != "" { values.Add("status-types", status) } } - if o.ToStatus != "" { - values.Set("to-status", o.ToStatus) + if toStatus != "" { + values.Set("to-status", toStatus) } - if o.LastReadAt != nil { - values.Set("last_read_at", o.LastReadAt.Format(time.RFC3339)) + 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, filters ...NotificationListOptions) ([]types.NotificationThread, error) { return s.listAll(ctx, "/api/v1/notifications", filters...) @@ -115,6 +131,21 @@ func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string, 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)) diff --git a/notifications_test.go b/notifications_test.go index 79ee76b..45635bd 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -217,6 +217,56 @@ func TestNotificationService_GetThread_Good(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 { From 1f6dc6fa763591bd6fd6db1903f14f911e4b4c8a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:06:05 +0000 Subject: [PATCH 129/181] feat(repos): add diff patch endpoint Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/repos.go b/repos.go index 92499d9..fdabd79 100644 --- a/repos.go +++ b/repos.go @@ -383,6 +383,16 @@ func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath return s.client.GetRaw(ctx, path) } +// 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)) diff --git a/repos_test.go b/repos_test.go index 7c0343e..9c39ab9 100644 --- a/repos_test.go +++ b/repos_test.go @@ -573,6 +573,50 @@ func TestRepoService_GetRawFileOrLFS_Good(t *testing.T) { } } +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 { From 628de4704b776253503761f755992406c35d6f20 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:10:10 +0000 Subject: [PATCH 130/181] feat(admin): add actions run listing Co-Authored-By: Virgil --- admin.go | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++ admin_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/admin.go b/admin.go index c3f0ae1..eb84feb 100644 --- a/admin.go +++ b/admin.go @@ -3,6 +3,9 @@ package forge import ( "context" "iter" + "net/http" + "net/url" + "strconv" "dappco.re/go/core/forge/types" ) @@ -19,6 +22,38 @@ type AdminService struct { client *Client } +// AdminActionsRunListOptions controls filtering for admin Actions run listings. +type AdminActionsRunListOptions struct { + Event string + Branch string + Status string + Actor string + HeadSHA 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 +} + func newAdminService(c *Client) *AdminService { return &AdminService{client: c} } @@ -245,6 +280,74 @@ 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, 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}) diff --git a/admin_test.go b/admin_test.go index 900c818..fdae775 100644 --- a/admin_test.go +++ b/admin_test.go @@ -801,6 +801,100 @@ func TestAdminService_AdoptRepo_Good(t *testing.T) { } } +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 { From 11e614af4a2eff184baa7479b48878efc689e6d2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:13:29 +0000 Subject: [PATCH 131/181] feat(repos): add editorconfig endpoint Co-Authored-By: Virgil --- repos.go | 16 ++++++++++++++++ repos_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/repos.go b/repos.go index fdabd79..b40af7f 100644 --- a/repos.go +++ b/repos.go @@ -383,6 +383,22 @@ func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath 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 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)) diff --git a/repos_test.go b/repos_test.go index 9c39ab9..6fca0ce 100644 --- a/repos_test.go +++ b/repos_test.go @@ -573,6 +573,42 @@ func TestRepoService_GetRawFileOrLFS_Good(t *testing.T) { } } +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 { From 452b190e9dcd16971edd064fddc04091fe5ca812 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:16:30 +0000 Subject: [PATCH 132/181] feat(users): add oauth2 application management Co-Authored-By: Virgil --- users.go | 45 +++++++++++++++ users_test.go | 155 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/users.go b/users.go index 7340613..7baf8af 100644 --- a/users.go +++ b/users.go @@ -126,6 +126,51 @@ func (s *UserService) DeleteAvatar(ctx context.Context) error { return s.client.Delete(ctx, "/api/v1/user/avatar") } +// 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) diff --git a/users_test.go b/users_test.go index e37b07f..00d3c43 100644 --- a/users_test.go +++ b/users_test.go @@ -735,6 +735,161 @@ func TestUserService_DeleteAvatar_Good(t *testing.T) { } } +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 { From 2460a7b09e357b7891f8bd483df91d48e60f5fca Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:20:35 +0000 Subject: [PATCH 133/181] feat(users): add user search endpoint Co-Authored-By: Virgil --- users.go | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ users_test.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/users.go b/users.go index 7baf8af..b01b528 100644 --- a/users.go +++ b/users.go @@ -4,6 +4,8 @@ import ( "context" "iter" "net/http" + "net/url" + "strconv" "dappco.re/go/core/forge/types" ) @@ -18,6 +20,25 @@ type UserService struct { Resource[types.User, struct{}, struct{}] } +// UserSearchOptions controls filtering for user searches. +type UserSearchOptions struct { + UID int64 +} + +func (o UserSearchOptions) queryParams() map[string]string { + if o.UID == 0 { + return nil + } + return map[string]string{ + "uid": strconv.FormatInt(o.UID, 10), + } +} + +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{}]( @@ -62,6 +83,97 @@ func (s *UserService) GetQuota(ctx context.Context) (*types.QuotaInfo, error) { 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, 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) diff --git a/users_test.go b/users_test.go index 00d3c43..654d4c3 100644 --- a/users_test.go +++ b/users_test.go @@ -5,6 +5,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "strconv" "testing" "dappco.re/go/core/forge/types" @@ -163,6 +164,111 @@ func TestUserService_GetQuota_Good(t *testing.T) { } } +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 { From 9f686bfa899018bebce7419fe7eb993b57ac3f8f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:23:37 +0000 Subject: [PATCH 134/181] feat(users): add authenticated GPG key management Co-Authored-By: Virgil --- users.go | 35 +++++++++++++ users_test.go | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/users.go b/users.go index b01b528..eba5b8b 100644 --- a/users.go +++ b/users.go @@ -238,6 +238,41 @@ func (s *UserService) DeleteAvatar(ctx context.Context) error { return s.client.Delete(ctx, "/api/v1/user/avatar") } +// 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) +} + // 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) diff --git a/users_test.go b/users_test.go index 654d4c3..9801059 100644 --- a/users_test.go +++ b/users_test.go @@ -841,6 +841,146 @@ func TestUserService_DeleteAvatar_Good(t *testing.T) { } } +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_ListOAuth2Applications_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 779d017ad960b2fe64e5c0267f7125d5b6d1a6f5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:26:47 +0000 Subject: [PATCH 135/181] feat(users): add authenticated public key management Co-Authored-By: Virgil --- users.go | 67 +++++++++++++++++++++++ users_test.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/users.go b/users.go index eba5b8b..6162185 100644 --- a/users.go +++ b/users.go @@ -34,6 +34,20 @@ func (o UserSearchOptions) queryParams() map[string]string { } } +// UserKeyListOptions controls filtering for authenticated user public key listings. +type UserKeyListOptions struct { + Fingerprint 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"` @@ -238,6 +252,59 @@ 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) +} + // 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) diff --git a/users_test.go b/users_test.go index 9801059..a7c1936 100644 --- a/users_test.go +++ b/users_test.go @@ -841,6 +841,153 @@ func TestUserService_DeleteAvatar_Good(t *testing.T) { } } +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 { From 1a8fdf53efc18b5b3175bdae5e6ebe5039652f7d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:02:22 +0000 Subject: [PATCH 136/181] feat(users): add access token and user key endpoints Co-Authored-By: Virgil --- users.go | 88 +++++++++++++++++++++++ users_test.go | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) diff --git a/users.go b/users.go index 6162185..a9de6ed 100644 --- a/users.go +++ b/users.go @@ -305,6 +305,36 @@ func (s *UserService) DeleteKey(ctx context.Context, id int64) error { 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) @@ -340,6 +370,64 @@ func (s *UserService) DeleteGPGKey(ctx context.Context, id int64) error { 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) diff --git a/users_test.go b/users_test.go index a7c1936..0488800 100644 --- a/users_test.go +++ b/users_test.go @@ -1128,6 +1128,194 @@ func TestUserService_DeleteGPGKey_Good(t *testing.T) { } } +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 { From 3fd44911ea7379b68ec2b31157f919cb5d75404b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:06:01 +0000 Subject: [PATCH 137/181] docs(forge): align service counts with activitypub Co-Authored-By: Virgil --- CLAUDE.md | 4 ++-- docs/architecture.md | 2 +- docs/index.md | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c1fe29..fead863 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 19 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`). 19 services total: repos, issues, pulls, orgs, users, teams, admin, branches, releases, labels, webhooks, notifications, packages, actions, contents, wiki, commits, 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/docs/architecture.md b/docs/architecture.md index 3b1162c..f75bcac 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 19 service structs │ ├─────────────────────────────────────────────────┤ │ Service layer │ │ RepoService, IssueService, PullService, ... │ diff --git a/docs/index.md b/docs/index.md index 2f23188..93c8b20 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 19 API domains (repositories, issues, pull requests, organisations, 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 19 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) @@ -114,7 +114,7 @@ go-forge/ ## Services -The `Forge` struct exposes 18 service fields, each handling a different API domain: +The `Forge` struct exposes 19 service fields, each handling a different API domain: | Service | Struct | Embedding | Domain | |-----------------|---------------------|----------------------------------|--------------------------------------| @@ -136,6 +136,7 @@ The `Forge` struct exposes 18 service fields, each handling a different API doma | `Wiki` | `WikiService` | (standalone) | Wiki pages | | `Commits` | `CommitService` | (standalone) | Commit statuses, git notes | | `Misc` | `MiscService` | (standalone) | Markdown, licences, gitignore, version | +| `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. From 491278904d96042c71b6aa9377fa33d477503d0d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:08:46 +0000 Subject: [PATCH 138/181] docs(forge): align service docs with milestones Co-Authored-By: Virgil --- CLAUDE.md | 4 ++-- docs/architecture.md | 2 +- docs/index.md | 13 ++++++++----- milestones.go | 7 ++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fead863..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 19 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`). 19 services total: repos, issues, pulls, orgs, users, teams, admin, branches, releases, labels, webhooks, notifications, packages, actions, contents, wiki, commits, misc, activitypub. +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/docs/architecture.md b/docs/architecture.md index f75bcac..557820c 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 19 service structs │ +│ Aggregates 20 service structs │ ├─────────────────────────────────────────────────┤ │ Service layer │ │ RepoService, IssueService, PullService, ... │ diff --git a/docs/index.md b/docs/index.md index 93c8b20..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 19 API domains (repositories, issues, pull requests, organisations, 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. +`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 19 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) @@ -95,8 +95,10 @@ go-forge/ ├── 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 19 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 | |-----------------|---------------------|----------------------------------|--------------------------------------| @@ -134,8 +136,9 @@ The `Forge` struct exposes 19 service fields, each handling a different API doma | `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. diff --git a/milestones.go b/milestones.go index 27658a8..aa55f51 100644 --- a/milestones.go +++ b/milestones.go @@ -102,11 +102,8 @@ func milestoneQuery(filters ...MilestoneListOptions) map[string]string { query := make(map[string]string, 2) for _, filter := range filters { - if filter.State != "" { - query["state"] = filter.State - } - if filter.Name != "" { - query["name"] = filter.Name + for key, value := range filter.queryParams() { + query[key] = value } } if len(query) == 0 { From acdcfc07cfcdbe642714d8f52184064097c4a28f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:20:27 +0000 Subject: [PATCH 139/181] feat(forge): add missing Forgejo endpoints Co-Authored-By: Virgil --- actions.go | 2 +- admin.go | 64 +++++++++++++++++++++ issues.go | 18 +++++- labels.go | 61 ++++++++++++++++++++ orgs.go | 118 ++++++++++++++++++++++++++++++++++++--- orgs_test.go | 18 +++--- packages.go | 8 +-- pulls.go | 78 +++++++++++++++++++++++++- repos.go | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++ teams.go | 40 ++++++++++--- users.go | 73 ++++++++++++++++++++++++ 11 files changed, 598 insertions(+), 36 deletions(-) diff --git a/actions.go b/actions.go index 68742f1..23a69b4 100644 --- a/actions.go +++ b/actions.go @@ -203,7 +203,7 @@ func (s *ActionsService) DeleteUserSecret(ctx context.Context, name string) erro // DispatchWorkflow triggers a workflow run. func (s *ActionsService) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, opts map[string]any) error { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", pathParams("owner", owner, "repo", repo, "workflow", 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) } diff --git a/admin.go b/admin.go index eb84feb..ab35e03 100644 --- a/admin.go +++ b/admin.go @@ -77,6 +77,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}) @@ -190,6 +242,18 @@ func (s *AdminService) DeleteQuotaGroup(ctx context.Context, quotagroup string) 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}) diff --git a/issues.go b/issues.go index 6c78e7d..f72d2b2 100644 --- a/issues.go +++ b/issues.go @@ -245,7 +245,7 @@ func (s *IssueService) AddLabels(ctx context.Context, owner, repo string, index // RemoveLabel removes a single label from an issue. func (s *IssueService) RemoveLabel(ctx context.Context, owner, repo string, index int64, labelID int64) error { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/labels/{labelID}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "labelID", int64String(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) } @@ -272,6 +272,22 @@ 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) +} + // 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))) diff --git a/labels.go b/labels.go index 341e7d3..37a12bb 100644 --- a/labels.go +++ b/labels.go @@ -91,3 +91,64 @@ func (s *LabelService) CreateOrgLabel(ctx context.Context, org string, opts *typ } 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/orgs.go b/orgs.go index aaf1e74..20b732a 100644 --- a/orgs.go +++ b/orgs.go @@ -79,20 +79,20 @@ func (s *OrgService) IsMember(ctx context.Context, org, username string) (bool, } // ListBlockedUsers returns all users blocked by an organisation. -func (s *OrgService) ListBlockedUsers(ctx context.Context, org string) ([]types.User, error) { - path := ResolvePath("/api/v1/orgs/{org}/blocks", pathParams("org", org)) - return ListAll[types.User](ctx, s.client, path, nil) +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.User, error] { - path := ResolvePath("/api/v1/orgs/{org}/blocks", pathParams("org", org)) - return ListIter[types.User](ctx, s.client, path, nil) +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}/blocks/{username}", pathParams("org", org, "username", username)) + 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) { @@ -142,16 +142,116 @@ func (s *OrgService) ConcealMember(ctx context.Context, org, username string) er // Block blocks a user within an organisation. func (s *OrgService) Block(ctx context.Context, org, username string) error { - path := ResolvePath("/api/v1/orgs/{org}/blocks/{username}", pathParams("org", org, "username", username)) + 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}/blocks/{username}", pathParams("org", org, "username", username)) + 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)) diff --git a/orgs_test.go b/orgs_test.go index 94bb985..81ac6d6 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -144,13 +144,13 @@ func TestOrgService_ListBlockedUsers_Good(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if r.URL.Path != "/api/v1/orgs/core/blocks" { + 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.User{ - {ID: 1, UserName: "alice"}, - {ID: 2, UserName: "bob"}, + json.NewEncoder(w).Encode([]types.BlockedUser{ + {BlockID: 1}, + {BlockID: 2}, }) })) defer srv.Close() @@ -163,8 +163,8 @@ func TestOrgService_ListBlockedUsers_Good(t *testing.T) { if len(blocked) != 2 { t.Fatalf("got %d blocked users, want 2", len(blocked)) } - if blocked[0].UserName != "alice" { - t.Errorf("got username=%q, want %q", blocked[0].UserName, "alice") + if blocked[0].BlockID != 1 { + t.Errorf("got block_id=%d, want %d", blocked[0].BlockID, 1) } } @@ -209,7 +209,7 @@ func TestOrgService_Block_Good(t *testing.T) { if r.Method != http.MethodPut { t.Errorf("expected PUT, got %s", r.Method) } - if r.URL.Path != "/api/v1/orgs/core/blocks/alice" { + if r.URL.Path != "/api/v1/orgs/core/block/alice" { t.Errorf("wrong path: %s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) @@ -227,7 +227,7 @@ func TestOrgService_Unblock_Good(t *testing.T) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) } - if r.URL.Path != "/api/v1/orgs/core/blocks/alice" { + if r.URL.Path != "/api/v1/orgs/core/unblock/alice" { t.Errorf("wrong path: %s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) @@ -311,7 +311,7 @@ func TestOrgService_IsBlocked_Good(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if r.URL.Path != "/api/v1/orgs/core/blocks/alice" { + if r.URL.Path != "/api/v1/orgs/core/block/alice" { t.Errorf("wrong path: %s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) diff --git a/packages.go b/packages.go index 2553349..43784a1 100644 --- a/packages.go +++ b/packages.go @@ -36,7 +36,7 @@ func (s *PackageService) Iter(ctx context.Context, owner string) iter.Seq2[types // 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 := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}", pathParams("owner", owner, "pkgType", pkgType, "name", name, "version", 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 @@ -46,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 := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}", pathParams("owner", owner, "pkgType", pkgType, "name", name, "version", 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 := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}/files", pathParams("owner", owner, "pkgType", pkgType, "name", name, "version", 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 := ResolvePath("/api/v1/packages/{owner}/{pkgType}/{name}/{version}/files", pathParams("owner", owner, "pkgType", pkgType, "name", name, "version", 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/pulls.go b/pulls.go index 5f00e98..ca5f865 100644 --- a/pulls.go +++ b/pulls.go @@ -44,6 +44,24 @@ func (s *PullService) Update(ctx context.Context, owner, repo string, index int6 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) @@ -112,7 +130,7 @@ func (s *PullService) CancelReviewRequests(ctx context.Context, owner, repo stri } // 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) { +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 { @@ -121,15 +139,69 @@ 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) +} + +// 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{reviewID}/dismissals", pathParams("owner", owner, "repo", repo, "index", int64String(index), "reviewID", int64String(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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{reviewID}/undismissals", pathParams("owner", owner, "repo", repo, "index", int64String(index), "reviewID", int64String(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/repos.go b/repos.go index b40af7f..bb94868 100644 --- a/repos.go +++ b/repos.go @@ -55,6 +55,30 @@ func (o ActivityFeedListOptions) queryParams() map[string]string { } } +// RepoTimeListOptions controls filtering for repository tracked times. +type RepoTimeListOptions struct { + User string + Since *time.Time + Before *time.Time +} + +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]( @@ -72,6 +96,16 @@ func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOption 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/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) { path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) @@ -635,6 +669,31 @@ func (s *RepoService) DeleteTopic(ctx context.Context, owner, repo, topic string 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)) @@ -812,6 +871,84 @@ func (s *RepoService) SyncPushMirrors(ctx context.Context, owner, repo string) e 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 @@ -832,6 +969,23 @@ func repoKeyQuery(filters ...RepoKeyListOptions) map[string]string { 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 diff --git a/teams.go b/teams.go index c37a94a..0baa9fe 100644 --- a/teams.go +++ b/teams.go @@ -27,25 +27,25 @@ func newTeamService(c *Client) *TeamService { // ListMembers returns all members of a team. func (s *TeamService) ListMembers(ctx context.Context, teamID int64) ([]types.User, error) { - path := ResolvePath("/api/v1/teams/{teamID}/members", pathParams("teamID", int64String(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 := ResolvePath("/api/v1/teams/{teamID}/members", pathParams("teamID", int64String(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 := ResolvePath("/api/v1/teams/{teamID}/members/{username}", pathParams("teamID", int64String(teamID), "username", 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/{teamID}/members/{username}", pathParams("teamID", int64String(teamID), "username", username)) + 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 @@ -55,34 +55,44 @@ func (s *TeamService) GetMember(ctx context.Context, teamID int64, username stri // RemoveMember removes a user from a team. func (s *TeamService) RemoveMember(ctx context.Context, teamID int64, username string) error { - path := ResolvePath("/api/v1/teams/{teamID}/members/{username}", pathParams("teamID", int64String(teamID), "username", 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 := ResolvePath("/api/v1/teams/{teamID}/repos", pathParams("teamID", int64String(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 := ResolvePath("/api/v1/teams/{teamID}/repos", pathParams("teamID", int64String(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 := ResolvePath("/api/v1/teams/{teamID}/repos/{org}/{repo}", pathParams("teamID", int64String(teamID), "org", org, "repo", 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 := ResolvePath("/api/v1/teams/{teamID}/repos/{org}/{repo}", pathParams("teamID", int64String(teamID), "org", org, "repo", 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 := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) @@ -94,3 +104,15 @@ func (s *TeamService) IterOrgTeams(ctx context.Context, org string) iter.Seq2[ty 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/users.go b/users.go index a9de6ed..a097792 100644 --- a/users.go +++ b/users.go @@ -525,6 +525,55 @@ func (s *UserService) IterMyStarred(ctx context.Context) iter.Seq2[types.Reposit 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) +} + +// 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 := ResolvePath("/api/v1/users/{username}/followers", pathParams("username", username)) @@ -561,6 +610,30 @@ func (s *UserService) IterFollowing(ctx context.Context, username string) iter.S 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 := ResolvePath("/api/v1/user/following/{username}", pathParams("username", username)) From db31014f5fc3dfbebaa85d640e4c6353b58dcef9 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:24:47 +0000 Subject: [PATCH 140/181] feat(forge): add iterator variants for list endpoints Co-Authored-By: Virgil --- admin.go | 32 ++++++++++++++++++++++++ admin_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ commits.go | 16 ++++++++++++ commits_test.go | 33 +++++++++++++++++++++++++ repos.go | 16 ++++++++++++ repos_test.go | 43 ++++++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+) diff --git a/admin.go b/admin.go index ab35e03..3af3560 100644 --- a/admin.go +++ b/admin.go @@ -217,6 +217,22 @@ func (s *AdminService) ListQuotaGroups(ctx context.Context) ([]types.QuotaGroup, 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 @@ -283,6 +299,22 @@ func (s *AdminService) ListQuotaRules(ctx context.Context) ([]types.QuotaRuleInf 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 diff --git a/admin_test.go b/admin_test.go index fdae775..b6bc11f 100644 --- a/admin_test.go +++ b/admin_test.go @@ -411,6 +411,39 @@ func TestAdminService_ListQuotaGroups_Good(t *testing.T) { } } +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 { @@ -606,6 +639,39 @@ func TestAdminService_ListQuotaRules_Good(t *testing.T) { } } +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 { diff --git a/commits.go b/commits.go index 499d745..a7fd518 100644 --- a/commits.go +++ b/commits.go @@ -94,6 +94,22 @@ 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/statuses/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) diff --git a/commits_test.go b/commits_test.go index 96c54ef..ef9c09d 100644 --- a/commits_test.go +++ b/commits_test.go @@ -185,6 +185,39 @@ func TestCommitService_ListStatuses_Good(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 { diff --git a/repos.go b/repos.go index bb94868..c283119 100644 --- a/repos.go +++ b/repos.go @@ -497,6 +497,22 @@ func (s *RepoService) ListIssueTemplates(ctx context.Context, owner, repo string 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)) diff --git a/repos_test.go b/repos_test.go index 6fca0ce..1a32fdd 100644 --- a/repos_test.go +++ b/repos_test.go @@ -1009,6 +1009,49 @@ func TestRepoService_ListIssueTemplates_Good(t *testing.T) { } } +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 { From ad2bab3835bc530ed957ce8f4568aa75bba2f4b6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:30:02 +0000 Subject: [PATCH 141/181] feat(forge): add missing admin and issue endpoints Co-Authored-By: Virgil --- admin.go | 45 ++++++++++++++++++++ admin_test.go | 52 +++++++++++++++++++++++ issues.go | 75 ++++++++++++++++++++++++++++++++ issues_test.go | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ misc.go | 9 ++++ misc_test.go | 23 ++++++++++ 6 files changed, 317 insertions(+) diff --git a/admin.go b/admin.go index 3af3560..9974a44 100644 --- a/admin.go +++ b/admin.go @@ -54,6 +54,18 @@ func (o AdminActionsRunListOptions) queryParams() map[string]string { return query } +// AdminUnadoptedListOptions controls filtering for unadopted repository listings. +type AdminUnadoptedListOptions struct { + Pattern 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} } @@ -350,6 +362,16 @@ func (s *AdminService) DeleteQuotaRule(ctx context.Context, quotarule string) er 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}) @@ -450,6 +472,29 @@ func (s *AdminService) AdoptRepo(ctx context.Context, owner, repo string) error 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 b6bc11f..8b31cb2 100644 --- a/admin_test.go +++ b/admin_test.go @@ -867,6 +867,58 @@ func TestAdminService_AdoptRepo_Good(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 { diff --git a/issues.go b/issues.go index f72d2b2..5490dd1 100644 --- a/issues.go +++ b/issues.go @@ -26,6 +26,26 @@ type AttachmentUploadOptions struct { UpdatedAt *time.Time } +// RepoCommentListOptions controls filtering for repository-wide issue comment listings. +type RepoCommentListOptions struct { + Since *time.Time + Before *time.Time +} + +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]( @@ -288,6 +308,44 @@ func (s *IssueService) DeleteComment(ctx context.Context, owner, repo string, in 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))) @@ -552,6 +610,23 @@ 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 != "" { diff --git a/issues_test.go b/issues_test.go index e9d2ef5..6d65335 100644 --- a/issues_test.go +++ b/issues_test.go @@ -353,6 +353,119 @@ func TestIssueService_CreateComment_Good(t *testing.T) { } } +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") + 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 { diff --git a/misc.go b/misc.go index 85e23d3..d53d15f 100644 --- a/misc.go +++ b/misc.go @@ -135,6 +135,15 @@ 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 diff --git a/misc_test.go b/misc_test.go index 14f5c98..3e03580 100644 --- a/misc_test.go +++ b/misc_test.go @@ -451,6 +451,29 @@ func TestMiscService_GetNodeInfo_Good(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) From 39ae1b3dda66778bd8b359c4e0756f9452e3813f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:34:26 +0000 Subject: [PATCH 142/181] fix: return repository transfer responses Co-Authored-By: Virgil --- repos.go | 24 +++++++++--- repos_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/repos.go b/repos.go index c283119..79fefab 100644 --- a/repos.go +++ b/repos.go @@ -848,21 +848,33 @@ func (s *RepoService) IterForks(ctx context.Context, owner, repo string) iter.Se } // Transfer initiates a repository transfer. -func (s *RepoService) Transfer(ctx context.Context, owner, repo string, opts map[string]any) error { +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)) - return s.client.Post(ctx, path, opts, nil) + 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 { +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)) - return s.client.Post(ctx, path, nil, nil) + 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 { +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)) - return s.client.Post(ctx, path, nil, nil) + 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. diff --git a/repos_test.go b/repos_test.go index 1a32fdd..7da727e 100644 --- a/repos_test.go +++ b/repos_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" json "github.com/goccy/go-json" + "io" "net/http" "net/http/httptest" "reflect" @@ -2004,6 +2005,105 @@ func TestRepoService_ForkWithOptions_Good(t *testing.T) { } } +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" From 55616b5e1e82152fcbe71fab72bc113551713017 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:37:42 +0000 Subject: [PATCH 143/181] chore(forge): align stdlib io usage with AX conventions Co-Authored-By: Virgil --- client.go | 19 ++++++++++--------- issues.go | 9 +++++---- releases.go | 7 ++++--- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/client.go b/client.go index 9fac2db..e3a41aa 100644 --- a/client.go +++ b/client.go @@ -4,12 +4,13 @@ import ( "bytes" "context" json "github.com/goccy/go-json" - "io" "mime/multipart" "net/http" "net/url" "strconv" + goio "io" + core "dappco.re/go/core" ) @@ -196,7 +197,7 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er 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 { @@ -226,7 +227,7 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte return nil, c.parseError(resp, path) } - data, err := io.ReadAll(resp.Body) + data, err := goio.ReadAll(resp.Body) if err != nil { return nil, core.E("Client.PostRaw", "forge: read response body", err) } @@ -259,7 +260,7 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return nil, c.parseError(resp, path) } - data, err := io.ReadAll(resp.Body) + data, err := goio.ReadAll(resp.Body) if err != nil { return nil, core.E("Client.PostText", "forge: read response body", err) } @@ -267,7 +268,7 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return data, nil } -func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fields map[string]string, fieldName, fileName string, content io.Reader, out any) error { +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) @@ -293,7 +294,7 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s return core.E("Client.PostMultipart", "forge: create multipart form file", err) } if content != nil { - if _, err := io.Copy(part, content); err != nil { + if _, err := goio.Copy(part, content); err != nil { return core.E("Client.PostMultipart", "forge: write multipart form file", err) } } @@ -357,7 +358,7 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { return nil, c.parseError(resp, path) } - data, err := io.ReadAll(resp.Body) + data, err := goio.ReadAll(resp.Body) if err != nil { return nil, core.E("Client.GetRaw", "forge: read response body", err) } @@ -373,7 +374,7 @@ 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 { @@ -423,7 +424,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/issues.go b/issues.go index 5490dd1..b993ebe 100644 --- a/issues.go +++ b/issues.go @@ -2,11 +2,12 @@ package forge import ( "context" - "io" "iter" "strconv" "time" + goio "io" + "dappco.re/go/core/forge/types" ) @@ -391,7 +392,7 @@ func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { return query } -func (s *IssueService) createAttachment(ctx context.Context, path string, opts *AttachmentUploadOptions, filename string, content io.Reader) (*types.Attachment, error) { +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 @@ -400,7 +401,7 @@ func (s *IssueService) createAttachment(ctx context.Context, path string, opts * } // 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 io.Reader) (*types.Attachment, error) { +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) } @@ -466,7 +467,7 @@ func (s *IssueService) GetCommentAttachment(ctx context.Context, owner, repo str } // 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 io.Reader) (*types.Attachment, error) { +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) } diff --git a/releases.go b/releases.go index 66e5ef4..48a8176 100644 --- a/releases.go +++ b/releases.go @@ -2,9 +2,10 @@ package forge import ( "context" - "io" "iter" + goio "io" + "dappco.re/go/core/forge/types" ) @@ -79,7 +80,7 @@ func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, rel // // 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 io.Reader) (*types.Attachment, error) { +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/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID))) fields := make(map[string]string, 1) fieldName := "attachment" @@ -107,7 +108,7 @@ func (s *ReleaseService) EditAttachment(ctx context.Context, owner, repo string, } // 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 io.Reader) (*types.Attachment, error) { +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) } From 870d143f7889a5c2f3929f31a9e2437f5460827b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:45:54 +0000 Subject: [PATCH 144/181] chore(forge): centralise default pagination limit Co-Authored-By: Virgil --- notifications.go | 6 +++--- pagination.go | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/notifications.go b/notifications.go index 77166a0..720a727 100644 --- a/notifications.go +++ b/notifications.go @@ -187,7 +187,7 @@ func (s *NotificationService) listAll(ctx context.Context, path string, filters page := 1 for { - result, err := s.listPage(ctx, path, ListOptions{Page: page, Limit: 50}, filters...) + result, err := s.listPage(ctx, path, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) if err != nil { return nil, err } @@ -205,7 +205,7 @@ func (s *NotificationService) listIter(ctx context.Context, path string, filters return func(yield func(types.NotificationThread, error) bool) { page := 1 for { - result, err := s.listPage(ctx, path, ListOptions{Page: page, Limit: 50}, filters...) + result, err := s.listPage(ctx, path, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) if err != nil { yield(*new(types.NotificationThread), err) return @@ -228,7 +228,7 @@ func (s *NotificationService) listPage(ctx context.Context, path string, opts Li opts.Page = 1 } if opts.Limit < 1 { - opts.Limit = 50 + opts.Limit = defaultPageLimit } u, err := url.Parse(path) diff --git a/pagination.go b/pagination.go index 3c2c468..77b8b22 100644 --- a/pagination.go +++ b/pagination.go @@ -10,6 +10,8 @@ import ( core "dappco.re/go/core" ) +const defaultPageLimit = 50 + // ListOptions controls pagination. // // Usage: @@ -54,7 +56,7 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri opts.Page = 1 } if opts.Limit < 1 { - opts.Limit = 50 + opts.Limit = defaultPageLimit } u, err := url.Parse(path) @@ -100,7 +102,7 @@ func ListAll[T any](ctx context.Context, c *Client, path string, query map[strin 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 } @@ -125,7 +127,7 @@ func ListIter[T any](ctx context.Context, c *Client, path string, query map[stri 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 From 7444ad47f9717a34d9ce049de7f76d1db41db014 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:48:19 +0000 Subject: [PATCH 145/181] chore(forge): centralise default pagination limit Co-Authored-By: Virgil --- pagination.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pagination.go b/pagination.go index 77b8b22..40bf4f0 100644 --- a/pagination.go +++ b/pagination.go @@ -29,7 +29,7 @@ type ListOptions struct { // // page, err := forge.ListPage[types.Repository](ctx, client, path, nil, forge.DefaultList) // _ = page -var DefaultList = ListOptions{Page: 1, Limit: 50} +var DefaultList = ListOptions{Page: 1, Limit: defaultPageLimit} // PagedResult holds a single page of results with metadata. // From 597f4e0bb8b5b25bb0a7ff402fcc4d72489f002d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:51:49 +0000 Subject: [PATCH 146/181] fix(forge): track rate limits for raw requests Co-Authored-By: Virgil --- client.go | 6 +++++ client_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ misc_test.go | 7 ++++++ 3 files changed, 72 insertions(+) diff --git a/client.go b/client.go index e3a41aa..a91c96d 100644 --- a/client.go +++ b/client.go @@ -223,6 +223,8 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return nil, c.parseError(resp, path) } @@ -256,6 +258,8 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return nil, c.parseError(resp, path) } @@ -354,6 +358,8 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return nil, c.parseError(resp, path) } diff --git a/client_test.go b/client_test.go index 57fb5c6..25cf217 100644 --- a/client_test.go +++ b/client_test.go @@ -63,6 +63,36 @@ func TestClient_Post_Good(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 { @@ -79,6 +109,35 @@ func TestClient_Delete_Good(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) diff --git a/misc_test.go b/misc_test.go index 3e03580..9685084 100644 --- a/misc_test.go +++ b/misc_test.go @@ -101,6 +101,9 @@ func TestMiscService_RenderMarkdownRaw_Good(t *testing.T) { 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")) })) @@ -115,6 +118,10 @@ func TestMiscService_RenderMarkdownRaw_Good(t *testing.T) { 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) { From 49daff2cb626167294186c462232ed8bb1e342fd Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:57:47 +0000 Subject: [PATCH 147/181] feat(forge): add missing collection wrappers Co-Authored-By: Virgil --- branches.go | 21 +++++++++++++ branches_extra_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ commits.go | 10 ++++++ commits_extra_test.go | 36 ++++++++++++++++++++++ issues.go | 21 +++++++++++++ issues_extra_test.go | 63 ++++++++++++++++++++++++++++++++++++++ orgs.go | 19 ++++++++++++ orgs_extra_test.go | 63 ++++++++++++++++++++++++++++++++++++++ pulls.go | 21 +++++++++++++ pulls_extra_test.go | 67 ++++++++++++++++++++++++++++++++++++++++ releases.go | 27 +++++++++++++++-- releases_extra_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ users.go | 10 ++++++ users_extra_test.go | 34 +++++++++++++++++++++ webhooks.go | 21 +++++++++++++ webhooks_extra_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 branches_extra_test.go create mode 100644 commits_extra_test.go create mode 100644 issues_extra_test.go create mode 100644 orgs_extra_test.go create mode 100644 pulls_extra_test.go create mode 100644 releases_extra_test.go create mode 100644 users_extra_test.go create mode 100644 webhooks_extra_test.go diff --git a/branches.go b/branches.go index b7d4fd1..305fadc 100644 --- a/branches.go +++ b/branches.go @@ -25,6 +25,27 @@ func newBranchService(c *Client) *BranchService { } } +// 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 +} + // ListBranchProtections returns all branch protections for a repository. func (s *BranchService) ListBranchProtections(ctx context.Context, owner, repo string) ([]types.BranchProtection, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections", pathParams("owner", owner, "repo", repo)) 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/commits.go b/commits.go index a7fd518..38631fc 100644 --- a/commits.go +++ b/commits.go @@ -84,6 +84,16 @@ func (s *CommitService) GetCombinedStatus(ctx context.Context, owner, repo, ref 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 + } + return &out, nil +} + // ListStatuses returns all commit statuses for a given ref. func (s *CommitService) ListStatuses(ctx context.Context, owner, repo, ref string) ([]types.CommitStatus, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/commits/{ref}/statuses", pathParams("owner", owner, "repo", repo, "ref", ref)) 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/issues.go b/issues.go index b993ebe..450b0eb 100644 --- a/issues.go +++ b/issues.go @@ -142,6 +142,27 @@ func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOp 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) ([]types.Issue, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Issue](ctx, s.client, path, nil) +} + +// IterIssues returns an iterator over all issues in a repository. +func (s *IssueService) IterIssues(ctx context.Context, owner, repo string) 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, nil) +} + +// 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) 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/orgs.go b/orgs.go index 20b732a..d963fe4 100644 --- a/orgs.go +++ b/orgs.go @@ -41,6 +41,25 @@ 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 := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) 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/pulls.go b/pulls.go index ca5f865..895d9b3 100644 --- a/pulls.go +++ b/pulls.go @@ -25,6 +25,27 @@ func newPullService(c *Client) *PullService { } } +// ListPullRequests returns all pull requests in a repository. +func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string) ([]types.PullRequest, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) + return ListAll[types.PullRequest](ctx, s.client, path, nil) +} + +// IterPullRequests returns an iterator over all pull requests in a repository. +func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string) iter.Seq2[types.PullRequest, error] { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) + return ListIter[types.PullRequest](ctx, s.client, path, nil) +} + +// 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/merge", pathParams("owner", owner, "repo", repo, "index", int64String(index))) 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/releases.go b/releases.go index 48a8176..872aabb 100644 --- a/releases.go +++ b/releases.go @@ -44,6 +44,27 @@ func newReleaseService(c *Client) *ReleaseService { } } +// ListReleases returns all releases in a repository. +func (s *ReleaseService) ListReleases(ctx context.Context, owner, repo string) ([]types.Release, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) + return ListAll[types.Release](ctx, s.client, path, nil) +} + +// IterReleases returns an iterator over all releases in a repository. +func (s *ReleaseService) IterReleases(ctx context.Context, owner, repo string) 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, nil) +} + +// 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/tags/{tag}", pathParams("owner", owner, "repo", repo, "tag", tag)) @@ -72,7 +93,7 @@ func (s *ReleaseService) DeleteByTag(ctx context.Context, owner, repo, tag strin // ListAssets returns all assets for a release. func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, releaseID int64) ([]types.Attachment, error) { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(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) } @@ -81,7 +102,7 @@ func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, rel // 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/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID))) + 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 != "" { @@ -119,7 +140,7 @@ func (s *ReleaseService) EditAsset(ctx context.Context, owner, repo string, rele // 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(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) } 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/users.go b/users.go index a097792..a9d3609 100644 --- a/users.go +++ b/users.go @@ -535,6 +535,16 @@ func (s *UserService) IterMyFollowers(ctx context.Context) iter.Seq2[types.User, 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) 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/webhooks.go b/webhooks.go index a700ea3..bdd0818 100644 --- a/webhooks.go +++ b/webhooks.go @@ -26,6 +26,27 @@ 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/{id}/tests", pathParams("owner", owner, "repo", repo, "id", int64String(id))) 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) + } +} From c7596dfea3ad1d44d47ebf2ec721150696d62048 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:01:04 +0000 Subject: [PATCH 148/181] fix(forge): use canonical org repo creation route Co-Authored-By: Virgil --- repos.go | 2 +- repos_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/repos.go b/repos.go index 79fefab..6ccb040 100644 --- a/repos.go +++ b/repos.go @@ -98,7 +98,7 @@ func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOption // 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/org/{org}/repos", pathParams("org", org)) + 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 diff --git a/repos_test.go b/repos_test.go index 7da727e..a5616fc 100644 --- a/repos_test.go +++ b/repos_test.go @@ -2127,6 +2127,34 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { } }) + 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("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" From dab3d472e91d648c65ce32a6717bdea9353b2897 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:04:25 +0000 Subject: [PATCH 149/181] feat(repos): add deprecated org repo route Co-Authored-By: Virgil --- repos.go | 10 ++++++++++ repos_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/repos.go b/repos.go index 6ccb040..5686ced 100644 --- a/repos.go +++ b/repos.go @@ -106,6 +106,16 @@ func (s *RepoService) CreateOrgRepo(ctx context.Context, org string, opts *types 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) { path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) diff --git a/repos_test.go b/repos_test.go index a5616fc..f813e2d 100644 --- a/repos_test.go +++ b/repos_test.go @@ -2155,6 +2155,34 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { } }) + 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("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" From 2dcb22fb3f1673862faea9c2a2327a70578a48ab Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:09:36 +0000 Subject: [PATCH 150/181] docs(forge): add usage examples to option types Co-Authored-By: Virgil --- admin.go | 8 ++++++++ issues.go | 12 ++++++++++++ milestones.go | 4 ++++ notifications.go | 12 ++++++++++++ orgs.go | 4 ++++ releases.go | 4 ++++ repos.go | 12 ++++++++++++ users.go | 8 ++++++++ 8 files changed, 64 insertions(+) diff --git a/admin.go b/admin.go index 9974a44..779f73e 100644 --- a/admin.go +++ b/admin.go @@ -23,6 +23,10 @@ type AdminService struct { } // AdminActionsRunListOptions controls filtering for admin Actions run listings. +// +// Usage: +// +// opts := forge.AdminActionsRunListOptions{Event: "push", Status: "success"} type AdminActionsRunListOptions struct { Event string Branch string @@ -55,6 +59,10 @@ func (o AdminActionsRunListOptions) queryParams() map[string]string { } // AdminUnadoptedListOptions controls filtering for unadopted repository listings. +// +// Usage: +// +// opts := forge.AdminUnadoptedListOptions{Pattern: "core/*"} type AdminUnadoptedListOptions struct { Pattern string } diff --git a/issues.go b/issues.go index 450b0eb..3d56461 100644 --- a/issues.go +++ b/issues.go @@ -22,12 +22,20 @@ type IssueService struct { } // AttachmentUploadOptions controls metadata sent when uploading an attachment. +// +// Usage: +// +// opts := forge.AttachmentUploadOptions{Name: "screenshot.png"} type AttachmentUploadOptions struct { Name string UpdatedAt *time.Time } // 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 @@ -56,6 +64,10 @@ 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 diff --git a/milestones.go b/milestones.go index aa55f51..e560f6a 100644 --- a/milestones.go +++ b/milestones.go @@ -8,6 +8,10 @@ import ( ) // MilestoneListOptions controls filtering for repository milestone listings. +// +// Usage: +// +// opts := forge.MilestoneListOptions{State: "open"} type MilestoneListOptions struct { State string Name string diff --git a/notifications.go b/notifications.go index 720a727..5e84dc7 100644 --- a/notifications.go +++ b/notifications.go @@ -12,6 +12,10 @@ import ( ) // NotificationListOptions controls filtering for notification listings. +// +// Usage: +// +// opts := forge.NotificationListOptions{All: true, StatusTypes: []string{"unread"}} type NotificationListOptions struct { All bool StatusTypes []string @@ -54,6 +58,10 @@ type NotificationService struct { } // NotificationRepoMarkOptions controls how repository notifications are marked. +// +// Usage: +// +// opts := forge.NotificationRepoMarkOptions{All: true, ToStatus: "read"} type NotificationRepoMarkOptions struct { All bool StatusTypes []string @@ -62,6 +70,10 @@ type NotificationRepoMarkOptions struct { } // NotificationMarkOptions controls how authenticated-user notifications are marked. +// +// Usage: +// +// opts := forge.NotificationMarkOptions{All: true, ToStatus: "read"} type NotificationMarkOptions struct { All bool StatusTypes []string diff --git a/orgs.go b/orgs.go index d963fe4..0b655b1 100644 --- a/orgs.go +++ b/orgs.go @@ -20,6 +20,10 @@ type OrgService struct { } // OrgActivityFeedListOptions controls filtering for organisation activity feeds. +// +// Usage: +// +// opts := forge.OrgActivityFeedListOptions{Date: &day} type OrgActivityFeedListOptions struct { Date *time.Time } diff --git a/releases.go b/releases.go index 872aabb..d530305 100644 --- a/releases.go +++ b/releases.go @@ -20,6 +20,10 @@ type ReleaseService struct { } // ReleaseAttachmentUploadOptions controls metadata sent when uploading a release attachment. +// +// Usage: +// +// opts := forge.ReleaseAttachmentUploadOptions{Name: "release.zip"} type ReleaseAttachmentUploadOptions struct { Name string ExternalURL string diff --git a/repos.go b/repos.go index 5686ced..a266285 100644 --- a/repos.go +++ b/repos.go @@ -22,6 +22,10 @@ type RepoService struct { } // RepoKeyListOptions controls filtering for repository key listings. +// +// Usage: +// +// opts := forge.RepoKeyListOptions{Fingerprint: "AB:CD"} type RepoKeyListOptions struct { KeyID int64 Fingerprint string @@ -42,6 +46,10 @@ func (o RepoKeyListOptions) queryParams() map[string]string { } // ActivityFeedListOptions controls filtering for repository activity feeds. +// +// Usage: +// +// opts := forge.ActivityFeedListOptions{Date: &day} type ActivityFeedListOptions struct { Date *time.Time } @@ -56,6 +64,10 @@ func (o ActivityFeedListOptions) queryParams() map[string]string { } // RepoTimeListOptions controls filtering for repository tracked times. +// +// Usage: +// +// opts := forge.RepoTimeListOptions{User: "alice"} type RepoTimeListOptions struct { User string Since *time.Time diff --git a/users.go b/users.go index a9d3609..3f90f12 100644 --- a/users.go +++ b/users.go @@ -21,6 +21,10 @@ type UserService struct { } // UserSearchOptions controls filtering for user searches. +// +// Usage: +// +// opts := forge.UserSearchOptions{UID: 1001} type UserSearchOptions struct { UID int64 } @@ -35,6 +39,10 @@ func (o UserSearchOptions) queryParams() map[string]string { } // UserKeyListOptions controls filtering for authenticated user public key listings. +// +// Usage: +// +// opts := forge.UserKeyListOptions{Fingerprint: "AB:CD"} type UserKeyListOptions struct { Fingerprint string } From 3fd449a11e960f298bed3f2649a088cb0723831b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:14:47 +0000 Subject: [PATCH 151/181] docs(forgegen): add usage examples to generated option types Co-Authored-By: Virgil --- cmd/forgegen/generator.go | 133 +++++++++++++- cmd/forgegen/generator_test.go | 34 ++++ cmd/forgegen/parser.go | 1 + types/action.go | 57 +++--- types/activity.go | 27 +-- types/admin.go | 15 +- types/branch.go | 189 ++++++++++---------- types/comment.go | 23 +-- types/commit.go | 57 +++--- types/common.go | 37 ++-- types/content.go | 129 +++++++------- types/error.go | 19 +- types/federation.go | 33 ++-- types/git.go | 85 +++++---- types/hook.go | 87 ++++++---- types/issue.go | 161 +++++++++-------- types/key.go | 83 +++++---- types/label.go | 47 +++-- types/milestone.go | 45 +++-- types/misc.go | 289 ++++++++++++++++++------------- types/notification.go | 29 ++-- types/oauth.go | 41 +++-- types/org.go | 69 ++++---- types/package.go | 27 +-- types/pr.go | 227 +++++++++++++----------- types/quota.go | 59 ++++--- types/reaction.go | 11 +- types/release.go | 71 ++++---- types/repo.go | 305 ++++++++++++++++++--------------- types/review.go | 1 + types/settings.go | 29 ++-- types/status.go | 27 +-- types/tag.go | 49 ++++-- types/team.go | 55 +++--- types/time_tracking.go | 29 ++-- types/topic.go | 11 +- types/user.go | 193 ++++++++++++--------- types/wiki.go | 39 +++-- 38 files changed, 1673 insertions(+), 1150 deletions(-) diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index e339161..9bee666 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -5,6 +5,7 @@ import ( "cmp" "maps" "slices" + "strings" "text/template" core "dappco.re/go/core" @@ -175,6 +176,12 @@ import "time" {{- if .Description}} // {{.Name}} — {{sanitise .Description}} {{- end}} +{{- if .Usage}} +// +// Usage: +// +// opts := {{.Usage}} +{{- end}} {{- if .IsEnum}} type {{.Name}} string @@ -215,6 +222,8 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { return core.E("Generate", "create output directory", err) } + populateUsageExamples(types) + // Group types by output file. groups := make(map[string][]*GoType) for _, gt := range types { @@ -243,6 +252,127 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { return nil } +func populateUsageExamples(types map[string]*GoType) { + for _, gt := range types { + if !shouldHaveUsage(gt.Name) { + continue + } + gt.Usage = usageExample(gt) + } +} + +func shouldHaveUsage(name string) bool { + if core.HasSuffix(name, "Option") || core.HasSuffix(name, "Options") { + return true + } + for _, prefix := range []string{ + "Create", "Edit", "Update", "Delete", "Add", "Set", + "Dispatch", "Migrate", "Generate", "Replace", "Submit", "Transfer", + } { + if core.HasPrefix(name, prefix) { + return true + } + } + return false +} + +func usageExample(gt *GoType) string { + 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 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 "map[string]string{\"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 { @@ -264,7 +394,8 @@ func writeFile(path string, types []*GoType) error { return core.E("writeFile", "execute template", err) } - if err := coreio.Local.Write(path, buf.String()); err != nil { + content := strings.TrimRight(buf.String(), "\n") + "\n" + if err := coreio.Local.Write(path, content); err != nil { return core.E("writeFile", "write file", err) } diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 9fd1b72..8e5910e 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -170,3 +170,37 @@ func TestGenerate_AdditionalProperties_Good(t *testing.T) { 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) + } +} diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index 5c0acee..9a20fe2 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -71,6 +71,7 @@ type SchemaProperty struct { type GoType struct { Name string Description string + Usage string Fields []GoField IsEnum bool EnumValues []string diff --git a/types/action.go b/types/action.go index ebd7fec..b3eaa20 100644 --- a/types/action.go +++ b/types/action.go @@ -4,56 +4,69 @@ package types import "time" + // ActionTask — ActionTask represents a ActionTask 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 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 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]string `json:"inputs,omitempty"` // Input keys and values configured in the workflow file. - Ref string `json:"ref"` // Git reference for the workflow + Ref string `json:"ref"` // Git reference for the workflow } // Secret — Secret represents a secret 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 d3793c8..63bf392 100644 --- a/types/activity.go +++ b/types/activity.go @@ -4,20 +4,21 @@ package types import "time" + 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 diff --git a/types/admin.go b/types/admin.go index f9418d2..7b89b65 100644 --- a/types/admin.go +++ b/types/admin.go @@ -4,16 +4,21 @@ package types import "time" + // Cron — Cron represents a Cron task 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 fd46ab5..1503a73 100644 --- a/types/branch.go +++ b/types/branch.go @@ -4,114 +4,131 @@ package types import "time" + // Branch — Branch represents a repository branch 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 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 57867ca..06c703f 100644 --- a/types/comment.go +++ b/types/comment.go @@ -4,17 +4,18 @@ package types import "time" + // Comment — Comment represents a comment on a commit or issue 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 5a654b6..c6ddbe0 100644 --- a/types/commit.go +++ b/types/commit.go @@ -4,55 +4,60 @@ package types import "time" + 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 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"` } 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 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 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" @@ -60,7 +65,7 @@ type CommitStatus struct { type CommitStatusState struct{} 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 e5c2586..5e0d84a 100644 --- a/types/common.go +++ b/types/common.go @@ -4,33 +4,40 @@ package types import "time" + // Attachment — Attachment a generic attachment 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 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 is the state of an issue or PR: "open", "closed". -type StateType string +// StateType — StateType issue state type +// StateType has no fields in the swagger spec. +type StateType struct{} -// TimeStamp is a Forgejo timestamp string. -type TimeStamp string +// TimeStamp — TimeStamp defines a timestamp +// TimeStamp has no fields in the swagger spec. +type TimeStamp struct{} diff --git a/types/content.go b/types/content.go index 057cf5f..b5908b3 100644 --- a/types/content.go +++ b/types/content.go @@ -4,99 +4,112 @@ package types import "time" + // ContentsResponse — ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content 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. } 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 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 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 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 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 ba5c0b6..aa734e7 100644 --- a/types/error.go +++ b/types/error.go @@ -2,39 +2,40 @@ package types + // APIError — APIError is an api error with a message type APIError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } type APIForbiddenError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } type APIInvalidTopicsError struct { InvalidTopics []string `json:"invalidTopics,omitempty"` - Message string `json:"message,omitempty"` + Message string `json:"message,omitempty"` } 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"` } type APIRepoArchivedError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } type APIUnauthorizedError struct { Message string `json:"message,omitempty"` - URL string `json:"url,omitempty"` + URL string `json:"url,omitempty"` } 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 f798101..99d2bdb 100644 --- a/types/federation.go +++ b/types/federation.go @@ -2,41 +2,42 @@ package types + // NodeInfo — NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks 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 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 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 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 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 ec723f5..332ef6d 100644 --- a/types/git.go +++ b/types/git.go @@ -2,91 +2,100 @@ package types + // AnnotatedTag — AnnotatedTag represents an annotated tag 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 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 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 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 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 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"` } 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 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 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 2ecc2fe..8a81c9f 100644 --- a/types/hook.go +++ b/types/hook.go @@ -4,63 +4,76 @@ 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 +// +// Usage: +// +// opts := CreateHookOptionConfig{} 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]string `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 type Hook struct { - 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"` + 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 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 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 6aa51b4..1249095 100644 --- a/types/issue.go +++ b/types/issue.go @@ -4,93 +4,114 @@ 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"` - Updated *time.Time `json:"updated_at,omitempty"` + 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 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"` } 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"` } 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"` } type IssueConfigValidation struct { Message string `json:"message,omitempty"` - Valid bool `json:"valid,omitempty"` + Valid bool `json:"valid,omitempty"` } // IssueDeadline — IssueDeadline represents an issue deadline @@ -100,11 +121,11 @@ type IssueDeadline struct { // IssueFormField — IssueFormField represents a form field 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. @@ -115,28 +136,32 @@ type IssueFormFieldType struct{} type IssueFormFieldVisible struct{} // 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 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 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. diff --git a/types/key.go b/types/key.go index 7b472c9..3f57825 100644 --- a/types/key.go +++ b/types/key.go @@ -4,64 +4,73 @@ 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 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 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 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 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 117677c..b79c21f 100644 --- a/types/label.go +++ b/types/label.go @@ -4,44 +4,57 @@ 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 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 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 974d826..e22aa1a 100644 --- a/types/milestone.go +++ b/types/milestone.go @@ -4,32 +4,41 @@ 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 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 a1eada1..e7ab3d5 100644 --- a/types/misc.go +++ b/types/misc.go @@ -4,59 +4,84 @@ 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 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. } 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 @@ -64,106 +89,126 @@ type DismissPullReviewOptions struct { 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 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 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 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 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 type NewIssuePinsAllowed struct { - Issues bool `json:"issues,omitempty"` + Issues bool `json:"issues,omitempty"` PullRequests bool `json:"pull_requests,omitempty"` } @@ -173,27 +218,31 @@ type NotifySubjectType struct{} // PRBranchInfo — PRBranchInfo information about a branch 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 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"` } 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"` } @@ -201,7 +250,7 @@ type ReplaceFlagsOption struct { // SearchResults — SearchResults results of a successful search 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 @@ -211,43 +260,43 @@ type ServerVersion struct { // TimelineComment — TimelineComment represents a timeline comment (comment of any type) on a commit or issue 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 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 6e9fe0d..72e3381 100644 --- a/types/notification.go +++ b/types/notification.go @@ -4,6 +4,7 @@ package types import "time" + // NotificationCount — NotificationCount number of unread notifications type NotificationCount struct { New int64 `json:"new,omitempty"` @@ -11,22 +12,22 @@ type NotificationCount struct { // NotificationSubject — NotificationSubject contains the notification subject (Issue/Pull/Commit) 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 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 b6d27d8..acb1038 100644 --- a/types/oauth.go +++ b/types/oauth.go @@ -4,33 +4,42 @@ package types import "time" + 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"` } 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 bf084a0..d5611d2 100644 --- a/types/org.go +++ b/types/org.go @@ -2,49 +2,58 @@ 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 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 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 491c5a7..02e639e 100644 --- a/types/package.go +++ b/types/package.go @@ -4,26 +4,27 @@ package types import "time" + // Package — Package represents a package 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 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 feb8bfd..9077c3b 100644 --- a/types/pr.go +++ b/types/pr.go @@ -4,148 +4,177 @@ 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 +// +// Usage: +// +// opts := CreatePullReviewCommentOptions{} // CreatePullReviewCommentOptions has no fields in the swagger spec. type CreatePullReviewCommentOptions struct{} // 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 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 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 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 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"` + Body string `json:"body,omitempty"` Event *ReviewStateType `json:"event,omitempty"` } diff --git a/types/quota.go b/types/quota.go index 62a0224..592af82 100644 --- a/types/quota.go +++ b/types/quota.go @@ -2,28 +2,41 @@ 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 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 } @@ -34,13 +47,13 @@ type QuotaGroupList struct{} // QuotaInfo — QuotaInfo represents information about a user's quota type QuotaInfo struct { Groups *QuotaGroupList `json:"groups,omitempty"` - Used *QuotaUsed `json:"used,omitempty"` + Used *QuotaUsed `json:"used,omitempty"` } // QuotaRuleInfo — QuotaRuleInfo contains information about a quota rule 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 } @@ -52,8 +65,8 @@ type QuotaUsed struct { // QuotaUsedArtifact — QuotaUsedArtifact represents an artifact counting towards a user's quota 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 @@ -62,10 +75,10 @@ type QuotaUsedArtifactList struct{} // QuotaUsedAttachment — QuotaUsedAttachment represents an attachment counting towards a user's quota 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 @@ -75,10 +88,10 @@ type QuotaUsedAttachmentList struct{} // QuotaUsedPackage — QuotaUsedPackage represents a package counting towards a user's quota 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 @@ -88,20 +101,20 @@ type QuotaUsedPackageList struct{} // QuotaUsedSize — QuotaUsedSize represents the size-based quota usage of a user 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 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 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 } @@ -118,5 +131,5 @@ type QuotaUsedSizeGit struct { // QuotaUsedSizeRepos — QuotaUsedSizeRepos represents the size-based repository quota usage of a user 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 77cefd2..415d52c 100644 --- a/types/reaction.go +++ b/types/reaction.go @@ -4,14 +4,19 @@ 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 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 305eceb..b419a2e 100644 --- a/types/release.go +++ b/types/release.go @@ -4,46 +4,55 @@ 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 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 b36f811..adca65d 100644 --- a/types/repo.go +++ b/types/repo.go @@ -4,75 +4,88 @@ 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 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 @@ -83,133 +96,145 @@ type ExternalWiki struct { // InternalTracker — InternalTracker represents settings for internal tracker 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 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 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"` } 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 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 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 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 acdb918..3270e1b 100644 --- a/types/review.go +++ b/types/review.go @@ -2,6 +2,7 @@ package types + // ReviewStateType — ReviewStateType review state type // ReviewStateType has no fields in the swagger spec. type ReviewStateType struct{} diff --git a/types/settings.go b/types/settings.go index 0cc7bb6..e27581c 100644 --- a/types/settings.go +++ b/types/settings.go @@ -2,36 +2,37 @@ package types + // GeneralAPISettings — GeneralAPISettings contains global api settings exposed by it 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 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 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 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 05f7c36..82cc42a 100644 --- a/types/status.go +++ b/types/status.go @@ -2,21 +2,26 @@ package types + // CombinedStatus — CombinedStatus holds the combined state of several statuses for a single commit 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 a4529d8..5b2d5ee 100644 --- a/types/tag.go +++ b/types/tag.go @@ -4,50 +4,63 @@ 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 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 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 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 c1aaf41..a8c7505 100644 --- a/types/team.go +++ b/types/team.go @@ -2,37 +2,46 @@ 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]string `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]string `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 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]string `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 fe5216b..bf3de14 100644 --- a/types/time_tracking.go +++ b/types/time_tracking.go @@ -4,24 +4,25 @@ package types import "time" + // StopWatch — StopWatch represent a running stopwatch 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 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 50dc3f7..4e95128 100644 --- a/types/topic.go +++ b/types/topic.go @@ -4,6 +4,7 @@ package types import "time" + // TopicName — TopicName a list of repo topic names type TopicName struct { TopicNames []string `json:"topics,omitempty"` @@ -11,9 +12,9 @@ type TopicName struct { // TopicResponse — TopicResponse for returning topics 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 7003c69..c02c1aa 100644 --- a/types/user.go +++ b/types/user.go @@ -4,137 +4,166 @@ package types import "time" + 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 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 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 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 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 cba56b9..38c2af2 100644 --- a/types/wiki.go +++ b/types/wiki.go @@ -2,43 +2,48 @@ 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 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 type WikiCommitList struct { - Count int64 `json:"count,omitempty"` + Count int64 `json:"count,omitempty"` WikiCommits []*WikiCommit `json:"commits,omitempty"` } // WikiPage — WikiPage a wiki page 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 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"` } From 8473c5676fb207f751ac6722b4e0cf16af6b6af7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:19:20 +0000 Subject: [PATCH 152/181] docs(forge): add AX usage examples to core API Co-Authored-By: Virgil --- client.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ forge.go | 4 ++++ resource.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/client.go b/client.go index a91c96d..eeb1174 100644 --- a/client.go +++ b/client.go @@ -27,6 +27,11 @@ type APIError struct { 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 core.Concat("forge: ", e.URL, " ", strconv.Itoa(e.StatusCode), ": ", e.Message) } @@ -119,11 +124,19 @@ type Client struct { } // BaseURL returns the configured Forgejo base URL. +// +// Usage: +// +// baseURL := client.BaseURL() func (c *Client) BaseURL() string { return c.baseURL } // RateLimit returns the last known rate limit information. +// +// Usage: +// +// rl := client.RateLimit() func (c *Client) RateLimit() RateLimit { return c.rateLimit } @@ -152,36 +165,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 @@ -190,6 +231,10 @@ 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) } @@ -339,6 +384,10 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s // 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 diff --git a/forge.go b/forge.go index 1e6a9ba..22ff0f2 100644 --- a/forge.go +++ b/forge.go @@ -64,4 +64,8 @@ func NewForge(url, token string, opts ...Option) *Forge { } // Client returns the underlying HTTP client. +// +// Usage: +// +// client := f.Client() func (f *Forge) Client() *Client { return f.client } diff --git a/resource.go b/resource.go index f6fb521..d56fd47 100644 --- a/resource.go +++ b/resource.go @@ -42,21 +42,39 @@ func NewResource[T any, C any, U any](c *Client, path string) *Resource[T, C, U] } // 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.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.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.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 { @@ -66,6 +84,10 @@ 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.collection, params), body, &out); err != nil { @@ -75,6 +97,10 @@ func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) } // 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 { @@ -84,6 +110,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)) } From f8224ed05dfed53835bd4d4ef8cc289386c43ba0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:22:52 +0000 Subject: [PATCH 153/181] docs(forge): improve top-level usage examples Co-Authored-By: Virgil --- forge.go | 6 ++++-- pagination.go | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/forge.go b/forge.go index 22ff0f2..1407325 100644 --- a/forge.go +++ b/forge.go @@ -4,8 +4,9 @@ package forge // // Usage: // +// ctx := context.Background() // f := forge.NewForge("https://forge.lthn.ai", "token") -// _ = f.Repos +// repo, err := f.Repos.Get(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) type Forge struct { client *Client @@ -35,8 +36,9 @@ type Forge struct { // // Usage: // +// ctx := context.Background() // f := forge.NewForge("https://forge.lthn.ai", "token") -// _ = f +// repos, err := f.Repos.ListOrgRepos(ctx, "core") func NewForge(url, token string, opts ...Option) *Forge { c := NewClient(url, token, opts...) f := &Forge{client: c} diff --git a/pagination.go b/pagination.go index 40bf4f0..d375633 100644 --- a/pagination.go +++ b/pagination.go @@ -23,7 +23,7 @@ type ListOptions struct { Limit int // items per page (default 50) } -// DefaultList returns sensible default pagination. +// DefaultList provides sensible default pagination. // // Usage: // From 84d7c6a7962f3865722154a7b4f4cbcde73fee34 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:26:13 +0000 Subject: [PATCH 154/181] feat(forge): expose top-level client metadata Co-Authored-By: Virgil --- forge.go | 14 ++++++++++++++ forge_test.go | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/forge.go b/forge.go index 1407325..3c70143 100644 --- a/forge.go +++ b/forge.go @@ -71,3 +71,17 @@ func NewForge(url, token string, opts ...Option) *Forge { // // client := f.Client() func (f *Forge) Client() *Client { return f.client } + +// BaseURL returns the configured Forgejo base URL. +// +// Usage: +// +// baseURL := f.BaseURL() +func (f *Forge) BaseURL() string { return f.client.BaseURL() } + +// RateLimit returns the last known rate limit information. +// +// Usage: +// +// rl := f.RateLimit() +func (f *Forge) RateLimit() RateLimit { return f.client.RateLimit() } diff --git a/forge_test.go b/forge_test.go index af1ce57..ad5fc58 100644 --- a/forge_test.go +++ b/forge_test.go @@ -38,6 +38,20 @@ func TestForge_Client_Good(t *testing.T) { } } +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 TestRepoService_ListOrgRepos_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 933dc982f766522240297f80c2eb3ee43e4c16f1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:29:00 +0000 Subject: [PATCH 155/181] feat(forge): expose user agent metadata Co-Authored-By: Virgil --- client.go | 9 +++++++++ client_test.go | 3 +++ docs/api-contract.md | 3 +++ forge.go | 7 +++++++ forge_test.go | 7 +++++++ 5 files changed, 29 insertions(+) diff --git a/client.go b/client.go index eeb1174..e3c2230 100644 --- a/client.go +++ b/client.go @@ -141,6 +141,15 @@ func (c *Client) RateLimit() RateLimit { return c.rateLimit } +// UserAgent returns the configured User-Agent header value. +// +// Usage: +// +// ua := client.UserAgent() +func (c *Client) UserAgent() string { + return c.userAgent +} + // NewClient creates a new Forgejo API client. // // Usage: diff --git a/client_test.go b/client_test.go index 25cf217..1f3b6e3 100644 --- a/client_test.go +++ b/client_test.go @@ -195,6 +195,9 @@ func TestClient_Options_Good(t *testing.T) { 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_WithHTTPClient_Good(t *testing.T) { diff --git a/docs/api-contract.md b/docs/api-contract.md index 434d392..c135ef0 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -98,6 +98,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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` | @@ -112,6 +113,8 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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` | diff --git a/forge.go b/forge.go index 3c70143..f8ca6be 100644 --- a/forge.go +++ b/forge.go @@ -85,3 +85,10 @@ func (f *Forge) BaseURL() string { return f.client.BaseURL() } // // rl := f.RateLimit() func (f *Forge) RateLimit() RateLimit { return f.client.RateLimit() } + +// UserAgent returns the configured User-Agent header value. +// +// Usage: +// +// ua := f.UserAgent() +func (f *Forge) UserAgent() string { return f.client.UserAgent() } diff --git a/forge_test.go b/forge_test.go index ad5fc58..f47bac9 100644 --- a/forge_test.go +++ b/forge_test.go @@ -52,6 +52,13 @@ func TestForge_RateLimit_Good(t *testing.T) { } } +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 TestRepoService_ListOrgRepos_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 0d03174b4968d74218dbd6b028b51d0a8df0a215 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:31:56 +0000 Subject: [PATCH 156/181] feat(forge): expose auth metadata Co-Authored-By: Virgil --- client.go | 11 +++++++++++ client_test.go | 14 ++++++++++++++ docs/api-contract.md | 2 ++ forge.go | 9 +++++++++ forge_test.go | 14 ++++++++++++++ 5 files changed, 50 insertions(+) diff --git a/client.go b/client.go index e3c2230..499d3f9 100644 --- a/client.go +++ b/client.go @@ -150,6 +150,17 @@ func (c *Client) UserAgent() string { return c.userAgent } +// HasToken reports whether the client was configured with an API token. +// +// Usage: +// +// if c.HasToken() { +// _ = "authenticated" +// } +func (c *Client) HasToken() bool { + return c.token != "" +} + // NewClient creates a new Forgejo API client. // // Usage: diff --git a/client_test.go b/client_test.go index 1f3b6e3..7edb888 100644 --- a/client_test.go +++ b/client_test.go @@ -200,6 +200,20 @@ func TestClient_Options_Good(t *testing.T) { } } +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_WithHTTPClient_Good(t *testing.T) { custom := &http.Client{} c := NewClient("https://forge.lthn.ai", "tok", WithHTTPClient(custom)) diff --git a/docs/api-contract.md b/docs/api-contract.md index c135ef0..e898e9f 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -93,6 +93,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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. | @@ -113,6 +114,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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.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. | diff --git a/forge.go b/forge.go index f8ca6be..6e9f9b8 100644 --- a/forge.go +++ b/forge.go @@ -92,3 +92,12 @@ func (f *Forge) RateLimit() RateLimit { return f.client.RateLimit() } // // ua := f.UserAgent() func (f *Forge) UserAgent() string { return f.client.UserAgent() } + +// HasToken reports whether the Forge client was configured with an API token. +// +// Usage: +// +// if f.HasToken() { +// _ = "authenticated" +// } +func (f *Forge) HasToken() bool { return f.client.HasToken() } diff --git a/forge_test.go b/forge_test.go index f47bac9..d25b155 100644 --- a/forge_test.go +++ b/forge_test.go @@ -59,6 +59,20 @@ func TestForge_UserAgent_Good(t *testing.T) { } } +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 TestRepoService_ListOrgRepos_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 09ae6e6623cef0d1a4f16cf32fbf228deb53cdb5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:35:37 +0000 Subject: [PATCH 157/181] feat(forge): expose HTTP client metadata Co-Authored-By: Virgil --- client.go | 9 +++++++++ client_test.go | 3 +++ docs/api-contract.md | 2 ++ docs/architecture.md | 1 + forge.go | 9 +++++++++ forge_test.go | 8 ++++++++ 6 files changed, 32 insertions(+) diff --git a/client.go b/client.go index 499d3f9..fb5e288 100644 --- a/client.go +++ b/client.go @@ -150,6 +150,15 @@ func (c *Client) UserAgent() string { return c.userAgent } +// HTTPClient returns the configured underlying HTTP client. +// +// Usage: +// +// hc := client.HTTPClient() +func (c *Client) HTTPClient() *http.Client { + return c.httpClient +} + // HasToken reports whether the client was configured with an API token. // // Usage: diff --git a/client_test.go b/client_test.go index 7edb888..fbae590 100644 --- a/client_test.go +++ b/client_test.go @@ -220,6 +220,9 @@ func TestClient_WithHTTPClient_Good(t *testing.T) { 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_Error_Good(t *testing.T) { diff --git a/docs/api-contract.md b/docs/api-contract.md index e898e9f..0f56abc 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -94,6 +94,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | @@ -115,6 +116,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | 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. | diff --git a/docs/architecture.md b/docs/architecture.md index 557820c..ee9c270 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/forge.go b/forge.go index 6e9f9b8..c3a3e34 100644 --- a/forge.go +++ b/forge.go @@ -1,5 +1,7 @@ package forge +import "net/http" + // Forge is the top-level client for the Forgejo API. // // Usage: @@ -93,6 +95,13 @@ func (f *Forge) RateLimit() RateLimit { return f.client.RateLimit() } // ua := f.UserAgent() func (f *Forge) UserAgent() string { return f.client.UserAgent() } +// HTTPClient returns the configured underlying HTTP client. +// +// Usage: +// +// hc := f.HTTPClient() +func (f *Forge) HTTPClient() *http.Client { return f.client.HTTPClient() } + // HasToken reports whether the Forge client was configured with an API token. // // Usage: diff --git a/forge_test.go b/forge_test.go index d25b155..89968c7 100644 --- a/forge_test.go +++ b/forge_test.go @@ -59,6 +59,14 @@ func TestForge_UserAgent_Good(t *testing.T) { } } +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() { From 9bac6e5c2c846c2cf4b1287ddc047947790b1e9b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:37:58 +0000 Subject: [PATCH 158/181] chore(forge): verify AX parity Co-Authored-By: Virgil From 194a9d38c8433f0f75edce27917f067d36f82966 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:42:26 +0000 Subject: [PATCH 159/181] feat(forge): add iterator variants for remaining collections Co-Authored-By: Virgil --- contents.go | 18 ++++++++++++++++++ contents_test.go | 32 ++++++++++++++++++++++++++++++++ webhooks.go | 6 ++++++ webhooks_test.go | 31 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/contents.go b/contents.go index 01d1ea2..ac08739 100644 --- a/contents.go +++ b/contents.go @@ -2,6 +2,7 @@ package forge import ( "context" + "iter" "net/url" "dappco.re/go/core/forge/types" @@ -44,6 +45,23 @@ func (s *ContentService) ListContents(ctx context.Context, owner, repo, ref stri 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) diff --git a/contents_test.go b/contents_test.go index 2a7966c..abc0b6d 100644 --- a/contents_test.go +++ b/contents_test.go @@ -41,6 +41,38 @@ func TestContentService_ListContents_Good(t *testing.T) { } } +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 { diff --git a/webhooks.go b/webhooks.go index bdd0818..0824aba 100644 --- a/webhooks.go +++ b/webhooks.go @@ -59,6 +59,12 @@ func (s *WebhookService) ListGitHooks(ctx context.Context, owner, repo string) ( 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)) diff --git a/webhooks_test.go b/webhooks_test.go index 84965d0..68d1ff9 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -155,6 +155,37 @@ func TestWebhookService_ListGitHooks_Good(t *testing.T) { } } +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 { From a5bc1a3ed8c9028bfd4c485f28b144d005221756 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:46:05 +0000 Subject: [PATCH 160/181] feat(forge): add safe client stringers Co-Authored-By: Virgil --- client.go | 13 +++++++++++++ client_test.go | 13 +++++++++++++ forge.go | 9 +++++++++ forge_test.go | 13 +++++++++++++ 4 files changed, 48 insertions(+) diff --git a/client.go b/client.go index fb5e288..86ace96 100644 --- a/client.go +++ b/client.go @@ -159,6 +159,19 @@ func (c *Client) HTTPClient() *http.Client { return c.httpClient } +// String returns a safe summary of the client configuration. +// +// Usage: +// +// s := client.String() +func (c *Client) String() string { + tokenState := "unset" + if c.HasToken() { + tokenState = "set" + } + return core.Concat("forge.Client{baseURL=", strconv.Quote(c.baseURL), ", token=", tokenState, ", userAgent=", strconv.Quote(c.userAgent), "}") +} + // HasToken reports whether the client was configured with an API token. // // Usage: diff --git a/client_test.go b/client_test.go index fbae590..79cd19a 100644 --- a/client_test.go +++ b/client_test.go @@ -2,6 +2,7 @@ package forge import ( "context" + "fmt" json "github.com/goccy/go-json" "net/http" "net/http/httptest" @@ -225,6 +226,18 @@ func TestClient_WithHTTPClient_Good(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) + } +} + func TestAPIError_Error_Good(t *testing.T) { e := &APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"} got := e.Error() diff --git a/forge.go b/forge.go index c3a3e34..5d0ce5c 100644 --- a/forge.go +++ b/forge.go @@ -110,3 +110,12 @@ func (f *Forge) HTTPClient() *http.Client { return f.client.HTTPClient() } // _ = "authenticated" // } func (f *Forge) HasToken() bool { return f.client.HasToken() } + +// String returns a safe summary of the Forge client. +// +// Usage: +// +// s := f.String() +func (f *Forge) String() string { + return "forge.Forge{" + f.client.String() + "}" +} diff --git a/forge_test.go b/forge_test.go index 89968c7..701a36b 100644 --- a/forge_test.go +++ b/forge_test.go @@ -3,6 +3,7 @@ package forge import ( "bytes" "context" + "fmt" json "github.com/goccy/go-json" "net/http" "net/http/httptest" @@ -81,6 +82,18 @@ func TestForge_HasToken_Bad(t *testing.T) { } } +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{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) + } +} + func TestRepoService_ListOrgRepos_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From 40934c2e4346a915298b81e7c4045d224d01a02a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:48:37 +0000 Subject: [PATCH 161/181] feat(forge): add safe Go stringers Co-Authored-By: Virgil --- client.go | 7 +++++++ client_test.go | 3 +++ forge.go | 7 +++++++ forge_test.go | 3 +++ 4 files changed, 20 insertions(+) diff --git a/client.go b/client.go index 86ace96..3ecf86f 100644 --- a/client.go +++ b/client.go @@ -172,6 +172,13 @@ func (c *Client) String() string { 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: diff --git a/client_test.go b/client_test.go index 79cd19a..6267101 100644 --- a/client_test.go +++ b/client_test.go @@ -236,6 +236,9 @@ func TestClient_String_Good(t *testing.T) { 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) { diff --git a/forge.go b/forge.go index 5d0ce5c..fab6e2e 100644 --- a/forge.go +++ b/forge.go @@ -119,3 +119,10 @@ func (f *Forge) HasToken() bool { return f.client.HasToken() } func (f *Forge) String() string { return "forge.Forge{" + 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 701a36b..bec674d 100644 --- a/forge_test.go +++ b/forge_test.go @@ -92,6 +92,9 @@ func TestForge_String_Good(t *testing.T) { 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_ListOrgRepos_Good(t *testing.T) { From 7e8340a3d43f0702b2b3ec97553552190f690cf2 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:52:00 +0000 Subject: [PATCH 162/181] feat(forge): add safe resource stringers Co-Authored-By: Virgil --- resource.go | 23 +++++++++++++++++++++++ resource_string_test.go | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 resource_string_test.go diff --git a/resource.go b/resource.go index d56fd47..d2f34f1 100644 --- a/resource.go +++ b/resource.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" core "dappco.re/go/core" ) @@ -20,6 +21,28 @@ type Resource[T any, C any, U any] struct { 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 { + 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 should be the item path (e.g., /repos/{owner}/{repo}/issues/{index}). // The collection path is derived by stripping the last /{placeholder} segment. 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) + } +} From edbf3f70889ab567a7f53c87c4232288663bcc29 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:54:28 +0000 Subject: [PATCH 163/181] fix(forge): make stringers nil-safe Co-Authored-By: Virgil --- client.go | 3 +++ forge.go | 6 ++++++ resource.go | 3 +++ stringer_nil_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 stringer_nil_test.go diff --git a/client.go b/client.go index 3ecf86f..04643c9 100644 --- a/client.go +++ b/client.go @@ -165,6 +165,9 @@ func (c *Client) HTTPClient() *http.Client { // // s := client.String() func (c *Client) String() string { + if c == nil { + return "forge.Client{}" + } tokenState := "unset" if c.HasToken() { tokenState = "set" diff --git a/forge.go b/forge.go index fab6e2e..3c3a976 100644 --- a/forge.go +++ b/forge.go @@ -117,6 +117,12 @@ func (f *Forge) HasToken() bool { return f.client.HasToken() } // // 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{" + f.client.String() + "}" } diff --git a/resource.go b/resource.go index d2f34f1..712a610 100644 --- a/resource.go +++ b/resource.go @@ -27,6 +27,9 @@ type Resource[T any, C any, U any] struct { // // 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), diff --git a/stringer_nil_test.go b/stringer_nil_test.go new file mode 100644 index 0000000..8295a68 --- /dev/null +++ b/stringer_nil_test.go @@ -0,0 +1,48 @@ +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) + } +} From 0dd5916f4e44fa2cf86838957c1d907bdd61fd9a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 07:58:17 +0000 Subject: [PATCH 164/181] feat(forge): add config file persistence Co-Authored-By: Virgil --- config.go | 85 +++++++++++++++++++++++++++++++++++++-- config_test.go | 105 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/config.go b/config.go index d1e442f..21e88cd 100644 --- a/config.go +++ b/config.go @@ -1,9 +1,13 @@ package forge import ( + "encoding/json" + "os" + "path/filepath" "syscall" core "dappco.re/go/core" + coreio "dappco.re/go/core/io" ) const ( @@ -16,8 +20,62 @@ const ( DefaultURL = "http://localhost:3000" ) +const defaultConfigPath = ".config/forge/config.json" + +type configFile struct { + URL string `json:"url"` + Token string `json:"token"` +} + +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. +// +// Usage: +// +// _ = forge.SaveConfig("https://forge.example.com", "token") +func SaveConfig(url, token string) error { + path, err := configPath() + if err != nil { + return 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 @@ -29,8 +87,19 @@ const ( // _ = url // _ = token func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - url, _ = syscall.Getenv("FORGE_URL") - token, _ = syscall.Getenv("FORGE_TOKEN") + if fileURL, fileToken, fileErr := readConfigFile(); fileErr != nil { + return "", "", fileErr + } else { + url = fileURL + token = fileToken + } + + if envURL, ok := syscall.Getenv("FORGE_URL"); ok && envURL != "" { + url = envURL + } + if envToken, ok := syscall.Getenv("FORGE_TOKEN"); ok && envToken != "" { + token = envToken + } if flagURL != "" { url = flagURL @@ -44,6 +113,16 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { 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. // diff --git a/config_test.go b/config_test.go index 009789e..f0326d0 100644 --- a/config_test.go +++ b/config_test.go @@ -1,10 +1,15 @@ package forge import ( + "encoding/json" + "path/filepath" "testing" + + coreio "dappco.re/go/core/io" ) 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,6 +26,7 @@ func TestResolveConfig_EnvOverrides_Good(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,6 +43,7 @@ func TestResolveConfig_FlagOverridesEnv_Good(t *testing.T) { } func TestResolveConfig_DefaultURL_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "") t.Setenv("FORGE_TOKEN", "") @@ -49,7 +56,63 @@ func TestResolveConfig_DefaultURL_Good(t *testing.T) { } } +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 TestNewForgeFromConfig_NoToken_Bad(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "") t.Setenv("FORGE_TOKEN", "") @@ -58,3 +121,45 @@ func TestNewForgeFromConfig_NoToken_Bad(t *testing.T) { 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"]) + } +} From 36f9619fc457b8ab5053f8aa882587fa1e5dc2c6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:01:02 +0000 Subject: [PATCH 165/181] feat(forge): add safe stringers for core value types Co-Authored-By: Virgil --- ax_stringer_test.go | 81 +++++++++++++++++++++++++++++++++++++++++++++ client.go | 25 ++++++++++++++ pagination.go | 53 +++++++++++++++++++++++++++++ params.go | 40 ++++++++++++++++++++++ 4 files changed, 199 insertions(+) create mode 100644 ax_stringer_test.go diff --git a/ax_stringer_test.go b/ax_stringer_test.go new file mode 100644 index 0000000..167bc02 --- /dev/null +++ b/ax_stringer_test.go @@ -0,0 +1,81 @@ +package forge + +import ( + "fmt" + "testing" +) + +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) + } +} diff --git a/client.go b/client.go index 04643c9..fff8a8c 100644 --- a/client.go +++ b/client.go @@ -109,6 +109,31 @@ type RateLimit struct { 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: diff --git a/pagination.go b/pagination.go index d375633..203b15a 100644 --- a/pagination.go +++ b/pagination.go @@ -23,6 +23,28 @@ type ListOptions struct { Limit int // items per page (default 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: @@ -44,6 +66,37 @@ 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. // diff --git a/params.go b/params.go index 9646e97..50c291f 100644 --- a/params.go +++ b/params.go @@ -2,6 +2,9 @@ package forge import ( "net/url" + "sort" + "strconv" + "strings" core "dappco.re/go/core" ) @@ -15,6 +18,43 @@ import ( // _ = 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: From b8bc948fc010844cf20eb035a5a12f9410673f35 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:04:08 +0000 Subject: [PATCH 166/181] feat(forge): add APIError stringers Co-Authored-By: Virgil --- client.go | 17 +++++++++++++++++ client_test.go | 9 +++++++++ stringer_nil_test.go | 14 ++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/client.go b/client.go index fff8a8c..a0bf99e 100644 --- a/client.go +++ b/client.go @@ -33,9 +33,26 @@ type APIError struct { // // err := (&forge.APIError{StatusCode: 404, Message: "not found", URL: "/api/v1/repos/x/y"}).Error() func (e *APIError) Error() string { + 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: diff --git a/client_test.go b/client_test.go index 6267101..c8e8555 100644 --- a/client_test.go +++ b/client_test.go @@ -248,6 +248,15 @@ func TestAPIError_Error_Good(t *testing.T) { 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_Match_Good(t *testing.T) { diff --git a/stringer_nil_test.go b/stringer_nil_test.go index 8295a68..f5d2ea1 100644 --- a/stringer_nil_test.go +++ b/stringer_nil_test.go @@ -46,3 +46,17 @@ func TestResource_String_NilSafe(t *testing.T) { 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) + } +} From bdf669db39db0513af76e625139e93f7fcd8e56a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:07:18 +0000 Subject: [PATCH 167/181] feat(forge): add safe stringers for option types Co-Authored-By: Virgil --- admin.go | 22 +++++++++++ ax_stringer_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++ helpers.go | 75 ++++++++++++++++++++++++++++++++++++ issues.go | 46 ++++++++++++++++++++++ milestones.go | 8 ++++ notifications.go | 40 +++++++++++++++++++ orgs.go | 8 ++++ releases.go | 11 ++++++ repos.go | 28 ++++++++++++++ users.go | 16 ++++++++ 10 files changed, 348 insertions(+) diff --git a/admin.go b/admin.go index 779f73e..c7f7f19 100644 --- a/admin.go +++ b/admin.go @@ -35,6 +35,20 @@ type AdminActionsRunListOptions struct { 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 != "" { @@ -67,6 +81,14 @@ 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 diff --git a/ax_stringer_test.go b/ax_stringer_test.go index 167bc02..443d791 100644 --- a/ax_stringer_test.go +++ b/ax_stringer_test.go @@ -3,6 +3,7 @@ package forge import ( "fmt" "testing" + "time" ) func TestParams_String_Good(t *testing.T) { @@ -79,3 +80,96 @@ func TestPagedResult_String_Good(t *testing.T) { 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: "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) + } + }) + } +} diff --git a/helpers.go b/helpers.go index 936af77..7f08cd1 100644 --- a/helpers.go +++ b/helpers.go @@ -1,7 +1,10 @@ package forge import ( + "fmt" "strconv" + "strings" + "time" core "dappco.re/go/core" ) @@ -25,6 +28,78 @@ func pathParams(values ...string) Params { 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 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 time.Time: + return strconv.Quote(x.Format(time.RFC3339)) + default: + return fmt.Sprintf("%#v", v) + } +} + func lastIndexByte(s string, b byte) int { for i := len(s) - 1; i >= 0; i-- { if s[i] == b { diff --git a/issues.go b/issues.go index 3d56461..177866a 100644 --- a/issues.go +++ b/issues.go @@ -31,6 +31,17 @@ type AttachmentUploadOptions struct { 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: @@ -41,6 +52,17 @@ type RepoCommentListOptions struct { 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 { @@ -86,6 +108,30 @@ type SearchIssuesOptions struct { 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 != "" { diff --git a/milestones.go b/milestones.go index e560f6a..bdf1050 100644 --- a/milestones.go +++ b/milestones.go @@ -17,6 +17,14 @@ type MilestoneListOptions struct { 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 != "" { diff --git a/notifications.go b/notifications.go index 5e84dc7..661de6d 100644 --- a/notifications.go +++ b/notifications.go @@ -24,6 +24,20 @@ type NotificationListOptions struct { 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") @@ -69,6 +83,19 @@ type NotificationRepoMarkOptions struct { 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: @@ -81,6 +108,19 @@ type NotificationMarkOptions struct { 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} } diff --git a/orgs.go b/orgs.go index 0b655b1..c76540f 100644 --- a/orgs.go +++ b/orgs.go @@ -28,6 +28,14 @@ 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 diff --git a/releases.go b/releases.go index d530305..5e58496 100644 --- a/releases.go +++ b/releases.go @@ -29,6 +29,17 @@ type ReleaseAttachmentUploadOptions struct { 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 diff --git a/repos.go b/repos.go index a266285..08f9fee 100644 --- a/repos.go +++ b/repos.go @@ -31,6 +31,14 @@ type RepoKeyListOptions struct { 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 { @@ -54,6 +62,14 @@ 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 @@ -74,6 +90,18 @@ type RepoTimeListOptions struct { 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 != "" { diff --git a/users.go b/users.go index 3f90f12..86d9125 100644 --- a/users.go +++ b/users.go @@ -29,6 +29,14 @@ 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 @@ -47,6 +55,14 @@ 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 From dba985256788e62615dc14334954294c268fc32e Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:10:50 +0000 Subject: [PATCH 168/181] feat(forge): add safe service stringers Co-Authored-By: Virgil --- ax_stringer_test.go | 59 ++++++++++++ helpers.go | 4 + service_string.go | 221 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 service_string.go diff --git a/ax_stringer_test.go b/ax_stringer_test.go index 443d791..0f96a3e 100644 --- a/ax_stringer_test.go +++ b/ax_stringer_test.go @@ -173,3 +173,62 @@ func TestOption_Stringers_Empty(t *testing.T) { }) } } + +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/helpers.go b/helpers.go index 7f08cd1..64a0b12 100644 --- a/helpers.go +++ b/helpers.go @@ -100,6 +100,10 @@ func formatOptionValue(v any) string { } } +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 { diff --git a/service_string.go b/service_string.go new file mode 100644 index 0000000..9bb619c --- /dev/null +++ b/service_string.go @@ -0,0 +1,221 @@ +package forge + +// String returns a safe summary of the actions service. +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. +func (s *ActionsService) GoString() string { return s.String() } + +// String returns a safe summary of the ActivityPub service. +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. +func (s *ActivityPubService) GoString() string { return s.String() } + +// String returns a safe summary of the admin service. +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. +func (s *AdminService) GoString() string { return s.String() } + +// String returns a safe summary of the branch service. +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. +func (s *BranchService) GoString() string { return s.String() } + +// String returns a safe summary of the commit service. +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. +func (s *CommitService) GoString() string { return s.String() } + +// String returns a safe summary of the content service. +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. +func (s *ContentService) GoString() string { return s.String() } + +// String returns a safe summary of the issue service. +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. +func (s *IssueService) GoString() string { return s.String() } + +// String returns a safe summary of the label service. +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. +func (s *LabelService) GoString() string { return s.String() } + +// String returns a safe summary of the milestone service. +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. +func (s *MilestoneService) GoString() string { return s.String() } + +// String returns a safe summary of the misc service. +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. +func (s *MiscService) GoString() string { return s.String() } + +// String returns a safe summary of the notification service. +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. +func (s *NotificationService) GoString() string { return s.String() } + +// String returns a safe summary of the organisation service. +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. +func (s *OrgService) GoString() string { return s.String() } + +// String returns a safe summary of the package service. +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. +func (s *PackageService) GoString() string { return s.String() } + +// String returns a safe summary of the pull request service. +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. +func (s *PullService) GoString() string { return s.String() } + +// String returns a safe summary of the release service. +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. +func (s *ReleaseService) GoString() string { return s.String() } + +// String returns a safe summary of the repository service. +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. +func (s *RepoService) GoString() string { return s.String() } + +// String returns a safe summary of the team service. +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. +func (s *TeamService) GoString() string { return s.String() } + +// String returns a safe summary of the user service. +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. +func (s *UserService) GoString() string { return s.String() } + +// String returns a safe summary of the webhook service. +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. +func (s *WebhookService) GoString() string { return s.String() } + +// String returns a safe summary of the wiki service. +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. +func (s *WikiService) GoString() string { return s.String() } From d553cbaa2de99d87c9d58eb3b2ded13bcb156377 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:18:28 +0000 Subject: [PATCH 169/181] fix(forge): correct generated schema aliases Co-Authored-By: Virgil --- cmd/forgegen/parser.go | 97 +++++++++++++++++++++++++++++++++---- cmd/forgegen/parser_test.go | 43 ++++++++++++++++ types/commit.go | 5 +- types/common.go | 6 +-- types/issue.go | 17 +++---- types/milestone.go | 2 +- types/misc.go | 3 +- types/notification.go | 4 +- types/pr.go | 11 ++--- types/quota.go | 14 ++---- types/review.go | 3 +- types/status.go | 4 +- types/user.go | 2 +- users_test.go | 2 +- 14 files changed, 160 insertions(+), 53 deletions(-) diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index 9a20fe2..a73a38f 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -39,6 +39,9 @@ type SpecInfo struct { // _ = SchemaDefinition{Type: "object"} type SchemaDefinition struct { 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"` @@ -140,9 +143,10 @@ func ExtractTypes(spec *Spec) map[string]*GoType { result[name] = gt continue } - if len(def.Properties) == 0 && def.AdditionalProperties != nil { + + if aliasType, ok := definitionAliasType(def, spec.Definitions); ok { gt.IsAlias = true - gt.AliasType = resolveMapType(*def.AdditionalProperties) + gt.AliasType = aliasType result[name] = gt continue } @@ -157,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], @@ -172,6 +176,46 @@ func ExtractTypes(spec *Spec) map[string]*GoType { 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. // @@ -201,10 +245,9 @@ func DetectCRUDPairs(spec *Spec) []CRUDPair { } // 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 := core.Split(prop.Ref, "/") - return "*" + parts[len(parts)-1] + return refGoType(prop.Ref, defs) } switch prop.Type { case "string": @@ -236,25 +279,59 @@ 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 resolveMapType(prop) + return resolveMapType(prop, defs) default: return "any" } } // resolveMapType maps a swagger object with additionalProperties to a Go map type. -func resolveMapType(prop SchemaProperty) string { +func resolveMapType(prop SchemaProperty, defs map[string]SchemaDefinition) string { valueType := "any" if prop.AdditionalProperties != nil { - valueType = resolveGoType(*prop.AdditionalProperties) + 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 { diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go index a18f009..e88da5f 100644 --- a/cmd/forgegen/parser_test.go +++ b/cmd/forgegen/parser_test.go @@ -124,3 +124,46 @@ func TestParser_AdditionalPropertiesAlias_Good(t *testing.T) { 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/types/commit.go b/types/commit.go index c6ddbe0..e340f79 100644 --- a/types/commit.go +++ b/types/commit.go @@ -54,15 +54,14 @@ type CommitStatus struct { Creator *User `json:"creator,omitempty"` Description string `json:"description,omitempty"` ID int64 `json:"id,omitempty"` - Status *CommitStatusState `json:"status,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{} +type CommitStatusState string type CommitUser struct { Date string `json:"date,omitempty"` diff --git a/types/common.go b/types/common.go index 5e0d84a..860e1e5 100644 --- a/types/common.go +++ b/types/common.go @@ -35,9 +35,7 @@ type Permission struct { } // StateType — StateType issue state type -// StateType has no fields in the swagger spec. -type StateType struct{} +type StateType string // TimeStamp — TimeStamp defines a timestamp -// TimeStamp has no fields in the swagger spec. -type TimeStamp struct{} +type TimeStamp int64 diff --git a/types/issue.go b/types/issue.go index 1249095..f5a9b25 100644 --- a/types/issue.go +++ b/types/issue.go @@ -91,7 +91,7 @@ type Issue struct { PullRequest *PullRequestMeta `json:"pull_request,omitempty"` Ref string `json:"ref,omitempty"` Repository *RepositoryMeta `json:"repository,omitempty"` - State *StateType `json:"state,omitempty"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` Updated time.Time `json:"updated_at,omitempty"` @@ -123,17 +123,15 @@ type IssueDeadline struct { type IssueFormField struct { Attributes map[string]any `json:"attributes,omitempty"` ID string `json:"id,omitempty"` - Type *IssueFormFieldType `json:"type,omitempty"` + Type IssueFormFieldType `json:"type,omitempty"` Validations map[string]any `json:"validations,omitempty"` - Visible []*IssueFormFieldVisible `json:"visible,omitempty"` + Visible []IssueFormFieldVisible `json:"visible,omitempty"` } -// IssueFormFieldType has no fields in the swagger spec. -type IssueFormFieldType struct{} +type IssueFormFieldType string // IssueFormFieldVisible — IssueFormFieldVisible defines issue form field visible -// IssueFormFieldVisible has no fields in the swagger spec. -type IssueFormFieldVisible struct{} +type IssueFormFieldVisible string // IssueLabelsOption — IssueLabelsOption a collection of labels // @@ -158,11 +156,10 @@ type IssueTemplate struct { Content string `json:"content,omitempty"` Fields []*IssueFormField `json:"body,omitempty"` FileName string `json:"file_name,omitempty"` - Labels *IssueTemplateLabels `json:"labels,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{} +type IssueTemplateLabels []string diff --git a/types/milestone.go b/types/milestone.go index e22aa1a..b91f75c 100644 --- a/types/milestone.go +++ b/types/milestone.go @@ -38,7 +38,7 @@ type Milestone struct { Description string `json:"description,omitempty"` ID int64 `json:"id,omitempty"` OpenIssues int64 `json:"open_issues,omitempty"` - State *StateType `json:"state,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 e7ab3d5..217d48e 100644 --- a/types/misc.go +++ b/types/misc.go @@ -213,8 +213,7 @@ type NewIssuePinsAllowed struct { } // NotifySubjectType — NotifySubjectType represent type of notification subject -// NotifySubjectType has no fields in the swagger spec. -type NotifySubjectType struct{} +type NotifySubjectType string // PRBranchInfo — PRBranchInfo information about a branch type PRBranchInfo struct { diff --git a/types/notification.go b/types/notification.go index 72e3381..9d188d1 100644 --- a/types/notification.go +++ b/types/notification.go @@ -15,9 +15,9 @@ 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"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` - Type *NotifySubjectType `json:"type,omitempty"` + Type NotifySubjectType `json:"type,omitempty"` URL string `json:"url,omitempty"` } diff --git a/types/pr.go b/types/pr.go index 9077c3b..cac3d0e 100644 --- a/types/pr.go +++ b/types/pr.go @@ -39,8 +39,7 @@ type CreatePullReviewComment struct { // Usage: // // opts := CreatePullReviewCommentOptions{} -// CreatePullReviewCommentOptions has no fields in the swagger spec. -type CreatePullReviewCommentOptions struct{} +type CreatePullReviewCommentOptions CreatePullReviewComment // CreatePullReviewOptions — CreatePullReviewOptions are options to create a pull review // @@ -51,7 +50,7 @@ type CreatePullReviewOptions struct { Body string `json:"body,omitempty"` Comments []*CreatePullReviewComment `json:"comments,omitempty"` CommitID string `json:"commit_id,omitempty"` - Event *ReviewStateType `json:"event,omitempty"` + Event ReviewStateType `json:"event,omitempty"` } // EditPullRequestOption — EditPullRequestOption options when modify pull request @@ -107,7 +106,7 @@ type PullRequest struct { 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"` + State StateType `json:"state,omitempty"` Title string `json:"title,omitempty"` URL string `json:"url,omitempty"` Updated time.Time `json:"updated_at,omitempty"` @@ -133,7 +132,7 @@ type PullReview struct { ID int64 `json:"id,omitempty"` Official bool `json:"official,omitempty"` Stale bool `json:"stale,omitempty"` - State *ReviewStateType `json:"state,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"` @@ -176,5 +175,5 @@ type PullReviewRequestOptions struct { // opts := SubmitPullReviewOptions{Body: "example"} type SubmitPullReviewOptions struct { Body string `json:"body,omitempty"` - Event *ReviewStateType `json:"event,omitempty"` + Event ReviewStateType `json:"event,omitempty"` } diff --git a/types/quota.go b/types/quota.go index 592af82..2639a10 100644 --- a/types/quota.go +++ b/types/quota.go @@ -41,12 +41,11 @@ type QuotaGroup struct { } // QuotaGroupList — QuotaGroupList represents a list of quota groups -// QuotaGroupList has no fields in the swagger spec. -type QuotaGroupList struct{} +type QuotaGroupList []*QuotaGroup // QuotaInfo — QuotaInfo represents information about a user's quota type QuotaInfo struct { - Groups *QuotaGroupList `json:"groups,omitempty"` + Groups QuotaGroupList `json:"groups,omitempty"` Used *QuotaUsed `json:"used,omitempty"` } @@ -70,8 +69,7 @@ type QuotaUsedArtifact struct { } // QuotaUsedArtifactList — QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota -// QuotaUsedArtifactList has no fields in the swagger spec. -type QuotaUsedArtifactList struct{} +type QuotaUsedArtifactList []*QuotaUsedArtifact // QuotaUsedAttachment — QuotaUsedAttachment represents an attachment counting towards a user's quota type QuotaUsedAttachment struct { @@ -82,8 +80,7 @@ type QuotaUsedAttachment struct { } // QuotaUsedAttachmentList — QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota -// QuotaUsedAttachmentList has no fields in the swagger spec. -type QuotaUsedAttachmentList struct{} +type QuotaUsedAttachmentList []*QuotaUsedAttachment // QuotaUsedPackage — QuotaUsedPackage represents a package counting towards a user's quota type QuotaUsedPackage struct { @@ -95,8 +92,7 @@ type QuotaUsedPackage struct { } // QuotaUsedPackageList — QuotaUsedPackageList represents a list of packages counting towards a user's quota -// QuotaUsedPackageList has no fields in the swagger spec. -type QuotaUsedPackageList struct{} +type QuotaUsedPackageList []*QuotaUsedPackage // QuotaUsedSize — QuotaUsedSize represents the size-based quota usage of a user type QuotaUsedSize struct { diff --git a/types/review.go b/types/review.go index 3270e1b..e4ef6fa 100644 --- a/types/review.go +++ b/types/review.go @@ -4,5 +4,4 @@ package types // ReviewStateType — ReviewStateType review state type -// ReviewStateType has no fields in the swagger spec. -type ReviewStateType struct{} +type ReviewStateType string diff --git a/types/status.go b/types/status.go index 82cc42a..b1d432d 100644 --- a/types/status.go +++ b/types/status.go @@ -8,7 +8,7 @@ type CombinedStatus struct { CommitURL string `json:"commit_url,omitempty"` Repository *Repository `json:"repository,omitempty"` SHA string `json:"sha,omitempty"` - State *CommitStatusState `json:"state,omitempty"` + State CommitStatusState `json:"state,omitempty"` Statuses []*CommitStatus `json:"statuses,omitempty"` TotalCount int64 `json:"total_count,omitempty"` URL string `json:"url,omitempty"` @@ -22,6 +22,6 @@ type CombinedStatus struct { type CreateStatusOption struct { Context string `json:"context,omitempty"` Description string `json:"description,omitempty"` - State *CommitStatusState `json:"state,omitempty"` + State CommitStatusState `json:"state,omitempty"` TargetURL string `json:"target_url,omitempty"` } diff --git a/types/user.go b/types/user.go index c02c1aa..d157974 100644 --- a/types/user.go +++ b/types/user.go @@ -131,7 +131,7 @@ type User struct { // UserHeatmapData — UserHeatmapData represents the data needed to create a heatmap type UserHeatmapData struct { Contributions int64 `json:"contributions,omitempty"` - Timestamp *TimeStamp `json:"timestamp,omitempty"` + Timestamp TimeStamp `json:"timestamp,omitempty"` } // UserSettings — UserSettings represents user settings diff --git a/users_test.go b/users_test.go index 0488800..9fe78e2 100644 --- a/users_test.go +++ b/users_test.go @@ -138,7 +138,7 @@ func TestUserService_GetQuota_Good(t *testing.T) { t.Errorf("wrong path: %s", r.URL.Path) } json.NewEncoder(w).Encode(types.QuotaInfo{ - Groups: &types.QuotaGroupList{}, + Groups: types.QuotaGroupList{}, Used: &types.QuotaUsed{ Size: &types.QuotaUsedSize{ Repos: &types.QuotaUsedSizeRepos{ From ddff64bc8eee485103e2ff5d67db284145436ae6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:22:44 +0000 Subject: [PATCH 170/181] feat(forge): add missing repo and team helpers Co-Authored-By: Virgil --- branches.go | 26 ++++++++++++++++++++++++-- branches_test.go | 27 +++++++++++++++++++++++++++ repos.go | 9 +++++++++ repos_test.go | 30 ++++++++++++++++++++++++++++++ teams.go | 10 ++++++++++ teams_test.go | 31 +++++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 2 deletions(-) diff --git a/branches.go b/branches.go index 305fadc..e7d4320 100644 --- a/branches.go +++ b/branches.go @@ -14,12 +14,12 @@ import ( // 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}", ), } @@ -46,6 +46,28 @@ func (s *BranchService) CreateBranch(ctx context.Context, owner, repo string, op 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 := ResolvePath("/api/v1/repos/{owner}/{repo}/branch_protections", pathParams("owner", owner, "repo", repo)) diff --git a/branches_test.go b/branches_test.go index ca1f99e..650507d 100644 --- a/branches_test.go +++ b/branches_test.go @@ -61,6 +61,33 @@ func TestBranchService_Get_Good(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 { diff --git a/repos.go b/repos.go index 08f9fee..5de4411 100644 --- a/repos.go +++ b/repos.go @@ -136,6 +136,15 @@ func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOption 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)) diff --git a/repos_test.go b/repos_test.go index f813e2d..3c6c22b 100644 --- a/repos_test.go +++ b/repos_test.go @@ -2183,6 +2183,36 @@ func TestRepoService_PathParamsAreEscaped_Good(t *testing.T) { } }) + 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" diff --git a/teams.go b/teams.go index 0baa9fe..88e414c 100644 --- a/teams.go +++ b/teams.go @@ -25,6 +25,16 @@ 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 := ResolvePath("/api/v1/teams/{id}/members", pathParams("id", int64String(teamID))) diff --git a/teams_test.go b/teams_test.go index b18dbc8..0572411 100644 --- a/teams_test.go +++ b/teams_test.go @@ -32,6 +32,37 @@ func TestTeamService_Get_Good(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 { From 0e8bc8918e13261ab52f4f966aeb4394b2af98cb Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:26:07 +0000 Subject: [PATCH 171/181] fix(forge): clarify top-level client summary Co-Authored-By: Virgil --- forge.go | 2 +- forge_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/forge.go b/forge.go index 3c3a976..0f7eaf8 100644 --- a/forge.go +++ b/forge.go @@ -123,7 +123,7 @@ func (f *Forge) String() string { if f.client == nil { return "forge.Forge{client=}" } - return "forge.Forge{" + f.client.String() + "}" + return "forge.Forge{client=" + f.client.String() + "}" } // GoString returns a safe Go-syntax summary of the Forge client. diff --git a/forge_test.go b/forge_test.go index bec674d..8a47bf5 100644 --- a/forge_test.go +++ b/forge_test.go @@ -85,7 +85,7 @@ func TestForge_HasToken_Bad(t *testing.T) { 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{forge.Client{baseURL="https://forge.lthn.ai", token=set, userAgent="go-forge/1.0"}}` + 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) } From 09d01bee96ee9543b990f3feabdbcc6d9398333a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:33:11 +0000 Subject: [PATCH 172/181] feat(forge): add repository list filters Co-Authored-By: Virgil --- ax_stringer_test.go | 32 ++++++++++ commits.go | 82 ++++++++++++++++++++++++-- commits_test.go | 49 ++++++++++++++++ helpers.go | 7 +++ issues.go | 96 ++++++++++++++++++++++++++++-- issues_test.go | 58 +++++++++++++++++++ pulls.go | 138 ++++++++++++++++++++++++++++++++++++++++++-- pulls_test.go | 48 +++++++++++++++ releases.go | 62 ++++++++++++++++++-- releases_test.go | 41 +++++++++++++ 10 files changed, 593 insertions(+), 20 deletions(-) diff --git a/ax_stringer_test.go b/ax_stringer_test.go index 0f96a3e..e638ca5 100644 --- a/ax_stringer_test.go +++ b/ax_stringer_test.go @@ -109,6 +109,38 @@ func TestOption_Stringers_Good(t *testing.T) { 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"}, diff --git a/commits.go b/commits.go index 38631fc..8735b46 100644 --- a/commits.go +++ b/commits.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -20,6 +21,62 @@ 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}" @@ -30,18 +87,18 @@ func newCommitService(c *Client) *CommitService { } // List returns a single page of commits for a repository. -func (s *CommitService) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[types.Commit], error) { - return ListPage[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil, opts) +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) ([]types.Commit, error) { - return ListAll[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil) +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) iter.Seq2[types.Commit, error] { - return ListIter[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil) +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. @@ -155,3 +212,16 @@ func (s *CommitService) DeleteNote(ctx context.Context, owner, repo, sha string) 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_test.go b/commits_test.go index ef9c09d..0de4c6a 100644 --- a/commits_test.go +++ b/commits_test.go @@ -62,6 +62,55 @@ func TestCommitService_List_Good(t *testing.T) { } } +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 { diff --git a/helpers.go b/helpers.go index 64a0b12..4c15c90 100644 --- a/helpers.go +++ b/helpers.go @@ -69,6 +69,8 @@ func isZeroOptionValue(v any) bool { return len(x) == 0 case *time.Time: return x == nil + case *bool: + return x == nil case time.Time: return x.IsZero() default: @@ -93,6 +95,11 @@ func formatOptionValue(v any) string { 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: diff --git a/issues.go b/issues.go index 177866a..b26c4a9 100644 --- a/issues.go +++ b/issues.go @@ -21,6 +21,81 @@ 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: @@ -201,15 +276,15 @@ func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOp } // ListIssues returns all issues in a repository. -func (s *IssueService) ListIssues(ctx context.Context, owner, repo string) ([]types.Issue, error) { +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, nil) + 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) iter.Seq2[types.Issue, error] { +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, nil) + return ListIter[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } // CreateIssue creates a new issue in a repository. @@ -454,6 +529,19 @@ func (s *IssueService) DeleteCommentReaction(ctx context.Context, owner, repo st 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 diff --git a/issues_test.go b/issues_test.go index 6d65335..38f8f21 100644 --- a/issues_test.go +++ b/issues_test.go @@ -78,6 +78,64 @@ func TestIssueService_List_Good(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 { diff --git a/pulls.go b/pulls.go index 895d9b3..7916a34 100644 --- a/pulls.go +++ b/pulls.go @@ -3,6 +3,8 @@ package forge import ( "context" "iter" + "net/url" + "strconv" "dappco.re/go/core/forge/types" ) @@ -17,6 +19,53 @@ 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]( @@ -26,15 +75,13 @@ func newPullService(c *Client) *PullService { } // ListPullRequests returns all pull requests in a repository. -func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string) ([]types.PullRequest, error) { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) - return ListAll[types.PullRequest](ctx, s.client, path, nil) +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) iter.Seq2[types.PullRequest, error] { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) - return ListIter[types.PullRequest](ctx, s.client, path, nil) +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. @@ -176,6 +223,85 @@ func (s *PullService) DeleteReview(ctx context.Context, owner, repo string, inde 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, 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))) diff --git a/pulls_test.go b/pulls_test.go index 79571fc..7569d3f 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -5,6 +5,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "reflect" "testing" "dappco.re/go/core/forge/types" @@ -41,6 +42,53 @@ func TestPullService_List_Good(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 { diff --git a/releases.go b/releases.go index 5e58496..906d259 100644 --- a/releases.go +++ b/releases.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" goio "io" @@ -19,6 +20,46 @@ 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: @@ -60,15 +101,15 @@ func newReleaseService(c *Client) *ReleaseService { } // ListReleases returns all releases in a repository. -func (s *ReleaseService) ListReleases(ctx context.Context, owner, repo string) ([]types.Release, error) { +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, nil) + 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) iter.Seq2[types.Release, error] { +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, nil) + return ListIter[types.Release](ctx, s.client, path, releaseListQuery(filters...)) } // CreateRelease creates a release in a repository. @@ -174,3 +215,16 @@ func (s *ReleaseService) DeleteAsset(ctx context.Context, owner, repo string, re 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_test.go b/releases_test.go index cfe8380..8d5ca7d 100644 --- a/releases_test.go +++ b/releases_test.go @@ -84,6 +84,47 @@ func TestReleaseService_List_Good(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 { From 4b33c1b71c629bbbfc41622a120e408384dcad0f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:37:32 +0000 Subject: [PATCH 173/181] docs(forgegen): expand generated usage examples Co-Authored-By: Virgil --- cmd/forgegen/generator.go | 61 ++++--- cmd/forgegen/generator_test.go | 49 +++++ types/action.go | 61 ++++--- types/activity.go | 34 ++-- types/admin.go | 15 +- types/branch.go | 181 ++++++++++--------- types/comment.go | 27 +-- types/commit.go | 78 +++++--- types/common.go | 39 ++-- types/content.go | 140 +++++++++------ types/error.go | 41 +++-- types/federation.go | 53 ++++-- types/git.go | 113 +++++++----- types/hook.go | 89 +++++---- types/issue.go | 174 +++++++++++------- types/key.go | 91 ++++++---- types/label.go | 43 +++-- types/milestone.go | 41 +++-- types/misc.go | 304 ++++++++++++++++++------------- types/notification.go | 41 +++-- types/oauth.go | 39 ++-- types/org.go | 69 +++---- types/package.go | 35 ++-- types/pr.go | 217 +++++++++++----------- types/quota.go | 115 +++++++++--- types/reaction.go | 11 +- types/release.go | 67 +++---- types/repo.go | 317 ++++++++++++++++++--------------- types/review.go | 5 +- types/settings.go | 45 +++-- types/status.go | 27 +-- types/tag.go | 49 +++-- types/team.go | 51 +++--- types/time_tracking.go | 37 ++-- types/topic.go | 19 +- types/user.go | 184 ++++++++++--------- types/wiki.go | 51 ++++-- 37 files changed, 1807 insertions(+), 1206 deletions(-) diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index 9bee666..83f89b2 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -5,6 +5,7 @@ import ( "cmp" "maps" "slices" + "strconv" "strings" "text/template" @@ -254,34 +255,23 @@ func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { func populateUsageExamples(types map[string]*GoType) { for _, gt := range types { - if !shouldHaveUsage(gt.Name) { - continue - } gt.Usage = usageExample(gt) } } -func shouldHaveUsage(name string) bool { - if core.HasSuffix(name, "Option") || core.HasSuffix(name, "Options") { - return true - } - for _, prefix := range []string{ - "Create", "Edit", "Update", "Delete", "Add", "Set", - "Dispatch", "Migrate", "Generate", "Replace", "Submit", "Transfer", - } { - if core.HasPrefix(name, prefix) { - return true - } - } - return false -} - func usageExample(gt *GoType) string { - example := exampleTypeLiteral(gt) - if example == "" { - example = gt.Name + "{}" + 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 } - return example } func exampleTypeLiteral(gt *GoType) string { @@ -297,6 +287,31 @@ func exampleTypeLiteral(gt *GoType) string { 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) @@ -352,7 +367,7 @@ func exampleValue(field GoField) string { case core.HasPrefix(field.GoType, "[]int"): return "[]int{1}" case core.HasPrefix(field.GoType, "map["): - return "map[string]string{\"key\": \"value\"}" + return field.GoType + "{\"key\": \"value\"}" default: return "{}" } diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 8e5910e..68ebf7f 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -204,3 +204,52 @@ func TestGenerate_UsageExamples_Good(t *testing.T) { 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/types/action.go b/types/action.go index b3eaa20..4f65057 100644 --- a/types/action.go +++ b/types/action.go @@ -4,36 +4,47 @@ 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 @@ -52,13 +63,17 @@ type CreateVariableOption struct { // opts := DispatchWorkflowOption{Ref: "main"} type DispatchWorkflowOption struct { 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 + 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 @@ -67,6 +82,6 @@ type Secret struct { // // 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 63bf392..57fafd0 100644 --- a/types/activity.go +++ b/types/activity.go @@ -4,24 +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 7b89b65..74899be 100644 --- a/types/admin.go +++ b/types/admin.go @@ -4,14 +4,17 @@ 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 diff --git a/types/branch.go b/types/branch.go index 1503a73..23854de 100644 --- a/types/branch.go +++ b/types/branch.go @@ -4,49 +4,56 @@ 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 @@ -55,31 +62,31 @@ type BranchProtection struct { // // 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 @@ -88,9 +95,9 @@ type CreateBranchProtectionOption struct { // // 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 @@ -99,29 +106,29 @@ type CreateBranchRepoOption struct { // // 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 diff --git a/types/comment.go b/types/comment.go index 06c703f..c5eebb6 100644 --- a/types/comment.go +++ b/types/comment.go @@ -4,18 +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 e340f79..8c899e2 100644 --- a/types/commit.go +++ b/types/commit.go @@ -4,24 +4,30 @@ 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 @@ -30,41 +36,59 @@ type CommitAffectedFiles struct { // // 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" +// +// 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 860e1e5..fe42c98 100644 --- a/types/common.go +++ b/types/common.go @@ -4,17 +4,20 @@ 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 @@ -24,18 +27,30 @@ type Attachment struct { // 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 +// +// Usage: +// +// opts := StateType("example") type StateType string // TimeStamp — TimeStamp defines a timestamp +// +// Usage: +// +// opts := TimeStamp(1) type TimeStamp int64 diff --git a/types/content.go b/types/content.go index b5908b3..8693d8f 100644 --- a/types/content.go +++ b/types/content.go @@ -4,24 +4,27 @@ 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) @@ -30,14 +33,14 @@ type ContentsResponse struct { // // 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) @@ -46,53 +49,72 @@ type CreateFileOptions struct { // // 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"` } @@ -102,14 +124,14 @@ type FilesResponse struct { // // 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 aa734e7..355b9fc 100644 --- a/types/error.go +++ b/types/error.go @@ -2,40 +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 99d2bdb..2b7dc72 100644 --- a/types/federation.go +++ b/types/federation.go @@ -2,42 +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 332ef6d..d9b153c 100644 --- a/types/git.go +++ b/types/git.go @@ -2,37 +2,48 @@ 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 @@ -45,54 +56,76 @@ type EditGitHookOption struct { } // 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"} diff --git a/types/hook.go b/types/hook.go index 8a81c9f..27015a8 100644 --- a/types/hook.go +++ b/types/hook.go @@ -4,26 +4,25 @@ 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 // // Usage: // -// opts := CreateHookOptionConfig{} +// opts := CreateHookOptionConfig(map[string]any{"key": "value"}) type CreateHookOptionConfig map[string]any // EditHookOption — EditHookOption options when modify one hook @@ -32,48 +31,60 @@ type CreateHookOptionConfig map[string]any // // 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]string `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]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"` + 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 f5a9b25..b04b9e4 100644 --- a/types/issue.go +++ b/types/issue.go @@ -4,14 +4,13 @@ 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"` } @@ -21,15 +20,15 @@ type CreateIssueCommentOption struct { // // 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 @@ -47,7 +46,7 @@ type EditDeadlineOption struct { // // opts := EditIssueCommentOption{Body: "example"} type EditIssueCommentOption struct { - Body string `json:"body"` + Body string `json:"body"` Updated time.Time `json:"updated_at,omitempty"` } @@ -57,80 +56,108 @@ type EditIssueCommentOption struct { // // 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"` } +// Usage: +// +// opts := IssueFormFieldType("example") type IssueFormFieldType string // IssueFormFieldVisible — IssueFormFieldVisible defines issue form field visible +// +// Usage: +// +// opts := IssueFormFieldVisible("example") type IssueFormFieldVisible string // IssueLabelsOption — IssueLabelsOption a collection of labels @@ -139,27 +166,38 @@ type IssueFormFieldVisible string // // 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"` } +// Usage: +// +// opts := IssueTemplateLabels([]string{"example"}) type IssueTemplateLabels []string diff --git a/types/key.go b/types/key.go index 3f57825..c5ec942 100644 --- a/types/key.go +++ b/types/key.go @@ -4,7 +4,6 @@ package types import "time" - // CreateGPGKeyOption — CreateGPGKeyOption options create user GPG key // // Usage: @@ -12,7 +11,7 @@ import "time" // 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 @@ -21,56 +20,72 @@ type CreateGPGKeyOption struct { // // 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 b79c21f..d44d692 100644 --- a/types/label.go +++ b/types/label.go @@ -4,18 +4,17 @@ 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 @@ -33,28 +32,36 @@ type DeleteLabelsOption struct { // // 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 b91f75c..ba1f0b4 100644 --- a/types/milestone.go +++ b/types/milestone.go @@ -4,17 +4,16 @@ 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 @@ -23,22 +22,26 @@ type CreateMilestoneOption struct { // // 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 217d48e..3da46b9 100644 --- a/types/misc.go +++ b/types/misc.go @@ -4,7 +4,6 @@ package types import "time" - // AddCollaboratorOption — AddCollaboratorOption options when adding a user as a collaborator of a repository // // Usage: @@ -21,17 +20,21 @@ type AddCollaboratorOption struct { // 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) @@ -40,19 +43,22 @@ type ChangeFileOperation struct { // // 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 @@ -61,7 +67,7 @@ type Compare struct { // // 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 } @@ -81,10 +87,15 @@ type CreateOrUpdateSecretOption struct { // 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{} @@ -94,46 +105,62 @@ type ForgeLike struct{} // // 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 @@ -143,9 +170,9 @@ type LicensesTemplateListEntry struct { // 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 @@ -155,11 +182,11 @@ type MarkdownOption struct { // 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 @@ -168,14 +195,14 @@ type MarkupOption struct { // // 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 @@ -184,57 +211,76 @@ type MergePullRequestOption struct { // // 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 +// +// 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 @@ -247,55 +293,71 @@ type ReplaceFlagsOption struct { } // 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 9d188d1..3cbae6c 100644 --- a/types/notification.go +++ b/types/notification.go @@ -4,30 +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 acb1038..cc41c61 100644 --- a/types/oauth.go +++ b/types/oauth.go @@ -4,13 +4,15 @@ 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 @@ -19,7 +21,7 @@ type AccessToken struct { // // opts := CreateAccessTokenOption{Name: "example"} type CreateAccessTokenOption struct { - Name string `json:"name"` + Name string `json:"name"` Scopes []string `json:"scopes,omitempty"` } @@ -29,17 +31,20 @@ type CreateAccessTokenOption struct { // // 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 d5611d2..24c62ce 100644 --- a/types/org.go +++ b/types/org.go @@ -2,21 +2,20 @@ 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 @@ -25,35 +24,43 @@ type CreateOrgOption struct { // // 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 02e639e..2576ca4 100644 --- a/types/package.go +++ b/types/package.go @@ -4,27 +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 cac3d0e..7204103 100644 --- a/types/pr.go +++ b/types/pr.go @@ -4,22 +4,21 @@ 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 @@ -28,17 +27,17 @@ type CreatePullRequestOption struct { // // 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 // // Usage: // -// opts := CreatePullReviewCommentOptions{} +// opts := CreatePullReviewCommentOptions(CreatePullReviewComment{}) type CreatePullReviewCommentOptions CreatePullReviewComment // CreatePullReviewOptions — CreatePullReviewOptions are options to create a pull review @@ -47,10 +46,10 @@ type CreatePullReviewCommentOptions CreatePullReviewComment // // 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 @@ -59,103 +58,119 @@ type CreatePullReviewOptions struct { // // 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 @@ -164,7 +179,7 @@ type PullReviewComment struct { // // opts := PullReviewRequestOptions{Reviewers: []string{"example"}} type PullReviewRequestOptions struct { - Reviewers []string `json:"reviewers,omitempty"` + Reviewers []string `json:"reviewers,omitempty"` TeamReviewers []string `json:"team_reviewers,omitempty"` } @@ -174,6 +189,6 @@ type PullReviewRequestOptions struct { // // opts := SubmitPullReviewOptions{Body: "example"} type SubmitPullReviewOptions struct { - Body string `json:"body,omitempty"` + Body string `json:"body,omitempty"` Event ReviewStateType `json:"event,omitempty"` } diff --git a/types/quota.go b/types/quota.go index 2639a10..c5e5d08 100644 --- a/types/quota.go +++ b/types/quota.go @@ -2,14 +2,13 @@ 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. } @@ -19,8 +18,8 @@ type CreateQuotaGroupOptions struct { // // 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 } @@ -30,102 +29,170 @@ type CreateQuotaRuleOptions struct { // // 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 +// +// 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"` + 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 +// +// 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 +// +// 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 +// +// 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 415d52c..a4ca981 100644 --- a/types/reaction.go +++ b/types/reaction.go @@ -4,7 +4,6 @@ package types import "time" - // EditReactionOption — EditReactionOption contain the reaction type // // Usage: @@ -15,8 +14,12 @@ type EditReactionOption struct { } // 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 b419a2e..7270456 100644 --- a/types/release.go +++ b/types/release.go @@ -4,20 +4,19 @@ 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 @@ -26,33 +25,37 @@ type CreateReleaseOption struct { // // 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 adca65d..b51392a 100644 --- a/types/repo.go +++ b/types/repo.go @@ -4,18 +4,16 @@ 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 @@ -24,18 +22,18 @@ type CreatePushMirrorOption struct { // // 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 @@ -44,88 +42,111 @@ type CreateRepoOption struct { // // 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"` } @@ -139,85 +160,97 @@ type RepoTopicOptions struct { } // 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 @@ -226,8 +259,8 @@ type RepositoryMeta struct { // // 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 diff --git a/types/review.go b/types/review.go index e4ef6fa..2c892cc 100644 --- a/types/review.go +++ b/types/review.go @@ -2,6 +2,9 @@ package types - // ReviewStateType — ReviewStateType review state type +// +// Usage: +// +// opts := ReviewStateType("example") type ReviewStateType string diff --git a/types/settings.go b/types/settings.go index e27581c..943065b 100644 --- a/types/settings.go +++ b/types/settings.go @@ -2,37 +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 b1d432d..bf945f8 100644 --- a/types/status.go +++ b/types/status.go @@ -2,16 +2,19 @@ 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 @@ -20,8 +23,8 @@ type CombinedStatus struct { // // 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 5b2d5ee..180bde9 100644 --- a/types/tag.go +++ b/types/tag.go @@ -4,7 +4,6 @@ package types import "time" - // CreateTagOption — CreateTagOption options when creating a tag // // Usage: @@ -13,7 +12,7 @@ import "time" 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 @@ -22,8 +21,8 @@ type CreateTagOption struct { // // 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"` } @@ -33,34 +32,46 @@ type CreateTagProtectionOption struct { // // 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 a8c7505..6084466 100644 --- a/types/team.go +++ b/types/team.go @@ -2,20 +2,19 @@ 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]string `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 @@ -24,24 +23,28 @@ type CreateTeamOption struct { // // 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]string `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]string `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 bf3de14..a44b0b9 100644 --- a/types/time_tracking.go +++ b/types/time_tracking.go @@ -4,25 +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 4e95128..5f212f9 100644 --- a/types/topic.go +++ b/types/topic.go @@ -4,17 +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 d157974..cb76725 100644 --- a/types/user.go +++ b/types/user.go @@ -4,9 +4,11 @@ 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"` } @@ -25,17 +27,17 @@ type CreateEmailOption struct { // // 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 @@ -53,34 +55,38 @@ type DeleteEmailOption struct { // // 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 @@ -102,51 +108,63 @@ type UpdateUserAvatarOption struct { } // 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 @@ -155,15 +173,15 @@ type UserSettings struct { // // 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 38c2af2..a570999 100644 --- a/types/wiki.go +++ b/types/wiki.go @@ -2,7 +2,6 @@ package types - // CreateWikiPageOptions — CreateWikiPageOptions form for creating wiki // // Usage: @@ -10,40 +9,56 @@ package types // 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"` } From 4b27f1072a92763d0165010adb2bb07bae73f763 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:40:50 +0000 Subject: [PATCH 174/181] refactor(forge): align config lookup and service docs with AX Co-Authored-By: Virgil --- config.go | 5 +- service_string.go | 200 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/config.go b/config.go index 21e88cd..5f79fa7 100644 --- a/config.go +++ b/config.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "path/filepath" - "syscall" core "dappco.re/go/core" coreio "dappco.re/go/core/io" @@ -94,10 +93,10 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { token = fileToken } - if envURL, ok := syscall.Getenv("FORGE_URL"); ok && envURL != "" { + if envURL, ok := os.LookupEnv("FORGE_URL"); ok && envURL != "" { url = envURL } - if envToken, ok := syscall.Getenv("FORGE_TOKEN"); ok && envToken != "" { + if envToken, ok := os.LookupEnv("FORGE_TOKEN"); ok && envToken != "" { token = envToken } diff --git a/service_string.go b/service_string.go index 9bb619c..c29476a 100644 --- a/service_string.go +++ b/service_string.go @@ -1,6 +1,11 @@ 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{}" @@ -9,9 +14,19 @@ func (s *ActionsService) String() string { } // 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{}" @@ -20,9 +35,19 @@ func (s *ActivityPubService) String() string { } // 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{}" @@ -31,9 +56,19 @@ func (s *AdminService) String() string { } // 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{}" @@ -42,9 +77,19 @@ func (s *BranchService) String() string { } // 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{}" @@ -53,9 +98,19 @@ func (s *CommitService) String() string { } // 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{}" @@ -64,9 +119,19 @@ func (s *ContentService) String() string { } // 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{}" @@ -75,9 +140,19 @@ func (s *IssueService) String() string { } // 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{}" @@ -86,9 +161,19 @@ func (s *LabelService) String() string { } // 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{}" @@ -97,9 +182,19 @@ func (s *MilestoneService) String() string { } // 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{}" @@ -108,9 +203,19 @@ func (s *MiscService) String() string { } // 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{}" @@ -119,9 +224,19 @@ func (s *NotificationService) String() string { } // 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{}" @@ -130,9 +245,19 @@ func (s *OrgService) String() string { } // 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{}" @@ -141,9 +266,19 @@ func (s *PackageService) String() string { } // 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{}" @@ -152,9 +287,19 @@ func (s *PullService) String() string { } // 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{}" @@ -163,9 +308,19 @@ func (s *ReleaseService) String() string { } // 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{}" @@ -174,9 +329,19 @@ func (s *RepoService) String() string { } // 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{}" @@ -185,9 +350,19 @@ func (s *TeamService) String() string { } // 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{}" @@ -196,9 +371,19 @@ func (s *UserService) String() string { } // 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{}" @@ -207,9 +392,19 @@ func (s *WebhookService) String() string { } // 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{}" @@ -218,4 +413,9 @@ func (s *WikiService) String() string { } // 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() } From 6236446883f578c4500c12eddaa4bd2576cf6606 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:44:36 +0000 Subject: [PATCH 175/181] fix(forge): create config directory before saving Co-Authored-By: Virgil --- config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config.go b/config.go index 5f79fa7..97f38e0 100644 --- a/config.go +++ b/config.go @@ -56,6 +56,7 @@ func readConfigFile() (url, token string, err error) { } // 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: // @@ -65,6 +66,9 @@ func SaveConfig(url, token string) error { 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) From 51dfbe8de0c80d53dc04aac8df1066c230439a87 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:46:50 +0000 Subject: [PATCH 176/181] fix(forge): honor config precedence before file reads Co-Authored-By: Virgil --- config.go | 19 ++++++++++++------- config_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/config.go b/config.go index 97f38e0..00e24d6 100644 --- a/config.go +++ b/config.go @@ -90,13 +90,6 @@ func SaveConfig(url, token string) error { // _ = url // _ = token func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - if fileURL, fileToken, fileErr := readConfigFile(); fileErr != nil { - return "", "", fileErr - } else { - url = fileURL - token = fileToken - } - if envURL, ok := os.LookupEnv("FORGE_URL"); ok && envURL != "" { url = envURL } @@ -110,6 +103,18 @@ 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 } diff --git a/config_test.go b/config_test.go index f0326d0..1263e42 100644 --- a/config_test.go +++ b/config_test.go @@ -111,6 +111,32 @@ func TestResolveConfig_EnvOverridesConfig_Good(t *testing.T) { } } +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", "") From 61d2b8440afbdfb1ca6a22db0d57761307fdbe48 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:50:37 +0000 Subject: [PATCH 177/181] fix(forge): wrap helper parse errors Co-Authored-By: Virgil --- actions.go | 3 ++- admin.go | 3 ++- contents.go | 3 ++- notifications.go | 3 ++- pulls.go | 3 ++- repos.go | 7 ++++--- users.go | 3 ++- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/actions.go b/actions.go index 23a69b4..bb26a99 100644 --- a/actions.go +++ b/actions.go @@ -6,6 +6,7 @@ import ( "net/url" "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -214,7 +215,7 @@ func (s *ActionsService) ListRepoTasks(ctx context.Context, owner, repo string, if opts.Page > 0 || opts.Limit > 0 { u, err := url.Parse(path) if err != nil { - return nil, err + return nil, core.E("ActionsService.ListRepoTasks", "forge: parse path", err) } q := u.Query() if opts.Page > 0 { diff --git a/admin.go b/admin.go index c7f7f19..331c18f 100644 --- a/admin.go +++ b/admin.go @@ -7,6 +7,7 @@ import ( "net/url" "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -439,7 +440,7 @@ func (s *AdminService) ListActionsRuns(ctx context.Context, filters AdminActions u, err := url.Parse("/api/v1/admin/actions/runs") if err != nil { - return nil, err + return nil, core.E("AdminService.ListActionsRuns", "forge: parse path", err) } q := u.Query() diff --git a/contents.go b/contents.go index ac08739..78d5c21 100644 --- a/contents.go +++ b/contents.go @@ -5,6 +5,7 @@ import ( "iter" "net/url" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -30,7 +31,7 @@ func (s *ContentService) ListContents(ctx context.Context, owner, repo, ref stri if ref != "" { u, err := url.Parse(path) if err != nil { - return nil, err + return nil, core.E("ContentService.ListContents", "forge: parse path", err) } q := u.Query() q.Set("ref", ref) diff --git a/notifications.go b/notifications.go index 661de6d..cad311d 100644 --- a/notifications.go +++ b/notifications.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -285,7 +286,7 @@ func (s *NotificationService) listPage(ctx context.Context, path string, opts Li u, err := url.Parse(path) if err != nil { - return nil, err + return nil, core.E("NotificationService.listPage", "forge: parse path", err) } values := u.Query() diff --git a/pulls.go b/pulls.go index 7916a34..56cd6eb 100644 --- a/pulls.go +++ b/pulls.go @@ -6,6 +6,7 @@ import ( "net/url" "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -234,7 +235,7 @@ func (s *PullService) listPage(ctx context.Context, owner, repo string, opts Lis path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) u, err := url.Parse(path) if err != nil { - return nil, err + return nil, core.E("PullService.listPage", "forge: parse path", err) } values := u.Query() diff --git a/repos.go b/repos.go index 5de4411..8b826a8 100644 --- a/repos.go +++ b/repos.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -466,7 +467,7 @@ func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath if ref != "" { u, err := url.Parse(path) if err != nil { - return nil, err + return nil, core.E("RepoService.GetRawFileOrLFS", "forge: parse path", err) } q := u.Query() q.Set("ref", ref) @@ -482,7 +483,7 @@ func (s *RepoService) GetEditorConfig(ctx context.Context, owner, repo, filepath if ref != "" { u, err := url.Parse(path) if err != nil { - return err + return core.E("RepoService.GetEditorConfig", "forge: parse path", err) } q := u.Query() q.Set("ref", ref) @@ -651,7 +652,7 @@ func (s *RepoService) SearchRepositoriesPage(ctx context.Context, query string, u, err := url.Parse("/api/v1/repos/search") if err != nil { - return nil, err + return nil, core.E("RepoService.SearchRepositoriesPage", "forge: parse path", err) } q := u.Query() diff --git a/users.go b/users.go index 86d9125..d0254a8 100644 --- a/users.go +++ b/users.go @@ -7,6 +7,7 @@ import ( "net/url" "strconv" + core "dappco.re/go/core" "dappco.re/go/core/forge/types" ) @@ -132,7 +133,7 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt u, err := url.Parse("/api/v1/users/search") if err != nil { - return nil, err + return nil, core.E("UserService.SearchUsersPage", "forge: parse path", err) } q := u.Query() From d331c64fa65fb4ea4f4cd6d16134cd4daa8c1f11 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:54:24 +0000 Subject: [PATCH 178/181] refactor(forge): expose config path helper Co-Authored-By: Virgil --- config.go | 18 +++++++++++++----- config_test.go | 14 ++++++++++++++ forge.go | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/config.go b/config.go index 00e24d6..6da3f02 100644 --- a/config.go +++ b/config.go @@ -26,16 +26,23 @@ type configFile struct { Token string `json:"token"` } -func configPath() (string, error) { +// 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 "", core.E("ConfigPath", "forge: resolve home directory", err) } return filepath.Join(home, defaultConfigPath), nil } func readConfigFile() (url, token string, err error) { - path, err := configPath() + path, err := ConfigPath() if err != nil { return "", "", err } @@ -62,7 +69,7 @@ func readConfigFile() (url, token string, err error) { // // _ = forge.SaveConfig("https://forge.example.com", "token") func SaveConfig(url, token string) error { - path, err := configPath() + path, err := ConfigPath() if err != nil { return err } @@ -132,7 +139,8 @@ func NewFromConfig(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. +// It returns an error if no API token is available from flags, environment, +// or the saved config file. // // Usage: // diff --git a/config_test.go b/config_test.go index 1263e42..5d35f47 100644 --- a/config_test.go +++ b/config_test.go @@ -189,3 +189,17 @@ func TestSaveConfig_Good(t *testing.T) { 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/forge.go b/forge.go index 0f7eaf8..22e08fe 100644 --- a/forge.go +++ b/forge.go @@ -67,7 +67,7 @@ func NewForge(url, token string, opts ...Option) *Forge { return f } -// Client returns the underlying HTTP client. +// Client returns the underlying Forge client. // // Usage: // From d08c875fcfdd24230b5d90b0f0c817faecd867a5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:57:16 +0000 Subject: [PATCH 179/181] chore(forge): verify RFC coverage Co-Authored-By: Virgil From 00a30817f2dfde7c281e5396cacab30cd6b76d78 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 09:00:15 +0000 Subject: [PATCH 180/181] fix(forge): make client accessors nil-safe Co-Authored-By: Virgil --- client.go | 15 +++++++++++++++ client_test.go | 19 +++++++++++++++++++ forge.go | 42 ++++++++++++++++++++++++++++++++++++------ forge_test.go | 22 ++++++++++++++++++++++ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/client.go b/client.go index a0bf99e..1b650a7 100644 --- a/client.go +++ b/client.go @@ -171,6 +171,9 @@ type Client struct { // // baseURL := client.BaseURL() func (c *Client) BaseURL() string { + if c == nil { + return "" + } return c.baseURL } @@ -180,6 +183,9 @@ func (c *Client) BaseURL() string { // // rl := client.RateLimit() func (c *Client) RateLimit() RateLimit { + if c == nil { + return RateLimit{} + } return c.rateLimit } @@ -189,6 +195,9 @@ func (c *Client) RateLimit() RateLimit { // // ua := client.UserAgent() func (c *Client) UserAgent() string { + if c == nil { + return "" + } return c.userAgent } @@ -198,6 +207,9 @@ func (c *Client) UserAgent() string { // // hc := client.HTTPClient() func (c *Client) HTTPClient() *http.Client { + if c == nil { + return nil + } return c.httpClient } @@ -232,6 +244,9 @@ func (c *Client) GoString() string { return c.String() } // _ = "authenticated" // } func (c *Client) HasToken() bool { + if c == nil { + return false + } return c.token != "" } diff --git a/client_test.go b/client_test.go index c8e8555..818383a 100644 --- a/client_test.go +++ b/client_test.go @@ -215,6 +215,25 @@ func TestClient_HasToken_Bad(t *testing.T) { } } +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_WithHTTPClient_Good(t *testing.T) { custom := &http.Client{} c := NewClient("https://forge.lthn.ai", "tok", WithHTTPClient(custom)) diff --git a/forge.go b/forge.go index 22e08fe..e24eb8b 100644 --- a/forge.go +++ b/forge.go @@ -72,35 +72,60 @@ func NewForge(url, token string, opts ...Option) *Forge { // Usage: // // client := f.Client() -func (f *Forge) Client() *Client { return 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 { return f.client.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 { return f.client.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 { return f.client.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 { return f.client.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. // @@ -109,7 +134,12 @@ func (f *Forge) HTTPClient() *http.Client { return f.client.HTTPClient() } // if f.HasToken() { // _ = "authenticated" // } -func (f *Forge) HasToken() bool { return f.client.HasToken() } +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. // diff --git a/forge_test.go b/forge_test.go index 8a47bf5..78e3792 100644 --- a/forge_test.go +++ b/forge_test.go @@ -82,6 +82,28 @@ func TestForge_HasToken_Bad(t *testing.T) { } } +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) From 88bad49d24fcefce9c7f6a25c14e45a36fb9cafc Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 4 Apr 2026 16:21:12 +0100 Subject: [PATCH 181/181] fix: migrate module paths from forge.lthn.ai to dappco.re Co-Authored-By: Virgil --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0111cc8..b3a3c6b 100644 --- a/go.mod +++ b/go.mod @@ -8,4 +8,4 @@ require ( 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