From 1e731a53e15da815c254738913f01b281b881ec5 Mon Sep 17 00:00:00 2001 From: Jeffrey Tolar Date: Fri, 1 May 2026 17:40:08 -0500 Subject: [PATCH 1/2] fix: use plural filter param names for /v1/oncalls endpoint The API expects filter[schedule_ids], filter[service_ids], etc. but the CLI was sending singular forms (filter[schedule_id]) which the server silently ignores. Co-authored-by: Cursor --- internal/api/client.go | 9 +++++---- internal/cmd/oncall/cmd_test.go | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 68f3481..94d2b21 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httputil" + neturl "net/url" "os" "regexp" "strings" @@ -2961,16 +2962,16 @@ func (c *Client) ListOnCallsCLI(ctx context.Context, params OnCallsParams) (*OnC qp = append(qp, "time_zone="+params.TimeZone) } if params.ScheduleIDs != "" { - qp = append(qp, "filter[schedule_id]="+params.ScheduleIDs) + qp = append(qp, "filter[schedule_ids]="+neturl.QueryEscape(params.ScheduleIDs)) } if params.ServiceIDs != "" { - qp = append(qp, "filter[service_id]="+params.ServiceIDs) + qp = append(qp, "filter[service_ids]="+neturl.QueryEscape(params.ServiceIDs)) } if params.EscalationPolicyIDs != "" { - qp = append(qp, "filter[escalation_policy_id]="+params.EscalationPolicyIDs) + qp = append(qp, "filter[escalation_policy_ids]="+neturl.QueryEscape(params.EscalationPolicyIDs)) } if params.UserIDs != "" { - qp = append(qp, "filter[user_id]="+params.UserIDs) + qp = append(qp, "filter[user_ids]="+neturl.QueryEscape(params.UserIDs)) } url += strings.Join(qp, "&") diff --git a/internal/cmd/oncall/cmd_test.go b/internal/cmd/oncall/cmd_test.go index 6a1a6fd..1e5aaa5 100644 --- a/internal/cmd/oncall/cmd_test.go +++ b/internal/cmd/oncall/cmd_test.go @@ -353,8 +353,8 @@ func TestRunShiftsJSON(t *testing.T) { func TestRunShiftsWithScheduleFilter(t *testing.T) { setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { query := r.URL.RawQuery - if !strings.Contains(query, "filter[schedule_id]=sched-1") { - t.Errorf("expected schedule_id filter in query, got: %s", query) + if !strings.Contains(query, "filter[schedule_ids]=sched-1") { + t.Errorf("expected schedule_ids filter in query, got: %s", query) } w.Header().Set("Content-Type", "application/vnd.api+json") w.Write([]byte(oncallsResponse())) @@ -455,8 +455,8 @@ func TestRunWhoEarliestFalse(t *testing.T) { func TestRunWhoWithServiceFilter(t *testing.T) { setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { query := r.URL.RawQuery - if !strings.Contains(query, "filter[service_id]=svc-1") { - t.Errorf("expected service_id filter in query, got: %s", query) + if !strings.Contains(query, "filter[service_ids]=svc-1") { + t.Errorf("expected service_ids filter in query, got: %s", query) } w.Header().Set("Content-Type", "application/vnd.api+json") w.Write([]byte(oncallsResponse())) @@ -538,8 +538,8 @@ func TestRunWhoNoActiveShifts(t *testing.T) { func TestRunWhoWithScheduleFilter(t *testing.T) { setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { query := r.URL.RawQuery - if !strings.Contains(query, "filter[schedule_id]=sched-1") { - t.Errorf("expected schedule_id filter in query, got: %s", query) + if !strings.Contains(query, "filter[schedule_ids]=sched-1") { + t.Errorf("expected schedule_ids filter in query, got: %s", query) } w.Header().Set("Content-Type", "application/vnd.api+json") w.Write([]byte(oncallsResponse())) From c09697419e02037f5be860374d79d02b3d4ca158 Mon Sep 17 00:00:00 2001 From: Jeffrey Tolar Date: Fri, 1 May 2026 18:17:27 -0500 Subject: [PATCH 2/2] feat: add name-based filtering for oncall shifts and who commands Allow filtering by human-readable names instead of opaque IDs: --schedule="Primary On-Call" (resolves via /v1/schedules) --service="API Gateway" (resolves via /v1/services) --team="Platform Engineering" (resolves via /v1/teams) --user="alice@example.com" (resolves via /v1/users) Each name flag is mutually exclusive with its ID counterpart. User lookup uses filter[email] for addresses containing @, filter[search] otherwise. Also adds --team-id/--team flags (maps to filter[group_ids]), URL-encodes filter values to handle spaces and special chars, and refactors tests with addShiftsFlags/addWhoFlags helpers. Co-authored-by: Cursor --- internal/api/client.go | 128 ++++++++- internal/cmd/oncall/cmd_test.go | 462 +++++++++++++++++++++++++------- internal/cmd/oncall/oncall.go | 61 ++++- internal/cmd/oncall/shifts.go | 35 ++- internal/cmd/oncall/who.go | 36 ++- 5 files changed, 604 insertions(+), 118 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 94d2b21..7979202 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -924,7 +924,7 @@ func (c *Client) ListIncidentsCLI(ctx context.Context, page, pageSize int, sort // Add filters (e.g., filter[status]=started, filter[severity]=critical) for key, value := range filters { - url += fmt.Sprintf("&filter[%s]=%s", key, value) + url += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) } req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) @@ -1351,7 +1351,7 @@ func (c *Client) ListAlertsCLI(ctx context.Context, page, pageSize int, sort str // Add filters (e.g., filter[status]=triggered, filter[source]=sentry) for key, value := range filters { - url += fmt.Sprintf("&filter[%s]=%s", key, value) + url += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) } req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) @@ -1922,7 +1922,7 @@ func (c *Client) ListServicesCLI(ctx context.Context, page, pageSize int, sort s // Add filters (e.g., filter[name]=foo, filter[slug]=bar) for key, value := range filters { - url += fmt.Sprintf("&filter[%s]=%s", key, value) + url += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) } req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) @@ -2364,7 +2364,7 @@ func (c *Client) ListTeamsCLI(ctx context.Context, page, pageSize int, sort stri // Add filters (e.g., filter[name]=foo) for key, value := range filters { - url += fmt.Sprintf("&filter[%s]=%s", key, value) + url += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) } req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) @@ -2835,10 +2835,11 @@ type OnCallsParams struct { Until string // ISO-8601 end time Earliest bool // only first on-call user per escalation level TimeZone string // e.g. America/New_York - ScheduleIDs string // filter by schedule ID - ServiceIDs string // filter by service ID - EscalationPolicyIDs string // filter by escalation policy ID - UserIDs string // filter by user ID + ScheduleIDs string // filter by schedule ID(s), comma-separated + ServiceIDs string // filter by service ID(s), comma-separated + EscalationPolicyIDs string // filter by escalation policy ID(s), comma-separated + UserIDs string // filter by user ID(s), comma-separated + GroupIDs string // filter by group/team ID(s), comma-separated } // ListSchedulesCLI lists on-call schedules (read-only). @@ -2859,7 +2860,7 @@ func (c *Client) ListSchedulesCLI(ctx context.Context, page, pageSize int, filte // Add filters (e.g., filter[name]=foo) for key, value := range filters { - url += fmt.Sprintf("&filter[%s]=%s", key, value) + url += fmt.Sprintf("&filter[%s]=%s", key, neturl.QueryEscape(value)) } req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) @@ -2973,6 +2974,9 @@ func (c *Client) ListOnCallsCLI(ctx context.Context, params OnCallsParams) (*OnC if params.UserIDs != "" { qp = append(qp, "filter[user_ids]="+neturl.QueryEscape(params.UserIDs)) } + if params.GroupIDs != "" { + qp = append(qp, "filter[group_ids]="+neturl.QueryEscape(params.GroupIDs)) + } url += strings.Join(qp, "&") @@ -3085,6 +3089,112 @@ func (c *Client) ListOnCallsCLI(ctx context.Context, params OnCallsParams) (*OnC }, nil } +// ResolveScheduleIDByName looks up a schedule by name and returns its ID. +// Uses filter[name] on the schedules endpoint for an exact match. +func (c *Client) ResolveScheduleIDByName(ctx context.Context, name string) (string, error) { + result, err := c.ListSchedulesCLI(ctx, 1, 25, map[string]string{"name": name}) + if err != nil { + return "", fmt.Errorf("failed to look up schedule %q: %w", name, err) + } + if len(result.Schedules) == 0 { + return "", fmt.Errorf("no schedule found with name %q", name) + } + if len(result.Schedules) > 1 { + return "", fmt.Errorf("multiple schedules match name %q; use --schedule-id to specify", name) + } + return result.Schedules[0].ID, nil +} + +// ResolveServiceIDByName looks up a service by name and returns its ID. +func (c *Client) ResolveServiceIDByName(ctx context.Context, name string) (string, error) { + result, err := c.ListServicesCLI(ctx, 1, 25, "", map[string]string{"name": name}) + if err != nil { + return "", fmt.Errorf("failed to look up service %q: %w", name, err) + } + if len(result.Services) == 0 { + return "", fmt.Errorf("no service found with name %q", name) + } + if len(result.Services) > 1 { + return "", fmt.Errorf("multiple services match name %q; use --service-id to specify", name) + } + return result.Services[0].ID, nil +} + +// ResolveTeamIDByName looks up a team (group) by name and returns its ID. +func (c *Client) ResolveTeamIDByName(ctx context.Context, name string) (string, error) { + result, err := c.ListTeamsCLI(ctx, 1, 25, "", map[string]string{"name": name}) + if err != nil { + return "", fmt.Errorf("failed to look up team %q: %w", name, err) + } + if len(result.Teams) == 0 { + return "", fmt.Errorf("no team found with name %q", name) + } + if len(result.Teams) > 1 { + return "", fmt.Errorf("multiple teams match name %q; use --team-id to specify", name) + } + return result.Teams[0].ID, nil +} + +// ResolveUserID looks up a user by email or name and returns their ID. +// Tries filter[email] first (exact); falls back to filter[search] (fuzzy). +func (c *Client) ResolveUserID(ctx context.Context, query string) (string, error) { + baseURL := c.endpoint + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + + filterKey := "search" + if strings.Contains(query, "@") { + filterKey = "email" + } + + url := fmt.Sprintf("%s/v1/users?page[size]=25&filter[%s]=%s", baseURL, filterKey, neturl.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/vnd.api+json") + + httpResp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to look up user %q: %w", query, err) + } + defer func() { _ = httpResp.Body.Close() }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if httpResp.StatusCode != 200 { + return "", fmt.Errorf("user lookup returned status %d", httpResp.StatusCode) + } + + var response struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Email string `json:"email"` + FullName *string `json:"full_name"` + } `json:"attributes"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(response.Data) == 0 { + return "", fmt.Errorf("no user found matching %q", query) + } + if len(response.Data) > 1 && filterKey == "search" { + return "", fmt.Errorf("multiple users match %q; use --user-id or an email address to be more specific", query) + } + return response.Data[0].ID, nil +} + // CreatePulseCLI creates a new pulse using raw HTTP POST. func (c *Client) CreatePulseCLI(ctx context.Context, summary string, opts PulseOpts) (*Pulse, error) { // Build JSON:API request body diff --git a/internal/cmd/oncall/cmd_test.go b/internal/cmd/oncall/cmd_test.go index 1e5aaa5..662e4ee 100644 --- a/internal/cmd/oncall/cmd_test.go +++ b/internal/cmd/oncall/cmd_test.go @@ -20,6 +20,54 @@ func newTestCmd() *cobra.Command { return cmd } +// addShiftsFlags registers all flags that runShifts reads. +func addShiftsFlags(cmd *cobra.Command, overrides map[string]string) { + defaults := map[string]string{ + "schedule-id": "", + "schedule": "", + "service-id": "", + "service": "", + "escalation-policy-id": "", + "user-id": "", + "user": "", + "team-id": "", + "team": "", + "time-zone": "", + "include": "user,schedule,escalation_policy", + } + for k, v := range overrides { + defaults[k] = v + } + cmd.Flags().Int("days", 7, "") + for k, v := range defaults { + cmd.Flags().String(k, v, "") + } +} + +// addWhoFlags registers all flags that runWho reads. +func addWhoFlags(cmd *cobra.Command, overrides map[string]string) { + defaults := map[string]string{ + "schedule-id": "", + "schedule": "", + "service-id": "", + "service": "", + "escalation-policy-id": "", + "user-id": "", + "user": "", + "team-id": "", + "team": "", + "time-zone": "", + "include": "user,schedule,escalation_policy", + } + for k, v := range overrides { + defaults[k] = v + } + cmd.Flags().Bool("earliest", true, "") + for k, v := range defaults { + cmd.Flags().String(k, v, "") + } +} + func schedulesResponse() string { return `{ "data": [{ @@ -45,7 +93,65 @@ func schedulesResponse() string { }` } -// oncallsResponse returns a /v1/oncalls response with two on-call entries. +func singleScheduleResponse() string { + return `{ + "data": [{ + "id": "sched-1", + "attributes": { + "name": "Primary On-Call", + "description": "Primary rotation", + "created_at": "2025-01-01T00:00:00Z" + } + }], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }` +} + +func singleServiceResponse() string { + return `{ + "data": [{ + "id": "svc-42", + "attributes": { + "name": "API Gateway", + "slug": "api-gateway", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } + }], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }` +} + +func singleUserResponse() string { + return `{ + "data": [{ + "id": "user-99", + "attributes": { + "email": "alice@example.com", + "full_name": "Alice Smith", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } + }], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }` +} + +func singleTeamResponse() string { + return `{ + "data": [{ + "id": "team-7", + "attributes": { + "name": "Platform Engineering", + "slug": "platform-engineering", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } + }], + "meta": {"current_page": 1, "total_pages": 1, "total_count": 1} + }` +} + func oncallsResponse() string { now := time.Now() endsAt := now.Add(23 * time.Hour).Format(time.RFC3339) @@ -92,6 +198,13 @@ func emptyOncallsResponse() string { }` } +func emptyListResponse() string { + return `{ + "data": [], + "meta": {"current_page": 1, "total_pages": 0, "total_count": 0} + }` +} + func setupTestServer(t *testing.T, handler http.HandlerFunc) { t.Helper() server := httptest.NewServer(handler) @@ -233,13 +346,7 @@ func TestRunShiftsTable(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().Int("days", 7, "") - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addShiftsFlags(cmd, nil) output := captureStdout(t, func() { err := runShifts(cmd, nil) @@ -268,13 +375,7 @@ func TestRunShiftsEmptyResults(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().Int("days", 7, "") - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addShiftsFlags(cmd, nil) output := captureStdout(t, func() { err := runShifts(cmd, nil) @@ -283,7 +384,6 @@ func TestRunShiftsEmptyResults(t *testing.T) { } }) - // Empty table should still have headers if strings.Contains(output, "Alice") { t.Errorf("expected no data rows, got: %s", output) } @@ -305,13 +405,7 @@ func TestRunShiftsSinceUntilParams(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().Int("days", 14, "") - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addShiftsFlags(cmd, nil) captureStdout(t, func() { err := runShifts(cmd, nil) @@ -330,13 +424,7 @@ func TestRunShiftsJSON(t *testing.T) { viper.Set("format", "json") cmd := newTestCmd() - cmd.Flags().Int("days", 7, "") - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addShiftsFlags(cmd, nil) output := captureStdout(t, func() { err := runShifts(cmd, nil) @@ -350,7 +438,7 @@ func TestRunShiftsJSON(t *testing.T) { } } -func TestRunShiftsWithScheduleFilter(t *testing.T) { +func TestRunShiftsWithScheduleIDFilter(t *testing.T) { setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { query := r.URL.RawQuery if !strings.Contains(query, "filter[schedule_ids]=sched-1") { @@ -363,13 +451,77 @@ func TestRunShiftsWithScheduleFilter(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().Int("days", 7, "") - cmd.Flags().String("schedule-id", "sched-1", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addShiftsFlags(cmd, map[string]string{"schedule-id": "sched-1"}) + + captureStdout(t, func() { + err := runShifts(cmd, nil) + if err != nil { + t.Fatalf("runShifts returned error: %v", err) + } + }) +} + +func TestRunShiftsWithScheduleNameFilter(t *testing.T) { + var oncallsRequested bool + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + if strings.Contains(r.URL.Path, "/v1/schedules") { + if !strings.Contains(r.URL.RawQuery, "filter[name]=Primary+On-Call") && + !strings.Contains(r.URL.RawQuery, "filter[name]=Primary%20On-Call") { + t.Errorf("expected schedule name filter, got: %s", r.URL.RawQuery) + } + w.Write([]byte(singleScheduleResponse())) + return + } + if strings.Contains(r.URL.Path, "/v1/oncalls") { + oncallsRequested = true + if !strings.Contains(r.URL.RawQuery, "filter[schedule_ids]=sched-1") { + t.Errorf("expected resolved schedule ID in oncalls query, got: %s", r.URL.RawQuery) + } + w.Write([]byte(oncallsResponse())) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + addShiftsFlags(cmd, map[string]string{"schedule": "Primary On-Call"}) + + captureStdout(t, func() { + err := runShifts(cmd, nil) + if err != nil { + t.Fatalf("runShifts returned error: %v", err) + } + }) + + if !oncallsRequested { + t.Error("expected /v1/oncalls to be called after schedule lookup") + } +} + +func TestRunShiftsWithServiceNameFilter(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + if strings.Contains(r.URL.Path, "/v1/services") { + w.Write([]byte(singleServiceResponse())) + return + } + if strings.Contains(r.URL.Path, "/v1/oncalls") { + if !strings.Contains(r.URL.RawQuery, "filter[service_ids]=svc-42") { + t.Errorf("expected resolved service ID, got: %s", r.URL.RawQuery) + } + w.Write([]byte(oncallsResponse())) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + addShiftsFlags(cmd, map[string]string{"service": "API Gateway"}) captureStdout(t, func() { err := runShifts(cmd, nil) @@ -379,6 +531,108 @@ func TestRunShiftsWithScheduleFilter(t *testing.T) { }) } +func TestRunShiftsWithUserEmailFilter(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + if strings.Contains(r.URL.Path, "/v1/users") { + if !strings.Contains(r.URL.RawQuery, "filter[email]=alice") { + t.Errorf("expected email filter for user lookup, got: %s", r.URL.RawQuery) + } + w.Write([]byte(singleUserResponse())) + return + } + if strings.Contains(r.URL.Path, "/v1/oncalls") { + if !strings.Contains(r.URL.RawQuery, "filter[user_ids]=user-99") { + t.Errorf("expected resolved user ID, got: %s", r.URL.RawQuery) + } + w.Write([]byte(oncallsResponse())) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + addShiftsFlags(cmd, map[string]string{"user": "alice@example.com"}) + + captureStdout(t, func() { + err := runShifts(cmd, nil) + if err != nil { + t.Fatalf("runShifts returned error: %v", err) + } + }) +} + +func TestRunShiftsWithTeamNameFilter(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + if strings.Contains(r.URL.Path, "/v1/teams") { + w.Write([]byte(singleTeamResponse())) + return + } + if strings.Contains(r.URL.Path, "/v1/oncalls") { + if !strings.Contains(r.URL.RawQuery, "filter[group_ids]=team-7") { + t.Errorf("expected resolved team/group ID, got: %s", r.URL.RawQuery) + } + w.Write([]byte(oncallsResponse())) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + addShiftsFlags(cmd, map[string]string{"team": "Platform Engineering"}) + + captureStdout(t, func() { + err := runShifts(cmd, nil) + if err != nil { + t.Fatalf("runShifts returned error: %v", err) + } + }) +} + +func TestRunShiftsMutualExclusion(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.Write([]byte(oncallsResponse())) + }) + + cmd := newTestCmd() + addShiftsFlags(cmd, map[string]string{ + "schedule-id": "sched-1", + "schedule": "Primary On-Call", + }) + + err := runShifts(cmd, nil) + if err == nil { + t.Fatal("expected error when both --schedule-id and --schedule are set") + } + if !strings.Contains(err.Error(), "not both") { + t.Errorf("expected mutual exclusion error, got: %v", err) + } +} + +func TestRunShiftsScheduleNotFound(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.Write([]byte(emptyListResponse())) + }) + + cmd := newTestCmd() + addShiftsFlags(cmd, map[string]string{"schedule": "Nonexistent"}) + + err := runShifts(cmd, nil) + if err == nil { + t.Fatal("expected error when schedule not found") + } + if !strings.Contains(err.Error(), "no schedule found") { + t.Errorf("expected 'no schedule found' error, got: %v", err) + } +} + // --- runWho tests --- func TestRunWhoTable(t *testing.T) { @@ -397,13 +651,7 @@ func TestRunWhoTable(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", true, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, nil) output := captureStdout(t, func() { err := runWho(cmd, nil) @@ -436,13 +684,8 @@ func TestRunWhoEarliestFalse(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", false, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, nil) + cmd.Flags().Set("earliest", "false") captureStdout(t, func() { err := runWho(cmd, nil) @@ -452,7 +695,7 @@ func TestRunWhoEarliestFalse(t *testing.T) { }) } -func TestRunWhoWithServiceFilter(t *testing.T) { +func TestRunWhoWithServiceIDFilter(t *testing.T) { setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { query := r.URL.RawQuery if !strings.Contains(query, "filter[service_ids]=svc-1") { @@ -465,13 +708,7 @@ func TestRunWhoWithServiceFilter(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "svc-1", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", true, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, map[string]string{"service-id": "svc-1"}) captureStdout(t, func() { err := runWho(cmd, nil) @@ -490,13 +727,7 @@ func TestRunWhoJSON(t *testing.T) { viper.Set("format", "json") cmd := newTestCmd() - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", true, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, nil) output := captureStdout(t, func() { err := runWho(cmd, nil) @@ -519,13 +750,7 @@ func TestRunWhoNoActiveShifts(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", true, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, nil) captureStdout(t, func() { err := runWho(cmd, nil) @@ -535,7 +760,7 @@ func TestRunWhoNoActiveShifts(t *testing.T) { }) } -func TestRunWhoWithScheduleFilter(t *testing.T) { +func TestRunWhoWithScheduleIDFilter(t *testing.T) { setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { query := r.URL.RawQuery if !strings.Contains(query, "filter[schedule_ids]=sched-1") { @@ -548,13 +773,70 @@ func TestRunWhoWithScheduleFilter(t *testing.T) { viper.Set("format", "table") cmd := newTestCmd() - cmd.Flags().String("schedule-id", "sched-1", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", true, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, map[string]string{"schedule-id": "sched-1"}) + + captureStdout(t, func() { + err := runWho(cmd, nil) + if err != nil { + t.Fatalf("runWho returned error: %v", err) + } + }) +} + +func TestRunWhoWithScheduleNameFilter(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + if strings.Contains(r.URL.Path, "/v1/schedules") { + w.Write([]byte(singleScheduleResponse())) + return + } + if strings.Contains(r.URL.Path, "/v1/oncalls") { + if !strings.Contains(r.URL.RawQuery, "filter[schedule_ids]=sched-1") { + t.Errorf("expected resolved schedule ID, got: %s", r.URL.RawQuery) + } + w.Write([]byte(oncallsResponse())) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + addWhoFlags(cmd, map[string]string{"schedule": "Primary On-Call"}) + + captureStdout(t, func() { + err := runWho(cmd, nil) + if err != nil { + t.Fatalf("runWho returned error: %v", err) + } + }) +} + +func TestRunWhoWithUserSearchFilter(t *testing.T) { + setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + if strings.Contains(r.URL.Path, "/v1/users") { + if !strings.Contains(r.URL.RawQuery, "filter[search]=Alice") { + t.Errorf("expected search filter for user lookup, got: %s", r.URL.RawQuery) + } + w.Write([]byte(singleUserResponse())) + return + } + if strings.Contains(r.URL.Path, "/v1/oncalls") { + if !strings.Contains(r.URL.RawQuery, "filter[user_ids]=user-99") { + t.Errorf("expected resolved user ID, got: %s", r.URL.RawQuery) + } + w.Write([]byte(oncallsResponse())) + return + } + t.Errorf("unexpected path: %s", r.URL.Path) + }) + + viper.Set("format", "table") + + cmd := newTestCmd() + addWhoFlags(cmd, map[string]string{"user": "Alice"}) captureStdout(t, func() { err := runWho(cmd, nil) @@ -572,13 +854,7 @@ func TestRunShiftsNoToken(t *testing.T) { t.Setenv("USERPROFILE", tmpDir) cmd := newTestCmd() - cmd.Flags().Int("days", 7, "") - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addShiftsFlags(cmd, nil) err := runShifts(cmd, nil) if err == nil { @@ -597,13 +873,7 @@ func TestRunWhoNoToken(t *testing.T) { t.Setenv("USERPROFILE", tmpDir) cmd := newTestCmd() - cmd.Flags().String("schedule-id", "", "") - cmd.Flags().String("service-id", "", "") - cmd.Flags().String("escalation-policy-id", "", "") - cmd.Flags().String("user-id", "", "") - cmd.Flags().String("time-zone", "", "") - cmd.Flags().Bool("earliest", true, "") - cmd.Flags().String("include", "user,schedule,escalation_policy", "") + addWhoFlags(cmd, nil) err := runWho(cmd, nil) if err == nil { diff --git a/internal/cmd/oncall/oncall.go b/internal/cmd/oncall/oncall.go index 3587346..8a4de8c 100644 --- a/internal/cmd/oncall/oncall.go +++ b/internal/cmd/oncall/oncall.go @@ -1,6 +1,7 @@ package oncall import ( + "context" "fmt" "github.com/spf13/cobra" @@ -31,9 +32,11 @@ Note: Schedules are managed in the Rootly UI. This command provides read-only ac # See who is on-call right now rootly oncall who - # Filter by schedule or service - rootly oncall who --schedule-id=sched-123 - rootly oncall shifts --service-id=svc-456`, + # Filter by name or ID + rootly oncall who --schedule="Primary On-Call" + rootly oncall shifts --service="API Gateway" + rootly oncall who --user="alice@example.com" + rootly oncall shifts --schedule-id=sched-123`, } // getAPIClient creates a stateless API client for CLI operations. @@ -56,3 +59,55 @@ func getAPIClient() (*api.Client, error) { } return api.NewClient(cfg) } + +// resolveScheduleID returns the schedule ID from either --schedule-id or --schedule (name lookup). +func resolveScheduleID(ctx context.Context, client *api.Client, cmd *cobra.Command) (string, error) { + id, _ := cmd.Flags().GetString("schedule-id") + name, _ := cmd.Flags().GetString("schedule") + if id != "" && name != "" { + return "", fmt.Errorf("specify either --schedule-id or --schedule, not both") + } + if name != "" { + return client.ResolveScheduleIDByName(ctx, name) + } + return id, nil +} + +// resolveServiceID returns the service ID from either --service-id or --service (name lookup). +func resolveServiceID(ctx context.Context, client *api.Client, cmd *cobra.Command) (string, error) { + id, _ := cmd.Flags().GetString("service-id") + name, _ := cmd.Flags().GetString("service") + if id != "" && name != "" { + return "", fmt.Errorf("specify either --service-id or --service, not both") + } + if name != "" { + return client.ResolveServiceIDByName(ctx, name) + } + return id, nil +} + +// resolveUserID returns the user ID from either --user-id or --user (name/email lookup). +func resolveUserID(ctx context.Context, client *api.Client, cmd *cobra.Command) (string, error) { + id, _ := cmd.Flags().GetString("user-id") + query, _ := cmd.Flags().GetString("user") + if id != "" && query != "" { + return "", fmt.Errorf("specify either --user-id or --user, not both") + } + if query != "" { + return client.ResolveUserID(ctx, query) + } + return id, nil +} + +// resolveTeamID returns the team/group ID from either --team-id or --team (name lookup). +func resolveTeamID(ctx context.Context, client *api.Client, cmd *cobra.Command) (string, error) { + id, _ := cmd.Flags().GetString("team-id") + name, _ := cmd.Flags().GetString("team") + if id != "" && name != "" { + return "", fmt.Errorf("specify either --team-id or --team, not both") + } + if name != "" { + return client.ResolveTeamIDByName(ctx, name) + } + return id, nil +} diff --git a/internal/cmd/oncall/shifts.go b/internal/cmd/oncall/shifts.go index d12452c..8935841 100644 --- a/internal/cmd/oncall/shifts.go +++ b/internal/cmd/oncall/shifts.go @@ -24,9 +24,15 @@ var shiftsCmd = &cobra.Command{ # View shifts for the next 14 days rootly oncall shifts --days=14 - # Filter by schedule ID + # Filter by schedule name or ID + rootly oncall shifts --schedule="Primary On-Call" rootly oncall shifts --schedule-id=sched-123 + # Filter by service, team, or user + rootly oncall shifts --service="API Gateway" + rootly oncall shifts --team="Platform Engineering" + rootly oncall shifts --user="alice@example.com" + # Output as JSON rootly oncall shifts --format=json`, RunE: runShifts, @@ -35,9 +41,14 @@ var shiftsCmd = &cobra.Command{ func init() { shiftsCmd.Flags().Int("days", 7, "Number of days ahead to show shifts (default: 7)") shiftsCmd.Flags().String("schedule-id", "", "Filter by schedule ID") + shiftsCmd.Flags().String("schedule", "", "Filter by schedule name (looked up automatically)") shiftsCmd.Flags().String("service-id", "", "Filter by service ID") + shiftsCmd.Flags().String("service", "", "Filter by service name (looked up automatically)") shiftsCmd.Flags().String("escalation-policy-id", "", "Filter by escalation policy ID") shiftsCmd.Flags().String("user-id", "", "Filter by user ID") + shiftsCmd.Flags().String("user", "", "Filter by user name or email (looked up automatically)") + shiftsCmd.Flags().String("team-id", "", "Filter by team ID") + shiftsCmd.Flags().String("team", "", "Filter by team name (looked up automatically)") shiftsCmd.Flags().String("time-zone", "", "Time zone (e.g. America/New_York)") shiftsCmd.Flags().String("include", "user,schedule,escalation_policy", "Included resources (comma-separated)") @@ -50,14 +61,29 @@ func runShifts(cmd *cobra.Command, args []string) error { return err } + ctx := cmd.Context() days, _ := cmd.Flags().GetInt("days") include, _ := cmd.Flags().GetString("include") - scheduleID, _ := cmd.Flags().GetString("schedule-id") - serviceID, _ := cmd.Flags().GetString("service-id") escalationPolicyID, _ := cmd.Flags().GetString("escalation-policy-id") - userID, _ := cmd.Flags().GetString("user-id") timeZone, _ := cmd.Flags().GetString("time-zone") + scheduleID, err := resolveScheduleID(ctx, apiClient, cmd) + if err != nil { + return err + } + serviceID, err := resolveServiceID(ctx, apiClient, cmd) + if err != nil { + return err + } + userID, err := resolveUserID(ctx, apiClient, cmd) + if err != nil { + return err + } + teamID, err := resolveTeamID(ctx, apiClient, cmd) + if err != nil { + return err + } + now := time.Now().UTC() until := now.AddDate(0, 0, days) @@ -70,6 +96,7 @@ func runShifts(cmd *cobra.Command, args []string) error { ServiceIDs: serviceID, EscalationPolicyIDs: escalationPolicyID, UserIDs: userID, + GroupIDs: teamID, } result, err := apiClient.ListOnCallsCLI(cmd.Context(), params) diff --git a/internal/cmd/oncall/who.go b/internal/cmd/oncall/who.go index 352f8a4..83eee6a 100644 --- a/internal/cmd/oncall/who.go +++ b/internal/cmd/oncall/who.go @@ -21,11 +21,14 @@ var whoCmd = &cobra.Command{ Example: ` # See who is on-call right now rootly oncall who - # Filter by schedule + # Filter by schedule name or ID + rootly oncall who --schedule="Primary On-Call" rootly oncall who --schedule-id=sched-123 - # Filter by service - rootly oncall who --service-id=svc-456 + # Filter by service, team, or user + rootly oncall who --service="API Gateway" + rootly oncall who --team="Platform Engineering" + rootly oncall who --user="alice@example.com" # Output as JSON rootly oncall who --format=json`, @@ -34,9 +37,14 @@ var whoCmd = &cobra.Command{ func init() { whoCmd.Flags().String("schedule-id", "", "Filter by schedule ID") + whoCmd.Flags().String("schedule", "", "Filter by schedule name (looked up automatically)") whoCmd.Flags().String("service-id", "", "Filter by service ID") + whoCmd.Flags().String("service", "", "Filter by service name (looked up automatically)") whoCmd.Flags().String("escalation-policy-id", "", "Filter by escalation policy ID") whoCmd.Flags().String("user-id", "", "Filter by user ID") + whoCmd.Flags().String("user", "", "Filter by user name or email (looked up automatically)") + whoCmd.Flags().String("team-id", "", "Filter by team ID") + whoCmd.Flags().String("team", "", "Filter by team name (looked up automatically)") whoCmd.Flags().String("time-zone", "", "Time zone (e.g. America/New_York)") whoCmd.Flags().Bool("earliest", true, "Only show first on-call user per escalation level") whoCmd.Flags().String("include", "user,schedule,escalation_policy", "Included resources (comma-separated)") @@ -50,15 +58,30 @@ func runWho(cmd *cobra.Command, args []string) error { return err } + ctx := cmd.Context() now := time.Now().UTC().Format(time.RFC3339) earliest, _ := cmd.Flags().GetBool("earliest") include, _ := cmd.Flags().GetString("include") - scheduleID, _ := cmd.Flags().GetString("schedule-id") - serviceID, _ := cmd.Flags().GetString("service-id") escalationPolicyID, _ := cmd.Flags().GetString("escalation-policy-id") - userID, _ := cmd.Flags().GetString("user-id") timeZone, _ := cmd.Flags().GetString("time-zone") + scheduleID, err := resolveScheduleID(ctx, apiClient, cmd) + if err != nil { + return err + } + serviceID, err := resolveServiceID(ctx, apiClient, cmd) + if err != nil { + return err + } + userID, err := resolveUserID(ctx, apiClient, cmd) + if err != nil { + return err + } + teamID, err := resolveTeamID(ctx, apiClient, cmd) + if err != nil { + return err + } + params := api.OnCallsParams{ Include: include, Since: now, @@ -69,6 +92,7 @@ func runWho(cmd *cobra.Command, args []string) error { ServiceIDs: serviceID, EscalationPolicyIDs: escalationPolicyID, UserIDs: userID, + GroupIDs: teamID, } result, err := apiClient.ListOnCallsCLI(cmd.Context(), params)