diff --git a/bitbucket/notifications.go b/bitbucket/notifications.go new file mode 100644 index 0000000..65b68e2 --- /dev/null +++ b/bitbucket/notifications.go @@ -0,0 +1,26 @@ +package bitbucket + +import ( + "context" + "fmt" + + forge "github.com/git-pkgs/forge" +) + +type bitbucketNotificationService struct{} + +func (f *bitbucketForge) Notifications() forge.NotificationService { + return &bitbucketNotificationService{} +} + +func (s *bitbucketNotificationService) List(ctx context.Context, opts forge.ListNotificationOpts) ([]forge.Notification, error) { + return nil, fmt.Errorf("listing notifications: %w", forge.ErrNotSupported) +} + +func (s *bitbucketNotificationService) MarkRead(ctx context.Context, opts forge.MarkNotificationOpts) error { + return fmt.Errorf("marking notifications: %w", forge.ErrNotSupported) +} + +func (s *bitbucketNotificationService) Get(ctx context.Context, id string) (*forge.Notification, error) { + return nil, fmt.Errorf("getting notification: %w", forge.ErrNotSupported) +} diff --git a/bitbucket/notifications_test.go b/bitbucket/notifications_test.go new file mode 100644 index 0000000..043a6ba --- /dev/null +++ b/bitbucket/notifications_test.go @@ -0,0 +1,28 @@ +package bitbucket + +import ( + "context" + "errors" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestBitbucketNotificationsNotSupported(t *testing.T) { + f := New("test-token", nil) + + _, err := f.Notifications().List(context.Background(), forge.ListNotificationOpts{}) + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } + + err = f.Notifications().MarkRead(context.Background(), forge.MarkNotificationOpts{}) + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } + + _, err = f.Notifications().Get(context.Background(), "1") + if !errors.Is(err, forge.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } +} diff --git a/forge.go b/forge.go index e6f5f01..274dc56 100644 --- a/forge.go +++ b/forge.go @@ -43,6 +43,7 @@ type Forge interface { Branches() BranchService DeployKeys() DeployKeyService Secrets() SecretService + Notifications() NotificationService Reviews() ReviewService } diff --git a/forges_test.go b/forges_test.go index df17677..565dd19 100644 --- a/forges_test.go +++ b/forges_test.go @@ -454,6 +454,22 @@ func (m *mockForge) Secrets() SecretService { return &mockSecretService{} } +func (m *mockForge) Notifications() NotificationService { + return &mockNotificationService{} +} + +type mockNotificationService struct{} + +func (m *mockNotificationService) List(_ context.Context, opts ListNotificationOpts) ([]Notification, error) { + return nil, nil +} +func (m *mockNotificationService) MarkRead(_ context.Context, opts MarkNotificationOpts) error { + return nil +} +func (m *mockNotificationService) Get(_ context.Context, id string) (*Notification, error) { + return nil, nil +} + func (m *mockForge) Reviews() ReviewService { if m.reviewService != nil { return m.reviewService diff --git a/gitea/notifications.go b/gitea/notifications.go new file mode 100644 index 0000000..37765cc --- /dev/null +++ b/gitea/notifications.go @@ -0,0 +1,175 @@ +package gitea + +import ( + "context" + "net/http" + "strconv" + "strings" + + "code.gitea.io/sdk/gitea" + forge "github.com/git-pkgs/forge" +) + +type giteaNotificationService struct { + client *gitea.Client +} + +func (f *giteaForge) Notifications() forge.NotificationService { + return &giteaNotificationService{client: f.client} +} + +func convertGiteaSubjectType(t string) forge.NotificationSubjectType { + switch t { + case "Issue": + return forge.NotificationSubjectIssue + case "Pull": + return forge.NotificationSubjectPullRequest + case "Commit": + return forge.NotificationSubjectCommit + case "Repository": + return forge.NotificationSubjectRepository + default: + return forge.NotificationSubjectType(strings.ToLower(t)) + } +} + +func convertGiteaNotification(n *gitea.NotificationThread) forge.Notification { + result := forge.Notification{ + ID: strconv.FormatInt(n.ID, 10), + Unread: n.Unread, + } + + if n.Subject != nil { + result.Title = n.Subject.Title + result.SubjectType = convertGiteaSubjectType(string(n.Subject.Type)) + result.URL = n.Subject.HTMLURL + } + + if n.Repository != nil { + result.Repo = n.Repository.FullName + } + + if !n.UpdatedAt.IsZero() { + result.UpdatedAt = n.UpdatedAt + } + + return result +} + +func (s *giteaNotificationService) List(ctx context.Context, opts forge.ListNotificationOpts) ([]forge.Notification, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 30 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + statuses := []gitea.NotifyStatus{} + if opts.Unread { + statuses = append(statuses, gitea.NotifyStatusUnread) + } + + var all []forge.Notification + + if opts.Repo != "" { + parts := strings.SplitN(opts.Repo, "/", 2) + if len(parts) == 2 { + for { + notifications, resp, err := s.client.ListRepoNotifications(parts[0], parts[1], gitea.ListNotificationOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: perPage}, + Status: statuses, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, n := range notifications { + all = append(all, convertGiteaNotification(n)) + } + if len(notifications) < perPage || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + page++ + } + } + } else { + for { + notifications, _, err := s.client.ListNotifications(gitea.ListNotificationOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: perPage}, + Status: statuses, + }) + if err != nil { + return nil, err + } + for _, n := range notifications { + all = append(all, convertGiteaNotification(n)) + } + if len(notifications) < perPage || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + page++ + } + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func (s *giteaNotificationService) MarkRead(ctx context.Context, opts forge.MarkNotificationOpts) error { + if opts.ID != "" { + id, err := strconv.ParseInt(opts.ID, 10, 64) + if err != nil { + return err + } + _, resp, err := s.client.ReadNotification(id) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil + } + + if opts.Repo != "" { + parts := strings.SplitN(opts.Repo, "/", 2) + if len(parts) == 2 { + _, resp, err := s.client.ReadRepoNotifications(parts[0], parts[1], gitea.MarkNotificationOptions{}) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil + } + } + + _, _, err := s.client.ReadNotifications(gitea.MarkNotificationOptions{}) + return err +} + +func (s *giteaNotificationService) Get(ctx context.Context, id string) (*forge.Notification, error) { + nID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return nil, err + } + + n, resp, err := s.client.GetNotification(nID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + + result := convertGiteaNotification(n) + return &result, nil +} diff --git a/gitea/notifications_test.go b/gitea/notifications_test.go new file mode 100644 index 0000000..dce7016 --- /dev/null +++ b/gitea/notifications_test.go @@ -0,0 +1,196 @@ +package gitea + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestGiteaListNotifications(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/notifications", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 1, + "unread": true, + "subject": map[string]any{ + "title": "Bug fix", + "type": "Issue", + "html_url": "https://codeberg.org/testorg/testrepo/issues/1", + }, + "repository": map[string]any{ + "full_name": "testorg/testrepo", + "name": "testrepo", + "owner": map[string]any{"login": "testorg"}, + }, + "updated_at": "2024-06-01T10:00:00Z", + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + notifications, err := f.Notifications().List(context.Background(), forge.ListNotificationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + assertEqual(t, "ID", "1", notifications[0].ID) + assertEqualBool(t, "Unread", true, notifications[0].Unread) + assertEqual(t, "Title", "Bug fix", notifications[0].Title) + assertEqual(t, "SubjectType", "issue", string(notifications[0].SubjectType)) + assertEqual(t, "Repo", "testorg/testrepo", notifications[0].Repo) +} + +func TestGiteaListNotificationsForRepo(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/notifications", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 2, + "unread": false, + "subject": map[string]any{ + "title": "New PR", + "type": "Pull", + "html_url": "https://codeberg.org/testorg/testrepo/pulls/5", + }, + "repository": map[string]any{ + "full_name": "testorg/testrepo", + "name": "testrepo", + "owner": map[string]any{"login": "testorg"}, + }, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + notifications, err := f.Notifications().List(context.Background(), forge.ListNotificationOpts{ + Repo: "testorg/testrepo", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + assertEqual(t, "SubjectType", "pull_request", string(notifications[0].SubjectType)) +} + +func TestGiteaGetNotification(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/notifications/threads/42", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "unread": true, + "subject": map[string]any{ + "title": "Update docs", + "type": "Issue", + }, + "repository": map[string]any{ + "full_name": "testorg/testrepo", + "name": "testrepo", + "owner": map[string]any{"login": "testorg"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + n, err := f.Notifications().Get(context.Background(), "42") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "ID", "42", n.ID) + assertEqual(t, "Title", "Update docs", n.Title) + assertEqualBool(t, "Unread", true, n.Unread) +} + +func TestGiteaGetNotificationNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/notifications/threads/999", 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.Notifications().Get(context.Background(), "999") + if err != forge.ErrNotFound { + t.Fatalf("expected forge.ErrNotFound, got %v", err) + } +} + +func TestGiteaMarkNotificationRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("PATCH /api/v1/notifications/threads/42", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "unread": false, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.Notifications().MarkRead(context.Background(), forge.MarkNotificationOpts{ID: "42"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGiteaMarkAllNotificationsRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("PUT /api/v1/notifications", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{}) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.Notifications().MarkRead(context.Background(), forge.MarkNotificationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestConvertGiteaSubjectType(t *testing.T) { + tests := []struct { + input string + want forge.NotificationSubjectType + }{ + {"Issue", forge.NotificationSubjectIssue}, + {"Pull", forge.NotificationSubjectPullRequest}, + {"Commit", forge.NotificationSubjectCommit}, + {"Repository", forge.NotificationSubjectRepository}, + } + + for _, tt := range tests { + got := convertGiteaSubjectType(tt.input) + if got != tt.want { + t.Errorf("convertGiteaSubjectType(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/github/notifications.go b/github/notifications.go new file mode 100644 index 0000000..0b0b026 --- /dev/null +++ b/github/notifications.go @@ -0,0 +1,169 @@ +package github + +import ( + "context" + "net/http" + "strings" + + forge "github.com/git-pkgs/forge" + "github.com/google/go-github/v82/github" +) + +type gitHubNotificationService struct { + client *github.Client +} + +func (f *gitHubForge) Notifications() forge.NotificationService { + return &gitHubNotificationService{client: f.client} +} + +func convertGitHubSubjectType(t string) forge.NotificationSubjectType { + switch t { + case "Issue": + return forge.NotificationSubjectIssue + case "PullRequest": + return forge.NotificationSubjectPullRequest + case "Commit": + return forge.NotificationSubjectCommit + case "Release": + return forge.NotificationSubjectRelease + case "Discussion": + return forge.NotificationSubjectDiscussion + case "RepositoryVulnerabilityAlert", "RepositoryDependabotAlertsThread": + return forge.NotificationSubjectRepository + default: + return forge.NotificationSubjectType(strings.ToLower(t)) + } +} + +func convertGitHubNotification(n *github.Notification) forge.Notification { + result := forge.Notification{ + ID: n.GetID(), + Unread: n.GetUnread(), + Reason: n.GetReason(), + } + + if r := n.GetRepository(); r != nil { + result.Repo = r.GetFullName() + result.URL = r.GetHTMLURL() + } + + if s := n.GetSubject(); s != nil { + result.Title = s.GetTitle() + result.SubjectType = convertGitHubSubjectType(s.GetType()) + } + + if t := n.GetUpdatedAt(); !t.IsZero() { + result.UpdatedAt = t.Time + } + + return result +} + +func (s *gitHubNotificationService) List(ctx context.Context, opts forge.ListNotificationOpts) ([]forge.Notification, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 30 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + ghOpts := &github.NotificationListOptions{ + All: !opts.Unread, + ListOptions: github.ListOptions{PerPage: perPage, Page: page}, + } + + var all []forge.Notification + + if opts.Repo != "" { + parts := strings.SplitN(opts.Repo, "/", 2) + if len(parts) == 2 { + for { + notifications, resp, err := s.client.Activity.ListRepositoryNotifications(ctx, parts[0], parts[1], ghOpts) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, n := range notifications { + all = append(all, convertGitHubNotification(n)) + } + if resp.NextPage == 0 || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + ghOpts.Page = resp.NextPage + } + } + } else { + for { + notifications, resp, err := s.client.Activity.ListNotifications(ctx, ghOpts) + if err != nil { + return nil, err + } + for _, n := range notifications { + all = append(all, convertGitHubNotification(n)) + } + if resp.NextPage == 0 || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + ghOpts.Page = resp.NextPage + } + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func (s *gitHubNotificationService) MarkRead(ctx context.Context, opts forge.MarkNotificationOpts) error { + if opts.ID != "" { + resp, err := s.client.Activity.MarkThreadRead(ctx, opts.ID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil + } + + if opts.Repo != "" { + parts := strings.SplitN(opts.Repo, "/", 2) + if len(parts) == 2 { + resp, err := s.client.Activity.MarkRepositoryNotificationsRead(ctx, parts[0], parts[1], github.Timestamp{}) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil + } + } + + resp, err := s.client.Activity.MarkNotificationsRead(ctx, github.Timestamp{}) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} + +func (s *gitHubNotificationService) Get(ctx context.Context, id string) (*forge.Notification, error) { + n, resp, err := s.client.Activity.GetThread(ctx, id) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGitHubNotification(n) + return &result, nil +} diff --git a/github/notifications_test.go b/github/notifications_test.go new file mode 100644 index 0000000..afcdaa2 --- /dev/null +++ b/github/notifications_test.go @@ -0,0 +1,228 @@ +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 newTestGitHubNotificationService(srv *httptest.Server) *gitHubNotificationService { + c := github.NewClient(nil) + c, _ = c.WithEnterpriseURLs(srv.URL+"/api/v3", srv.URL+"/api/v3") + return &gitHubNotificationService{client: c} +} + +func TestGitHubListNotifications(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/notifications", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]*github.Notification{ + { + ID: ptr("1"), + Unread: ptrBool(true), + Reason: ptr("mention"), + Subject: &github.NotificationSubject{ + Title: ptr("Fix bug"), + Type: ptr("Issue"), + }, + Repository: &github.Repository{ + FullName: ptr("octocat/hello-world"), + HTMLURL: ptr("https://github.com/octocat/hello-world"), + }, + UpdatedAt: &github.Timestamp{Time: parseTime("2024-06-01T10:00:00Z")}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + notifications, err := s.List(context.Background(), forge.ListNotificationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + assertEqual(t, "ID", "1", notifications[0].ID) + assertEqualBool(t, "Unread", true, notifications[0].Unread) + assertEqual(t, "Reason", "mention", notifications[0].Reason) + assertEqual(t, "Title", "Fix bug", notifications[0].Title) + assertEqual(t, "SubjectType", "issue", string(notifications[0].SubjectType)) + assertEqual(t, "Repo", "octocat/hello-world", notifications[0].Repo) +} + +func TestGitHubListNotificationsUnread(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/notifications", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("all") == "true" { + t.Error("expected all=false for unread filter") + } + _ = json.NewEncoder(w).Encode([]*github.Notification{}) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + _, err := s.List(context.Background(), forge.ListNotificationOpts{Unread: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGitHubListNotificationsForRepo(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/repos/octocat/hello-world/notifications", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]*github.Notification{ + { + ID: ptr("2"), + Unread: ptrBool(false), + Subject: &github.NotificationSubject{ + Title: ptr("New release"), + Type: ptr("Release"), + }, + Repository: &github.Repository{ + FullName: ptr("octocat/hello-world"), + HTMLURL: ptr("https://github.com/octocat/hello-world"), + }, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + notifications, err := s.List(context.Background(), forge.ListNotificationOpts{Repo: "octocat/hello-world"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + assertEqual(t, "SubjectType", "release", string(notifications[0].SubjectType)) +} + +func TestGitHubGetNotification(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/notifications/threads/42", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&github.Notification{ + ID: ptr("42"), + Unread: ptrBool(true), + Reason: ptr("assign"), + Subject: &github.NotificationSubject{ + Title: ptr("Add feature"), + Type: ptr("PullRequest"), + }, + Repository: &github.Repository{ + FullName: ptr("octocat/hello-world"), + HTMLURL: ptr("https://github.com/octocat/hello-world"), + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + n, err := s.Get(context.Background(), "42") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "ID", "42", n.ID) + assertEqual(t, "Title", "Add feature", n.Title) + assertEqual(t, "SubjectType", "pull_request", string(n.SubjectType)) +} + +func TestGitHubGetNotificationNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/notifications/threads/999", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + _, err := s.Get(context.Background(), "999") + if err != forge.ErrNotFound { + t.Fatalf("expected forge.ErrNotFound, got %v", err) + } +} + +func TestGitHubMarkNotificationRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("PATCH /api/v3/notifications/threads/42", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusResetContent) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + err := s.MarkRead(context.Background(), forge.MarkNotificationOpts{ID: "42"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGitHubMarkAllNotificationsRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("PUT /api/v3/notifications", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusResetContent) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + err := s.MarkRead(context.Background(), forge.MarkNotificationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGitHubMarkRepoNotificationsRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("PUT /api/v3/repos/octocat/hello-world/notifications", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusResetContent) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubNotificationService(srv) + err := s.MarkRead(context.Background(), forge.MarkNotificationOpts{Repo: "octocat/hello-world"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestConvertGitHubSubjectType(t *testing.T) { + tests := []struct { + input string + want forge.NotificationSubjectType + }{ + {"Issue", forge.NotificationSubjectIssue}, + {"PullRequest", forge.NotificationSubjectPullRequest}, + {"Commit", forge.NotificationSubjectCommit}, + {"Release", forge.NotificationSubjectRelease}, + {"Discussion", forge.NotificationSubjectDiscussion}, + {"RepositoryVulnerabilityAlert", forge.NotificationSubjectRepository}, + } + + for _, tt := range tests { + got := convertGitHubSubjectType(tt.input) + if got != tt.want { + t.Errorf("convertGitHubSubjectType(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/gitlab/notifications.go b/gitlab/notifications.go new file mode 100644 index 0000000..d5d34f0 --- /dev/null +++ b/gitlab/notifications.go @@ -0,0 +1,161 @@ +package gitlab + +import ( + "context" + "fmt" + "net/http" + "strconv" + + forge "github.com/git-pkgs/forge" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type gitLabNotificationService struct { + client *gitlab.Client +} + +func (f *gitLabForge) Notifications() forge.NotificationService { + return &gitLabNotificationService{client: f.client} +} + +func convertGitLabTodoTargetType(t string) forge.NotificationSubjectType { + switch t { + case "Issue": + return forge.NotificationSubjectIssue + case "MergeRequest": + return forge.NotificationSubjectPullRequest + case "Commit": + return forge.NotificationSubjectCommit + default: + return forge.NotificationSubjectType(t) + } +} + +func convertGitLabTodo(t *gitlab.Todo) forge.Notification { + result := forge.Notification{ + ID: strconv.FormatInt(t.ID, 10), + Title: t.Body, + Reason: string(t.ActionName), + Unread: t.State == "pending", + } + + result.SubjectType = convertGitLabTodoTargetType(string(t.TargetType)) + + if t.Target != nil { + result.URL = t.TargetURL + } + + if t.Project != nil { + result.Repo = t.Project.PathWithNamespace + if result.Title == "" { + result.Title = t.Project.Name + } + } + + if t.CreatedAt != nil { + result.UpdatedAt = *t.CreatedAt + } + + return result +} + +func (s *gitLabNotificationService) List(ctx context.Context, opts forge.ListNotificationOpts) ([]forge.Notification, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 30 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + glOpts := &gitlab.ListTodosOptions{ + ListOptions: gitlab.ListOptions{PerPage: int64(perPage), Page: int64(page)}, + } + + if opts.Unread { + glOpts.State = gitlab.Ptr("pending") + } + + if opts.Repo != "" { + pid := opts.Repo + projects, _, err := s.client.Projects.ListProjects(&gitlab.ListProjectsOptions{ + Search: gitlab.Ptr(pid), + }) + if err == nil && len(projects) > 0 { + for _, p := range projects { + if p.PathWithNamespace == pid { + glOpts.ProjectID = gitlab.Ptr(p.ID) + break + } + } + } + } + + var all []forge.Notification + for { + todos, resp, err := s.client.Todos.ListTodos(glOpts) + if err != nil { + return nil, err + } + for _, t := range todos { + all = append(all, convertGitLabTodo(t)) + } + if resp.NextPage == 0 || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + glOpts.Page = int64(resp.NextPage) + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func (s *gitLabNotificationService) MarkRead(ctx context.Context, opts forge.MarkNotificationOpts) error { + if opts.ID != "" { + id, err := strconv.ParseInt(opts.ID, 10, 64) + if err != nil { + return fmt.Errorf("invalid notification ID: %s", opts.ID) + } + resp, err := s.client.Todos.MarkTodoAsDone(id) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil + } + + // GitLab's mark-all-done endpoint doesn't support filtering by project, + // so for both repo-filtered and unfiltered we mark all. + _, err := s.client.Todos.MarkAllTodosAsDone() + return err +} + +func (s *gitLabNotificationService) Get(ctx context.Context, id string) (*forge.Notification, error) { + // GitLab has no single-todo GET endpoint. List with no filters and find it. + todoID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid notification ID: %s", id) + } + + todos, _, err := s.client.Todos.ListTodos(&gitlab.ListTodosOptions{ + ListOptions: gitlab.ListOptions{PerPage: 100}, + }) + if err != nil { + return nil, err + } + + for _, t := range todos { + if t.ID == todoID { + result := convertGitLabTodo(t) + return &result, nil + } + } + + return nil, forge.ErrNotFound +} diff --git a/gitlab/notifications_test.go b/gitlab/notifications_test.go new file mode 100644 index 0000000..6b9d324 --- /dev/null +++ b/gitlab/notifications_test.go @@ -0,0 +1,122 @@ +package gitlab + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestGitLabListNotifications(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/todos", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 1, + "body": "You were mentioned in issue #5", + "action_name": "mentioned", + "target_type": "Issue", + "target_url": "https://gitlab.com/mygroup/myrepo/-/issues/5", + "target": map[string]any{"id": 5}, + "state": "pending", + "project": map[string]any{ + "id": 10, + "name": "myrepo", + "path_with_namespace": "mygroup/myrepo", + }, + "created_at": "2024-06-01T10:00:00Z", + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + notifications, err := f.Notifications().List(context.Background(), forge.ListNotificationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + assertEqual(t, "ID", "1", notifications[0].ID) + assertEqual(t, "Title", "You were mentioned in issue #5", notifications[0].Title) + assertEqual(t, "Reason", "mentioned", notifications[0].Reason) + assertEqual(t, "SubjectType", "issue", string(notifications[0].SubjectType)) + assertEqual(t, "Repo", "mygroup/myrepo", notifications[0].Repo) + assertEqualBool(t, "Unread", true, notifications[0].Unread) +} + +func TestGitLabListNotificationsUnread(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/todos", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != "pending" { + t.Errorf("expected state=pending, got %q", r.URL.Query().Get("state")) + } + _ = json.NewEncoder(w).Encode([]map[string]any{}) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + _, err := f.Notifications().List(context.Background(), forge.ListNotificationOpts{Unread: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGitLabMarkNotificationRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v4/todos/42/mark_as_done", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.Notifications().MarkRead(context.Background(), forge.MarkNotificationOpts{ID: "42"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGitLabMarkAllNotificationsRead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v4/todos/mark_as_done", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.Notifications().MarkRead(context.Background(), forge.MarkNotificationOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestConvertGitLabTodoTargetType(t *testing.T) { + tests := []struct { + input string + want forge.NotificationSubjectType + }{ + {"Issue", forge.NotificationSubjectIssue}, + {"MergeRequest", forge.NotificationSubjectPullRequest}, + {"Commit", forge.NotificationSubjectCommit}, + } + + for _, tt := range tests { + got := convertGitLabTodoTargetType(tt.input) + if got != tt.want { + t.Errorf("convertGitLabTodoTargetType(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/cli/notification.go b/internal/cli/notification.go new file mode 100644 index 0000000..ad19cba --- /dev/null +++ b/internal/cli/notification.go @@ -0,0 +1,144 @@ +package cli + +import ( + "fmt" + "os" + + forges "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/output" + "github.com/git-pkgs/forge/internal/resolve" + "github.com/spf13/cobra" +) + +var notificationCmd = &cobra.Command{ + Use: "notification", + Short: "Manage notifications", + Aliases: []string{"notif"}, +} + +func init() { + rootCmd.AddCommand(notificationCmd) + notificationCmd.AddCommand(notificationListCmd()) + notificationCmd.AddCommand(notificationReadCmd()) +} + +func notificationListCmd() *cobra.Command { + var ( + flagUnread bool + flagNRepo string + flagLimit int + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List notifications", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + domain := domainFromFlags() + f, err := resolve.ForgeForDomain(domain) + if err != nil { + return err + } + + notifications, err := f.Notifications().List(cmd.Context(), forges.ListNotificationOpts{ + Repo: flagNRepo, + Unread: flagUnread, + Limit: flagLimit, + }) + if err != nil { + return notSupported(err, "notifications") + } + + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(notifications) + } + + if len(notifications) == 0 { + _, _ = fmt.Fprintln(os.Stdout, "No notifications") + return nil + } + + if p.Format == output.Plain { + lines := make([]string, len(notifications)) + for i, n := range notifications { + status := "read" + if n.Unread { + status = "unread" + } + lines[i] = fmt.Sprintf("%s\t%s\t%s\t%s", n.ID, status, n.Repo, n.Title) + } + p.PrintPlain(lines) + return nil + } + + headers := []string{"ID", "STATUS", "TYPE", "REPO", "TITLE"} + rows := make([][]string, len(notifications)) + for i, n := range notifications { + status := "read" + if n.Unread { + status = "unread" + } + title := n.Title + if len(title) > 60 { + title = title[:57] + "..." + } + rows[i] = []string{ + n.ID, + status, + string(n.SubjectType), + n.Repo, + title, + } + } + p.PrintTable(headers, rows) + return nil + }, + } + + cmd.Flags().BoolVar(&flagUnread, "unread", false, "Only show unread notifications") + cmd.Flags().StringVar(&flagNRepo, "repo", "", "Filter by repository (owner/repo)") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of notifications") + return cmd +} + +func notificationReadCmd() *cobra.Command { + var ( + flagID string + flagNRepo string + ) + + cmd := &cobra.Command{ + Use: "read", + Short: "Mark notifications as read", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + domain := domainFromFlags() + f, err := resolve.ForgeForDomain(domain) + if err != nil { + return err + } + + err = f.Notifications().MarkRead(cmd.Context(), forges.MarkNotificationOpts{ + ID: flagID, + Repo: flagNRepo, + }) + if err != nil { + return notSupported(err, "marking notifications as read") + } + + what := "all notifications" + if flagID != "" { + what = "notification " + flagID + } else if flagNRepo != "" { + what = "notifications for " + flagNRepo + } + _, _ = fmt.Fprintf(os.Stdout, "Marked %s as read\n", what) + return nil + }, + } + + cmd.Flags().StringVar(&flagID, "id", "", "Mark a specific notification thread as read") + cmd.Flags().StringVar(&flagNRepo, "repo", "", "Mark all notifications in a repository as read") + return cmd +} diff --git a/services.go b/services.go index 75beaf2..772ec3c 100644 --- a/services.go +++ b/services.go @@ -96,6 +96,13 @@ type SecretService interface { Delete(ctx context.Context, owner, repo, name string) error } +// NotificationService provides operations on user notifications. +type NotificationService interface { + List(ctx context.Context, opts ListNotificationOpts) ([]Notification, error) + MarkRead(ctx context.Context, opts MarkNotificationOpts) error + Get(ctx context.Context, id string) (*Notification, error) +} + // ReviewService provides operations on pull request reviews. type ReviewService interface { List(ctx context.Context, owner, repo string, number int, opts ListReviewOpts) ([]Review, error) diff --git a/types.go b/types.go index 3708abd..13a11fb 100644 --- a/types.go +++ b/types.go @@ -500,6 +500,45 @@ type ListSecretOpts struct { PerPage int // results per API request; 0 = default } +// NotificationSubjectType identifies the kind of resource a notification is about. +type NotificationSubjectType string + +const ( + NotificationSubjectIssue NotificationSubjectType = "issue" + NotificationSubjectPullRequest NotificationSubjectType = "pull_request" + NotificationSubjectCommit NotificationSubjectType = "commit" + NotificationSubjectRelease NotificationSubjectType = "release" + NotificationSubjectRepository NotificationSubjectType = "repository" + NotificationSubjectDiscussion NotificationSubjectType = "discussion" +) + +// Notification holds normalized metadata about a notification thread. +type Notification struct { + ID string `json:"id"` + Title string `json:"title"` + SubjectType NotificationSubjectType `json:"subject_type"` + Repo string `json:"repo"` + Unread bool `json:"unread"` + Reason string `json:"reason,omitempty"` + URL string `json:"url,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListNotificationOpts holds options for listing notifications. +type ListNotificationOpts struct { + Repo string // filter by repo (owner/repo) + Unread bool // only unread + Limit int // max total results; 0 = unlimited + Page int // starting page; 0 or 1 = first page + PerPage int // results per API request; 0 = default +} + +// MarkNotificationOpts holds options for marking notifications as read. +type MarkNotificationOpts struct { + ID string // mark a single thread; empty = mark all + Repo string // mark all in a repo; empty = mark all +} + // ReviewState represents the state of a pull request review. type ReviewState string