From 6a2f3c874a2d70379e8b7390758220615c53f853 Mon Sep 17 00:00:00 2001 From: nash Date: Mon, 1 Jun 2026 12:43:19 +0500 Subject: [PATCH 1/4] feat(sentry): add first-class Sentry.io integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full Sentry provider mirroring the existing Cloudflare recipe: internal/sentry package (REST client with cursor pagination, 429 backoff honoring Retry-After + X-Sentry-Rate-Limit-Reset, per-org conversation history) plus `clanker sentry` cobra tree (list / get / resolve / ignore / assign / monitor / alert subcommands) and `clanker sentry ask` for natural-language triage. Self-hosted Sentry is supported via a configurable host field (default sentry.io). The ask agent fetches issues / releases / monitors / alerts based on keyword routing and forwards Sentry search syntax verbatim — no client-side parsing — so operators can use `is:unresolved level:error environment:prod`-style queries directly. MCP tools: clanker_sentry_ask, _list_issues, _get_issue, _resolve_issues, _list_releases. Tests cover the happy path, 429 retry, Link-header cursor parsing, error envelope extraction, PUT ?id=... repeated-key encoding, and per-org history round-trip. Closes nothing — net-new integration. --- .clanker.example.yaml | 12 + cmd/mcp.go | 1 + cmd/mcp_sentry.go | 249 +++++++++++ cmd/root.go | 8 + cmd/sentry.go | 309 +++++++++++++ internal/sentry/alerts.go | 88 ++++ internal/sentry/client.go | 379 ++++++++++++++++ internal/sentry/client_test.go | 277 ++++++++++++ internal/sentry/conversation.go | 154 +++++++ internal/sentry/conversation_test.go | 75 ++++ internal/sentry/events.go | 46 ++ internal/sentry/issues.go | 134 ++++++ internal/sentry/monitors.go | 81 ++++ internal/sentry/orgs.go | 43 ++ internal/sentry/projects.go | 80 ++++ internal/sentry/releases.go | 65 +++ internal/sentry/static_commands.go | 620 +++++++++++++++++++++++++++ internal/sentry/status.go | 41 ++ internal/sentry/teams.go | 40 ++ internal/sentry/types.go | 227 ++++++++++ 20 files changed, 2929 insertions(+) create mode 100644 cmd/mcp_sentry.go create mode 100644 cmd/sentry.go create mode 100644 internal/sentry/alerts.go create mode 100644 internal/sentry/client.go create mode 100644 internal/sentry/client_test.go create mode 100644 internal/sentry/conversation.go create mode 100644 internal/sentry/conversation_test.go create mode 100644 internal/sentry/events.go create mode 100644 internal/sentry/issues.go create mode 100644 internal/sentry/monitors.go create mode 100644 internal/sentry/orgs.go create mode 100644 internal/sentry/projects.go create mode 100644 internal/sentry/releases.go create mode 100644 internal/sentry/static_commands.go create mode 100644 internal/sentry/status.go create mode 100644 internal/sentry/teams.go create mode 100644 internal/sentry/types.go diff --git a/.clanker.example.yaml b/.clanker.example.yaml index f8db36a..40fba97 100644 --- a/.clanker.example.yaml +++ b/.clanker.example.yaml @@ -192,6 +192,18 @@ infra: # org_slug: "" # Optional org slug filter (or set FLY_ORG / FLY_ORG_SLUG). # # Empty = see resources across every org the token can access. +# Sentry.io (for `clanker sentry ask ...` and `clanker sentry list issues`): +# sentry: +# auth_token: "" # Sentry User Auth Token (or set SENTRY_AUTH_TOKEN) +# # Generate at https://sentry.io/settings/account/api/auth-tokens/ +# # Recommended scopes: org:read, project:read, event:read, event:admin, +# # alerts:read, alerts:write, project:releases +# org_slug: "" # Sentry org slug (or set SENTRY_ORG) +# default_project: "" # Optional default project slug for events/releases/alerts +# # (or set SENTRY_PROJECT) +# host: "sentry.io" # Override for self-hosted Sentry (or set SENTRY_HOST) +# # Single-tenant EU customers use ".sentry.io" + # Verda Cloud (for `clanker verda ...` and `clanker ask --verda ...`): # verda: # client_id: "" # Verda OAuth2 client ID (or set VERDA_CLIENT_ID, or run `verda auth login`) diff --git a/cmd/mcp.go b/cmd/mcp.go index b6f1aae..5d776c0 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -357,6 +357,7 @@ func newClankerMCPServer() *mcptransport.MCPServer { }), ) + registerSentryMCPTools(server) registerK8sMCPTools(server) return server diff --git a/cmd/mcp_sentry.go b/cmd/mcp_sentry.go new file mode 100644 index 0000000..c03b96f --- /dev/null +++ b/cmd/mcp_sentry.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/sentry" + "github.com/mark3labs/mcp-go/mcp" + mcptransport "github.com/mark3labs/mcp-go/server" + "github.com/spf13/viper" +) + +// MCP tool argument types — exported via JSON Schema by mark3labs/mcp-go. + +type sentryAskMCPArgs struct { + Question string `json:"question" jsonschema:"description=Natural language question about Sentry issues/events/releases,required"` + OrgSlug string `json:"orgSlug,omitempty" jsonschema:"description=Sentry org slug (falls back to config/env)"` + Project string `json:"project,omitempty" jsonschema:"description=Optional default project slug for release/alert/event context"` + Environment string `json:"environment,omitempty" jsonschema:"description=Filter to a specific environment (e.g. prod)"` + Token string `json:"token,omitempty" jsonschema:"description=Sentry User Auth Token (falls back to config/env)"` + Host string `json:"host,omitempty" jsonschema:"description=Sentry host; defaults to sentry.io"` + Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug output"` +} + +type sentryListIssuesMCPArgs struct { + OrgSlug string `json:"orgSlug,omitempty"` + Query string `json:"query,omitempty" jsonschema:"description=Sentry search query (passed through verbatim)"` + Environment string `json:"environment,omitempty"` + StatsPeriod string `json:"statsPeriod,omitempty" jsonschema:"description=e.g. 24h, 7d, 14d"` + Limit int `json:"limit,omitempty"` + Token string `json:"token,omitempty"` + Host string `json:"host,omitempty"` +} + +type sentryGetIssueMCPArgs struct { + IssueID string `json:"issueId" jsonschema:"description=Issue ID,required"` + Token string `json:"token,omitempty"` + Host string `json:"host,omitempty"` +} + +type sentryResolveIssuesMCPArgs struct { + OrgSlug string `json:"orgSlug,omitempty"` + IssueIDs []string `json:"issueIds" jsonschema:"description=Issue IDs to resolve,required"` + Token string `json:"token,omitempty"` + Host string `json:"host,omitempty"` +} + +type sentryListReleasesMCPArgs struct { + OrgSlug string `json:"orgSlug,omitempty"` + Project string `json:"project" jsonschema:"description=Sentry project slug,required"` + Token string `json:"token,omitempty"` + Host string `json:"host,omitempty"` +} + +func registerSentryMCPTools(server *mcptransport.MCPServer) { + server.AddTool( + mcp.NewTool( + "clanker_sentry_ask", + mcp.WithDescription("Ask a natural-language question about Sentry. Fetches relevant issues, releases, and monitors and answers via the configured AI provider."), + mcp.WithInputSchema[sentryAskMCPArgs](), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args sentryAskMCPArgs) (*mcp.CallToolResult, error) { + return handleMCPSentryAsk(ctx, args) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_sentry_list_issues", + mcp.WithDescription("List Sentry issues. Query passes through Sentry's search syntax (e.g. 'is:unresolved level:error environment:prod')."), + mcp.WithInputSchema[sentryListIssuesMCPArgs](), + mcp.WithReadOnlyHintAnnotation(true), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args sentryListIssuesMCPArgs) (*mcp.CallToolResult, error) { + return handleMCPSentryListIssues(ctx, args) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_sentry_get_issue", + mcp.WithDescription("Fetch a single Sentry issue by ID, including recent events."), + mcp.WithInputSchema[sentryGetIssueMCPArgs](), + mcp.WithReadOnlyHintAnnotation(true), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args sentryGetIssueMCPArgs) (*mcp.CallToolResult, error) { + return handleMCPSentryGetIssue(ctx, args) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_sentry_resolve_issues", + mcp.WithDescription("Mark one or more Sentry issues as resolved. Mutates upstream — confirm with the user before calling."), + mcp.WithInputSchema[sentryResolveIssuesMCPArgs](), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args sentryResolveIssuesMCPArgs) (*mcp.CallToolResult, error) { + return handleMCPSentryResolveIssues(ctx, args) + }), + ) + + server.AddTool( + mcp.NewTool( + "clanker_sentry_list_releases", + mcp.WithDescription("List Sentry releases for a project."), + mcp.WithInputSchema[sentryListReleasesMCPArgs](), + mcp.WithReadOnlyHintAnnotation(true), + ), + mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args sentryListReleasesMCPArgs) (*mcp.CallToolResult, error) { + return handleMCPSentryListReleases(ctx, args) + }), + ) +} + +func mcpSentryClient(token, orgSlug, host string, debug bool) (*sentry.Client, string, error) { + if token == "" { + token = sentry.ResolveAuthToken() + } + if token == "" { + return nil, "", fmt.Errorf("sentry auth token not configured (set sentry.auth_token in ~/.clanker.yaml or SENTRY_AUTH_TOKEN)") + } + if orgSlug == "" { + orgSlug = sentry.ResolveOrgSlug() + } + if host == "" { + host = sentry.ResolveHost() + } + client, err := sentry.NewClient(token, orgSlug, host, debug) + if err != nil { + return nil, "", err + } + return client, orgSlug, nil +} + +func handleMCPSentryAsk(ctx context.Context, args sentryAskMCPArgs) (*mcp.CallToolResult, error) { + client, org, err := mcpSentryClient(args.Token, args.OrgSlug, args.Host, args.Debug) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if org == "" { + return mcp.NewToolResultError("sentry org slug is required (set sentry.org_slug or pass orgSlug)"), nil + } + + contextStr, err := gatherSentryContext(ctx, client, args.Question, args.Project, args.Environment, args.Debug) + if err != nil && args.Debug { + return mcp.NewToolResultError(fmt.Sprintf("gather context: %v", err)), nil + } + + status, _ := sentry.GatherAccountStatus(ctx, client, org) + statusStr := "" + if status != nil { + statusStr = fmt.Sprintf("Org: %s — Projects: %d — Unresolved: %d", org, status.ProjectCount, status.UnresolvedCount) + } + + prompt := buildSentryPrompt(args.Question, contextStr, "", statusStr) + + aiProfile := viper.GetString("ai.default_provider") + apiKey := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey, args.Debug) + answer, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("AI query failed: %v", err)), nil + } + return mcp.NewToolResultText(answer), nil +} + +func handleMCPSentryListIssues(ctx context.Context, args sentryListIssuesMCPArgs) (*mcp.CallToolResult, error) { + client, org, err := mcpSentryClient(args.Token, args.OrgSlug, args.Host, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if org == "" { + return mcp.NewToolResultError("sentry org slug is required"), nil + } + period := args.StatsPeriod + if period == "" { + period = "14d" + } + limit := args.Limit + if limit == 0 { + limit = 50 + } + issues, nextCursor, err := client.ListIssues(ctx, org, sentry.IssueListOptions{ + Query: args.Query, + Environment: args.Environment, + StatsPeriod: period, + Limit: limit, + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(map[string]any{ + "issues": issues, + "nextCursor": nextCursor, + }) +} + +func handleMCPSentryGetIssue(ctx context.Context, args sentryGetIssueMCPArgs) (*mcp.CallToolResult, error) { + if strings.TrimSpace(args.IssueID) == "" { + return mcp.NewToolResultError("issueId is required"), nil + } + client, _, err := mcpSentryClient(args.Token, "", args.Host, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issue, err := client.GetIssue(ctx, args.IssueID) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + events, err := client.GetIssueEvents(ctx, args.IssueID, 5) + if err != nil { + return mcp.NewToolResultJSON(map[string]any{"issue": issue, "eventsError": err.Error()}) + } + return mcp.NewToolResultJSON(map[string]any{"issue": issue, "recentEvents": events}) +} + +func handleMCPSentryResolveIssues(ctx context.Context, args sentryResolveIssuesMCPArgs) (*mcp.CallToolResult, error) { + if len(args.IssueIDs) == 0 { + return mcp.NewToolResultError("issueIds is required"), nil + } + client, org, err := mcpSentryClient(args.Token, args.OrgSlug, args.Host, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if org == "" { + return mcp.NewToolResultError("sentry org slug is required"), nil + } + if err := client.ResolveIssues(ctx, org, args.IssueIDs); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(map[string]any{"resolved": args.IssueIDs}) +} + +func handleMCPSentryListReleases(ctx context.Context, args sentryListReleasesMCPArgs) (*mcp.CallToolResult, error) { + client, org, err := mcpSentryClient(args.Token, args.OrgSlug, args.Host, false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if org == "" { + return mcp.NewToolResultError("sentry org slug is required"), nil + } + releases, err := client.ListReleases(ctx, org, args.Project) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(releases) +} diff --git a/cmd/root.go b/cmd/root.go index f7b6ead..abcc564 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/bgdnvk/clanker/internal/gcp" "github.com/bgdnvk/clanker/internal/hetzner" "github.com/bgdnvk/clanker/internal/railway" + "github.com/bgdnvk/clanker/internal/sentry" "github.com/bgdnvk/clanker/internal/tencent" "github.com/bgdnvk/clanker/internal/vercel" "github.com/bgdnvk/clanker/internal/verda" @@ -108,6 +109,13 @@ func init() { AddCfDeployCommands(cfCmd) rootCmd.AddCommand(cfCmd) + // Register Sentry static commands + ask command. Natural-language queries + // go through `clanker sentry ask "..."`; list/get/resolve/etc. live on + // the same root via internal/sentry.CreateSentryCommands(). + sentryCmd := sentry.CreateSentryCommands() + AddSentryAskCommand(sentryCmd) + rootCmd.AddCommand(sentryCmd) + // Register Digital Ocean static commands doCmd := digitalocean.CreateDigitalOceanCommands() rootCmd.AddCommand(doCmd) diff --git a/cmd/sentry.go b/cmd/sentry.go new file mode 100644 index 0000000..58b7ec9 --- /dev/null +++ b/cmd/sentry.go @@ -0,0 +1,309 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/sentry" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var sentryAskCmd = &cobra.Command{ + Use: "ask [question]", + Short: "Ask natural-language questions about your Sentry organisation", + Long: `Ask natural-language questions about your Sentry organisation using AI. + +The assistant fetches relevant Sentry data (issues, events, releases, alerts, +monitors) based on the question and replies in markdown. Conversation history +is maintained per-org so you can ask follow-up questions. + +Examples: + clanker sentry ask "what's the worst error today?" + clanker sentry ask "any new errors since the last release?" + clanker sentry ask "are any monitors failing?" + clanker sentry ask "show me unresolved issues in prod" --environment prod`, + Args: cobra.ExactArgs(1), + RunE: runSentryAsk, +} + +var ( + sentryAskAuthToken string + sentryAskOrgSlug string + sentryAskProject string + sentryAskHost string + sentryAskEnvironment string + sentryAskAIProfile string + sentryAskDebug bool +) + +func init() { + sentryAskCmd.Flags().StringVar(&sentryAskAuthToken, "auth-token", "", "Sentry User Auth Token") + sentryAskCmd.Flags().StringVar(&sentryAskOrgSlug, "org", "", "Sentry org slug") + sentryAskCmd.Flags().StringVar(&sentryAskProject, "project", "", "Default Sentry project slug") + sentryAskCmd.Flags().StringVar(&sentryAskHost, "host", "", "Sentry host (default sentry.io)") + sentryAskCmd.Flags().StringVar(&sentryAskEnvironment, "environment", "", "Filter to a specific environment") + sentryAskCmd.Flags().StringVar(&sentryAskAIProfile, "ai-profile", "", "AI profile to use for LLM queries") + sentryAskCmd.Flags().BoolVar(&sentryAskDebug, "debug", false, "Enable debug output") +} + +// AddSentryAskCommand wires the ask subcommand onto the base sentry command. +func AddSentryAskCommand(sentryCmd *cobra.Command) { + sentryCmd.AddCommand(sentryAskCmd) +} + +func runSentryAsk(cmd *cobra.Command, args []string) error { + question := strings.TrimSpace(args[0]) + if question == "" { + return fmt.Errorf("question cannot be empty") + } + + debug := sentryAskDebug || viper.GetBool("debug") + + authToken := sentryAskAuthToken + if authToken == "" { + authToken = sentry.ResolveAuthToken() + } + if authToken == "" { + return fmt.Errorf("sentry auth token is required (set --auth-token, SENTRY_AUTH_TOKEN, or sentry.auth_token in config)") + } + + org := sentryAskOrgSlug + if org == "" { + org = sentry.ResolveOrgSlug() + } + if org == "" { + return fmt.Errorf("sentry org slug is required (set --org, SENTRY_ORG, or sentry.org_slug in config)") + } + + host := sentryAskHost + if host == "" { + host = sentry.ResolveHost() + } + + project := sentryAskProject + if project == "" { + project = sentry.ResolveDefaultProject() + } + + client, err := sentry.NewClient(authToken, org, host, debug) + if err != nil { + return fmt.Errorf("create sentry client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + history := sentry.NewConversationHistory(org) + if err := history.Load(); err != nil && debug { + fmt.Printf("[debug] load history: %v\n", err) + } + + if status, err := sentry.GatherAccountStatus(ctx, client, org); err == nil && status != nil { + history.UpdateAccountStatus(status) + } else if debug { + fmt.Printf("[debug] gather status: %v\n", err) + } + + context, err := gatherSentryContext(ctx, client, question, project, sentryAskEnvironment, debug) + if err != nil && debug { + fmt.Printf("[debug] gather context: %v\n", err) + } + + prompt := buildSentryPrompt(question, context, history.GetRecentContext(5), history.GetAccountStatusContext()) + + aiProfile := sentryAskAIProfile + if aiProfile == "" { + aiProfile = viper.GetString("ai.default_provider") + } + apiKey := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey, debug) + + answer, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return fmt.Errorf("AI query failed: %w", err) + } + + fmt.Println(answer) + + history.AddEntry(question, answer, org) + if err := history.Save(); err != nil && debug { + fmt.Printf("[debug] save history: %v\n", err) + } + + return nil +} + +// gatherSentryContext fetches Sentry data relevant to the question. Keyword +// routing is deliberately lightweight — Sentry's search syntax is rich enough +// that we mostly want to forward `is:unresolved`-style filters through and let +// the LLM correlate from a focused page of issues + recent releases. +func gatherSentryContext(ctx context.Context, client *sentry.Client, question, project, environment string, debug bool) (string, error) { + questionLower := strings.ToLower(question) + + var sb strings.Builder + + wantIssues := containsAny(questionLower, []string{"issue", "error", "crash", "exception", "problem", "fail", "bug", "what's", "whats", "blowing", "broken"}) + wantReleases := containsAny(questionLower, []string{"release", "deploy", "version", "rollout", "regress"}) + wantMonitors := containsAny(questionLower, []string{"monitor", "cron", "schedule"}) + wantAlerts := containsAny(questionLower, []string{"alert", "rule", "notify", "page"}) + + // Default to issues if the question doesn't pattern-match — most "what's + // going on" questions want issues first. + if !wantIssues && !wantReleases && !wantMonitors && !wantAlerts { + wantIssues = true + } + + if wantIssues { + query := "is:unresolved" + if strings.Contains(questionLower, "error") || strings.Contains(questionLower, "crash") { + query = "is:unresolved level:error" + } + issues, _, err := client.ListIssues(ctx, client.OrgSlug(), sentry.IssueListOptions{ + Query: query, + Environment: environment, + StatsPeriod: "24h", + Limit: 25, + }) + if err != nil { + if debug { + fmt.Printf("[debug] list issues: %v\n", err) + } + } else { + sb.WriteString("Recent unresolved issues (last 24h):\n") + for _, i := range issues { + sb.WriteString(fmt.Sprintf(" - [%s] %s — %s (count=%s, users=%d, lastSeen=%s)\n", + i.Level, i.ShortID, i.Title, i.Count, i.UserCount, i.LastSeen.Format(time.RFC3339))) + } + sb.WriteString("\n") + } + } + + if wantReleases && project != "" { + releases, err := client.ListReleases(ctx, client.OrgSlug(), project) + if err != nil { + if debug { + fmt.Printf("[debug] list releases: %v\n", err) + } + } else { + sb.WriteString("Recent releases:\n") + for i, r := range releases { + if i >= 10 { + break + } + released := "(unreleased)" + if r.DateReleased != nil && !r.DateReleased.IsZero() { + released = r.DateReleased.Format(time.RFC3339) + } + sb.WriteString(fmt.Sprintf(" - %s (newGroups=%d, released=%s)\n", r.ShortVersion, r.NewGroups, released)) + } + sb.WriteString("\n") + } + } + + if wantMonitors { + monitors, err := client.ListMonitors(ctx, client.OrgSlug()) + if err != nil { + if debug { + fmt.Printf("[debug] list monitors: %v\n", err) + } + } else { + sb.WriteString("Sentry Crons monitors:\n") + for _, m := range monitors { + sb.WriteString(fmt.Sprintf(" - %s (%s) status=%s muted=%v\n", m.Slug, m.Name, m.Status, m.IsMuted)) + } + sb.WriteString("\n") + } + } + + if wantAlerts && project != "" { + rules, err := client.ListIssueAlertRules(ctx, client.OrgSlug(), project) + if err != nil { + if debug { + fmt.Printf("[debug] list alert rules: %v\n", err) + } + } else { + sb.WriteString("Alert rules:\n") + for _, r := range rules { + sb.WriteString(fmt.Sprintf(" - %s (env=%s, frequency=%dmin)\n", r.Name, r.Environment, r.Frequency)) + } + sb.WriteString("\n") + } + } + + if sb.Len() == 0 { + return "No Sentry data fetched (check token permissions and org slug).", nil + } + return sb.String(), nil +} + +func buildSentryPrompt(question, dataContext, historyContext, statusContext string) string { + var sb strings.Builder + + sb.WriteString("You are a Sentry observability assistant. ") + sb.WriteString("Help the user triage and understand error patterns in their Sentry organisation.\n\n") + sb.WriteString("Vocabulary cheat-sheet: an *issue* is a deduplicated group of errors with the same fingerprint; ") + sb.WriteString("an *event* is a single occurrence. ") + sb.WriteString("`count` is total occurrences; `userCount` is unique users affected. ") + sb.WriteString("Recommend the next investigative step where useful.\n\n") + + if statusContext != "" { + sb.WriteString("Org status:\n") + sb.WriteString(statusContext) + sb.WriteString("\n\n") + } + + if dataContext != "" { + sb.WriteString("Sentry data:\n") + sb.WriteString(dataContext) + sb.WriteString("\n") + } + + if historyContext != "" { + sb.WriteString("Recent conversation:\n") + sb.WriteString(historyContext) + sb.WriteString("\n") + } + + sb.WriteString("User question: ") + sb.WriteString(question) + sb.WriteString("\n\n") + + sb.WriteString("Respond in concise markdown. When you reference an issue, include its short ID (e.g. BACKEND-42) so the user can jump to it.") + return sb.String() +} + +func containsAny(s string, needles []string) bool { + for _, n := range needles { + if strings.Contains(s, n) { + return true + } + } + return false +} + +// resolveAIKeyForProfile mirrors the dispatch in cmd/cf.go so the ask command +// finds the right API key for the configured AI provider. +func resolveAIKeyForProfile(profile string) string { + switch profile { + case "bedrock", "claude", "gemini": + return "" + case "gemini-api": + return resolveGeminiAPIKey("") + case "openai": + return resolveOpenAIKey("") + case "anthropic": + return resolveAnthropicKey("") + case "deepseek": + return resolveDeepSeekKey("") + case "cohere": + return resolveCohereKey("") + case "minimax": + return resolveMiniMaxKey("") + default: + return viper.GetString(fmt.Sprintf("ai.providers.%s.api_key", profile)) + } +} diff --git a/internal/sentry/alerts.go b/internal/sentry/alerts.go new file mode 100644 index 0000000..7aafe0c --- /dev/null +++ b/internal/sentry/alerts.go @@ -0,0 +1,88 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListIssueAlertRules returns project-scoped issue alert rules (the legacy +// /rules/ endpoint). For metric alerts, use ListMetricAlertRules. +func (c *Client) ListIssueAlertRules(ctx context.Context, orgSlug, projectSlug string) ([]IssueAlertRule, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" { + return nil, fmt.Errorf("org and project slug are required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/rules/", org, projectSlug), nil) + if err != nil { + return nil, err + } + var rules []IssueAlertRule + if err := DecodeJSON(body, &rules); err != nil { + return nil, err + } + return rules, nil +} + +// ListMetricAlertRules returns org-scoped metric alert rules. +func (c *Client) ListMetricAlertRules(ctx context.Context, orgSlug string) ([]MetricAlertRule, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, fmt.Errorf("org slug is required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/alert-rules/", org), nil) + if err != nil { + return nil, err + } + var rules []MetricAlertRule + if err := DecodeJSON(body, &rules); err != nil { + return nil, err + } + return rules, nil +} + +// CreateIssueAlertRule posts a new issue alert rule. The body is passed +// through as-is — the upstream schema for conditions/filters/actions is large +// enough that we don't model it strictly, leaving callers free to construct +// the payload from documentation. +func (c *Client) CreateIssueAlertRule(ctx context.Context, orgSlug, projectSlug string, rule any) (*IssueAlertRule, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" { + return nil, fmt.Errorf("org and project slug are required") + } + _, body, err := c.Do(ctx, "POST", fmt.Sprintf("/projects/%s/%s/rules/", org, projectSlug), rule) + if err != nil { + return nil, err + } + var created IssueAlertRule + if err := DecodeJSON(body, &created); err != nil { + return nil, err + } + return &created, nil +} + +// UpdateIssueAlertRule replaces an existing issue alert rule. +func (c *Client) UpdateIssueAlertRule(ctx context.Context, orgSlug, projectSlug, ruleID string, rule any) (*IssueAlertRule, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" || ruleID == "" { + return nil, fmt.Errorf("org, project slug, and rule ID are required") + } + _, body, err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%s/%s/rules/%s/", org, projectSlug, ruleID), rule) + if err != nil { + return nil, err + } + var updated IssueAlertRule + if err := DecodeJSON(body, &updated); err != nil { + return nil, err + } + return &updated, nil +} + +// DeleteIssueAlertRule removes an issue alert rule. +func (c *Client) DeleteIssueAlertRule(ctx context.Context, orgSlug, projectSlug, ruleID string) error { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" || ruleID == "" { + return fmt.Errorf("org, project slug, and rule ID are required") + } + _, _, err := c.Do(ctx, "DELETE", fmt.Sprintf("/projects/%s/%s/rules/%s/", org, projectSlug, ruleID), nil) + return err +} diff --git a/internal/sentry/client.go b/internal/sentry/client.go new file mode 100644 index 0000000..174b80b --- /dev/null +++ b/internal/sentry/client.go @@ -0,0 +1,379 @@ +package sentry + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/spf13/viper" +) + +const ( + defaultHost = "sentry.io" + apiPathPrefix = "/api/0" + userAgent = "clanker-cli" +) + +// Client is a thin REST wrapper around the Sentry management API. +// +// Auth is Bearer-token via the User Auth Token (Settings → Account → API). +// We hit https://{host}/api/0/ directly with net/http — there is no official +// Go management SDK; getsentry/sentry-go is for error reporting only. +type Client struct { + host string + authToken string + orgSlug string + httpClient *http.Client + debug bool +} + +// ResolveAuthToken returns the configured Sentry User Auth Token, checking +// config first then environment, mirroring how cloudflare/client.go resolves +// its credentials. +func ResolveAuthToken() string { + if t := strings.TrimSpace(viper.GetString("sentry.auth_token")); t != "" { + return t + } + if t := strings.TrimSpace(os.Getenv("SENTRY_AUTH_TOKEN")); t != "" { + return t + } + return "" +} + +func ResolveOrgSlug() string { + if s := strings.TrimSpace(viper.GetString("sentry.org_slug")); s != "" { + return s + } + if s := strings.TrimSpace(os.Getenv("SENTRY_ORG")); s != "" { + return s + } + return "" +} + +func ResolveDefaultProject() string { + if s := strings.TrimSpace(viper.GetString("sentry.default_project")); s != "" { + return s + } + if s := strings.TrimSpace(os.Getenv("SENTRY_PROJECT")); s != "" { + return s + } + return "" +} + +// ResolveHost returns the Sentry host, defaulting to sentry.io. Self-hosted +// users set this to their on-prem URL host; EU single-tenant users to +// `.sentry.io`. +func ResolveHost() string { + if h := strings.TrimSpace(viper.GetString("sentry.host")); h != "" { + return h + } + if h := strings.TrimSpace(os.Getenv("SENTRY_HOST")); h != "" { + return h + } + return defaultHost +} + +// NewClient returns a Client. orgSlug is optional — many endpoints scope by +// org but a handful (list orgs) don't. Empty host falls back to sentry.io. +func NewClient(authToken, orgSlug, host string, debug bool) (*Client, error) { + if strings.TrimSpace(authToken) == "" { + return nil, errors.New("sentry auth_token is required") + } + if strings.TrimSpace(host) == "" { + host = defaultHost + } + return &Client{ + host: host, + authToken: authToken, + orgSlug: strings.TrimSpace(orgSlug), + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + debug: debug, + }, nil +} + +// SetHTTPClient lets tests swap in an httptest.Server-backed client. +func (c *Client) SetHTTPClient(hc *http.Client) { + if hc != nil { + c.httpClient = hc + } +} + +func (c *Client) Host() string { return c.host } +func (c *Client) OrgSlug() string { return c.orgSlug } +func (c *Client) Debug() bool { return c.debug } + +// BaseURL returns the canonical base for Sentry REST calls. Always trailing +// `/api/0` with no slash; callers prepend `/...` paths. +func (c *Client) BaseURL() string { + return fmt.Sprintf("https://%s%s", c.host, apiPathPrefix) +} + +// APIError carries an HTTP-status-aware error from the upstream API. +type APIError struct { + Status int + Body string + Detail string +} + +func (e *APIError) Error() string { + if e.Detail != "" { + return fmt.Sprintf("sentry api error %d: %s", e.Status, e.Detail) + } + return fmt.Sprintf("sentry api error %d: %s", e.Status, e.Body) +} + +// Do executes a Sentry API call with exponential backoff on 429s, honoring +// Retry-After and X-Sentry-Rate-Limit-Reset. path must begin with `/`. If +// body is non-nil it is JSON-marshalled. +func (c *Client) Do(ctx context.Context, method, path string, body any) (*http.Response, []byte, error) { + const maxAttempts = 4 + var lastResp *http.Response + var lastBody []byte + var lastErr error + + for attempt := range maxAttempts { + resp, b, err := c.doOnce(ctx, method, path, body) + if err != nil { + lastErr = err + // Network-level errors are retryable with backoff (DNS hiccups, + // dropped connections). Hard auth errors come back through the + // resp.StatusCode path below, not as `err`. + if !isRetryableNetErr(err) || attempt == maxAttempts-1 { + return nil, nil, err + } + sleepWithJitter(time.Duration(200*(attempt+1)) * time.Millisecond) + continue + } + lastResp = resp + lastBody = b + + if resp.StatusCode != http.StatusTooManyRequests { + break + } + + // 429 — honor server-supplied wait if present. Retry-After is in + // seconds; X-Sentry-Rate-Limit-Reset is an absolute unix-seconds + // timestamp. Prefer the longer of the two so we don't hammer back + // in immediately when both are advertised. + wait := parseRetryWait(resp) + if c.debug { + fmt.Fprintf(os.Stderr, "[sentry] 429 rate-limited, waiting %s (attempt %d)\n", wait, attempt+1) + } + if attempt == maxAttempts-1 { + break + } + select { + case <-ctx.Done(): + return resp, b, ctx.Err() + case <-time.After(wait): + } + } + + if lastErr != nil { + return nil, nil, lastErr + } + if lastResp.StatusCode >= 400 { + return lastResp, lastBody, &APIError{ + Status: lastResp.StatusCode, + Body: string(lastBody), + Detail: extractErrorDetail(lastBody), + } + } + return lastResp, lastBody, nil +} + +func (c *Client) doOnce(ctx context.Context, method, path string, body any) (*http.Response, []byte, error) { + var bodyReader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, nil, fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(raw) + } + + fullURL := path + if !strings.HasPrefix(path, "http") { + fullURL = c.BaseURL() + path + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+c.authToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + if c.debug { + fmt.Fprintf(os.Stderr, "[sentry] %s %s\n", method, fullURL) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, fmt.Errorf("read body: %w", err) + } + return resp, respBody, nil +} + +// DecodeJSON unmarshals body into v, returning a clearer error than the raw +// json package message when the response is e.g. an HTML login redirect. +func DecodeJSON(body []byte, v any) error { + if len(body) == 0 { + return nil + } + if err := json.Unmarshal(body, v); err != nil { + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return fmt.Errorf("decode sentry response: %w (body: %s)", err, preview) + } + return nil +} + +// ParseNextCursor returns the next-page cursor from the Link header, or empty +// string when there is no next page (results="false" or no rel=next entry). +// Sentry uses RFC 5988 with a `results` extension that flags whether a +// follow-up call would actually return rows. +var linkEntryRE = regexp.MustCompile(`<([^>]+)>; rel="([^"]+)"(?:; results="([^"]+)")?(?:; cursor="([^"]+)")?`) + +func ParseNextCursor(resp *http.Response) string { + if resp == nil { + return "" + } + link := resp.Header.Get("Link") + if link == "" { + return "" + } + for _, match := range linkEntryRE.FindAllStringSubmatch(link, -1) { + if len(match) < 5 { + continue + } + rel := match[2] + results := match[3] + cursor := match[4] + if rel == "next" && results == "true" && cursor != "" { + return cursor + } + } + return "" +} + +// BuildQuery formats a Sentry endpoint query string from a values bag, +// stripping empty values so we don't send `?query=`-style noise. +func BuildQuery(params map[string]string) string { + if len(params) == 0 { + return "" + } + v := url.Values{} + for key, val := range params { + if strings.TrimSpace(val) == "" { + continue + } + v.Set(key, val) + } + if len(v) == 0 { + return "" + } + return "?" + v.Encode() +} + +func parseRetryWait(resp *http.Response) time.Duration { + candidates := []time.Duration{} + + if h := resp.Header.Get("Retry-After"); h != "" { + if secs, err := strconv.ParseFloat(h, 64); err == nil { + candidates = append(candidates, time.Duration(secs*float64(time.Second))) + } + } + if h := resp.Header.Get("X-Sentry-Rate-Limit-Reset"); h != "" { + if reset, err := strconv.ParseInt(h, 10, 64); err == nil { + wait := time.Until(time.Unix(reset, 0)) + if wait > 0 { + candidates = append(candidates, wait) + } + } + } + + max := 2 * time.Second + for _, d := range candidates { + if d > max { + max = d + } + } + // Cap the wait at 30s — beyond that the caller should see the failure + // instead of hanging on what is almost certainly a misconfigured limit. + if max > 30*time.Second { + max = 30 * time.Second + } + return max +} + +func sleepWithJitter(base time.Duration) { + jitter := time.Duration(rand.Int63n(int64(base) / 2)) + time.Sleep(base + jitter) +} + +func isRetryableNetErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "timeout") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "temporarily unavailable") || + strings.Contains(msg, "eof") +} + +// extractErrorDetail tries to pull a `detail` field from the upstream JSON +// error envelope. Sentry returns either `{"detail":"..."}` or `{"":["msg"]}`. +func extractErrorDetail(body []byte) string { + if len(body) == 0 { + return "" + } + var single struct { + Detail string `json:"detail"` + } + if err := json.Unmarshal(body, &single); err == nil && single.Detail != "" { + return single.Detail + } + var multi map[string]any + if err := json.Unmarshal(body, &multi); err == nil { + for k, v := range multi { + switch vv := v.(type) { + case string: + return fmt.Sprintf("%s: %s", k, vv) + case []any: + if len(vv) > 0 { + return fmt.Sprintf("%s: %v", k, vv[0]) + } + } + } + } + return "" +} diff --git a/internal/sentry/client_test.go b/internal/sentry/client_test.go new file mode 100644 index 0000000..1dc3cbc --- /dev/null +++ b/internal/sentry/client_test.go @@ -0,0 +1,277 @@ +package sentry + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +// newTestClient returns a Client wired to point at the given test server. +// We override Host but then have to swap the http.Client because the real +// Client always prepends https://; the http.Client transport is mocked to +// rewrite the URL back to the httptest target. +func newTestClient(t *testing.T, ts *httptest.Server) *Client { + t.Helper() + c, err := NewClient("test-token", "test-org", "sentry.io", false) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + c.SetHTTPClient(&http.Client{ + Transport: rewritingTransport{target: ts.URL}, + Timeout: 5 * time.Second, + }) + return c +} + +// rewritingTransport sends every request to the test server regardless of +// the URL the client constructed — saves us from having to plumb a base URL +// override through every helper. +type rewritingTransport struct { + target string +} + +func (rt rewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Preserve the path + query so handlers can assert on them. + target := rt.target + req.URL.Path + if req.URL.RawQuery != "" { + target += "?" + req.URL.RawQuery + } + cloned := req.Clone(req.Context()) + newReq, err := http.NewRequestWithContext(req.Context(), req.Method, target, req.Body) + if err != nil { + return nil, err + } + newReq.Header = cloned.Header + return http.DefaultTransport.RoundTrip(newReq) +} + +func TestClientDo_HappyPath(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("missing Bearer token, got %q", got) + } + if got := r.URL.Path; got != "/api/0/organizations/test-org/projects/" { + t.Errorf("unexpected path: %q", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[{"id":"1","slug":"backend","name":"Backend","platform":"go","dateCreated":"2024-01-01T00:00:00Z","isBookmarked":false}]`) + })) + defer ts.Close() + + c := newTestClient(t, ts) + projects, err := c.ListProjects(context.Background(), "") + if err != nil { + t.Fatalf("ListProjects: %v", err) + } + if len(projects) != 1 { + t.Fatalf("got %d projects, want 1", len(projects)) + } + if projects[0].Slug != "backend" { + t.Errorf("slug = %q, want backend", projects[0].Slug) + } +} + +// TestClientDo_RateLimit_RetryAfter exercises the 429 backoff: the first +// response is 429 with Retry-After:0 (so the test doesn't actually wait +// seconds), the second succeeds. +func TestClientDo_RateLimit_RetryAfter(t *testing.T) { + var attempts atomic.Int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"detail":"rate limited"}`) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `[]`) + })) + defer ts.Close() + + c := newTestClient(t, ts) + _, err := c.ListProjects(context.Background(), "") + if err != nil { + t.Fatalf("ListProjects after retry: %v", err) + } + if attempts.Load() != 2 { + t.Errorf("expected 2 attempts, got %d", attempts.Load()) + } +} + +// TestClientDo_APIError surfaces non-200 responses as an APIError with the +// detail extracted from the upstream JSON envelope. +func TestClientDo_APIError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"detail":"You do not have permission to perform this action."}`) + })) + defer ts.Close() + + c := newTestClient(t, ts) + _, err := c.ListProjects(context.Background(), "") + if err == nil { + t.Fatal("expected APIError, got nil") + } + if !strings.Contains(err.Error(), "permission") { + t.Errorf("error should mention 'permission': %v", err) + } + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.Status != http.StatusForbidden { + t.Errorf("Status = %d, want 403", apiErr.Status) + } +} + +func TestParseNextCursor(t *testing.T) { + cases := []struct { + name string + header string + want string + }{ + { + name: "no link header", + header: "", + want: "", + }, + { + name: "next with results=true", + header: `; rel="previous"; results="false"; cursor="abc:0:0", ; rel="next"; results="true"; cursor="def:0:0"`, + want: "def:0:0", + }, + { + name: "next with results=false stops pagination", + header: `; rel="next"; results="false"; cursor="abc:0:0"`, + want: "", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + resp := &http.Response{Header: http.Header{"Link": []string{tc.header}}} + if got := ParseNextCursor(resp); got != tc.want { + t.Errorf("ParseNextCursor = %q, want %q", got, tc.want) + } + }) + } +} + +func TestBuildQuery(t *testing.T) { + got := BuildQuery(map[string]string{ + "query": "is:unresolved", + "environment": "", + "statsPeriod": "24h", + }) + // Map iteration order is non-deterministic, so check the substring shape. + if !strings.HasPrefix(got, "?") { + t.Errorf("missing leading ?: %q", got) + } + if !strings.Contains(got, "query=is%3Aunresolved") { + t.Errorf("expected encoded query, got %q", got) + } + if strings.Contains(got, "environment=") { + t.Errorf("empty value should be stripped, got %q", got) + } +} + +func TestBuildQuery_AllEmpty(t *testing.T) { + got := BuildQuery(map[string]string{"a": "", "b": ""}) + if got != "" { + t.Errorf("expected empty string when all values empty, got %q", got) + } +} + +func TestUpdateIssues_RepeatedIDQuery(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Errorf("expected PUT, got %s", r.Method) + } + // Sentry expects ?id=A&id=B (repeated keys) not ?id=A,B. + ids := r.URL.Query()["id"] + if len(ids) != 3 { + t.Errorf("expected 3 ?id= params, got %d (%v)", len(ids), ids) + } + body, _ := io.ReadAll(r.Body) + var got IssueUpdate + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode body: %v", err) + } + if got.Status != "resolved" { + t.Errorf("status = %q, want resolved", got.Status) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + })) + defer ts.Close() + + c := newTestClient(t, ts) + if err := c.ResolveIssues(context.Background(), "", []string{"a", "b", "c"}); err != nil { + t.Fatalf("ResolveIssues: %v", err) + } +} + +func TestProjectStatsPoint_UnmarshalJSON(t *testing.T) { + var pts []ProjectStatsPoint + if err := json.Unmarshal([]byte(`[[1700000000, 42], [1700003600, 17]]`), &pts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(pts) != 2 { + t.Fatalf("len = %d, want 2", len(pts)) + } + if pts[0].Timestamp != 1700000000 || pts[0].Count != 42 { + t.Errorf("point[0] = %+v", pts[0]) + } +} + +// TestExtractErrorDetail probes both error envelope shapes Sentry returns — +// the single-detail form and the field-bag form. The latter happens on +// validation failures. +func TestExtractErrorDetail(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"empty", "", ""}, + {"single detail", `{"detail":"nope"}`, "nope"}, + {"field bag string", `{"slug":"slug is required"}`, "slug: slug is required"}, + {"field bag array", `{"name":["This field is required."]}`, "name: This field is required."}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := extractErrorDetail([]byte(tc.body)) + if got != tc.want { + t.Errorf("extractErrorDetail = %q, want %q", got, tc.want) + } + }) + } +} + +// TestParseRetryWait_PrefersLonger checks we wait the *longer* of the +// Retry-After / X-Sentry-Rate-Limit-Reset values when both are advertised, +// so we don't hammer back in too early. +func TestParseRetryWait_PrefersLonger(t *testing.T) { + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Retry-After", "0.5") + resp.Header.Set("X-Sentry-Rate-Limit-Reset", strconv.FormatInt(time.Now().Add(3*time.Second).Unix(), 10)) + wait := parseRetryWait(resp) + if wait < time.Second { + t.Errorf("expected wait >= 1s, got %v", wait) + } + if wait > 30*time.Second { + t.Errorf("wait should be capped at 30s, got %v", wait) + } +} diff --git a/internal/sentry/conversation.go b/internal/sentry/conversation.go new file mode 100644 index 0000000..e7f4040 --- /dev/null +++ b/internal/sentry/conversation.go @@ -0,0 +1,154 @@ +package sentry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ConversationEntry is a single Q&A turn against the Sentry ask agent. +type ConversationEntry struct { + Timestamp time.Time `json:"timestamp"` + Question string `json:"question"` + Answer string `json:"answer"` + OrgSlug string `json:"org_slug"` +} + +// ConversationHistory persists Sentry ask sessions per-org under +// ~/.clanker/sentry-{orgSlug}.json — same pattern as the Cloudflare history. +type ConversationHistory struct { + Entries []ConversationEntry `json:"entries"` + OrgSlug string `json:"org_slug"` + LastStatus *AccountStatus `json:"last_status,omitempty"` + mu sync.RWMutex +} + +const ( + MaxHistoryEntries = 20 + MaxAnswerLengthInContext = 500 +) + +func NewConversationHistory(orgSlug string) *ConversationHistory { + return &ConversationHistory{ + Entries: make([]ConversationEntry, 0), + OrgSlug: orgSlug, + } +} + +func (h *ConversationHistory) AddEntry(question, answer, orgSlug string) { + h.mu.Lock() + defer h.mu.Unlock() + h.Entries = append(h.Entries, ConversationEntry{ + Timestamp: time.Now(), + Question: question, + Answer: answer, + OrgSlug: orgSlug, + }) + if len(h.Entries) > MaxHistoryEntries { + h.Entries = h.Entries[len(h.Entries)-MaxHistoryEntries:] + } +} + +// UpdateAccountStatus stashes the latest snapshot so follow-up questions can +// reference orientation context (project count, unresolved count) without +// re-fetching. +func (h *ConversationHistory) UpdateAccountStatus(status *AccountStatus) { + h.mu.Lock() + defer h.mu.Unlock() + h.LastStatus = status +} + +// GetRecentContext renders the last maxEntries turns as a single string +// suitable for prepending to an LLM prompt. Answers are truncated so a +// single long response can't crowd out the user's actual question. +func (h *ConversationHistory) GetRecentContext(maxEntries int) string { + h.mu.RLock() + defer h.mu.RUnlock() + if len(h.Entries) == 0 { + return "" + } + start := 0 + if len(h.Entries) > maxEntries { + start = len(h.Entries) - maxEntries + } + var sb strings.Builder + for _, e := range h.Entries[start:] { + sb.WriteString("Q: ") + sb.WriteString(e.Question) + sb.WriteString("\nA: ") + ans := e.Answer + if len(ans) > MaxAnswerLengthInContext { + ans = ans[:MaxAnswerLengthInContext] + "..." + } + sb.WriteString(ans) + sb.WriteString("\n\n") + } + return sb.String() +} + +func (h *ConversationHistory) GetAccountStatusContext() string { + h.mu.RLock() + defer h.mu.RUnlock() + if h.LastStatus == nil { + return "" + } + return fmt.Sprintf( + "Org: %s — Projects: %d — Unresolved issues: %d — Errors in last 24h: %d (snapshot at %s)", + h.LastStatus.OrganizationSlug, + h.LastStatus.ProjectCount, + h.LastStatus.UnresolvedCount, + h.LastStatus.ErrorCount24h, + h.LastStatus.Timestamp.Format(time.RFC3339), + ) +} + +func historyPath(orgSlug string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".clanker") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + slug := orgSlug + if slug == "" { + slug = "default" + } + return filepath.Join(dir, fmt.Sprintf("sentry-%s.json", slug)), nil +} + +func (h *ConversationHistory) Load() error { + path, err := historyPath(h.OrgSlug) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + h.mu.Lock() + defer h.mu.Unlock() + return json.Unmarshal(data, h) +} + +func (h *ConversationHistory) Save() error { + path, err := historyPath(h.OrgSlug) + if err != nil { + return err + } + h.mu.RLock() + defer h.mu.RUnlock() + data, err := json.MarshalIndent(h, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/sentry/conversation_test.go b/internal/sentry/conversation_test.go new file mode 100644 index 0000000..0975474 --- /dev/null +++ b/internal/sentry/conversation_test.go @@ -0,0 +1,75 @@ +package sentry + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestConversationHistory_RoundTrip(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + h := NewConversationHistory("acme") + h.AddEntry("what's broken?", "DB is on fire", "acme") + h.AddEntry("how bad?", "very", "acme") + h.UpdateAccountStatus(&AccountStatus{ + Timestamp: time.Now(), + OrganizationSlug: "acme", + ProjectCount: 5, + UnresolvedCount: 12, + ErrorCount24h: 300, + }) + if err := h.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + // File must land in ~/.clanker/sentry-acme.json. + path := filepath.Join(tmpHome, ".clanker", "sentry-acme.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected history file at %s: %v", path, err) + } + + loaded := NewConversationHistory("acme") + if err := loaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Entries) != 2 { + t.Errorf("entries = %d, want 2", len(loaded.Entries)) + } + if loaded.LastStatus == nil || loaded.LastStatus.UnresolvedCount != 12 { + t.Errorf("status not round-tripped: %+v", loaded.LastStatus) + } +} + +// TestConversationHistory_TruncateAnswer_InContext verifies that very long +// answers don't dominate the history context — important because the LLM +// prompt has limited space and the latest question should always lead. +func TestConversationHistory_TruncateAnswer_InContext(t *testing.T) { + h := NewConversationHistory("acme") + long := strings.Repeat("x", MaxAnswerLengthInContext*2) + h.AddEntry("q", long, "acme") + ctx := h.GetRecentContext(5) + if !strings.Contains(ctx, "...") { + t.Errorf("expected truncation marker in context") + } + if strings.Count(ctx, "x") > MaxAnswerLengthInContext+10 { + t.Errorf("answer not truncated: len contains %d x's", strings.Count(ctx, "x")) + } +} + +// TestConversationHistory_TrimsOldEntries confirms the rolling cap on +// history entries — otherwise a long-running operator session would grow +// the JSON file indefinitely. +func TestConversationHistory_TrimsOldEntries(t *testing.T) { + h := NewConversationHistory("acme") + for i := range MaxHistoryEntries + 5 { + h.AddEntry("q", "a", "acme") + _ = i + } + if len(h.Entries) != MaxHistoryEntries { + t.Errorf("entries = %d, want cap %d", len(h.Entries), MaxHistoryEntries) + } +} diff --git a/internal/sentry/events.go b/internal/sentry/events.go new file mode 100644 index 0000000..61f6c81 --- /dev/null +++ b/internal/sentry/events.go @@ -0,0 +1,46 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListProjectEvents returns recent events for a project (newest first). +// limit caps the page size (max 100 per Sentry's API). +func (c *Client) ListProjectEvents(ctx context.Context, orgSlug, projectSlug string, limit int) ([]Event, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" { + return nil, fmt.Errorf("org and project slug are required") + } + params := map[string]string{} + if limit > 0 { + params["limit"] = fmt.Sprintf("%d", limit) + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/events/%s", org, projectSlug, BuildQuery(params)), nil) + if err != nil { + return nil, err + } + var events []Event + if err := DecodeJSON(body, &events); err != nil { + return nil, err + } + return events, nil +} + +// GetEvent fetches a single event by eventID within a project. eventID is the +// 32-char hex string (not the numeric primary key). +func (c *Client) GetEvent(ctx context.Context, orgSlug, projectSlug, eventID string) (*Event, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" || eventID == "" { + return nil, fmt.Errorf("org, project slug, and event ID are required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/events/%s/", org, projectSlug, eventID), nil) + if err != nil { + return nil, err + } + var ev Event + if err := DecodeJSON(body, &ev); err != nil { + return nil, err + } + return &ev, nil +} diff --git a/internal/sentry/issues.go b/internal/sentry/issues.go new file mode 100644 index 0000000..407cba4 --- /dev/null +++ b/internal/sentry/issues.go @@ -0,0 +1,134 @@ +package sentry + +import ( + "context" + "fmt" + "strings" +) + +// IssueListOptions controls /organizations/{org}/issues/. Query is Sentry's +// search-syntax string (e.g. `is:unresolved level:error environment:prod`) +// and is passed verbatim — we do no client-side parsing. +type IssueListOptions struct { + Query string + Environment string + StatsPeriod string // e.g. "24h", "14d" + Project string // project ID (numeric string) — multiple allowed via repeated params; we keep single for simplicity + Sort string // "new" | "priority" | "freq" | "user" + Limit int + Cursor string +} + +// ListIssues fetches a page of issues. Pagination is exposed via the returned +// NextCursor — callers that want all pages can iterate. +func (c *Client) ListIssues(ctx context.Context, orgSlug string, opts IssueListOptions) ([]Issue, string, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, "", fmt.Errorf("org slug is required to list issues") + } + params := map[string]string{ + "query": opts.Query, + "environment": opts.Environment, + "statsPeriod": opts.StatsPeriod, + "project": opts.Project, + "sort": opts.Sort, + "cursor": opts.Cursor, + } + if opts.Limit > 0 { + params["limit"] = fmt.Sprintf("%d", opts.Limit) + } + resp, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/issues/%s", org, BuildQuery(params)), nil) + if err != nil { + return nil, "", err + } + var issues []Issue + if err := DecodeJSON(body, &issues); err != nil { + return nil, "", err + } + return issues, ParseNextCursor(resp), nil +} + +// GetIssue fetches a single issue by ID. +func (c *Client) GetIssue(ctx context.Context, issueID string) (*Issue, error) { + if issueID == "" { + return nil, fmt.Errorf("issue ID is required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/issues/%s/", issueID), nil) + if err != nil { + return nil, err + } + var issue Issue + if err := DecodeJSON(body, &issue); err != nil { + return nil, err + } + return &issue, nil +} + +// GetIssueEvents returns events for a given issue, newest first. Limit caps +// the number returned (Sentry's per-page max is 100). +func (c *Client) GetIssueEvents(ctx context.Context, issueID string, limit int) ([]Event, error) { + if issueID == "" { + return nil, fmt.Errorf("issue ID is required") + } + params := map[string]string{} + if limit > 0 { + params["limit"] = fmt.Sprintf("%d", limit) + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/issues/%s/events/%s", issueID, BuildQuery(params)), nil) + if err != nil { + return nil, err + } + var events []Event + if err := DecodeJSON(body, &events); err != nil { + return nil, err + } + return events, nil +} + +// IssueUpdate is the payload Sentry expects on PUT /organizations/{org}/issues/. +// Status is one of "resolved" | "unresolved" | "ignored"; AssignedTo is a +// username string (or "" to clear). +type IssueUpdate struct { + Status string `json:"status,omitempty"` + AssignedTo string `json:"assignedTo,omitempty"` +} + +// UpdateIssues bulk-mutates issues. IDs are passed as repeated `id=` query +// params; the body carries the new status/assignment. +func (c *Client) UpdateIssues(ctx context.Context, orgSlug string, ids []string, update IssueUpdate) error { + org := c.resolveOrg(orgSlug) + if org == "" { + return fmt.Errorf("org slug is required") + } + if len(ids) == 0 { + return fmt.Errorf("at least one issue ID is required") + } + // Sentry expects ?id=A&id=B&id=C — url.Values handles repeats. We can't + // use BuildQuery here because that flattens to a single value per key. + var qb strings.Builder + qb.WriteByte('?') + for i, id := range ids { + if i > 0 { + qb.WriteByte('&') + } + qb.WriteString("id=") + qb.WriteString(id) + } + _, _, err := c.Do(ctx, "PUT", fmt.Sprintf("/organizations/%s/issues/%s", org, qb.String()), update) + return err +} + +// ResolveIssues is a convenience wrapper. +func (c *Client) ResolveIssues(ctx context.Context, orgSlug string, ids []string) error { + return c.UpdateIssues(ctx, orgSlug, ids, IssueUpdate{Status: "resolved"}) +} + +// IgnoreIssues marks issues as ignored. +func (c *Client) IgnoreIssues(ctx context.Context, orgSlug string, ids []string) error { + return c.UpdateIssues(ctx, orgSlug, ids, IssueUpdate{Status: "ignored"}) +} + +// AssignIssue assigns a single issue to a username. +func (c *Client) AssignIssue(ctx context.Context, orgSlug, issueID, assignee string) error { + return c.UpdateIssues(ctx, orgSlug, []string{issueID}, IssueUpdate{AssignedTo: assignee}) +} diff --git a/internal/sentry/monitors.go b/internal/sentry/monitors.go new file mode 100644 index 0000000..70c21f1 --- /dev/null +++ b/internal/sentry/monitors.go @@ -0,0 +1,81 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListMonitors returns Sentry Crons monitors for an org. +func (c *Client) ListMonitors(ctx context.Context, orgSlug string) ([]Monitor, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, fmt.Errorf("org slug is required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/monitors/", org), nil) + if err != nil { + return nil, err + } + var monitors []Monitor + if err := DecodeJSON(body, &monitors); err != nil { + return nil, err + } + return monitors, nil +} + +// GetMonitor fetches a single monitor by slug. +func (c *Client) GetMonitor(ctx context.Context, orgSlug, monitorSlug string) (*Monitor, error) { + org := c.resolveOrg(orgSlug) + if org == "" || monitorSlug == "" { + return nil, fmt.Errorf("org slug and monitor slug are required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/monitors/%s/", org, monitorSlug), nil) + if err != nil { + return nil, err + } + var m Monitor + if err := DecodeJSON(body, &m); err != nil { + return nil, err + } + return &m, nil +} + +// GetMonitorCheckins returns recent check-ins for a monitor. +func (c *Client) GetMonitorCheckins(ctx context.Context, orgSlug, monitorSlug string, limit int) ([]MonitorCheckin, error) { + org := c.resolveOrg(orgSlug) + if org == "" || monitorSlug == "" { + return nil, fmt.Errorf("org slug and monitor slug are required") + } + params := map[string]string{} + if limit > 0 { + params["per_page"] = fmt.Sprintf("%d", limit) + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/monitors/%s/checkins/%s", org, monitorSlug, BuildQuery(params)), nil) + if err != nil { + return nil, err + } + var checkins []MonitorCheckin + if err := DecodeJSON(body, &checkins); err != nil { + return nil, err + } + return checkins, nil +} + +// MuteMonitor marks a monitor as muted (no alerts on missed check-ins). +func (c *Client) MuteMonitor(ctx context.Context, orgSlug, monitorSlug string) error { + return c.setMonitorMute(ctx, orgSlug, monitorSlug, true) +} + +// UnmuteMonitor restores alerting on a previously muted monitor. +func (c *Client) UnmuteMonitor(ctx context.Context, orgSlug, monitorSlug string) error { + return c.setMonitorMute(ctx, orgSlug, monitorSlug, false) +} + +func (c *Client) setMonitorMute(ctx context.Context, orgSlug, monitorSlug string, muted bool) error { + org := c.resolveOrg(orgSlug) + if org == "" || monitorSlug == "" { + return fmt.Errorf("org slug and monitor slug are required") + } + body := map[string]any{"isMuted": muted} + _, _, err := c.Do(ctx, "PUT", fmt.Sprintf("/organizations/%s/monitors/%s/", org, monitorSlug), body) + return err +} diff --git a/internal/sentry/orgs.go b/internal/sentry/orgs.go new file mode 100644 index 0000000..61904b9 --- /dev/null +++ b/internal/sentry/orgs.go @@ -0,0 +1,43 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListOrganizations returns the auth-token holder's accessible orgs. +// Paginates internally and accumulates all pages. +func (c *Client) ListOrganizations(ctx context.Context) ([]Organization, error) { + var all []Organization + path := "/organizations/" + for { + resp, body, err := c.Do(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + var page []Organization + if err := DecodeJSON(body, &page); err != nil { + return nil, err + } + all = append(all, page...) + next := ParseNextCursor(resp) + if next == "" { + break + } + path = fmt.Sprintf("/organizations/%s", BuildQuery(map[string]string{"cursor": next})) + } + return all, nil +} + +// GetOrganization fetches a single org by slug. +func (c *Client) GetOrganization(ctx context.Context, slug string) (*Organization, error) { + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/", slug), nil) + if err != nil { + return nil, err + } + var org Organization + if err := DecodeJSON(body, &org); err != nil { + return nil, err + } + return &org, nil +} diff --git a/internal/sentry/projects.go b/internal/sentry/projects.go new file mode 100644 index 0000000..4f4e79f --- /dev/null +++ b/internal/sentry/projects.go @@ -0,0 +1,80 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListProjects returns all projects in the client's org. orgSlug overrides +// the client's default when non-empty. +func (c *Client) ListProjects(ctx context.Context, orgSlug string) ([]Project, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, fmt.Errorf("org slug is required to list projects") + } + var all []Project + path := fmt.Sprintf("/organizations/%s/projects/", org) + for { + resp, body, err := c.Do(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + var page []Project + if err := DecodeJSON(body, &page); err != nil { + return nil, err + } + all = append(all, page...) + next := ParseNextCursor(resp) + if next == "" { + break + } + path = fmt.Sprintf("/organizations/%s/projects/%s", org, BuildQuery(map[string]string{"cursor": next})) + } + return all, nil +} + +// GetProject fetches a single project. +func (c *Client) GetProject(ctx context.Context, orgSlug, projectSlug string) (*Project, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" { + return nil, fmt.Errorf("org and project slug are required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/", org, projectSlug), nil) + if err != nil { + return nil, err + } + var p Project + if err := DecodeJSON(body, &p); err != nil { + return nil, err + } + return &p, nil +} + +// GetProjectStats returns event-volume buckets for a project. `stat` is +// typically "received" or "rejected"; `resolution` is "10s" | "1h" | "1d". +func (c *Client) GetProjectStats(ctx context.Context, orgSlug, projectSlug, stat, resolution string) ([]ProjectStatsPoint, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" { + return nil, fmt.Errorf("org and project slug are required") + } + q := BuildQuery(map[string]string{ + "stat": stat, + "resolution": resolution, + }) + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/stats/%s", org, projectSlug, q), nil) + if err != nil { + return nil, err + } + var points []ProjectStatsPoint + if err := DecodeJSON(body, &points); err != nil { + return nil, err + } + return points, nil +} + +func (c *Client) resolveOrg(override string) string { + if override != "" { + return override + } + return c.orgSlug +} diff --git a/internal/sentry/releases.go b/internal/sentry/releases.go new file mode 100644 index 0000000..9a6ba67 --- /dev/null +++ b/internal/sentry/releases.go @@ -0,0 +1,65 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListReleases returns recent releases for a project (newest first). +func (c *Client) ListReleases(ctx context.Context, orgSlug, projectSlug string) ([]Release, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" { + return nil, fmt.Errorf("org and project slug are required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/releases/", org, projectSlug), nil) + if err != nil { + return nil, err + } + var releases []Release + if err := DecodeJSON(body, &releases); err != nil { + return nil, err + } + return releases, nil +} + +// GetRelease fetches a single release by version. +func (c *Client) GetRelease(ctx context.Context, orgSlug, projectSlug, version string) (*Release, error) { + org := c.resolveOrg(orgSlug) + if org == "" || projectSlug == "" || version == "" { + return nil, fmt.Errorf("org, project slug, and version are required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%s/%s/releases/%s/", org, projectSlug, version), nil) + if err != nil { + return nil, err + } + var r Release + if err := DecodeJSON(body, &r); err != nil { + return nil, err + } + return &r, nil +} + +// GetReleaseHealth returns crash-free session/user metrics for a release. +// Uses /organizations/{org}/sessions/ with the v2 sessions endpoint. +func (c *Client) GetReleaseHealth(ctx context.Context, orgSlug, projectSlug, version string) (*SessionsResponse, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, fmt.Errorf("org slug is required") + } + params := map[string]string{ + "field": "sum(session)", + "groupBy": "session.status", + "statsPeriod": "24h", + "project": projectSlug, + "query": fmt.Sprintf("release:%s", version), + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/sessions/%s", org, BuildQuery(params)), nil) + if err != nil { + return nil, err + } + var r SessionsResponse + if err := DecodeJSON(body, &r); err != nil { + return nil, err + } + return &r, nil +} diff --git a/internal/sentry/static_commands.go b/internal/sentry/static_commands.go new file mode 100644 index 0000000..708dfc4 --- /dev/null +++ b/internal/sentry/static_commands.go @@ -0,0 +1,620 @@ +package sentry + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CreateSentryCommands builds the `clanker sentry` command tree. The ask +// subcommand is added separately by cmd/sentry.go (so cmd/ keeps its +// dependency on internal/ai out of this package). +func CreateSentryCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "sentry", + Short: "Query Sentry issues, events, releases, alerts, and monitors", + Long: "Query Sentry directly without LLM interpretation. Useful for scripting and CI hooks.", + Aliases: []string{"sn"}, + } + + cmd.PersistentFlags().String("org", "", "Sentry org slug (overrides config)") + cmd.PersistentFlags().String("project", "", "Sentry project slug (overrides config)") + cmd.PersistentFlags().String("host", "", "Sentry host (default sentry.io)") + cmd.PersistentFlags().String("auth-token", "", "Sentry User Auth Token") + cmd.PersistentFlags().String("format", "table", "Output format: table | json") + + cmd.AddCommand(buildListCommand()) + cmd.AddCommand(buildGetCommand()) + cmd.AddCommand(buildResolveCommand()) + cmd.AddCommand(buildIgnoreCommand()) + cmd.AddCommand(buildAssignCommand()) + cmd.AddCommand(buildMonitorCommand()) + cmd.AddCommand(buildAlertCommand()) + + return cmd +} + +func buildListCommand() *cobra.Command { + listCmd := &cobra.Command{ + Use: "list ", + Short: "List Sentry resources", + Long: `List Sentry resources of a specific type. + +Supported resources: + orgs - Organizations the auth token can access + projects - Projects within an org (needs --org) + issues - Issues, with optional --query / --environment / --period + events - Recent events for a project (needs --project) + releases - Releases for a project (needs --project) + alerts - Issue alert rules for a project (needs --project) + monitors - Sentry Crons monitors in an org + teams - Teams in an org + members - Members in an org`, + Args: cobra.ExactArgs(1), + RunE: runList, + } + listCmd.Flags().String("query", "", "Sentry search query (passed through verbatim: e.g. 'is:unresolved level:error')") + listCmd.Flags().String("environment", "", "Filter by environment") + listCmd.Flags().String("period", "14d", "Stats period (24h, 7d, 14d, 30d, 90d)") + listCmd.Flags().Int("limit", 0, "Maximum rows to return") + listCmd.Flags().Bool("unresolved", false, "Shortcut for --query='is:unresolved'") + return listCmd +} + +func buildGetCommand() *cobra.Command { + getCmd := &cobra.Command{ + Use: "get ", + Short: "Get a single Sentry resource", + Long: `Get a single Sentry resource by ID. + +Examples: + clanker sentry get issue ABC-123 + clanker sentry get event --project backend + clanker sentry get release v1.2.3 --project backend`, + Args: cobra.ExactArgs(2), + RunE: runGet, + } + return getCmd +} + +func buildResolveCommand() *cobra.Command { + return &cobra.Command{ + Use: "resolve [issue-id...]", + Short: "Mark one or more issues as resolved", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.ResolveIssues(ctx, org, args); err != nil { + return err + } + fmt.Printf("Resolved %d issue(s)\n", len(args)) + return nil + }, + } +} + +func buildIgnoreCommand() *cobra.Command { + return &cobra.Command{ + Use: "ignore [issue-id...]", + Short: "Mark one or more issues as ignored", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.IgnoreIssues(ctx, org, args); err != nil { + return err + } + fmt.Printf("Ignored %d issue(s)\n", len(args)) + return nil + }, + } +} + +func buildAssignCommand() *cobra.Command { + return &cobra.Command{ + Use: "assign ", + Short: "Assign an issue to a user", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.AssignIssue(ctx, org, args[0], args[1]); err != nil { + return err + } + fmt.Printf("Assigned issue %s to %s\n", args[0], args[1]) + return nil + }, + } +} + +func buildMonitorCommand() *cobra.Command { + monCmd := &cobra.Command{ + Use: "monitor", + Short: "Manage Sentry Crons monitors", + } + monCmd.AddCommand(&cobra.Command{ + Use: "mute ", + Short: "Mute alerts for a monitor", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.MuteMonitor(ctx, org, args[0]); err != nil { + return err + } + fmt.Printf("Muted monitor %s\n", args[0]) + return nil + }, + }) + monCmd.AddCommand(&cobra.Command{ + Use: "unmute ", + Short: "Unmute a previously-muted monitor", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.UnmuteMonitor(ctx, org, args[0]); err != nil { + return err + } + fmt.Printf("Unmuted monitor %s\n", args[0]) + return nil + }, + }) + monCmd.AddCommand(&cobra.Command{ + Use: "checkins ", + Short: "Show recent check-ins for a monitor", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + checkins, err := client.GetMonitorCheckins(ctx, org, args[0], 20) + if err != nil { + return err + } + format, _ := cmd.Flags().GetString("format") + if format == "" { + format, _ = cmd.Root().PersistentFlags().GetString("format") + } + return renderCheckins(checkins, format) + }, + }) + return monCmd +} + +func buildAlertCommand() *cobra.Command { + alertCmd := &cobra.Command{ + Use: "alert", + Short: "Manage Sentry alert rules", + } + alertCmd.AddCommand(&cobra.Command{ + Use: "delete ", + Short: "Delete an issue alert rule (needs --project)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, org, err := mustClient(cmd) + if err != nil { + return err + } + project, _ := cmd.Root().PersistentFlags().GetString("project") + if project == "" { + project = ResolveDefaultProject() + } + if project == "" { + return fmt.Errorf("--project is required to delete alert rules") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.DeleteIssueAlertRule(ctx, org, project, args[0]); err != nil { + return err + } + fmt.Printf("Deleted alert rule %s\n", args[0]) + return nil + }, + }) + return alertCmd +} + +// mustClient resolves credentials + flags into a ready Client, returning the +// effective org slug separately so callers don't have to re-read flags. +func mustClient(cmd *cobra.Command) (*Client, string, error) { + authToken, _ := cmd.Root().PersistentFlags().GetString("auth-token") + if authToken == "" { + authToken = ResolveAuthToken() + } + if authToken == "" { + return nil, "", fmt.Errorf("sentry auth_token is required (set sentry.auth_token, SENTRY_AUTH_TOKEN, or --auth-token)") + } + + org, _ := cmd.Root().PersistentFlags().GetString("org") + if org == "" { + org = ResolveOrgSlug() + } + + host, _ := cmd.Root().PersistentFlags().GetString("host") + if host == "" { + host = ResolveHost() + } + + debug := viper.GetBool("debug") + client, err := NewClient(authToken, org, host, debug) + if err != nil { + return nil, "", err + } + return client, org, nil +} + +func runList(cmd *cobra.Command, args []string) error { + resource := strings.ToLower(args[0]) + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + format, _ := cmd.Root().PersistentFlags().GetString("format") + project, _ := cmd.Root().PersistentFlags().GetString("project") + if project == "" { + project = ResolveDefaultProject() + } + + switch resource { + case "orgs", "organizations": + items, err := client.ListOrganizations(ctx) + if err != nil { + return err + } + return renderOrgs(items, format) + + case "projects": + if org == "" { + return fmt.Errorf("--org or sentry.org_slug is required") + } + items, err := client.ListProjects(ctx, org) + if err != nil { + return err + } + return renderProjects(items, format) + + case "issues": + query, _ := cmd.Flags().GetString("query") + env, _ := cmd.Flags().GetString("environment") + period, _ := cmd.Flags().GetString("period") + limit, _ := cmd.Flags().GetInt("limit") + unresolved, _ := cmd.Flags().GetBool("unresolved") + if unresolved && query == "" { + query = "is:unresolved" + } + items, _, err := client.ListIssues(ctx, org, IssueListOptions{ + Query: query, + Environment: env, + StatsPeriod: period, + Limit: limit, + }) + if err != nil { + return err + } + return renderIssues(items, format) + + case "events": + if project == "" { + return fmt.Errorf("--project is required to list events") + } + limit, _ := cmd.Flags().GetInt("limit") + items, err := client.ListProjectEvents(ctx, org, project, limit) + if err != nil { + return err + } + return renderEvents(items, format) + + case "releases": + if project == "" { + return fmt.Errorf("--project is required to list releases") + } + items, err := client.ListReleases(ctx, org, project) + if err != nil { + return err + } + return renderReleases(items, format) + + case "alerts", "alert-rules": + if project == "" { + return fmt.Errorf("--project is required to list issue alert rules") + } + issueRules, err := client.ListIssueAlertRules(ctx, org, project) + if err != nil { + return err + } + return renderIssueAlertRules(issueRules, format) + + case "metric-alerts": + rules, err := client.ListMetricAlertRules(ctx, org) + if err != nil { + return err + } + return renderMetricAlertRules(rules, format) + + case "monitors": + items, err := client.ListMonitors(ctx, org) + if err != nil { + return err + } + return renderMonitors(items, format) + + case "teams": + items, err := client.ListTeams(ctx, org) + if err != nil { + return err + } + return renderTeams(items, format) + + case "members": + items, err := client.ListMembers(ctx, org) + if err != nil { + return err + } + return renderMembers(items, format) + + default: + return fmt.Errorf("unknown resource: %s (try orgs|projects|issues|events|releases|alerts|monitors|teams|members)", resource) + } +} + +func runGet(cmd *cobra.Command, args []string) error { + resource := strings.ToLower(args[0]) + id := args[1] + client, org, err := mustClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + format, _ := cmd.Root().PersistentFlags().GetString("format") + project, _ := cmd.Root().PersistentFlags().GetString("project") + if project == "" { + project = ResolveDefaultProject() + } + + switch resource { + case "issue": + issue, err := client.GetIssue(ctx, id) + if err != nil { + return err + } + return renderJSON(issue, format) + case "event": + if project == "" { + return fmt.Errorf("--project is required to fetch an event") + } + ev, err := client.GetEvent(ctx, org, project, id) + if err != nil { + return err + } + return renderJSON(ev, format) + case "release": + if project == "" { + return fmt.Errorf("--project is required to fetch a release") + } + rel, err := client.GetRelease(ctx, org, project, id) + if err != nil { + return err + } + return renderJSON(rel, format) + case "monitor": + m, err := client.GetMonitor(ctx, org, id) + if err != nil { + return err + } + return renderJSON(m, format) + case "org", "organization": + o, err := client.GetOrganization(ctx, id) + if err != nil { + return err + } + return renderJSON(o, format) + default: + return fmt.Errorf("unknown resource: %s (try issue|event|release|monitor|org)", resource) + } +} + +// renderers ----------------------------------------------------------------- + +func renderJSON(v any, format string) error { + if format == "" { + format = "table" + } + // "get" only ever returns a single object so a JSON dump is always the + // most useful output regardless of `format`. + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func newTabwriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) +} + +func renderOrgs(orgs []Organization, format string) error { + if format == "json" { + return renderJSON(orgs, format) + } + w := newTabwriter() + fmt.Fprintln(w, "SLUG\tNAME\tCREATED") + for _, o := range orgs { + fmt.Fprintf(w, "%s\t%s\t%s\n", o.Slug, o.Name, o.DateCreated.Format("2006-01-02")) + } + return w.Flush() +} + +func renderProjects(projects []Project, format string) error { + if format == "json" { + return renderJSON(projects, format) + } + w := newTabwriter() + fmt.Fprintln(w, "SLUG\tNAME\tPLATFORM") + for _, p := range projects { + fmt.Fprintf(w, "%s\t%s\t%s\n", p.Slug, p.Name, p.Platform) + } + return w.Flush() +} + +func renderIssues(issues []Issue, format string) error { + if format == "json" { + return renderJSON(issues, format) + } + w := newTabwriter() + fmt.Fprintln(w, "SHORT-ID\tLEVEL\tSTATUS\tCOUNT\tUSERS\tTITLE\tLAST-SEEN") + for _, i := range issues { + title := i.Title + if len(title) > 60 { + title = title[:57] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", + i.ShortID, i.Level, i.Status, i.Count, i.UserCount, title, i.LastSeen.Format("2006-01-02 15:04")) + } + return w.Flush() +} + +func renderEvents(events []Event, format string) error { + if format == "json" { + return renderJSON(events, format) + } + w := newTabwriter() + fmt.Fprintln(w, "EVENT-ID\tTITLE\tCREATED") + for _, e := range events { + title := e.Title + if len(title) > 60 { + title = title[:57] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\n", e.EventID, title, e.DateCreated.Format("2006-01-02 15:04")) + } + return w.Flush() +} + +func renderReleases(releases []Release, format string) error { + if format == "json" { + return renderJSON(releases, format) + } + w := newTabwriter() + fmt.Fprintln(w, "VERSION\tNEW-GROUPS\tCREATED\tRELEASED") + for _, r := range releases { + released := "—" + if r.DateReleased != nil && !r.DateReleased.IsZero() { + released = r.DateReleased.Format("2006-01-02") + } + fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", r.ShortVersion, r.NewGroups, r.DateCreated.Format("2006-01-02"), released) + } + return w.Flush() +} + +func renderIssueAlertRules(rules []IssueAlertRule, format string) error { + if format == "json" { + return renderJSON(rules, format) + } + w := newTabwriter() + fmt.Fprintln(w, "ID\tNAME\tENV\tFREQUENCY\tCREATED") + for _, r := range rules { + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", r.ID, r.Name, r.Environment, r.Frequency, r.DateCreated.Format("2006-01-02")) + } + return w.Flush() +} + +func renderMetricAlertRules(rules []MetricAlertRule, format string) error { + if format == "json" { + return renderJSON(rules, format) + } + w := newTabwriter() + fmt.Fprintln(w, "ID\tNAME\tQUERY\tAGGREGATE\tTHRESHOLD") + for _, r := range rules { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.2f\n", r.ID, r.Name, r.Query, r.Aggregate, r.Threshold) + } + return w.Flush() +} + +func renderMonitors(monitors []Monitor, format string) error { + if format == "json" { + return renderJSON(monitors, format) + } + w := newTabwriter() + fmt.Fprintln(w, "SLUG\tNAME\tSTATUS\tMUTED\tTYPE") + for _, m := range monitors { + fmt.Fprintf(w, "%s\t%s\t%s\t%v\t%s\n", m.Slug, m.Name, m.Status, m.IsMuted, m.Type) + } + return w.Flush() +} + +func renderCheckins(checkins []MonitorCheckin, format string) error { + if format == "json" { + return renderJSON(checkins, format) + } + w := newTabwriter() + fmt.Fprintln(w, "ID\tSTATUS\tDURATION-MS\tCREATED") + for _, c := range checkins { + dur := "—" + if c.Duration != nil { + dur = fmt.Sprintf("%.0f", *c.Duration) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.ID, c.Status, dur, c.DateCreated.Format("2006-01-02 15:04")) + } + return w.Flush() +} + +func renderTeams(teams []Team, format string) error { + if format == "json" { + return renderJSON(teams, format) + } + w := newTabwriter() + fmt.Fprintln(w, "SLUG\tNAME\tMEMBERS\tCREATED") + for _, t := range teams { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", t.Slug, t.Name, t.MemberCount, t.DateCreated.Format("2006-01-02")) + } + return w.Flush() +} + +func renderMembers(members []Member, format string) error { + if format == "json" { + return renderJSON(members, format) + } + w := newTabwriter() + fmt.Fprintln(w, "EMAIL\tNAME\tROLE") + for _, m := range members { + fmt.Fprintf(w, "%s\t%s\t%s\n", m.Email, m.Name, m.Role) + } + return w.Flush() +} diff --git a/internal/sentry/status.go b/internal/sentry/status.go new file mode 100644 index 0000000..10cc99e --- /dev/null +++ b/internal/sentry/status.go @@ -0,0 +1,41 @@ +package sentry + +import ( + "context" + "time" +) + +// GatherAccountStatus collects an at-a-glance snapshot for the conversation +// history. Errors are non-fatal — we degrade gracefully because the ask +// command should never fail just because one secondary fetch broke. +func GatherAccountStatus(ctx context.Context, c *Client, orgSlug string) (*AccountStatus, error) { + status := &AccountStatus{ + Timestamp: time.Now(), + OrganizationSlug: orgSlug, + } + + projects, err := c.ListProjects(ctx, orgSlug) + if err == nil { + status.ProjectCount = len(projects) + } + + unresolved, _, err := c.ListIssues(ctx, orgSlug, IssueListOptions{ + Query: "is:unresolved", + StatsPeriod: "24h", + Limit: 100, + }) + if err == nil { + status.UnresolvedCount = len(unresolved) + } + + errors24h, _, err := c.ListIssues(ctx, orgSlug, IssueListOptions{ + Query: "level:error", + StatsPeriod: "24h", + Limit: 100, + }) + if err == nil { + status.ErrorCount24h = len(errors24h) + } + + return status, nil +} diff --git a/internal/sentry/teams.go b/internal/sentry/teams.go new file mode 100644 index 0000000..a86fdee --- /dev/null +++ b/internal/sentry/teams.go @@ -0,0 +1,40 @@ +package sentry + +import ( + "context" + "fmt" +) + +// ListTeams returns teams in an org. +func (c *Client) ListTeams(ctx context.Context, orgSlug string) ([]Team, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, fmt.Errorf("org slug is required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/teams/", org), nil) + if err != nil { + return nil, err + } + var teams []Team + if err := DecodeJSON(body, &teams); err != nil { + return nil, err + } + return teams, nil +} + +// ListMembers returns members in an org. +func (c *Client) ListMembers(ctx context.Context, orgSlug string) ([]Member, error) { + org := c.resolveOrg(orgSlug) + if org == "" { + return nil, fmt.Errorf("org slug is required") + } + _, body, err := c.Do(ctx, "GET", fmt.Sprintf("/organizations/%s/members/", org), nil) + if err != nil { + return nil, err + } + var members []Member + if err := DecodeJSON(body, &members); err != nil { + return nil, err + } + return members, nil +} diff --git a/internal/sentry/types.go b/internal/sentry/types.go new file mode 100644 index 0000000..1255615 --- /dev/null +++ b/internal/sentry/types.go @@ -0,0 +1,227 @@ +package sentry + +import ( + "encoding/json" + "time" +) + +// Sentry response shapes use camelCase JSON and ISO-8601 timestamp strings. +// IDs are always returned as strings even when numeric — never unmarshal to int. +// See https://docs.sentry.io/api/ for the canonical reference. + +type Organization struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + DateCreated time.Time `json:"dateCreated"` +} + +type Project struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Platform string `json:"platform"` + DateCreated time.Time `json:"dateCreated"` + IsBookmarked bool `json:"isBookmarked"` + Organization *Organization `json:"organization,omitempty"` +} + +type Issue struct { + ID string `json:"id"` + ShortID string `json:"shortId"` + Title string `json:"title"` + Culprit string `json:"culprit"` + Permalink string `json:"permalink"` + Logger string `json:"logger"` + Level string `json:"level"` + Status string `json:"status"` + StatusDetails json.RawMessage `json:"statusDetails"` + IsPublic bool `json:"isPublic"` + Platform string `json:"platform"` + Project *Project `json:"project,omitempty"` + Type string `json:"type"` + Metadata map[string]any `json:"metadata"` + NumComments int `json:"numComments"` + AssignedTo json.RawMessage `json:"assignedTo"` + IsBookmarked bool `json:"isBookmarked"` + IsSubscribed bool `json:"isSubscribed"` + HasSeen bool `json:"hasSeen"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Count string `json:"count"` + UserCount int `json:"userCount"` +} + +type Tag struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// Event entries are a polymorphic array — each entry has a `type` field +// (exception, breadcrumbs, request, message, ...) and a `data` payload whose +// shape depends on the type. Callers that need to introspect should dispatch +// on Type and unmarshal Data with the appropriate concrete struct. +type EventEntry struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +type Event struct { + ID string `json:"id"` + EventID string `json:"eventID"` + GroupID string `json:"groupID"` + ProjectID string `json:"projectID"` + Title string `json:"title"` + Message string `json:"message"` + Platform string `json:"platform"` + Type string `json:"type"` + DateCreated time.Time `json:"dateCreated"` + DateReceived time.Time `json:"dateReceived"` + Tags []Tag `json:"tags"` + User json.RawMessage `json:"user"` + Entries []EventEntry `json:"entries"` + Contexts map[string]json.RawMessage `json:"contexts"` +} + +type Release struct { + Version string `json:"version"` + ShortVersion string `json:"shortVersion"` + Ref string `json:"ref"` + URL string `json:"url"` + DateCreated time.Time `json:"dateCreated"` + DateReleased *time.Time `json:"dateReleased"` + NewGroups int `json:"newGroups"` + Projects []struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"projects"` +} + +// SessionGroup carries release-health rollups returned by +// /organizations/{org}/sessions/. The shape is a tuple-array under `groups`; +// caller selects fields via the `field=` query (e.g. `sum(session)`). +type SessionGroup struct { + By map[string]string `json:"by"` + Totals map[string]float64 `json:"totals"` + Series map[string][]float64 `json:"series"` +} + +type SessionsResponse struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Intervals []time.Time `json:"intervals"` + Groups []SessionGroup `json:"groups"` +} + +// IssueAlertRule is a Sentry issue alert rule (the legacy /rules/ endpoint). +// MetricAlertRule (the newer /alert-rules/ endpoint) has a different shape. +type IssueAlertRule struct { + ID string `json:"id"` + Name string `json:"name"` + Environment string `json:"environment"` + Frequency int `json:"frequency"` + ActionMatch string `json:"actionMatch"` + FilterMatch string `json:"filterMatch"` + Conditions []json.RawMessage `json:"conditions"` + Filters []json.RawMessage `json:"filters"` + Actions []json.RawMessage `json:"actions"` + DateCreated time.Time `json:"dateCreated"` + CreatedBy json.RawMessage `json:"createdBy"` +} + +type MetricAlertRule struct { + ID string `json:"id"` + Name string `json:"name"` + Environment string `json:"environment"` + DataSet string `json:"dataset"` + Query string `json:"query"` + Aggregate string `json:"aggregate"` + TimeWindow float64 `json:"timeWindow"` + Threshold float64 `json:"threshold"` + Triggers []json.RawMessage `json:"triggers"` + Projects []string `json:"projects"` + DateCreated time.Time `json:"dateCreated"` +} + +type Monitor struct { + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + Type string `json:"type"` + IsMuted bool `json:"isMuted"` + Config json.RawMessage `json:"config"` + Project struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"project"` + DateCreated time.Time `json:"dateCreated"` +} + +type MonitorCheckin struct { + ID string `json:"id"` + Status string `json:"status"` + Duration *float64 `json:"duration"` + DateCreated time.Time `json:"dateCreated"` + Attachment json.RawMessage `json:"attachment"` +} + +type Team struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + IsMember bool `json:"isMember"` + MemberCount int `json:"memberCount"` + DateCreated time.Time `json:"dateCreated"` +} + +type Member struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + User json.RawMessage `json:"user"` +} + +// ProjectStatsPoint is one bucket of `/projects/{org}/{project}/stats/`. +// The endpoint returns tuples of [unix_seconds, count]. +type ProjectStatsPoint struct { + Timestamp int64 + Count int64 +} + +// UnmarshalJSON handles the tuple shape `[, ]`. +func (p *ProjectStatsPoint) UnmarshalJSON(data []byte) error { + var tuple [2]json.Number + if err := json.Unmarshal(data, &tuple); err != nil { + return err + } + ts, err := tuple[0].Int64() + if err != nil { + return err + } + count, err := tuple[1].Int64() + if err != nil { + return err + } + p.Timestamp = ts + p.Count = count + return nil +} + +// MarshalJSON keeps round-trip parity with the upstream tuple format so +// tests that re-encode a fixture and diff against the input don't drift. +func (p ProjectStatsPoint) MarshalJSON() ([]byte, error) { + return json.Marshal([2]int64{p.Timestamp, p.Count}) +} + +// AccountStatus is the at-a-glance snapshot the ask command stashes in +// conversation history so follow-up questions can be answered with +// orientation context (project count, recent error volume) without +// re-fetching everything. +type AccountStatus struct { + Timestamp time.Time `json:"timestamp"` + OrganizationSlug string `json:"organization_slug"` + ProjectCount int `json:"project_count"` + UnresolvedCount int `json:"unresolved_count"` + ErrorCount24h int `json:"error_count_24h"` +} From 02605a1e3aaad3aa74379218ff0454a0466b1a23 Mon Sep 17 00:00:00 2001 From: nash Date: Mon, 1 Jun 2026 13:18:52 +0500 Subject: [PATCH 2/4] =?UTF-8?q?fix(sentry):=20review=20pass=20=E2=80=94=20?= =?UTF-8?q?escape=20ids,=20sanitise=20slug,=20fix=20flag=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from a fresh-eyes review of the Sentry CLI: - cmd/sentry.go: rename local `context` so it stops shadowing the imported context package — compiles today but trips up any future context.WithTimeout call placed below it. - internal/sentry/issues.go: URL-escape ids in the ?id=A&id=B query of UpdateIssues via url.Values{}.Add. An id containing & or = (or a hostile MCP caller payload) could otherwise inject a status= param into the PUT. - internal/sentry/conversation.go: introduce safeSlug() to strip anything outside [A-Za-z0-9_-] from the org slug before building ~/.clanker/sentry-{slug}.json — closes a path-traversal hole where `../../etc/passwd` would write outside ~/.clanker because filepath.Join cleans `..` segments. - internal/sentry/static_commands.go: introduce sentryFlag() helper that reads via cmd.Flags() so persistent flags registered on the `sentry` command (not on rootCmd) are reachable from leaf subcommands three levels deep. Replaces every cmd.Root().PersistentFlags() call that silently returned "" at depth. - renderJSON no longer pretends to honour --format — comment clarifies intent and `format` parameter is renamed to `_` to make it obvious to readers. Test coverage: - TestUpdateIssues_EscapesMaliciousID exercises an id containing `&status=resolved`, asserting it round-trips as a single escaped id and does NOT smuggle a second query parameter. - TestSafeSlug_BlocksPathTraversal covers `../`, leading `/`, empty string, and dots-only inputs. Docs: updated the Sentry scope recommendation in .clanker.example.yaml to drop `project:releases` (an internal-integration-only scope) in favour of `project:write`, which is the canonical User Auth Token scope for release management. --- .clanker.example.yaml | 5 +-- cmd/sentry.go | 7 ++-- internal/sentry/client_test.go | 35 ++++++++++++++++++++ internal/sentry/conversation.go | 29 +++++++++++++--- internal/sentry/conversation_test.go | 28 ++++++++++++++++ internal/sentry/issues.go | 21 +++++------- internal/sentry/static_commands.go | 49 +++++++++++++++++----------- 7 files changed, 134 insertions(+), 40 deletions(-) diff --git a/.clanker.example.yaml b/.clanker.example.yaml index 40fba97..d1cb5a8 100644 --- a/.clanker.example.yaml +++ b/.clanker.example.yaml @@ -196,8 +196,9 @@ infra: # sentry: # auth_token: "" # Sentry User Auth Token (or set SENTRY_AUTH_TOKEN) # # Generate at https://sentry.io/settings/account/api/auth-tokens/ -# # Recommended scopes: org:read, project:read, event:read, event:admin, -# # alerts:read, alerts:write, project:releases +# # Recommended scopes: org:read, project:read, project:write, +# # event:read, event:admin, alerts:read, alerts:write +# # project:write covers release management and alert rule mutations. # org_slug: "" # Sentry org slug (or set SENTRY_ORG) # default_project: "" # Optional default project slug for events/releases/alerts # # (or set SENTRY_PROJECT) diff --git a/cmd/sentry.go b/cmd/sentry.go index 58b7ec9..a17bf84 100644 --- a/cmd/sentry.go +++ b/cmd/sentry.go @@ -108,12 +108,15 @@ func runSentryAsk(cmd *cobra.Command, args []string) error { fmt.Printf("[debug] gather status: %v\n", err) } - context, err := gatherSentryContext(ctx, client, question, project, sentryAskEnvironment, debug) + // Renamed from `context` because that local was shadowing the imported + // context package — a foot-gun for any future edit that needs to call + // context.WithTimeout / context.Background below this line. + dataContext, err := gatherSentryContext(ctx, client, question, project, sentryAskEnvironment, debug) if err != nil && debug { fmt.Printf("[debug] gather context: %v\n", err) } - prompt := buildSentryPrompt(question, context, history.GetRecentContext(5), history.GetAccountStatusContext()) + prompt := buildSentryPrompt(question, dataContext, history.GetRecentContext(5), history.GetAccountStatusContext()) aiProfile := sentryAskAIProfile if aiProfile == "" { diff --git a/internal/sentry/client_test.go b/internal/sentry/client_test.go index 1dc3cbc..1cdf343 100644 --- a/internal/sentry/client_test.go +++ b/internal/sentry/client_test.go @@ -198,6 +198,10 @@ func TestUpdateIssues_RepeatedIDQuery(t *testing.T) { t.Errorf("expected PUT, got %s", r.Method) } // Sentry expects ?id=A&id=B (repeated keys) not ?id=A,B. + // http.Request.URL.Query() already URL-decodes values, so a + // well-escaped client should produce three distinct ids even when + // one of them contains characters that would otherwise inject a + // new query parameter. ids := r.URL.Query()["id"] if len(ids) != 3 { t.Errorf("expected 3 ?id= params, got %d (%v)", len(ids), ids) @@ -221,6 +225,37 @@ func TestUpdateIssues_RepeatedIDQuery(t *testing.T) { } } +// TestUpdateIssues_EscapesMaliciousID confirms that an ID containing query +// metacharacters (& = #) is properly escaped, so it can't smuggle a new +// query parameter into the PUT — e.g. `1&status=resolved` must not arrive +// at Sentry as two separate ids plus an injected status. +func TestUpdateIssues_EscapesMaliciousID(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + ids := q["id"] + if len(ids) != 1 { + t.Errorf("expected exactly 1 ?id= param after escape, got %d (%v)", len(ids), ids) + } + if ids[0] != "1&status=resolved" { + t.Errorf("id should round-trip with escape, got %q", ids[0]) + } + // And we should NOT see an injected status= in the query. + if got := q.Get("status"); got != "" { + t.Errorf("status query param should be absent (only in body), got %q", got) + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{}`) + })) + defer ts.Close() + + c := newTestClient(t, ts) + // `1&status=resolved` is the canonical attack: without escaping it + // would split into id=1 + status=resolved at the server. + if err := c.IgnoreIssues(context.Background(), "", []string{"1&status=resolved"}); err != nil { + t.Fatalf("IgnoreIssues: %v", err) + } +} + func TestProjectStatsPoint_UnmarshalJSON(t *testing.T) { var pts []ProjectStatsPoint if err := json.Unmarshal([]byte(`[[1700000000, 42], [1700003600, 17]]`), &pts); err != nil { diff --git a/internal/sentry/conversation.go b/internal/sentry/conversation.go index e7f4040..5b86a6d 100644 --- a/internal/sentry/conversation.go +++ b/internal/sentry/conversation.go @@ -106,6 +106,29 @@ func (h *ConversationHistory) GetAccountStatusContext() string { ) } +// safeSlug strips anything that isn't lowercase a-z, digit, dash, or +// underscore — Sentry org slugs follow `[a-z0-9-]+` so this matches the +// upstream contract. Without this, an MCP caller passing +// orgSlug="../../etc/passwd" would coerce filepath.Join into resolving +// the `..` segments and writing outside ~/.clanker. +func safeSlug(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', + c >= 'A' && c <= 'Z', + c >= '0' && c <= '9', + c == '-' || c == '_': + out = append(out, c) + } + } + if len(out) == 0 { + return "default" + } + return string(out) +} + func historyPath(orgSlug string) (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -115,11 +138,7 @@ func historyPath(orgSlug string) (string, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } - slug := orgSlug - if slug == "" { - slug = "default" - } - return filepath.Join(dir, fmt.Sprintf("sentry-%s.json", slug)), nil + return filepath.Join(dir, fmt.Sprintf("sentry-%s.json", safeSlug(orgSlug))), nil } func (h *ConversationHistory) Load() error { diff --git a/internal/sentry/conversation_test.go b/internal/sentry/conversation_test.go index 0975474..0f90c12 100644 --- a/internal/sentry/conversation_test.go +++ b/internal/sentry/conversation_test.go @@ -73,3 +73,31 @@ func TestConversationHistory_TrimsOldEntries(t *testing.T) { t.Errorf("entries = %d, want cap %d", len(h.Entries), MaxHistoryEntries) } } + +// TestSafeSlug_BlocksPathTraversal confirms the slug sanitiser strips path +// separators so a hostile org slug from an MCP caller can't escape the +// ~/.clanker directory. The bug existed because filepath.Join cleans `..` +// segments, so `../../etc/passwd` would resolve outside the intended dir. +func TestSafeSlug_BlocksPathTraversal(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"acme", "acme"}, + {"my-org_42", "my-org_42"}, + {"../../etc/passwd", "etcpasswd"}, + {"/absolute/path", "absolutepath"}, + {"", "default"}, + {"...", "default"}, + {"acme/../etc", "acmeetc"}, + } + for _, c := range cases { + c := c + t.Run(c.in, func(t *testing.T) { + got := safeSlug(c.in) + if got != c.want { + t.Errorf("safeSlug(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} diff --git a/internal/sentry/issues.go b/internal/sentry/issues.go index 407cba4..a67d825 100644 --- a/internal/sentry/issues.go +++ b/internal/sentry/issues.go @@ -3,7 +3,7 @@ package sentry import ( "context" "fmt" - "strings" + "net/url" ) // IssueListOptions controls /organizations/{org}/issues/. Query is Sentry's @@ -103,18 +103,15 @@ func (c *Client) UpdateIssues(ctx context.Context, orgSlug string, ids []string, if len(ids) == 0 { return fmt.Errorf("at least one issue ID is required") } - // Sentry expects ?id=A&id=B&id=C — url.Values handles repeats. We can't - // use BuildQuery here because that flattens to a single value per key. - var qb strings.Builder - qb.WriteByte('?') - for i, id := range ids { - if i > 0 { - qb.WriteByte('&') - } - qb.WriteString("id=") - qb.WriteString(id) + // Sentry expects ?id=A&id=B&id=C — repeated keys, which `url.Values` + // emits when you `Add` the same key multiple times. Each id is escaped + // so a malicious or unusual ID (containing & = # …) can't inject extra + // query parameters into the request. + v := url.Values{} + for _, id := range ids { + v.Add("id", id) } - _, _, err := c.Do(ctx, "PUT", fmt.Sprintf("/organizations/%s/issues/%s", org, qb.String()), update) + _, _, err := c.Do(ctx, "PUT", fmt.Sprintf("/organizations/%s/issues/?%s", org, v.Encode()), update) return err } diff --git a/internal/sentry/static_commands.go b/internal/sentry/static_commands.go index 708dfc4..d305368 100644 --- a/internal/sentry/static_commands.go +++ b/internal/sentry/static_commands.go @@ -203,11 +203,7 @@ func buildMonitorCommand() *cobra.Command { if err != nil { return err } - format, _ := cmd.Flags().GetString("format") - if format == "" { - format, _ = cmd.Root().PersistentFlags().GetString("format") - } - return renderCheckins(checkins, format) + return renderCheckins(checkins, sentryFlag(cmd, "format")) }, }) return monCmd @@ -227,7 +223,7 @@ func buildAlertCommand() *cobra.Command { if err != nil { return err } - project, _ := cmd.Root().PersistentFlags().GetString("project") + project := sentryFlag(cmd, "project") if project == "" { project = ResolveDefaultProject() } @@ -246,10 +242,24 @@ func buildAlertCommand() *cobra.Command { return alertCmd } +// sentryFlag reads a persistent flag from any depth in the sentry command +// tree. Earlier revisions used `cmd.Root().PersistentFlags().GetString(...)` +// which only finds flags registered on the *root* command — but our flags +// are persistent on the `sentry` command, so that path silently returns "" +// from any leaf 2+ levels deep (e.g. `clanker sentry monitor checkins X`). +// `cmd.Flags()` merges inherited persistent flags from every ancestor, so +// it Just Works at any depth. +func sentryFlag(cmd *cobra.Command, name string) string { + if f := cmd.Flags().Lookup(name); f != nil { + return f.Value.String() + } + return "" +} + // mustClient resolves credentials + flags into a ready Client, returning the // effective org slug separately so callers don't have to re-read flags. func mustClient(cmd *cobra.Command) (*Client, string, error) { - authToken, _ := cmd.Root().PersistentFlags().GetString("auth-token") + authToken := sentryFlag(cmd, "auth-token") if authToken == "" { authToken = ResolveAuthToken() } @@ -257,12 +267,12 @@ func mustClient(cmd *cobra.Command) (*Client, string, error) { return nil, "", fmt.Errorf("sentry auth_token is required (set sentry.auth_token, SENTRY_AUTH_TOKEN, or --auth-token)") } - org, _ := cmd.Root().PersistentFlags().GetString("org") + org := sentryFlag(cmd, "org") if org == "" { org = ResolveOrgSlug() } - host, _ := cmd.Root().PersistentFlags().GetString("host") + host := sentryFlag(cmd, "host") if host == "" { host = ResolveHost() } @@ -284,8 +294,8 @@ func runList(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - format, _ := cmd.Root().PersistentFlags().GetString("format") - project, _ := cmd.Root().PersistentFlags().GetString("project") + format := sentryFlag(cmd, "format") + project := sentryFlag(cmd, "project") if project == "" { project = ResolveDefaultProject() } @@ -402,8 +412,8 @@ func runGet(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - format, _ := cmd.Root().PersistentFlags().GetString("format") - project, _ := cmd.Root().PersistentFlags().GetString("project") + format := sentryFlag(cmd, "format") + project := sentryFlag(cmd, "project") if project == "" { project = ResolveDefaultProject() } @@ -452,12 +462,13 @@ func runGet(cmd *cobra.Command, args []string) error { // renderers ----------------------------------------------------------------- -func renderJSON(v any, format string) error { - if format == "" { - format = "table" - } - // "get" only ever returns a single object so a JSON dump is always the - // most useful output regardless of `format`. +// renderJSON dumps v as indented JSON. The `format` parameter is unused +// because `get` subcommands return a single object whose shape varies +// per-resource (Issue, Event, Release, Monitor, Organization) — building a +// per-type table renderer for each would be overkill when the JSON form is +// already structured and pipeable. Pass any value; format is accepted to +// keep the call-site symmetric with renderIssues / renderProjects / etc. +func renderJSON(v any, _ string) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err From 8491cd1bf367c119cb31eb8170a145628db378b7 Mon Sep 17 00:00:00 2001 From: nash Date: Mon, 1 Jun 2026 14:17:54 +0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(sentry):=20round-2=20review=20=E2=80=94?= =?UTF-8?q?=20SSRF=20guard,=20MCP=20read-only=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass review surfaced an SSRF vector via the configured sentry.host: NewClient accepted any value and built a Bearer-token request against it, so a hostile sentry.host config or SENTRY_HOST env var could redirect the auth token at 169.254.169.254 or other internal endpoints. - internal/sentry/client.go: introduce validateHost(). Rejects IP literals (catches 169.254.169.254, ::1, etc.), hostnames containing port/path/userinfo characters (`:` `/` `@` `?` `#`), and a small block-list of cloud-metadata DNS names (localhost, metadata.google.internal, instance-data). DNS hostnames matching `[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.…)*` continue to work for both sentry.io SaaS and self-hosted installs. - internal/sentry/client_test.go: TestValidateHost_BlocksSSRF covers the allowed and blocked shapes; TestNewClient_RejectsHostileHost asserts the guard surfaces in the constructor, not silently. - cmd/mcp_sentry.go: add WithReadOnlyHintAnnotation to clanker_sentry_ask. The tool only calls ListIssues / ListReleases / ListMonitors / ListIssueAlertRules then routes through the LLM — cautious MCP clients (Claude Desktop's safe-tool list) can now invoke it without user confirmation. --- cmd/mcp_sentry.go | 6 +++++ internal/sentry/client.go | 38 ++++++++++++++++++++++++++++- internal/sentry/client_test.go | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/cmd/mcp_sentry.go b/cmd/mcp_sentry.go index c03b96f..2d16964 100644 --- a/cmd/mcp_sentry.go +++ b/cmd/mcp_sentry.go @@ -60,6 +60,12 @@ func registerSentryMCPTools(server *mcptransport.MCPServer) { "clanker_sentry_ask", mcp.WithDescription("Ask a natural-language question about Sentry. Fetches relevant issues, releases, and monitors and answers via the configured AI provider."), mcp.WithInputSchema[sentryAskMCPArgs](), + // The ask tool only reads from Sentry (ListIssues, ListReleases, + // ListMonitors, ListIssueAlertRules) then routes the result + // through the LLM. Marking it read-only lets cautious MCP + // clients (e.g. Claude Desktop's safe-tool list) invoke it + // without user confirmation. + mcp.WithReadOnlyHintAnnotation(true), ), mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args sentryAskMCPArgs) (*mcp.CallToolResult, error) { return handleMCPSentryAsk(ctx, args) diff --git a/internal/sentry/client.go b/internal/sentry/client.go index 174b80b..9c5f62b 100644 --- a/internal/sentry/client.go +++ b/internal/sentry/client.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "math/rand" + "net" "net/http" "net/url" "os" @@ -90,9 +91,13 @@ func NewClient(authToken, orgSlug, host string, debug bool) (*Client, error) { if strings.TrimSpace(authToken) == "" { return nil, errors.New("sentry auth_token is required") } - if strings.TrimSpace(host) == "" { + host = strings.TrimSpace(host) + if host == "" { host = defaultHost } + if err := validateHost(host); err != nil { + return nil, err + } return &Client{ host: host, authToken: authToken, @@ -104,6 +109,37 @@ func NewClient(authToken, orgSlug, host string, debug bool) (*Client, error) { }, nil } +// validHostRE accepts a hostname (no scheme, no path, no port). Sentry SaaS +// is sentry.io / *.sentry.io; self-hosted is whatever DNS name the operator +// runs Sentry on. Characters with hostname-injection potential (`:` `/` `@` +// `?` `#`) are rejected — those would be a clear SSRF attempt like +// `127.0.0.1:8080` or `evil.com/?@sentry.io`. +var validHostRE = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$`) + +// validateHost guards against SSRF via a hostile sentry.host config or +// SENTRY_HOST env. Without this, a user (or a process injecting env vars) +// could point the CLI at 169.254.169.254 to leak the Bearer token. We +// block IP literals, raw loopback / cloud-metadata names, and anything +// not shaped like a DNS hostname. +func validateHost(host string) error { + if len(host) > 253 { + return errors.New("sentry host too long") + } + if !validHostRE.MatchString(host) { + return fmt.Errorf("sentry host contains invalid characters: %q", host) + } + if net.ParseIP(host) != nil { + return fmt.Errorf("sentry host must be a DNS name, not an IP: %q", host) + } + lower := strings.ToLower(host) + for _, bad := range []string{"localhost", "metadata.google.internal", "instance-data"} { + if lower == bad || strings.HasSuffix(lower, "."+bad) { + return fmt.Errorf("sentry host refers to an internal address: %q", host) + } + } + return nil +} + // SetHTTPClient lets tests swap in an httptest.Server-backed client. func (c *Client) SetHTTPClient(hc *http.Client) { if hc != nil { diff --git a/internal/sentry/client_test.go b/internal/sentry/client_test.go index 1cdf343..48a11e9 100644 --- a/internal/sentry/client_test.go +++ b/internal/sentry/client_test.go @@ -310,3 +310,47 @@ func TestParseRetryWait_PrefersLonger(t *testing.T) { t.Errorf("wait should be capped at 30s, got %v", wait) } } + +// TestValidateHost_BlocksSSRF exercises the SSRF guard added to NewClient. +// Without this guard, a hostile sentry.host config or SENTRY_HOST env var +// could make the CLI ship the auth token at internal endpoints like +// 169.254.169.254 (cloud metadata). +func TestValidateHost_BlocksSSRF(t *testing.T) { + cases := []struct { + host string + wantErr bool + }{ + // Allowed + {"sentry.io", false}, + {"acme.sentry.io", false}, + {"sentry.mycompany.com", false}, + + // Blocked + {"127.0.0.1", true}, + {"169.254.169.254", true}, + {"localhost", true}, + {"app.localhost", true}, + {"metadata.google.internal", true}, + {"sentry.io:8080", true}, + {"sentry.io/admin", true}, + {"evil@sentry.io", true}, + {"::1", true}, + } + for _, c := range cases { + c := c + t.Run(c.host, func(t *testing.T) { + t.Parallel() + err := validateHost(c.host) + gotErr := err != nil + if gotErr != c.wantErr { + t.Errorf("validateHost(%q) err=%v, wantErr=%v", c.host, err, c.wantErr) + } + }) + } +} + +func TestNewClient_RejectsHostileHost(t *testing.T) { + if _, err := NewClient("tok", "org", "169.254.169.254", false); err == nil { + t.Errorf("expected error for IP host, got nil") + } +} From 3baf28caf86e03d9453c6879a29773f25b98e180 Mon Sep 17 00:00:00 2001 From: nash Date: Mon, 1 Jun 2026 14:35:46 +0500 Subject: [PATCH 4/4] =?UTF-8?q?refactor(sentry):=20simplify=20pass=20?= =?UTF-8?q?=E2=80=94=20parallel=20fetches,=20status=20constants,=20trim=20?= =?UTF-8?q?narration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI cleanup driven by a third review round. Perf: - internal/sentry/status.go: run ListProjects + the two ListIssues calls concurrently via errgroup. The ask command's cold-start was dominated by these sequential network round-trips. - cmd/sentry.go (gatherSentryContext): the up-to-4 selected sections (issues / releases / monitors / alerts) now fetch in parallel via errgroup, each writing into its own string block that gets stitched in fixed order. Worst-case ask latency drops ~4x. Quality: - internal/sentry/issues.go: introduce IssueStatus type + the three constants Sentry actually accepts. Status field on IssueUpdate is typed now, so a typo like "resolve" won't silently no-op. - internal/sentry/static_commands.go: rename mustClient → buildClient. Go convention reserves must* for panics; this returns an error. - internal/sentry/static_commands.go: drop the unused `format` param from renderJSON. get-resource output is JSON regardless of --format (varied per-resource shapes), so the symmetry-only parameter was misleading. - Trim PR-review-style comments ("Earlier revisions used...", "Renamed from `context`...") that were useful in the original commit message but rot in the source. --- cmd/sentry.go | 139 +++++++++++++++++------------ internal/sentry/conversation.go | 9 +- internal/sentry/issues.go | 22 +++-- internal/sentry/static_commands.go | 75 +++++++--------- internal/sentry/status.go | 55 +++++++----- 5 files changed, 172 insertions(+), 128 deletions(-) diff --git a/cmd/sentry.go b/cmd/sentry.go index a17bf84..32bfc28 100644 --- a/cmd/sentry.go +++ b/cmd/sentry.go @@ -10,6 +10,7 @@ import ( "github.com/bgdnvk/clanker/internal/sentry" "github.com/spf13/cobra" "github.com/spf13/viper" + "golang.org/x/sync/errgroup" ) var sentryAskCmd = &cobra.Command{ @@ -108,9 +109,6 @@ func runSentryAsk(cmd *cobra.Command, args []string) error { fmt.Printf("[debug] gather status: %v\n", err) } - // Renamed from `context` because that local was shadowing the imported - // context package — a foot-gun for any future edit that needs to call - // context.WithTimeout / context.Background below this line. dataContext, err := gatherSentryContext(ctx, client, question, project, sentryAskEnvironment, debug) if err != nil && debug { fmt.Printf("[debug] gather context: %v\n", err) @@ -140,15 +138,15 @@ func runSentryAsk(cmd *cobra.Command, args []string) error { return nil } -// gatherSentryContext fetches Sentry data relevant to the question. Keyword -// routing is deliberately lightweight — Sentry's search syntax is rich enough -// that we mostly want to forward `is:unresolved`-style filters through and let -// the LLM correlate from a focused page of issues + recent releases. +// gatherSentryContext fetches Sentry data relevant to the question. Sections +// are picked by keyword routing — Sentry's search syntax is rich enough that +// we mostly forward `is:unresolved`-style filters and let the LLM correlate. +// The matched sections run concurrently because they hit independent +// endpoints; serial would multiply ask-command latency by 4x in the +// worst case. func gatherSentryContext(ctx context.Context, client *sentry.Client, question, project, environment string, debug bool) (string, error) { questionLower := strings.ToLower(question) - var sb strings.Builder - wantIssues := containsAny(questionLower, []string{"issue", "error", "crash", "exception", "problem", "fail", "bug", "what's", "whats", "blowing", "broken"}) wantReleases := containsAny(questionLower, []string{"release", "deploy", "version", "rollout", "regress"}) wantMonitors := containsAny(questionLower, []string{"monitor", "cron", "schedule"}) @@ -160,39 +158,50 @@ func gatherSentryContext(ctx context.Context, client *sentry.Client, question, p wantIssues = true } + g, gctx := errgroup.WithContext(ctx) + var issuesBlock, releasesBlock, monitorsBlock, alertsBlock string + if wantIssues { - query := "is:unresolved" - if strings.Contains(questionLower, "error") || strings.Contains(questionLower, "crash") { - query = "is:unresolved level:error" - } - issues, _, err := client.ListIssues(ctx, client.OrgSlug(), sentry.IssueListOptions{ - Query: query, - Environment: environment, - StatsPeriod: "24h", - Limit: 25, - }) - if err != nil { - if debug { - fmt.Printf("[debug] list issues: %v\n", err) + g.Go(func() error { + query := "is:unresolved" + if strings.Contains(questionLower, "error") || strings.Contains(questionLower, "crash") { + query = "is:unresolved level:error" } - } else { - sb.WriteString("Recent unresolved issues (last 24h):\n") + issues, _, err := client.ListIssues(gctx, client.OrgSlug(), sentry.IssueListOptions{ + Query: query, + Environment: environment, + StatsPeriod: "24h", + Limit: 25, + }) + if err != nil { + if debug { + fmt.Printf("[debug] list issues: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Recent unresolved issues (last 24h):\n") for _, i := range issues { - sb.WriteString(fmt.Sprintf(" - [%s] %s — %s (count=%s, users=%d, lastSeen=%s)\n", - i.Level, i.ShortID, i.Title, i.Count, i.UserCount, i.LastSeen.Format(time.RFC3339))) + fmt.Fprintf(&b, " - [%s] %s — %s (count=%s, users=%d, lastSeen=%s)\n", + i.Level, i.ShortID, i.Title, i.Count, i.UserCount, i.LastSeen.Format(time.RFC3339)) } - sb.WriteString("\n") - } + b.WriteString("\n") + issuesBlock = b.String() + return nil + }) } if wantReleases && project != "" { - releases, err := client.ListReleases(ctx, client.OrgSlug(), project) - if err != nil { - if debug { - fmt.Printf("[debug] list releases: %v\n", err) + g.Go(func() error { + releases, err := client.ListReleases(gctx, client.OrgSlug(), project) + if err != nil { + if debug { + fmt.Printf("[debug] list releases: %v\n", err) + } + return nil } - } else { - sb.WriteString("Recent releases:\n") + var b strings.Builder + b.WriteString("Recent releases:\n") for i, r := range releases { if i >= 10 { break @@ -201,42 +210,62 @@ func gatherSentryContext(ctx context.Context, client *sentry.Client, question, p if r.DateReleased != nil && !r.DateReleased.IsZero() { released = r.DateReleased.Format(time.RFC3339) } - sb.WriteString(fmt.Sprintf(" - %s (newGroups=%d, released=%s)\n", r.ShortVersion, r.NewGroups, released)) + fmt.Fprintf(&b, " - %s (newGroups=%d, released=%s)\n", r.ShortVersion, r.NewGroups, released) } - sb.WriteString("\n") - } + b.WriteString("\n") + releasesBlock = b.String() + return nil + }) } if wantMonitors { - monitors, err := client.ListMonitors(ctx, client.OrgSlug()) - if err != nil { - if debug { - fmt.Printf("[debug] list monitors: %v\n", err) + g.Go(func() error { + monitors, err := client.ListMonitors(gctx, client.OrgSlug()) + if err != nil { + if debug { + fmt.Printf("[debug] list monitors: %v\n", err) + } + return nil } - } else { - sb.WriteString("Sentry Crons monitors:\n") + var b strings.Builder + b.WriteString("Sentry Crons monitors:\n") for _, m := range monitors { - sb.WriteString(fmt.Sprintf(" - %s (%s) status=%s muted=%v\n", m.Slug, m.Name, m.Status, m.IsMuted)) + fmt.Fprintf(&b, " - %s (%s) status=%s muted=%v\n", m.Slug, m.Name, m.Status, m.IsMuted) } - sb.WriteString("\n") - } + b.WriteString("\n") + monitorsBlock = b.String() + return nil + }) } if wantAlerts && project != "" { - rules, err := client.ListIssueAlertRules(ctx, client.OrgSlug(), project) - if err != nil { - if debug { - fmt.Printf("[debug] list alert rules: %v\n", err) + g.Go(func() error { + rules, err := client.ListIssueAlertRules(gctx, client.OrgSlug(), project) + if err != nil { + if debug { + fmt.Printf("[debug] list alert rules: %v\n", err) + } + return nil } - } else { - sb.WriteString("Alert rules:\n") + var b strings.Builder + b.WriteString("Alert rules:\n") for _, r := range rules { - sb.WriteString(fmt.Sprintf(" - %s (env=%s, frequency=%dmin)\n", r.Name, r.Environment, r.Frequency)) + fmt.Fprintf(&b, " - %s (env=%s, frequency=%dmin)\n", r.Name, r.Environment, r.Frequency) } - sb.WriteString("\n") - } + b.WriteString("\n") + alertsBlock = b.String() + return nil + }) } + _ = g.Wait() // every goroutine swallows its own error + + var sb strings.Builder + sb.WriteString(issuesBlock) + sb.WriteString(releasesBlock) + sb.WriteString(monitorsBlock) + sb.WriteString(alertsBlock) + if sb.Len() == 0 { return "No Sentry data fetched (check token permissions and org slug).", nil } diff --git a/internal/sentry/conversation.go b/internal/sentry/conversation.go index 5b86a6d..50beac6 100644 --- a/internal/sentry/conversation.go +++ b/internal/sentry/conversation.go @@ -106,11 +106,10 @@ func (h *ConversationHistory) GetAccountStatusContext() string { ) } -// safeSlug strips anything that isn't lowercase a-z, digit, dash, or -// underscore — Sentry org slugs follow `[a-z0-9-]+` so this matches the -// upstream contract. Without this, an MCP caller passing -// orgSlug="../../etc/passwd" would coerce filepath.Join into resolving -// the `..` segments and writing outside ~/.clanker. +// safeSlug strips anything outside [A-Za-z0-9_-] so a malicious orgSlug +// (e.g. "../../etc/passwd") can't escape the ~/.clanker directory when +// filepath.Join resolves the path. Matches the Sentry slug contract +// (`[a-z0-9-]+`). func safeSlug(s string) string { out := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { diff --git a/internal/sentry/issues.go b/internal/sentry/issues.go index a67d825..198b811 100644 --- a/internal/sentry/issues.go +++ b/internal/sentry/issues.go @@ -85,12 +85,22 @@ func (c *Client) GetIssueEvents(ctx context.Context, issueID string, limit int) return events, nil } +// IssueStatus is the set of accepted values for IssueUpdate.Status. Defining +// them as constants prevents typos (`"resolve"` would silently no-op against +// Sentry) and lets callers refer to them by name. +type IssueStatus string + +const ( + IssueStatusResolved IssueStatus = "resolved" + IssueStatusIgnored IssueStatus = "ignored" + IssueStatusUnresolved IssueStatus = "unresolved" +) + // IssueUpdate is the payload Sentry expects on PUT /organizations/{org}/issues/. -// Status is one of "resolved" | "unresolved" | "ignored"; AssignedTo is a -// username string (or "" to clear). +// AssignedTo is a username string (or "" to clear). type IssueUpdate struct { - Status string `json:"status,omitempty"` - AssignedTo string `json:"assignedTo,omitempty"` + Status IssueStatus `json:"status,omitempty"` + AssignedTo string `json:"assignedTo,omitempty"` } // UpdateIssues bulk-mutates issues. IDs are passed as repeated `id=` query @@ -117,12 +127,12 @@ func (c *Client) UpdateIssues(ctx context.Context, orgSlug string, ids []string, // ResolveIssues is a convenience wrapper. func (c *Client) ResolveIssues(ctx context.Context, orgSlug string, ids []string) error { - return c.UpdateIssues(ctx, orgSlug, ids, IssueUpdate{Status: "resolved"}) + return c.UpdateIssues(ctx, orgSlug, ids, IssueUpdate{Status: IssueStatusResolved}) } // IgnoreIssues marks issues as ignored. func (c *Client) IgnoreIssues(ctx context.Context, orgSlug string, ids []string) error { - return c.UpdateIssues(ctx, orgSlug, ids, IssueUpdate{Status: "ignored"}) + return c.UpdateIssues(ctx, orgSlug, ids, IssueUpdate{Status: IssueStatusIgnored}) } // AssignIssue assigns a single issue to a username. diff --git a/internal/sentry/static_commands.go b/internal/sentry/static_commands.go index d305368..cde4160 100644 --- a/internal/sentry/static_commands.go +++ b/internal/sentry/static_commands.go @@ -90,7 +90,7 @@ func buildResolveCommand() *cobra.Command { Short: "Mark one or more issues as resolved", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -111,7 +111,7 @@ func buildIgnoreCommand() *cobra.Command { Short: "Mark one or more issues as ignored", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -132,7 +132,7 @@ func buildAssignCommand() *cobra.Command { Short: "Assign an issue to a user", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -157,7 +157,7 @@ func buildMonitorCommand() *cobra.Command { Short: "Mute alerts for a monitor", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -175,7 +175,7 @@ func buildMonitorCommand() *cobra.Command { Short: "Unmute a previously-muted monitor", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -193,7 +193,7 @@ func buildMonitorCommand() *cobra.Command { Short: "Show recent check-ins for a monitor", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -219,7 +219,7 @@ func buildAlertCommand() *cobra.Command { Short: "Delete an issue alert rule (needs --project)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -243,12 +243,9 @@ func buildAlertCommand() *cobra.Command { } // sentryFlag reads a persistent flag from any depth in the sentry command -// tree. Earlier revisions used `cmd.Root().PersistentFlags().GetString(...)` -// which only finds flags registered on the *root* command — but our flags -// are persistent on the `sentry` command, so that path silently returns "" -// from any leaf 2+ levels deep (e.g. `clanker sentry monitor checkins X`). -// `cmd.Flags()` merges inherited persistent flags from every ancestor, so -// it Just Works at any depth. +// tree. cmd.Flags() merges inherited persistent flags from every ancestor, +// so this resolves flags registered on the `sentry` parent even when called +// from 3-level-deep leaves like `clanker sentry monitor checkins X`. func sentryFlag(cmd *cobra.Command, name string) string { if f := cmd.Flags().Lookup(name); f != nil { return f.Value.String() @@ -256,9 +253,9 @@ func sentryFlag(cmd *cobra.Command, name string) string { return "" } -// mustClient resolves credentials + flags into a ready Client, returning the +// buildClient resolves credentials + flags into a ready Client, returning the // effective org slug separately so callers don't have to re-read flags. -func mustClient(cmd *cobra.Command) (*Client, string, error) { +func buildClient(cmd *cobra.Command) (*Client, string, error) { authToken := sentryFlag(cmd, "auth-token") if authToken == "" { authToken = ResolveAuthToken() @@ -287,7 +284,7 @@ func mustClient(cmd *cobra.Command) (*Client, string, error) { func runList(cmd *cobra.Command, args []string) error { resource := strings.ToLower(args[0]) - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } @@ -405,14 +402,13 @@ func runList(cmd *cobra.Command, args []string) error { func runGet(cmd *cobra.Command, args []string) error { resource := strings.ToLower(args[0]) id := args[1] - client, org, err := mustClient(cmd) + client, org, err := buildClient(cmd) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - format := sentryFlag(cmd, "format") project := sentryFlag(cmd, "project") if project == "" { project = ResolveDefaultProject() @@ -424,7 +420,7 @@ func runGet(cmd *cobra.Command, args []string) error { if err != nil { return err } - return renderJSON(issue, format) + return renderJSON(issue) case "event": if project == "" { return fmt.Errorf("--project is required to fetch an event") @@ -433,7 +429,7 @@ func runGet(cmd *cobra.Command, args []string) error { if err != nil { return err } - return renderJSON(ev, format) + return renderJSON(ev) case "release": if project == "" { return fmt.Errorf("--project is required to fetch a release") @@ -442,19 +438,19 @@ func runGet(cmd *cobra.Command, args []string) error { if err != nil { return err } - return renderJSON(rel, format) + return renderJSON(rel) case "monitor": m, err := client.GetMonitor(ctx, org, id) if err != nil { return err } - return renderJSON(m, format) + return renderJSON(m) case "org", "organization": o, err := client.GetOrganization(ctx, id) if err != nil { return err } - return renderJSON(o, format) + return renderJSON(o) default: return fmt.Errorf("unknown resource: %s (try issue|event|release|monitor|org)", resource) } @@ -462,13 +458,10 @@ func runGet(cmd *cobra.Command, args []string) error { // renderers ----------------------------------------------------------------- -// renderJSON dumps v as indented JSON. The `format` parameter is unused -// because `get` subcommands return a single object whose shape varies -// per-resource (Issue, Event, Release, Monitor, Organization) — building a -// per-type table renderer for each would be overkill when the JSON form is -// already structured and pipeable. Pass any value; format is accepted to -// keep the call-site symmetric with renderIssues / renderProjects / etc. -func renderJSON(v any, _ string) error { +// renderJSON dumps v as indented JSON. Used by `get` subcommands where the +// returned object's shape varies per-resource and JSON is the most useful +// machine-readable form regardless of --format. +func renderJSON(v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err @@ -483,7 +476,7 @@ func newTabwriter() *tabwriter.Writer { func renderOrgs(orgs []Organization, format string) error { if format == "json" { - return renderJSON(orgs, format) + return renderJSON(orgs) } w := newTabwriter() fmt.Fprintln(w, "SLUG\tNAME\tCREATED") @@ -495,7 +488,7 @@ func renderOrgs(orgs []Organization, format string) error { func renderProjects(projects []Project, format string) error { if format == "json" { - return renderJSON(projects, format) + return renderJSON(projects) } w := newTabwriter() fmt.Fprintln(w, "SLUG\tNAME\tPLATFORM") @@ -507,7 +500,7 @@ func renderProjects(projects []Project, format string) error { func renderIssues(issues []Issue, format string) error { if format == "json" { - return renderJSON(issues, format) + return renderJSON(issues) } w := newTabwriter() fmt.Fprintln(w, "SHORT-ID\tLEVEL\tSTATUS\tCOUNT\tUSERS\tTITLE\tLAST-SEEN") @@ -524,7 +517,7 @@ func renderIssues(issues []Issue, format string) error { func renderEvents(events []Event, format string) error { if format == "json" { - return renderJSON(events, format) + return renderJSON(events) } w := newTabwriter() fmt.Fprintln(w, "EVENT-ID\tTITLE\tCREATED") @@ -540,7 +533,7 @@ func renderEvents(events []Event, format string) error { func renderReleases(releases []Release, format string) error { if format == "json" { - return renderJSON(releases, format) + return renderJSON(releases) } w := newTabwriter() fmt.Fprintln(w, "VERSION\tNEW-GROUPS\tCREATED\tRELEASED") @@ -556,7 +549,7 @@ func renderReleases(releases []Release, format string) error { func renderIssueAlertRules(rules []IssueAlertRule, format string) error { if format == "json" { - return renderJSON(rules, format) + return renderJSON(rules) } w := newTabwriter() fmt.Fprintln(w, "ID\tNAME\tENV\tFREQUENCY\tCREATED") @@ -568,7 +561,7 @@ func renderIssueAlertRules(rules []IssueAlertRule, format string) error { func renderMetricAlertRules(rules []MetricAlertRule, format string) error { if format == "json" { - return renderJSON(rules, format) + return renderJSON(rules) } w := newTabwriter() fmt.Fprintln(w, "ID\tNAME\tQUERY\tAGGREGATE\tTHRESHOLD") @@ -580,7 +573,7 @@ func renderMetricAlertRules(rules []MetricAlertRule, format string) error { func renderMonitors(monitors []Monitor, format string) error { if format == "json" { - return renderJSON(monitors, format) + return renderJSON(monitors) } w := newTabwriter() fmt.Fprintln(w, "SLUG\tNAME\tSTATUS\tMUTED\tTYPE") @@ -592,7 +585,7 @@ func renderMonitors(monitors []Monitor, format string) error { func renderCheckins(checkins []MonitorCheckin, format string) error { if format == "json" { - return renderJSON(checkins, format) + return renderJSON(checkins) } w := newTabwriter() fmt.Fprintln(w, "ID\tSTATUS\tDURATION-MS\tCREATED") @@ -608,7 +601,7 @@ func renderCheckins(checkins []MonitorCheckin, format string) error { func renderTeams(teams []Team, format string) error { if format == "json" { - return renderJSON(teams, format) + return renderJSON(teams) } w := newTabwriter() fmt.Fprintln(w, "SLUG\tNAME\tMEMBERS\tCREATED") @@ -620,7 +613,7 @@ func renderTeams(teams []Team, format string) error { func renderMembers(members []Member, format string) error { if format == "json" { - return renderJSON(members, format) + return renderJSON(members) } w := newTabwriter() fmt.Fprintln(w, "EMAIL\tNAME\tROLE") diff --git a/internal/sentry/status.go b/internal/sentry/status.go index 10cc99e..ada658e 100644 --- a/internal/sentry/status.go +++ b/internal/sentry/status.go @@ -3,39 +3,52 @@ package sentry import ( "context" "time" + + "golang.org/x/sync/errgroup" ) // GatherAccountStatus collects an at-a-glance snapshot for the conversation -// history. Errors are non-fatal — we degrade gracefully because the ask -// command should never fail just because one secondary fetch broke. +// history. The three Sentry calls run concurrently — `ask` cold-start +// latency is dominated by these network round-trips and they're independent. +// Errors are non-fatal: we want a partial snapshot rather than blocking the +// ask command on a single flaky endpoint. func GatherAccountStatus(ctx context.Context, c *Client, orgSlug string) (*AccountStatus, error) { status := &AccountStatus{ Timestamp: time.Now(), OrganizationSlug: orgSlug, } - projects, err := c.ListProjects(ctx, orgSlug) - if err == nil { - status.ProjectCount = len(projects) - } + g, gctx := errgroup.WithContext(ctx) - unresolved, _, err := c.ListIssues(ctx, orgSlug, IssueListOptions{ - Query: "is:unresolved", - StatsPeriod: "24h", - Limit: 100, + g.Go(func() error { + if projects, err := c.ListProjects(gctx, orgSlug); err == nil { + status.ProjectCount = len(projects) + } + return nil }) - if err == nil { - status.UnresolvedCount = len(unresolved) - } - - errors24h, _, err := c.ListIssues(ctx, orgSlug, IssueListOptions{ - Query: "level:error", - StatsPeriod: "24h", - Limit: 100, + g.Go(func() error { + unresolved, _, err := c.ListIssues(gctx, orgSlug, IssueListOptions{ + Query: "is:unresolved", + StatsPeriod: "24h", + Limit: 100, + }) + if err == nil { + status.UnresolvedCount = len(unresolved) + } + return nil + }) + g.Go(func() error { + errs, _, err := c.ListIssues(gctx, orgSlug, IssueListOptions{ + Query: "level:error", + StatsPeriod: "24h", + Limit: 100, + }) + if err == nil { + status.ErrorCount24h = len(errs) + } + return nil }) - if err == nil { - status.ErrorCount24h = len(errors24h) - } + _ = g.Wait() // every goroutine swallows its own error; Wait can't fail return status, nil }