Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ All API calls go to `/api/v1/*`. Key route groups:
- `GET /api/v1/favorites/check?entity_type=&entity_id=` — `favorite check` prints yes or no (exit 0).
- `PUT /api/v1/clusters/:id/shared-values/:valueId` replaces the full record (no GET for one entry): `shared-values update` reads the list, merges and sends name, description, values and priority. `--set` re-marshals the stored YAML (comments and key order are lost; a stderr note says so).
- `/api/v1/admin/notification-channels` (devops or admin) — `notification channel ...`. The responses carry the full `webhook_url` (it can hold a token): every output mode masks it to scheme and host (`maskWebhookURL`) unless `--show-url`. The server never returns the secret; the CLI reads it from `--secret-env NAME` or `--secret-stdin` only. The URL can also come from `--url-env NAME`; error texts never contain the URL. `PUT /:id` changes only the fields sent (`secret: ""` removes the secret; `filters` replaces all filters, `{}` removes them). `PUT /:id/subscriptions` replaces all event types (400 for an unknown one). `POST /:id/test` answers 200 with `success`; the command exits 1 when it is false. Create and update return `warnings` (unknown filter IDs) → stderr.
- `/api/v1/notifications` and `/count` — the server caps the counts (k8s-stack-manager #654): `total` is exact up to offset + 1000 (`total_capped`), `unread_count` up to 1000 (`unread_count_capped`); an older server sends neither flag (false). `notification list` prints "1000+" in the footer; `notification count` keeps stdout a bare integer and writes a note to stderr when capped; `-o json`/`-o yaml` carry the flags.
- `/api/v1/notifications/preferences` — `notification prefs set` warns for an event type not in `notificationEventTypes` (`notifier.AllEventTypes()` of k8s-stack-manager); v0.8.0+ servers return 400
- `/api/v1/stack-definitions` — CRUD + export/import
- `POST /:id/charts` and `DELETE /:id/charts/:chartId` (devops or admin; `definition add-chart`, `remove-chart`). A linked definition cannot lose a chart that the template requires (409 "Cannot delete required chart: X").
Expand Down
35 changes: 27 additions & 8 deletions cli/cmd/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ Examples:
if err := printer.PrintTable(headers, rows); err != nil {
return err
}
printer.PrintMessage("(%d unread of %d total)", page.UnreadCount, page.Total)
printer.PrintMessage("(%s unread of %s total)",
cappedCount(page.UnreadCount, page.UnreadCountCapped), cappedCount(page.Total, page.TotalCapped))
return nil
}
},
Expand All @@ -122,29 +123,38 @@ var notificationCountCmd = &cobra.Command{
Long: `Print the unread notification count for the authenticated user.

In table mode (default) the output is a single integer suitable for shell
scripting; -o json wraps it in {"unread_count": N} matching the server
response shape.
scripting; -o json prints the server response,
{"unread_count": N, "unread_count_capped": false}.

The server counts at most 1000 unread notifications. Above that, the
count is 1000, unread_count_capped is true and table mode also writes a
note to stderr.

Examples:
stackctl notification count # 7
stackctl notification count -o json # {"unread_count":7}`,
stackctl notification count -o json # {"unread_count":7,"unread_count_capped":false}`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
count, err := c.CountUnreadNotifications()
resp, err := c.UnreadNotificationCount()
if err != nil {
return err
}
switch printer.Format {
case output.FormatJSON:
return printer.PrintJSON(types.UnreadCountResponse{UnreadCount: count})
return printer.PrintJSON(resp)
case output.FormatYAML:
return printer.PrintYAML(types.UnreadCountResponse{UnreadCount: count})
return printer.PrintYAML(resp)
default:
fmt.Fprintln(printer.Writer, count)
// stdout stays a bare integer for scripts; the cap goes to stderr.
fmt.Fprintln(printer.Writer, resp.UnreadCount)
if resp.UnreadCountCapped {
fmt.Fprintf(cmd.ErrOrStderr(), "Note: more than %d notifications are unread; the server stops counting at %d.\n",
resp.UnreadCount, resp.UnreadCount)
}
return nil
}
},
Expand Down Expand Up @@ -462,3 +472,12 @@ func ResetNotificationFlagsForTest() {
}

func resetNotificationFlagsForTest() { ResetNotificationFlagsForTest() }

// cappedCount formats a notification count of the server: "1000+" when the
// server stopped counting (capped), else the number.
func cappedCount(n int64, capped bool) string {
if capped {
return strconv.FormatInt(n, 10) + "+"
}
return strconv.FormatInt(n, 10)
}
135 changes: 135 additions & 0 deletions cli/cmd/notification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,141 @@ func TestNotificationCountCmd_QuietOutput(t *testing.T) {
assert.Equal(t, "7", strings.TrimSpace(buf.String()))
}

// TestNotificationListCmd_CappedFooter checks the table footer with the
// capped counts of the server (k8s-stack-manager #654).
func TestNotificationListCmd_CappedFooter(t *testing.T) {
tests := []struct {
name string
page types.PaginatedNotifications
want string
}{
{name: "exact", page: types.PaginatedNotifications{Total: 2, UnreadCount: 1}, want: "(1 unread of 2 total)"},
{name: "total capped", page: types.PaginatedNotifications{Total: 1000, TotalCapped: true, UnreadCount: 3}, want: "(3 unread of 1000+ total)"},
{name: "both capped", page: types.PaginatedNotifications{Total: 1000, TotalCapped: true, UnreadCount: 1000, UnreadCountCapped: true}, want: "(1000+ unread of 1000+ total)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := tt.page
page.Notifications = sampleNotifications()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(page)
}))
defer server.Close()

buf := setupStackTestCmd(t, server.URL)
resetNotificationFlagsForTest()
defer resetNotificationFlagsForTest()
require.NoError(t, notificationListCmd.RunE(notificationListCmd, []string{}))
assert.Contains(t, buf.String(), tt.want)
})
}
}

// TestNotificationCountCmd_Capped checks that a capped count keeps stdout a
// bare integer and writes a note to stderr; -o json keeps the flag.
func TestNotificationCountCmd_Capped(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"unread_count":1000,"unread_count_capped":true}`))
}))
defer server.Close()

t.Run("table", func(t *testing.T) {
buf := setupStackTestCmd(t, server.URL)
var stderr bytes.Buffer
notificationCountCmd.SetErr(&stderr)
t.Cleanup(func() { notificationCountCmd.SetErr(nil) })

require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{}))
assert.Equal(t, "1000", strings.TrimSpace(buf.String()))
assert.Contains(t, stderr.String(), "more than 1000 notifications are unread")
})

t.Run("json", func(t *testing.T) {
buf := setupStackTestCmd(t, server.URL)
printer.Format = output.FormatJSON
require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{}))

var got types.UnreadCountResponse
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, types.UnreadCountResponse{UnreadCount: 1000, UnreadCountCapped: true}, got)
})

t.Run("yaml", func(t *testing.T) {
buf := setupStackTestCmd(t, server.URL)
printer.Format = output.FormatYAML
require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{}))
assert.Contains(t, buf.String(), "unread_count: 1000")
assert.Contains(t, buf.String(), "unread_count_capped: true")
})

t.Run("quiet", func(t *testing.T) {
buf := setupStackTestCmd(t, server.URL)
printer.Quiet = true
var stderr bytes.Buffer
notificationCountCmd.SetErr(&stderr)
t.Cleanup(func() { notificationCountCmd.SetErr(nil) })

require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{}))
assert.Equal(t, "1000", strings.TrimSpace(buf.String()), "stdout stays a bare integer")
assert.Contains(t, stderr.String(), "more than 1000")
})
}

// TestNotificationListCmd_JSONCarriesCapFlags checks that -o json passes the
// cap flags through, and gives false for an older server without them.
func TestNotificationListCmd_JSONCarriesCapFlags(t *testing.T) {
tests := []struct {
name string
body string
wantTotal bool
wantUnread bool
}{
{name: "capped", body: `{"notifications":[],"total":1000,"total_capped":true,"unread_count":1000,"unread_count_capped":true}`, wantTotal: true, wantUnread: true},
{name: "older server", body: `{"notifications":[],"total":2,"unread_count":1}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()

buf := setupStackTestCmd(t, server.URL)
resetNotificationFlagsForTest()
defer resetNotificationFlagsForTest()
printer.Format = output.FormatJSON
require.NoError(t, notificationListCmd.RunE(notificationListCmd, []string{}))

var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, tt.wantTotal, got["total_capped"])
assert.Equal(t, tt.wantUnread, got["unread_count_capped"])
})
}
}

// TestNotificationCountCmd_NotCappedNoNote checks that an exact count writes
// nothing to stderr.
func TestNotificationCountCmd_NotCappedNoNote(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"unread_count":7}`))
}))
defer server.Close()

buf := setupStackTestCmd(t, server.URL)
var stderr bytes.Buffer
notificationCountCmd.SetErr(&stderr)
t.Cleanup(func() { notificationCountCmd.SetErr(nil) })

require.NoError(t, notificationCountCmd.RunE(notificationCountCmd, []string{}))
assert.Equal(t, "7", strings.TrimSpace(buf.String()))
assert.Empty(t, stderr.String())
}

// ---------- notification read ----------

func TestNotificationReadCmd_Success(t *testing.T) {
Expand Down
19 changes: 16 additions & 3 deletions cli/pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2208,17 +2208,30 @@ func (c *Client) ListNotifications(p NotificationListParams) (*types.PaginatedNo
return &resp, nil
}

// CountUnreadNotifications returns the unread count for badge display.
// CountUnreadNotifications returns the unread count for badge display. The
// server caps it at 1000; use UnreadNotificationCount to see whether it did.
//
// @see GET /api/v1/notifications/count
func (c *Client) CountUnreadNotifications() (int64, error) {
var resp types.UnreadCountResponse
if err := c.Get("/api/v1/notifications/count", &resp); err != nil {
resp, err := c.UnreadNotificationCount()
if err != nil {
return 0, err
}
return resp.UnreadCount, nil
}

// UnreadNotificationCount returns the unread count and whether the server
// capped it (UnreadCountCapped: more than UnreadCount are unread).
//
// @see GET /api/v1/notifications/count
func (c *Client) UnreadNotificationCount() (*types.UnreadCountResponse, error) {
var resp types.UnreadCountResponse
if err := c.Get("/api/v1/notifications/count", &resp); err != nil {
return nil, err
}
return &resp, nil
}

// MarkNotificationAsRead marks a single notification as read. The backend
// verifies ownership and returns 404 if the notification belongs to another
// user (or does not exist).
Expand Down
29 changes: 29 additions & 0 deletions cli/pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3426,6 +3426,35 @@ func TestCountUnreadNotifications_Success(t *testing.T) {
assert.Equal(t, int64(42), got)
}

func TestUnreadNotificationCount(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
want types.UnreadCountResponse
}{
{name: "exact", body: `{"unread_count":42,"unread_count_capped":false}`, want: types.UnreadCountResponse{UnreadCount: 42}},
{name: "capped", body: `{"unread_count":1000,"unread_count_capped":true}`, want: types.UnreadCountResponse{UnreadCount: 1000, UnreadCountCapped: true}},
{name: "older server without the flag", body: `{"unread_count":5}`, want: types.UnreadCountResponse{UnreadCount: 5}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/notifications/count", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()

got, err := New(server.URL).UnreadNotificationCount()
require.NoError(t, err)
assert.Equal(t, tt.want, *got)
})
}
}

func TestMarkNotificationAsRead_Success(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
23 changes: 16 additions & 7 deletions cli/pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,10 +662,17 @@ type Notification struct {
// Mirrors backend models.PaginatedNotifications.
//
// Field declaration order is alphabetical by json tag (see Notification).
//
// The server caps both counts (k8s-stack-manager #654): Total is exact up to
// offset + 1000 and UnreadCount up to 1000. Above that, the count is the cap
// and TotalCapped or UnreadCountCapped is true (at least one more exists).
// An older server sends neither flag (false).
type PaginatedNotifications struct {
Notifications []Notification `json:"notifications" yaml:"notifications"`
Total int64 `json:"total" yaml:"total"`
UnreadCount int64 `json:"unread_count" yaml:"unread_count"`
Notifications []Notification `json:"notifications" yaml:"notifications"`
Total int64 `json:"total" yaml:"total"`
TotalCapped bool `json:"total_capped" yaml:"total_capped"`
UnreadCount int64 `json:"unread_count" yaml:"unread_count"`
UnreadCountCapped bool `json:"unread_count_capped" yaml:"unread_count_capped"`
}

// NotificationPreference is one row in the notification-preference payload
Expand Down Expand Up @@ -694,11 +701,13 @@ type NotificationPreference struct {
UserID string `json:"user_id,omitempty" yaml:"user_id,omitempty"`
}

// UnreadCountResponse is the success-path response of GET /api/v1/notifications/count.
// The endpoint returns a single key {"unread_count": N} rather than a wrapped
// object — modelled as a struct so JSON/YAML decode cleanly.
// UnreadCountResponse is the success-path response of GET /api/v1/notifications/count:
// {"unread_count": N, "unread_count_capped": false}. The server counts at most
// 1000 unread notifications (k8s-stack-manager #654); above that UnreadCount
// is 1000 and UnreadCountCapped is true. An older server sends no flag (false).
type UnreadCountResponse struct {
UnreadCount int64 `json:"unread_count" yaml:"unread_count"`
UnreadCount int64 `json:"unread_count" yaml:"unread_count"`
UnreadCountCapped bool `json:"unread_count_capped" yaml:"unread_count_capped"`
}

// Favorite is one row in the authenticated user's favorites collection.
Expand Down
Loading