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..e441e86 --- /dev/null +++ b/gitea/reactions.go @@ -0,0 +1,74 @@ +package gitea + +import ( + "context" + "net/http" + + "code.gitea.io/sdk/gitea" + forge "github.com/git-pkgs/forge" +) + +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 +}