From b53a9e85f6f2d4255aa21edaadafd9c012d58844 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 11 Mar 2026 09:16:06 +0000 Subject: [PATCH 1/2] Add comment reactions support Add ListReactions and AddReaction methods to IssueService and PullRequestService with implementations for all backends. GitHub and Gitea use the issue comment reactions API, GitLab uses award emoji on notes, and Bitbucket returns ErrNotSupported. CLI commands: issue react, issue reactions, pr react, pr reactions. Closes #29 --- bitbucket/reactions.go | 24 ++++ bitbucket/reactions_test.go | 33 ++++++ forges_test.go | 16 +++ gitea/reactions.go | 74 +++++++++++++ gitea/reactions_test.go | 113 +++++++++++++++++++ github/reactions.go | 93 ++++++++++++++++ github/reactions_test.go | 136 +++++++++++++++++++++++ gitlab/reactions.go | 79 +++++++++++++ gitlab/reactions_test.go | 133 ++++++++++++++++++++++ internal/cli/issue.go | 2 + internal/cli/pr.go | 2 + internal/cli/reaction.go | 203 ++++++++++++++++++++++++++++++++++ internal/cli/reaction_test.go | 63 +++++++++++ services.go | 4 + types.go | 7 ++ 15 files changed, 982 insertions(+) create mode 100644 bitbucket/reactions.go create mode 100644 bitbucket/reactions_test.go create mode 100644 gitea/reactions.go create mode 100644 gitea/reactions_test.go create mode 100644 github/reactions.go create mode 100644 github/reactions_test.go create mode 100644 gitlab/reactions.go create mode 100644 gitlab/reactions_test.go create mode 100644 internal/cli/reaction.go create mode 100644 internal/cli/reaction_test.go diff --git a/bitbucket/reactions.go b/bitbucket/reactions.go new file mode 100644 index 0000000..9b0fae6 --- /dev/null +++ b/bitbucket/reactions.go @@ -0,0 +1,24 @@ +package bitbucket + +import ( + "context" + "fmt" + + forge "github.com/git-pkgs/forge" +) + +func (s *bitbucketIssueService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + return nil, fmt.Errorf("listing reactions: %w", forge.ErrNotSupported) +} + +func (s *bitbucketIssueService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + return nil, fmt.Errorf("adding reaction: %w", forge.ErrNotSupported) +} + +func (s *bitbucketPRService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + return nil, fmt.Errorf("listing reactions: %w", forge.ErrNotSupported) +} + +func (s *bitbucketPRService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + return nil, fmt.Errorf("adding reaction: %w", forge.ErrNotSupported) +} diff --git a/bitbucket/reactions_test.go b/bitbucket/reactions_test.go new file mode 100644 index 0000000..1146ea0 --- /dev/null +++ b/bitbucket/reactions_test.go @@ -0,0 +1,33 @@ +package bitbucket + +import ( + "context" + "errors" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestBitbucketReactionsNotSupported(t *testing.T) { + f := New("test-token", nil) + + _, err := f.Issues().ListReactions(context.Background(), "owner", "repo", 1, 42) + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } + + _, err = f.Issues().AddReaction(context.Background(), "owner", "repo", 1, 42, "+1") + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } + + _, err = f.PullRequests().ListReactions(context.Background(), "owner", "repo", 1, 42) + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } + + _, err = f.PullRequests().AddReaction(context.Background(), "owner", "repo", 1, 42, "+1") + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } +} diff --git a/forges_test.go b/forges_test.go index ad2de9d..5551c6b 100644 --- a/forges_test.go +++ b/forges_test.go @@ -603,6 +603,14 @@ func (m *mockIssueService) ListComments(_ context.Context, owner, repo string, n return m.comments, nil } +func (m *mockIssueService) ListReactions(_ context.Context, owner, repo string, number int, commentID int64) ([]Reaction, error) { + return nil, nil +} + +func (m *mockIssueService) AddReaction(_ context.Context, owner, repo string, number int, commentID int64, reaction string) (*Reaction, error) { + return nil, nil +} + type mockPRService struct { pr *PullRequest prs []PullRequest @@ -682,6 +690,14 @@ func (m *mockPRService) ListComments(_ context.Context, owner, repo string, numb return m.comments, nil } +func (m *mockPRService) ListReactions(_ context.Context, owner, repo string, number int, commentID int64) ([]Reaction, error) { + return nil, nil +} + +func (m *mockPRService) AddReaction(_ context.Context, owner, repo string, number int, commentID int64, reaction string) (*Reaction, error) { + return nil, nil +} + type mockLabelService struct { label *Label labels []Label diff --git a/gitea/reactions.go b/gitea/reactions.go new file mode 100644 index 0000000..3940c15 --- /dev/null +++ b/gitea/reactions.go @@ -0,0 +1,74 @@ +package gitea + +import ( + "context" + "net/http" + + forge "github.com/git-pkgs/forge" + "code.gitea.io/sdk/gitea" +) + +func convertGiteaReaction(r *gitea.Reaction) forge.Reaction { + result := forge.Reaction{ + Content: r.Reaction, + } + if r.User != nil { + result.User = r.User.UserName + } + return result +} + +func (s *giteaIssueService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + reactions, resp, err := s.client.GetIssueCommentReactions(owner, repo, commentID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + var all []forge.Reaction + for _, r := range reactions { + all = append(all, convertGiteaReaction(r)) + } + return all, nil +} + +func (s *giteaIssueService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + r, resp, err := s.client.PostIssueCommentReaction(owner, repo, commentID, reaction) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGiteaReaction(r) + return &result, nil +} + +func (s *giteaPRService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + // Gitea uses the same issue comment reactions API for PR comments + reactions, resp, err := s.client.GetIssueCommentReactions(owner, repo, commentID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + var all []forge.Reaction + for _, r := range reactions { + all = append(all, convertGiteaReaction(r)) + } + return all, nil +} + +func (s *giteaPRService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + r, resp, err := s.client.PostIssueCommentReaction(owner, repo, commentID, reaction) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGiteaReaction(r) + return &result, nil +} diff --git a/gitea/reactions_test.go b/gitea/reactions_test.go new file mode 100644 index 0000000..959473a --- /dev/null +++ b/gitea/reactions_test.go @@ -0,0 +1,113 @@ +package gitea + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestGiteaListIssueCommentReactions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/issues/comments/42/reactions", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "user": map[string]any{"login": "alice"}, + "content": "+1", + }, + { + "user": map[string]any{"login": "bob"}, + "content": "heart", + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reactions, err := f.Issues().ListReactions(context.Background(), "testorg", "testrepo", 1, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reactions) != 2 { + t.Fatalf("expected 2 reactions, got %d", len(reactions)) + } + + assertEqual(t, "reactions[0].Content", "+1", reactions[0].Content) + assertEqual(t, "reactions[0].User", "alice", reactions[0].User) + assertEqual(t, "reactions[1].Content", "heart", reactions[1].Content) + assertEqual(t, "reactions[1].User", "bob", reactions[1].User) +} + +func TestGiteaAddIssueCommentReaction(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("POST /api/v1/repos/testorg/testrepo/issues/comments/42/reactions", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": map[string]any{"login": "alice"}, + "content": "rocket", + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reaction, err := f.Issues().AddReaction(context.Background(), "testorg", "testrepo", 1, 42, "rocket") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Content", "rocket", reaction.Content) + assertEqual(t, "User", "alice", reaction.User) +} + +func TestGiteaListIssueCommentReactionsNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/issues/comments/999/reactions", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + _, err := f.Issues().ListReactions(context.Background(), "testorg", "testrepo", 1, 999) + if err != forge.ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestGiteaPRListCommentReactions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/issues/comments/50/reactions", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "user": map[string]any{"login": "carol"}, + "content": "eyes", + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reactions, err := f.PullRequests().ListReactions(context.Background(), "testorg", "testrepo", 10, 50) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reactions) != 1 { + t.Fatalf("expected 1 reaction, got %d", len(reactions)) + } + assertEqual(t, "Content", "eyes", reactions[0].Content) + assertEqual(t, "User", "carol", reactions[0].User) +} diff --git a/github/reactions.go b/github/reactions.go new file mode 100644 index 0000000..cae1707 --- /dev/null +++ b/github/reactions.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "net/http" + + forge "github.com/git-pkgs/forge" + "github.com/google/go-github/v82/github" +) + +func convertGitHubReaction(r *github.Reaction) forge.Reaction { + result := forge.Reaction{ + ID: r.GetID(), + Content: r.GetContent(), + } + if u := r.GetUser(); u != nil { + result.User = u.GetLogin() + } + return result +} + +func (s *gitHubIssueService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + var all []forge.Reaction + opts := &github.ListReactionOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + for { + reactions, resp, err := s.client.Reactions.ListIssueCommentReactions(ctx, owner, repo, commentID, opts) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, r := range reactions { + all = append(all, convertGitHubReaction(r)) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return all, nil +} + +func (s *gitHubIssueService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + r, resp, err := s.client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, reaction) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGitHubReaction(r) + return &result, nil +} + +func (s *gitHubPRService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + // GitHub uses the same issue comment reactions API for PR comments + var all []forge.Reaction + opts := &github.ListReactionOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + for { + reactions, resp, err := s.client.Reactions.ListIssueCommentReactions(ctx, owner, repo, commentID, opts) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, r := range reactions { + all = append(all, convertGitHubReaction(r)) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return all, nil +} + +func (s *gitHubPRService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + r, resp, err := s.client.Reactions.CreateIssueCommentReaction(ctx, owner, repo, commentID, reaction) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGitHubReaction(r) + return &result, nil +} diff --git a/github/reactions_test.go b/github/reactions_test.go new file mode 100644 index 0000000..3af82da --- /dev/null +++ b/github/reactions_test.go @@ -0,0 +1,136 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/google/go-github/v82/github" +) + +func TestGitHubListIssueCommentReactions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/repos/octocat/hello-world/issues/comments/42/reactions", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]*github.Reaction{ + { + ID: ptrInt64(1), + Content: ptr("+1"), + User: &github.User{Login: ptr("alice")}, + }, + { + ID: ptrInt64(2), + Content: ptr("heart"), + User: &github.User{Login: ptr("bob")}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubIssueService(srv) + reactions, err := s.ListReactions(context.Background(), "octocat", "hello-world", 1, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reactions) != 2 { + t.Fatalf("expected 2 reactions, got %d", len(reactions)) + } + + assertEqual(t, "reactions[0].Content", "+1", reactions[0].Content) + assertEqual(t, "reactions[0].User", "alice", reactions[0].User) + assertEqual(t, "reactions[1].Content", "heart", reactions[1].Content) + assertEqual(t, "reactions[1].User", "bob", reactions[1].User) +} + +func TestGitHubAddIssueCommentReaction(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v3/repos/octocat/hello-world/issues/comments/42/reactions", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(&github.Reaction{ + ID: ptrInt64(10), + Content: ptr("rocket"), + User: &github.User{Login: ptr("alice")}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubIssueService(srv) + reaction, err := s.AddReaction(context.Background(), "octocat", "hello-world", 1, 42, "rocket") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Content", "rocket", reaction.Content) + assertEqual(t, "User", "alice", reaction.User) +} + +func TestGitHubListIssueCommentReactionsNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/repos/octocat/hello-world/issues/comments/999/reactions", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubIssueService(srv) + _, err := s.ListReactions(context.Background(), "octocat", "hello-world", 1, 999) + if err != forge.ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestGitHubPRListCommentReactions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/repos/octocat/hello-world/issues/comments/50/reactions", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]*github.Reaction{ + { + ID: ptrInt64(5), + Content: ptr("eyes"), + User: &github.User{Login: ptr("carol")}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubPRService(srv) + reactions, err := s.ListReactions(context.Background(), "octocat", "hello-world", 10, 50) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reactions) != 1 { + t.Fatalf("expected 1 reaction, got %d", len(reactions)) + } + assertEqual(t, "Content", "eyes", reactions[0].Content) + assertEqual(t, "User", "carol", reactions[0].User) +} + +func TestGitHubPRAddCommentReaction(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v3/repos/octocat/hello-world/issues/comments/50/reactions", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(&github.Reaction{ + ID: ptrInt64(11), + Content: ptr("laugh"), + User: &github.User{Login: ptr("carol")}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubPRService(srv) + reaction, err := s.AddReaction(context.Background(), "octocat", "hello-world", 10, 50, "laugh") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "Content", "laugh", reaction.Content) +} diff --git a/gitlab/reactions.go b/gitlab/reactions.go new file mode 100644 index 0000000..d4028c1 --- /dev/null +++ b/gitlab/reactions.go @@ -0,0 +1,79 @@ +package gitlab + +import ( + "context" + "net/http" + + forge "github.com/git-pkgs/forge" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +func convertGitLabAwardEmoji(e *gitlab.AwardEmoji) forge.Reaction { + return forge.Reaction{ + ID: e.ID, + User: e.User.Username, + Content: e.Name, + } +} + +func (s *gitLabIssueService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + pid := owner + "/" + repo + emojis, resp, err := s.client.AwardEmoji.ListIssuesAwardEmojiOnNote(pid, int64(number), commentID, &gitlab.ListAwardEmojiOptions{}) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + var all []forge.Reaction + for _, e := range emojis { + all = append(all, convertGitLabAwardEmoji(e)) + } + return all, nil +} + +func (s *gitLabIssueService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + pid := owner + "/" + repo + emoji, resp, err := s.client.AwardEmoji.CreateIssuesAwardEmojiOnNote(pid, int64(number), commentID, &gitlab.CreateAwardEmojiOptions{ + Name: reaction, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGitLabAwardEmoji(emoji) + return &result, nil +} + +func (s *gitLabPRService) ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]forge.Reaction, error) { + pid := owner + "/" + repo + emojis, resp, err := s.client.AwardEmoji.ListMergeRequestAwardEmojiOnNote(pid, int64(number), commentID, &gitlab.ListAwardEmojiOptions{}) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + var all []forge.Reaction + for _, e := range emojis { + all = append(all, convertGitLabAwardEmoji(e)) + } + return all, nil +} + +func (s *gitLabPRService) AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*forge.Reaction, error) { + pid := owner + "/" + repo + emoji, resp, err := s.client.AwardEmoji.CreateMergeRequestAwardEmojiOnNote(pid, int64(number), commentID, &gitlab.CreateAwardEmojiOptions{ + Name: reaction, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGitLabAwardEmoji(emoji) + return &result, nil +} diff --git a/gitlab/reactions_test.go b/gitlab/reactions_test.go new file mode 100644 index 0000000..6133161 --- /dev/null +++ b/gitlab/reactions_test.go @@ -0,0 +1,133 @@ +package gitlab + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestGitLabListIssueCommentReactions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/projects/mygroup%2Fmyrepo/issues/1/notes/42/award_emoji", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 1, + "name": "thumbsup", + "user": map[string]any{"username": "alice", "id": 10}, + }, + { + "id": 2, + "name": "heart", + "user": map[string]any{"username": "bob", "id": 11}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reactions, err := f.Issues().ListReactions(context.Background(), "mygroup", "myrepo", 1, 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reactions) != 2 { + t.Fatalf("expected 2 reactions, got %d", len(reactions)) + } + + assertEqual(t, "reactions[0].Content", "thumbsup", reactions[0].Content) + assertEqual(t, "reactions[0].User", "alice", reactions[0].User) + assertEqual(t, "reactions[1].Content", "heart", reactions[1].Content) + assertEqual(t, "reactions[1].User", "bob", reactions[1].User) +} + +func TestGitLabAddIssueCommentReaction(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v4/projects/mygroup%2Fmyrepo/issues/1/notes/42/award_emoji", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 10, + "name": "rocket", + "user": map[string]any{"username": "alice", "id": 10}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reaction, err := f.Issues().AddReaction(context.Background(), "mygroup", "myrepo", 1, 42, "rocket") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Content", "rocket", reaction.Content) + assertEqual(t, "User", "alice", reaction.User) +} + +func TestGitLabListMRCommentReactions(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/projects/mygroup%2Fmyrepo/merge_requests/5/notes/50/award_emoji", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 3, + "name": "eyes", + "user": map[string]any{"username": "carol", "id": 12}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reactions, err := f.PullRequests().ListReactions(context.Background(), "mygroup", "myrepo", 5, 50) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reactions) != 1 { + t.Fatalf("expected 1 reaction, got %d", len(reactions)) + } + assertEqual(t, "Content", "eyes", reactions[0].Content) + assertEqual(t, "User", "carol", reactions[0].User) +} + +func TestGitLabAddMRCommentReaction(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v4/projects/mygroup%2Fmyrepo/merge_requests/5/notes/50/award_emoji", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 11, + "name": "laugh", + "user": map[string]any{"username": "carol", "id": 12}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + reaction, err := f.PullRequests().AddReaction(context.Background(), "mygroup", "myrepo", 5, 50, "laugh") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "Content", "laugh", reaction.Content) +} + +func TestGitLabListReactionsNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/projects/mygroup%2Fmyrepo/issues/1/notes/999/award_emoji", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + _, err := f.Issues().ListReactions(context.Background(), "mygroup", "myrepo", 1, 999) + if err != forge.ErrNotFound { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/cli/issue.go b/internal/cli/issue.go index 996b4ab..0370347 100644 --- a/internal/cli/issue.go +++ b/internal/cli/issue.go @@ -27,6 +27,8 @@ func init() { issueCmd.AddCommand(issueEditCmd()) issueCmd.AddCommand(issueDeleteCmd()) issueCmd.AddCommand(issueCommentCmd()) + issueCmd.AddCommand(issueReactionsCmd()) + issueCmd.AddCommand(issueReactCmd()) } func issueViewCmd() *cobra.Command { diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 30465d8..2134123 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -29,6 +29,8 @@ func init() { prCmd.AddCommand(prMergeCmd()) prCmd.AddCommand(prDiffCmd()) prCmd.AddCommand(prCommentCmd()) + prCmd.AddCommand(prReactionsCmd()) + prCmd.AddCommand(prReactCmd()) } func prViewCmd() *cobra.Command { diff --git a/internal/cli/reaction.go b/internal/cli/reaction.go new file mode 100644 index 0000000..4b13e18 --- /dev/null +++ b/internal/cli/reaction.go @@ -0,0 +1,203 @@ +package cli + +import ( + "fmt" + "os" + "strconv" + + "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/output" + "github.com/git-pkgs/forge/internal/resolve" + "github.com/spf13/cobra" +) + +func issueReactionsCmd() *cobra.Command { + var flagComment int64 + + cmd := &cobra.Command{ + Use: "reactions ", + Short: "List reactions on an issue comment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid issue number: %s", args[0]) + } + if flagComment == 0 { + return fmt.Errorf("--comment is required") + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + reactions, err := forge.Issues().ListReactions(cmd.Context(), owner, repoName, number, flagComment) + if err != nil { + return notSupported(err, "comment reactions") + } + + return printReactions(reactions) + }, + } + + cmd.Flags().Int64Var(&flagComment, "comment", 0, "Comment ID") + return cmd +} + +func issueReactCmd() *cobra.Command { + var ( + flagComment int64 + flagReaction string + ) + + cmd := &cobra.Command{ + Use: "react ", + Short: "Add a reaction to an issue comment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid issue number: %s", args[0]) + } + if flagComment == 0 { + return fmt.Errorf("--comment is required") + } + if flagReaction == "" { + return fmt.Errorf("--reaction is required") + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + reaction, err := forge.Issues().AddReaction(cmd.Context(), owner, repoName, number, flagComment, flagReaction) + if err != nil { + return notSupported(err, "comment reactions") + } + + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(reaction) + } + + _, _ = fmt.Fprintf(os.Stdout, "Added %s reaction\n", reaction.Content) + return nil + }, + } + + cmd.Flags().Int64Var(&flagComment, "comment", 0, "Comment ID") + cmd.Flags().StringVar(&flagReaction, "reaction", "", "Reaction to add (e.g. +1, -1, laugh, hooray, confused, heart, rocket, eyes)") + return cmd +} + +func prReactionsCmd() *cobra.Command { + var flagComment int64 + + cmd := &cobra.Command{ + Use: "reactions ", + Short: "List reactions on a pull request comment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + if flagComment == 0 { + return fmt.Errorf("--comment is required") + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + reactions, err := forge.PullRequests().ListReactions(cmd.Context(), owner, repoName, number, flagComment) + if err != nil { + return notSupported(err, "comment reactions") + } + + return printReactions(reactions) + }, + } + + cmd.Flags().Int64Var(&flagComment, "comment", 0, "Comment ID") + return cmd +} + +func prReactCmd() *cobra.Command { + var ( + flagComment int64 + flagReaction string + ) + + cmd := &cobra.Command{ + Use: "react ", + Short: "Add a reaction to a pull request comment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + if flagComment == 0 { + return fmt.Errorf("--comment is required") + } + if flagReaction == "" { + return fmt.Errorf("--reaction is required") + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + reaction, err := forge.PullRequests().AddReaction(cmd.Context(), owner, repoName, number, flagComment, flagReaction) + if err != nil { + return notSupported(err, "comment reactions") + } + + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(reaction) + } + + _, _ = fmt.Fprintf(os.Stdout, "Added %s reaction\n", reaction.Content) + return nil + }, + } + + cmd.Flags().Int64Var(&flagComment, "comment", 0, "Comment ID") + cmd.Flags().StringVar(&flagReaction, "reaction", "", "Reaction to add (e.g. +1, -1, laugh, hooray, confused, heart, rocket, eyes)") + return cmd +} + +func printReactions(reactions []forges.Reaction) error { + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(reactions) + } + + if len(reactions) == 0 { + _, _ = fmt.Fprintln(os.Stdout, "No reactions") + return nil + } + + if p.Format == output.Plain { + lines := make([]string, len(reactions)) + for i, r := range reactions { + lines[i] = fmt.Sprintf("%s\t%s", r.Content, r.User) + } + p.PrintPlain(lines) + return nil + } + + headers := []string{"REACTION", "USER"} + rows := make([][]string, len(reactions)) + for i, r := range reactions { + rows[i] = []string{r.Content, r.User} + } + p.PrintTable(headers, rows) + return nil +} diff --git a/internal/cli/reaction_test.go b/internal/cli/reaction_test.go new file mode 100644 index 0000000..4bc1af7 --- /dev/null +++ b/internal/cli/reaction_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestReactionCmdStructure(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "issue reactions requires number", + args: []string{"issue", "reactions"}, + want: "accepts 1 arg", + }, + { + name: "issue react requires number", + args: []string{"issue", "react"}, + want: "accepts 1 arg", + }, + { + name: "pr reactions requires number", + args: []string{"pr", "reactions"}, + want: "accepts 1 arg", + }, + { + name: "pr react requires number", + args: []string{"pr", "react"}, + want: "accepts 1 arg", + }, + { + name: "issue reactions requires comment flag", + args: []string{"issue", "reactions", "1"}, + want: "--comment is required", + }, + { + name: "issue react requires comment flag", + args: []string{"issue", "react", "1"}, + want: "--comment is required", + }, + { + name: "issue react requires reaction flag", + args: []string{"issue", "react", "1", "--comment", "42"}, + want: "--reaction is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd.SetArgs(tt.args) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("expected error containing %q, got %q", tt.want, err.Error()) + } + }) + } +} diff --git a/services.go b/services.go index 772ec3c..1a7bf72 100644 --- a/services.go +++ b/services.go @@ -30,6 +30,8 @@ type PullRequestService interface { Diff(ctx context.Context, owner, repo string, number int) (string, error) CreateComment(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) ListComments(ctx context.Context, owner, repo string, number int) ([]Comment, error) + ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]Reaction, error) + AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*Reaction, error) } // LabelService provides operations on repository labels. @@ -122,4 +124,6 @@ type IssueService interface { Delete(ctx context.Context, owner, repo string, number int) error CreateComment(ctx context.Context, owner, repo string, number int, body string) (*Comment, error) ListComments(ctx context.Context, owner, repo string, number int) ([]Comment, error) + ListReactions(ctx context.Context, owner, repo string, number int, commentID int64) ([]Reaction, error) + AddReaction(ctx context.Context, owner, repo string, number int, commentID int64, reaction string) (*Reaction, error) } diff --git a/types.go b/types.go index bb76cb4..8a6da9d 100644 --- a/types.go +++ b/types.go @@ -579,3 +579,10 @@ type RateLimit struct { Remaining int `json:"remaining"` Reset time.Time `json:"reset"` } + +// Reaction holds normalized metadata about a comment reaction. +type Reaction struct { + ID int64 `json:"id"` + User string `json:"user"` + Content string `json:"content"` // +1, -1, laugh, hooray, confused, heart, rocket, eyes +} From c230c4923e8f68886dec9cc72505a34ebe6ddc80 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 11 Mar 2026 11:04:45 +0000 Subject: [PATCH 2/2] Fix import ordering in gitea/reactions.go --- gitea/reactions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitea/reactions.go b/gitea/reactions.go index 3940c15..e441e86 100644 --- a/gitea/reactions.go +++ b/gitea/reactions.go @@ -4,8 +4,8 @@ import ( "context" "net/http" - forge "github.com/git-pkgs/forge" "code.gitea.io/sdk/gitea" + forge "github.com/git-pkgs/forge" ) func convertGiteaReaction(r *gitea.Reaction) forge.Reaction {