From 33eb5bc91a45052816d48b995363418e769938f8 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:07:10 +0000 Subject: [PATCH 1/2] 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"` -- 2.45.3 From 206749eb8a0c1bd7ae0b85dc9f0f4990c462cdb0 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 14:19:28 +0000 Subject: [PATCH 2/2] 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) -- 2.45.3