feat: IssueService and PullService with actions

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-21 15:50:22 +00:00
parent abc8840fa4
commit f8c6090227
7 changed files with 387 additions and 4 deletions

View file

@ -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

View file

@ -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{}

105
issues.go Normal file
View file

@ -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
}

103
issues_test.go Normal file
View file

@ -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)
}
}

63
pulls.go Normal file
View file

@ -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)
}

109
pulls_test.go Normal file
View file

@ -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)
}
}

View file

@ -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{}