From a26af42cfc4af8fe65dcd925759ff10a32923c90 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 03:03:01 +0000 Subject: [PATCH] feat(notifications): add bulk mark endpoint --- docs/api-contract.md | 1 + notifications.go | 45 ++++++++++++++++++++++++++++++++------ notifications_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index 7d785f5..434d392 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -161,6 +161,7 @@ Coverage notes: rows list direct tests when a symbol is named in test names or r | method | NotificationService.IterRepo | `func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string) iter.Seq2[types.NotificationThread, error]` | IterRepo returns an iterator over all notifications for a specific repository. | No direct tests. | | method | NotificationService.List | `func (s *NotificationService) List(ctx context.Context) ([]types.NotificationThread, error)` | List returns all notifications for the authenticated user. | `TestNotificationService_Good_List` | | method | NotificationService.ListRepo | `func (s *NotificationService) ListRepo(ctx context.Context, owner, repo string) ([]types.NotificationThread, error)` | ListRepo returns all notifications for a specific repository. | `TestNotificationService_Good_ListRepo` | +| method | NotificationService.MarkNotifications | `func (s *NotificationService) MarkNotifications(ctx context.Context, opts *NotificationMarkOptions) ([]types.NotificationThread, error)` | MarkNotifications marks authenticated-user notification threads as read, pinned, or unread. | `TestNotificationService_MarkNotifications_Good` | | method | NotificationService.MarkRepoNotifications | `func (s *NotificationService) MarkRepoNotifications(ctx context.Context, owner, repo string, opts *NotificationRepoMarkOptions) ([]types.NotificationThread, error)` | MarkRepoNotifications marks repository notification threads as read, unread, or pinned. | `TestNotificationService_MarkRepoNotifications_Good` | | method | NotificationService.MarkRead | `func (s *NotificationService) MarkRead(ctx context.Context) error` | MarkRead marks all notifications as read. | `TestNotificationService_Good_MarkRead` | | method | NotificationService.MarkThreadRead | `func (s *NotificationService) MarkThreadRead(ctx context.Context, id int64) error` | MarkThreadRead marks a single notification thread as read. | `TestNotificationService_Good_MarkThreadRead` | diff --git a/notifications.go b/notifications.go index 09cd4cb..77166a0 100644 --- a/notifications.go +++ b/notifications.go @@ -61,29 +61,45 @@ type NotificationRepoMarkOptions struct { LastReadAt *time.Time } +// NotificationMarkOptions controls how authenticated-user notifications are marked. +type NotificationMarkOptions struct { + All bool + StatusTypes []string + ToStatus string + LastReadAt *time.Time +} + func newNotificationService(c *Client) *NotificationService { return &NotificationService{client: c} } -func (o NotificationRepoMarkOptions) queryString() string { +func notificationMarkQueryString(all bool, statusTypes []string, toStatus string, lastReadAt *time.Time) string { values := url.Values{} - if o.All { + if all { values.Set("all", "true") } - for _, status := range o.StatusTypes { + for _, status := range statusTypes { if status != "" { values.Add("status-types", status) } } - if o.ToStatus != "" { - values.Set("to-status", o.ToStatus) + if toStatus != "" { + values.Set("to-status", toStatus) } - if o.LastReadAt != nil { - values.Set("last_read_at", o.LastReadAt.Format(time.RFC3339)) + if lastReadAt != nil { + values.Set("last_read_at", lastReadAt.Format(time.RFC3339)) } return values.Encode() } +func (o NotificationRepoMarkOptions) queryString() string { + return notificationMarkQueryString(o.All, o.StatusTypes, o.ToStatus, o.LastReadAt) +} + +func (o NotificationMarkOptions) queryString() string { + return notificationMarkQueryString(o.All, o.StatusTypes, o.ToStatus, o.LastReadAt) +} + // List returns all notifications for the authenticated user. func (s *NotificationService) List(ctx context.Context, filters ...NotificationListOptions) ([]types.NotificationThread, error) { return s.listAll(ctx, "/api/v1/notifications", filters...) @@ -115,6 +131,21 @@ func (s *NotificationService) IterRepo(ctx context.Context, owner, repo string, return s.listIter(ctx, path, filters...) } +// MarkNotifications marks authenticated-user notification threads as read, pinned, or unread. +func (s *NotificationService) MarkNotifications(ctx context.Context, opts *NotificationMarkOptions) ([]types.NotificationThread, error) { + path := "/api/v1/notifications" + if opts != nil { + if query := opts.queryString(); query != "" { + path += "?" + query + } + } + var out []types.NotificationThread + if err := s.client.Put(ctx, path, nil, &out); err != nil { + return nil, err + } + return out, nil +} + // MarkRepoNotifications marks repository notification threads as read, unread, or pinned. func (s *NotificationService) MarkRepoNotifications(ctx context.Context, owner, repo string, opts *NotificationRepoMarkOptions) ([]types.NotificationThread, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/notifications", pathParams("owner", owner, "repo", repo)) diff --git a/notifications_test.go b/notifications_test.go index 79ee76b..45635bd 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -217,6 +217,56 @@ func TestNotificationService_GetThread_Good(t *testing.T) { } } +func TestNotificationService_MarkNotifications_Good(t *testing.T) { + lastReadAt := time.Date(2026, time.April, 2, 15, 4, 5, 0, time.UTC) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/api/v1/notifications" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("all"); got != "true" { + t.Errorf("got all=%q, want true", got) + } + if got := r.URL.Query()["status-types"]; len(got) != 2 || got[0] != "unread" || got[1] != "pinned" { + t.Errorf("got status-types=%v, want [unread pinned]", got) + } + if got := r.URL.Query().Get("to-status"); got != "read" { + t.Errorf("got to-status=%q, want read", got) + } + if got := r.URL.Query().Get("last_read_at"); got != lastReadAt.Format(time.RFC3339) { + t.Errorf("got last_read_at=%q, want %q", got, lastReadAt.Format(time.RFC3339)) + } + w.WriteHeader(http.StatusResetContent) + json.NewEncoder(w).Encode([]types.NotificationThread{ + {ID: 21, Unread: false, Subject: &types.NotificationSubject{Title: "Release notes"}}, + {ID: 22, Unread: false, Subject: &types.NotificationSubject{Title: "Issue triaged"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + threads, err := f.Notifications.MarkNotifications(context.Background(), &NotificationMarkOptions{ + All: true, + StatusTypes: []string{"unread", "pinned"}, + ToStatus: "read", + LastReadAt: &lastReadAt, + }) + if err != nil { + t.Fatal(err) + } + if len(threads) != 2 { + t.Fatalf("got %d threads, want 2", len(threads)) + } + if threads[0].ID != 21 || threads[1].ID != 22 { + t.Fatalf("got ids=%d,%d want 21,22", threads[0].ID, threads[1].ID) + } + if threads[0].Subject.Title != "Release notes" { + t.Errorf("got title=%q, want %q", threads[0].Subject.Title, "Release notes") + } +} + func TestNotificationService_MarkRead_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut {