Files
kandev/apps/backend/internal/sentry/models.go
T

237 lines
11 KiB
Go

// Package sentry implements the Sentry integration (Phase 1: configure + browse).
// A single install-wide configuration, a REST client for projects and issues,
// and the HTTP handlers that expose these capabilities to the frontend.
package sentry
import (
"time"
"github.com/kandev/kandev/internal/integrations/optional"
)
// AuthMethodAuthToken is the only auth method Sentry supports in Phase 1: a
// user or organization auth token sent as `Authorization: Bearer <token>`.
const AuthMethodAuthToken = "auth_token"
// SecretKey is the secret-store key used for the install-wide Sentry token.
const SecretKey = "sentry:singleton:token"
// SentryConfig is the install-wide configuration for the Sentry integration.
// The token is stored separately in the encrypted secret store under SecretKey.
type SentryConfig struct {
AuthMethod string `json:"authMethod" db:"auth_method"`
// URL is the base URL of the Sentry instance (e.g. https://sentry.io for
// SaaS, or a self-hosted host). The REST client appends /api/0. Defaults
// to the sentry.io SaaS endpoint when left blank.
URL string `json:"url" db:"url"`
HasSecret bool `json:"hasSecret" db:"-"`
// LastCheckedAt / LastOk / LastError are written by the background auth
// poller so the UI can render a "connected/disconnected + checked Xs ago"
// indicator without doing its own probing.
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty" db:"last_checked_at"`
LastOk bool `json:"lastOk" db:"last_ok"`
LastError string `json:"lastError,omitempty" db:"last_error"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
// SetConfigRequest is the payload sent by the UI to create or update the
// Sentry configuration. When Secret is empty on update, the existing secret
// is retained; when non-empty it replaces the stored value.
type SetConfigRequest struct {
AuthMethod string `json:"authMethod"`
// URL is the Sentry instance base URL. Optional: blank defaults to the
// sentry.io SaaS endpoint, preserving the prior single-tenant behavior.
URL string `json:"url"`
Secret string `json:"secret"`
}
// TestConnectionResult reports what the backend learned when pinging Sentry
// with the supplied credentials.
type TestConnectionResult struct {
OK bool `json:"ok"`
UserID string `json:"userId,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Email string `json:"email,omitempty"`
Error string `json:"error,omitempty"`
}
// SentryOrganization is the minimal shape used by the organization selector on
// the settings page.
type SentryOrganization struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
// SentryProject is the minimal shape used by the project selector on the
// settings page and by the issue browser to scope searches.
type SentryProject struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
OrgSlug string `json:"orgSlug"`
}
// SentryIssue is the subset of Sentry's issue payload Kandev consumes.
type SentryIssue struct {
ID string `json:"id"`
ShortID string `json:"shortId"`
Title string `json:"title"`
Culprit string `json:"culprit,omitempty"`
Permalink string `json:"permalink"`
ProjectSlug string `json:"projectSlug"`
ProjectName string `json:"projectName,omitempty"`
Level string `json:"level"`
Status string `json:"status"`
// Count is returned by Sentry as a string ("1234"), preserved as-is.
Count string `json:"count,omitempty"`
UserCount int `json:"userCount,omitempty"`
FirstSeen string `json:"firstSeen,omitempty"`
LastSeen string `json:"lastSeen,omitempty"`
AssigneeName string `json:"assigneeName,omitempty"`
}
// SearchFilter is a structured search filter used by SearchIssues. Sentry
// expresses level/status filters by appending tokens to the free-text `query`
// param; we expose them as structured fields so the UI can render multi-select
// chips and the backend builds the right query string.
type SearchFilter struct {
OrgSlug string `json:"orgSlug"`
ProjectSlug string `json:"projectSlug,omitempty"`
Environment string `json:"environment,omitempty"`
Levels []string `json:"levels,omitempty"`
Statuses []string `json:"statuses,omitempty"`
Query string `json:"query,omitempty"`
StatsPeriod string `json:"statsPeriod,omitempty"`
}
// SearchResult is a page of issues from a search. Sentry uses opaque cursors
// embedded in the Link header for pagination; NextPageToken carries that
// cursor verbatim so the next call can pass it back as `?cursor=...`.
type SearchResult struct {
Issues []SentryIssue `json:"issues"`
NextPageToken string `json:"nextPageToken,omitempty"`
IsLast bool `json:"isLast"`
}
// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher
// when the caller does not specify one. Five minutes balances freshness
// against Sentry rate limits when many watches are configured.
const DefaultIssueWatchPollInterval = 300
// MinIssueWatchPollInterval / MaxIssueWatchPollInterval bound the per-watch
// search re-run cadence.
const (
MinIssueWatchPollInterval = 60
MaxIssueWatchPollInterval = 3600
)
// IssueWatch persists a Sentry issue-search filter that becomes a kandev task
// whenever a new matching issue appears. Mirrors the Linear/Jira shape so the
// orchestrator's WatcherSource pipeline applies uniformly.
type IssueWatch struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspaceId" db:"workspace_id"`
WorkflowID string `json:"workflowId" db:"workflow_id"`
WorkflowStepID string `json:"workflowStepId" db:"workflow_step_id"`
// RepositoryID optionally binds watcher-created tasks to a repository so the
// agent launches in an isolated worktree of that repo instead of a blank
// scratch checkout. Empty = unbound, which preserves the historical
// repo-less behaviour. When set, the resulting task carries a single
// (repository_id, base_branch) pair.
RepositoryID string `json:"repositoryId" db:"repository_id"`
// BaseBranch is the branch the per-task worktree is cut from. Empty defaults
// to the repository's default branch (resolved at create/update time).
// Meaningful only when RepositoryID is set.
BaseBranch string `json:"baseBranch" db:"base_branch"`
Filter SearchFilter `json:"filter"`
AgentProfileID string `json:"agentProfileId" db:"agent_profile_id"`
ExecutorProfileID string `json:"executorProfileId" db:"executor_profile_id"`
Prompt string `json:"prompt" db:"prompt"`
Enabled bool `json:"enabled" db:"enabled"`
PollIntervalSeconds int `json:"pollIntervalSeconds" db:"poll_interval_seconds"`
// MaxInflightTasks caps how many open watcher-created tasks this watch can
// hold at once. nil = uncapped. Values <= 0 are rejected at the API layer.
MaxInflightTasks *int `json:"maxInflightTasks,omitempty" db:"max_inflight_tasks"`
LastPolledAt *time.Time `json:"lastPolledAt,omitempty" db:"last_polled_at"`
// LastError / LastErrorAt are stamped when the dispatch coordinator
// self-heals an orphaned watch (e.g. its bound agent profile was
// deleted). Surfaced to the settings UI so the user sees why a watch
// was disabled. Empty string / nil when the watch is healthy.
LastError string `json:"lastError,omitempty" db:"last_error"`
LastErrorAt *time.Time `json:"lastErrorAt,omitempty" db:"last_error_at"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
// IssueWatchTask deduplicates task creation per (watch, issue) tuple. Keyed on
// short_id (e.g. "PROJ-123") because it is stable, human-readable, and present
// on every Sentry search result.
type IssueWatchTask struct {
ID string `json:"id" db:"id"`
IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"`
IssueShortID string `json:"issueShortId" db:"issue_short_id"`
IssueURL string `json:"issueUrl" db:"issue_url"`
TaskID string `json:"taskId" db:"task_id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
// NewSentryIssueEvent is published on the bus whenever the poller observes an
// issue matching a watch that has no existing dedup row. The orchestrator
// consumes this to create (and optionally auto-start) a Kandev task.
type NewSentryIssueEvent struct {
IssueWatchID string `json:"issueWatchId"`
WorkspaceID string `json:"workspaceId"`
WorkflowID string `json:"workflowId"`
WorkflowStepID string `json:"workflowStepId"`
// RepositoryID / BaseBranch carry the watch's optional repository binding so
// the orchestrator source can populate IssueTaskRequest.Repositories without
// reloading the watch row. Empty RepositoryID = unbound (repo-less task).
RepositoryID string `json:"repositoryId,omitempty"`
BaseBranch string `json:"baseBranch,omitempty"`
AgentProfileID string `json:"agentProfileId"`
ExecutorProfileID string `json:"executorProfileId"`
Prompt string `json:"prompt"`
// MaxInflightTasks mirrors the watch row's per-watcher throttle cap so the
// orchestrator's gate can read it without loading the row again. nil =
// uncapped.
MaxInflightTasks *int `json:"maxInflightTasks,omitempty"`
Issue *SentryIssue `json:"issue"`
}
// CreateIssueWatchRequest is the payload for POST /api/v1/sentry/watches/issue.
type CreateIssueWatchRequest struct {
WorkspaceID string `json:"workspaceId"`
WorkflowID string `json:"workflowId"`
WorkflowStepID string `json:"workflowStepId"`
RepositoryID string `json:"repositoryId"`
BaseBranch string `json:"baseBranch"`
Filter SearchFilter `json:"filter"`
AgentProfileID string `json:"agentProfileId"`
ExecutorProfileID string `json:"executorProfileId"`
Prompt string `json:"prompt"`
PollIntervalSeconds int `json:"pollIntervalSeconds"`
MaxInflightTasks *int `json:"maxInflightTasks,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// UpdateIssueWatchRequest is the payload for PATCH /api/v1/sentry/watches/issue/:id.
// All fields are pointers so the caller can omit ones it doesn't want to change.
type UpdateIssueWatchRequest struct {
WorkflowID *string `json:"workflowId,omitempty"`
WorkflowStepID *string `json:"workflowStepId,omitempty"`
RepositoryID *string `json:"repositoryId,omitempty"`
BaseBranch *string `json:"baseBranch,omitempty"`
Filter *SearchFilter `json:"filter,omitempty"`
AgentProfileID *string `json:"agentProfileId,omitempty"`
ExecutorProfileID *string `json:"executorProfileId,omitempty"`
Prompt *string `json:"prompt,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
PollIntervalSeconds *int `json:"pollIntervalSeconds,omitempty"`
// MaxInflightTasks is tri-state so a partial PATCH that omits the field
// leaves the cap unchanged. Absent = unchanged, null = uncapped, positive
// int = cap.
MaxInflightTasks optional.Int `json:"maxInflightTasks"`
}