From f8c6090227f39eff34c081c74eebb2b693de3d72 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 15:50:22 +0000 Subject: [PATCH] feat: IssueService and PullService with actions Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- client.go | 5 +++ forge.go | 4 +- issues.go | 105 +++++++++++++++++++++++++++++++++++++++++++++ issues_test.go | 103 ++++++++++++++++++++++++++++++++++++++++++++ pulls.go | 63 +++++++++++++++++++++++++++ pulls_test.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++ services_stub.go | 2 - 7 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 issues.go create mode 100644 issues_test.go create mode 100644 pulls.go create mode 100644 pulls_test.go diff --git a/client.go b/client.go index e7f29dc..d215255 100644 --- a/client.go +++ b/client.go @@ -100,6 +100,11 @@ func (c *Client) Delete(ctx context.Context, path string) error { return c.do(ctx, http.MethodDelete, path, nil, nil) } +// DeleteWithBody performs a DELETE request with a JSON body. +func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error { + return c.do(ctx, http.MethodDelete, path, body, nil) +} + func (c *Client) do(ctx context.Context, method, path string, body, out any) error { url := c.baseURL + path diff --git a/forge.go b/forge.go index df80533..97199cd 100644 --- a/forge.go +++ b/forge.go @@ -28,10 +28,10 @@ func NewForge(url, token string, opts ...Option) *Forge { c := NewClient(url, token, opts...) f := &Forge{client: c} f.Repos = newRepoService(c) + f.Issues = newIssueService(c) + f.Pulls = newPullService(c) // Other services initialised in their respective tasks. // Stub them here so tests compile: - f.Issues = &IssueService{} - f.Pulls = &PullService{} f.Orgs = &OrgService{} f.Users = &UserService{} f.Teams = &TeamService{} diff --git a/issues.go b/issues.go new file mode 100644 index 0000000..5e0972c --- /dev/null +++ b/issues.go @@ -0,0 +1,105 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// IssueService handles issue operations within a repository. +type IssueService struct { + Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption] +} + +func newIssueService(c *Client) *IssueService { + return &IssueService{ + Resource: *NewResource[types.Issue, types.CreateIssueOption, types.EditIssueOption]( + c, "/api/v1/repos/{owner}/{repo}/issues/{index}", + ), + } +} + +// 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) + 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) + 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) + body := map[string]string{"due_date": deadline} + return s.client.Post(ctx, path, body, nil) +} + +// AddReaction adds a reaction to an issue. +func (s *IssueService) AddReaction(ctx context.Context, owner, repo string, index int64, reaction string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner, repo, index) + body := map[string]string{"content": reaction} + 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) + 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) + 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) + 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) + 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) + 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) + return ListAll[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) + opts := types.CreateIssueCommentOption{Body: body} + var out types.Comment + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// toAnySlice converts a slice of int64 to a slice of any for IssueLabelsOption. +func toAnySlice(ids []int64) []any { + out := make([]any, len(ids)) + for i, id := range ids { + out[i] = id + } + return out +} diff --git a/issues_test.go b/issues_test.go new file mode 100644 index 0000000..273763c --- /dev/null +++ b/issues_test.go @@ -0,0 +1,103 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestIssueService_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) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Issue{ + {ID: 1, Title: "bug report"}, + {ID: 2, Title: "feature request"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Issues.List(context.Background(), Params{"owner": "core", "repo": "go-forge"}, DefaultList) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 2 { + t.Errorf("got %d items, want 2", len(result.Items)) + } + if result.Items[0].Title != "bug report" { + t.Errorf("got title=%q, want %q", result.Items[0].Title, "bug report") + } +} + +func TestIssueService_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/issues/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Issue{ID: 1, Title: "bug report", Index: 1}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issue, err := f.Issues.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "index": "1"}) + if err != nil { + t.Fatal(err) + } + if issue.Title != "bug report" { + t.Errorf("got title=%q", issue.Title) + } +} + +func TestIssueService_Good_Create(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) + } + var body types.CreateIssueOption + json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + 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.Create(context.Background(), Params{"owner": "core", "repo": "go-forge"}, &types.CreateIssueOption{ + Title: "new issue", + Body: "description here", + }) + if err != nil { + t.Fatal(err) + } + if issue.Title != "new issue" { + t.Errorf("got title=%q", issue.Title) + } +} + +func TestIssueService_Good_Pin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues/42/pin" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Issues.Pin(context.Background(), "core", "go-forge", 42) + if err != nil { + t.Fatal(err) + } +} diff --git a/pulls.go b/pulls.go new file mode 100644 index 0000000..e83d076 --- /dev/null +++ b/pulls.go @@ -0,0 +1,63 @@ +package forge + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go-forge/types" +) + +// PullService handles pull request operations within a repository. +type PullService struct { + Resource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption] +} + +func newPullService(c *Client) *PullService { + return &PullService{ + Resource: *NewResource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption]( + c, "/api/v1/repos/{owner}/{repo}/pulls/{index}", + ), + } +} + +// 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) + 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) + 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) + return ListAll[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) + var out types.PullReview + if err := s.client.Post(ctx, path, review, &out); err != nil { + return nil, err + } + return &out, nil +} + +// 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) + 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) + return s.client.Post(ctx, path, nil, nil) +} diff --git a/pulls_test.go b/pulls_test.go new file mode 100644 index 0000000..3800911 --- /dev/null +++ b/pulls_test.go @@ -0,0 +1,109 @@ +package forge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/go-forge/types" +) + +func TestPullService_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) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.PullRequest{ + {ID: 1, Title: "add feature"}, + {ID: 2, Title: "fix bug"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + result, err := f.Pulls.List(context.Background(), Params{"owner": "core", "repo": "go-forge"}, DefaultList) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 2 { + t.Errorf("got %d items, want 2", len(result.Items)) + } + if result.Items[0].Title != "add feature" { + t.Errorf("got title=%q, want %q", result.Items[0].Title, "add feature") + } +} + +func TestPullService_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/pulls/1" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.PullRequest{ID: 1, Title: "add feature", Index: 1}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pr, err := f.Pulls.Get(context.Background(), Params{"owner": "core", "repo": "go-forge", "index": "1"}) + if err != nil { + t.Fatal(err) + } + if pr.Title != "add feature" { + t.Errorf("got title=%q", pr.Title) + } +} + +func TestPullService_Good_Create(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) + } + var body types.CreatePullRequestOption + json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + 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.Create(context.Background(), Params{"owner": "core", "repo": "go-forge"}, &types.CreatePullRequestOption{ + Title: "new pull request", + Head: "feature-branch", + Base: "main", + }) + if err != nil { + t.Fatal(err) + } + if pr.Title != "new pull request" { + t.Errorf("got title=%q", pr.Title) + } +} + +func TestPullService_Good_Merge(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/merge" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["Do"] != "merge" { + t.Errorf("got Do=%q, want %q", body["Do"], "merge") + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Pulls.Merge(context.Background(), "core", "go-forge", 7, "merge") + if err != nil { + t.Fatal(err) + } +} diff --git a/services_stub.go b/services_stub.go index 5cbaaf5..5978a4d 100644 --- a/services_stub.go +++ b/services_stub.go @@ -2,8 +2,6 @@ package forge // Stub service types — replaced as each service is implemented. -type IssueService struct{} -type PullService struct{} type OrgService struct{} type UserService struct{} type TeamService struct{}