package jira import ( "context" "database/sql" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) // Store persists the Jira configuration. Secret values are delegated to the // shared encrypted secret store and not stored here. The configuration is a // singleton — there is one Jira account per install, not one per workspace. type Store struct { db *sqlx.DB ro *sqlx.DB // migratedFromWorkspace records the workspace_id of the row that was // promoted into the singleton during initSchema. Provider reads this to // migrate the per-workspace secret to the new global key. Empty when no // migration ran (fresh install or already migrated). migratedFromWorkspace string } // NewStore creates a new Store and initializes the schema if needed. func NewStore(writer, reader *sqlx.DB) (*Store, error) { s := &Store{db: writer, ro: reader} if err := s.initSchema(); err != nil { return nil, fmt.Errorf("jira schema init: %w", err) } return s, nil } // MigratedFromWorkspace returns the workspace_id of the row promoted to the // singleton during the per-workspace → singleton schema migration, or "" when // no migration ran. Provider uses this to copy the secret over. func (s *Store) MigratedFromWorkspace() string { return s.migratedFromWorkspace } const createTablesSQL = ` CREATE TABLE IF NOT EXISTS jira_configs ( id TEXT PRIMARY KEY CHECK(id = 'singleton'), site_url TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', auth_method TEXT NOT NULL, instance_type TEXT NOT NULL DEFAULT 'cloud', default_project_key TEXT NOT NULL DEFAULT '', last_checked_at DATETIME, last_ok INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS jira_issue_watches ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workflow_id TEXT NOT NULL, workflow_step_id TEXT NOT NULL, jql TEXT NOT NULL, agent_profile_id TEXT NOT NULL DEFAULT '', executor_profile_id TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT '', enabled BOOLEAN NOT NULL DEFAULT 1, poll_interval_seconds INTEGER NOT NULL DEFAULT 300, -- Cap on concurrent open watcher-created tasks for this watch. -- NULL = uncapped. Positive integer = cap. Values <= 0 are rejected at -- the API layer. See docs/specs/throttle-watcher-fanout/. max_inflight_tasks INTEGER DEFAULT 5, last_polled_at DATETIME, last_error TEXT NOT NULL DEFAULT '', last_error_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_jira_issue_watches_workspace ON jira_issue_watches(workspace_id); CREATE TABLE IF NOT EXISTS jira_issue_watch_tasks ( id TEXT PRIMARY KEY, issue_watch_id TEXT NOT NULL, issue_key TEXT NOT NULL, issue_url TEXT NOT NULL, task_id TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, UNIQUE(issue_watch_id, issue_key), FOREIGN KEY(issue_watch_id) REFERENCES jira_issue_watches(id) ON DELETE CASCADE ); ` // singletonID is the synthetic primary key of the (only) row in jira_configs. // The CHECK constraint on the column makes inserting any other id an error, // so the table can never accidentally grow back to per-workspace rows. const singletonID = "singleton" func (s *Store) initSchema() error { if err := s.migrateLegacyPerWorkspaceTable(); err != nil { return err } if _, err := s.db.Exec(createTablesSQL); err != nil { return err } if err := s.addInstanceTypeColumn(); err != nil { return err } if err := s.addMaxInflightTasksColumn(); err != nil { return err } if err := s.addIssueWatchLastErrorColumns(); err != nil { return err } return nil } // addMaxInflightTasksColumn brings older databases up to the current schema by // adding the max_inflight_tasks column to jira_issue_watches when missing. // Existing rows backfill to the default (5). A fresh install hits the // column-already-present branch since createTablesSQL declares the column. func (s *Store) addMaxInflightTasksColumn() error { cols, err := s.tableColumns("jira_issue_watches") if err != nil { return err } if len(cols) == 0 { return nil } if _, ok := cols["max_inflight_tasks"]; ok { return nil } if _, err := s.db.Exec(`ALTER TABLE jira_issue_watches ADD COLUMN max_inflight_tasks INTEGER DEFAULT 5`); err != nil { return fmt.Errorf("add max_inflight_tasks column: %w", err) } return nil } // addIssueWatchLastErrorColumns brings older databases up to the current // schema by appending last_error / last_error_at to jira_issue_watches when // missing. Idempotent — column lookup before each ALTER avoids the // "duplicate column name" error on fresh installs that already have them. func (s *Store) addIssueWatchLastErrorColumns() error { cols, err := s.tableColumns("jira_issue_watches") if err != nil { return err } if _, ok := cols["last_error"]; !ok { if _, err := s.db.Exec(`ALTER TABLE jira_issue_watches ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`); err != nil { return fmt.Errorf("add last_error column: %w", err) } } if _, ok := cols["last_error_at"]; !ok { if _, err := s.db.Exec(`ALTER TABLE jira_issue_watches ADD COLUMN last_error_at DATETIME`); err != nil { return fmt.Errorf("add last_error_at column: %w", err) } } return nil } // addInstanceTypeColumn brings older databases up to the current schema by // adding the instance_type column to jira_configs when missing. Existing rows // were all written when the code only spoke Cloud, so 'cloud' is the safe // default. A fresh install hits the column-already-present branch since // createTablesSQL above declares the column with the same default. func (s *Store) addInstanceTypeColumn() error { cols, err := s.tableColumns("jira_configs") if err != nil { return err } if _, ok := cols["instance_type"]; ok { return nil } if _, err := s.db.Exec(`ALTER TABLE jira_configs ADD COLUMN instance_type TEXT NOT NULL DEFAULT 'cloud'`); err != nil { return fmt.Errorf("add instance_type column: %w", err) } return nil } // migrateLegacyPerWorkspaceTable detects the pre-singleton schema (where // jira_configs was keyed by workspace_id) and rewrites it into the singleton // shape. Picks the most-recently-updated row as the surviving config and // records the source workspace_id so the provider can migrate the secret. // // Idempotent: a fresh install has no legacy table and falls through to the // CREATE TABLE IF NOT EXISTS in createTablesSQL. func (s *Store) migrateLegacyPerWorkspaceTable() error { cols, err := s.tableColumns("jira_configs") if err != nil { return err } if len(cols) == 0 { return nil } if _, hasWorkspace := cols["workspace_id"]; !hasWorkspace { return nil } tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() // `last_checked_at`, `last_ok`, and `last_error` were added to the legacy // schema in a later release via ALTER TABLE. A deployment that upgrades // from the original schema would have a `workspace_id` column but not // these — selecting them unconditionally would crash startup. Build the // SELECT against only columns present in this database. healthCols := healthColumnsPresent(cols) selectCols := "workspace_id, site_url, email, auth_method, default_project_key" if healthCols { selectCols += ", last_checked_at, last_ok, last_error" } else { selectCols += ", NULL AS last_checked_at, 0 AS last_ok, '' AS last_error" } selectCols += ", created_at, updated_at" var sourceWorkspace, siteURL, email, authMethod, defaultProjectKey, lastError sql.NullString var lastCheckedAt sql.NullTime var lastOk sql.NullInt64 var createdAt, updatedAt sql.NullTime row := tx.QueryRow(`SELECT ` + selectCols + ` FROM jira_configs ORDER BY updated_at DESC LIMIT 1`) switch err := row.Scan(&sourceWorkspace, &siteURL, &email, &authMethod, &defaultProjectKey, &lastCheckedAt, &lastOk, &lastError, &createdAt, &updatedAt); { case errors.Is(err, sql.ErrNoRows): // Empty legacy table — drop and let createTablesSQL build the new shape. if _, err := tx.Exec(`DROP TABLE jira_configs`); err != nil { return err } return tx.Commit() case err != nil: return err } if _, err := tx.Exec(`DROP TABLE jira_configs`); err != nil { return err } if _, err := tx.Exec(` CREATE TABLE jira_configs ( id TEXT PRIMARY KEY CHECK(id = 'singleton'), site_url TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', auth_method TEXT NOT NULL, instance_type TEXT NOT NULL DEFAULT 'cloud', default_project_key TEXT NOT NULL DEFAULT '', last_checked_at DATETIME, last_ok INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL )`); err != nil { return err } // instance_type column is intentionally omitted from the INSERT: the column // default ('cloud') is the correct value for every legacy row, since prior // releases only supported Atlassian Cloud. if _, err := tx.Exec(` INSERT INTO jira_configs (id, site_url, email, auth_method, default_project_key, last_checked_at, last_ok, last_error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, singletonID, siteURL.String, email.String, authMethod.String, defaultProjectKey.String, nullableTime(lastCheckedAt), lastOk.Int64, lastError.String, nullableTime(createdAt), nullableTime(updatedAt)); err != nil { return err } if err := tx.Commit(); err != nil { return err } s.migratedFromWorkspace = sourceWorkspace.String return nil } // healthColumnsPresent reports whether the legacy jira_configs table has the // auth-health columns that were added in a later release. When all three are // missing we fall back to NULL/zero defaults rather than crashing on the // SELECT. func healthColumnsPresent(cols map[string]struct{}) bool { for _, name := range []string{"last_checked_at", "last_ok", "last_error"} { if _, ok := cols[name]; !ok { return false } } return true } func nullableTime(t sql.NullTime) interface{} { if !t.Valid { return nil } return t.Time } func (s *Store) tableColumns(table string) (map[string]struct{}, error) { rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) if err != nil { return nil, err } defer func() { _ = rows.Close() }() cols := make(map[string]struct{}) for rows.Next() { var ( cid int name string ctype string notnull int dflt sql.NullString pk int ) if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { return nil, err } cols[name] = struct{}{} } return cols, rows.Err() } const selectConfigColumns = `site_url, email, auth_method, instance_type, default_project_key, last_checked_at, last_ok, last_error, created_at, updated_at` // GetConfig returns the singleton Jira config, or nil when no row exists. func (s *Store) GetConfig(ctx context.Context) (*JiraConfig, error) { var cfg JiraConfig err := s.ro.GetContext(ctx, &cfg, `SELECT `+selectConfigColumns+` FROM jira_configs WHERE id = ?`, singletonID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &cfg, nil } // UpsertConfig inserts or updates the singleton config row. It never touches // the secret store — callers must persist the token separately. The last_* // health columns are deliberately not touched here; the poller owns those and // writes them via UpdateAuthHealth. func (s *Store) UpsertConfig(ctx context.Context, cfg *JiraConfig) error { now := time.Now().UTC() if cfg.CreatedAt.IsZero() { cfg.CreatedAt = now } cfg.UpdatedAt = now // Defensive: callers that bypass the service layer (tests, future // migrations) may leave InstanceType empty. Coerce to 'cloud' so reads // never surface a value the client doesn't understand. if cfg.InstanceType == "" { cfg.InstanceType = InstanceTypeCloud } _, err := s.db.ExecContext(ctx, ` INSERT INTO jira_configs (id, site_url, email, auth_method, instance_type, default_project_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET site_url = excluded.site_url, email = excluded.email, auth_method = excluded.auth_method, instance_type = excluded.instance_type, default_project_key = excluded.default_project_key, updated_at = excluded.updated_at`, singletonID, cfg.SiteURL, cfg.Email, cfg.AuthMethod, cfg.InstanceType, cfg.DefaultProjectKey, cfg.CreatedAt, cfg.UpdatedAt) return err } // DeleteConfig removes the singleton config row. Secrets must be cleared // separately by the caller. func (s *Store) DeleteConfig(ctx context.Context) error { _, err := s.db.ExecContext(ctx, `DELETE FROM jira_configs WHERE id = ?`, singletonID) return err } // HasConfig reports whether the singleton row exists. Used by the auth-health // poller to decide whether to probe at all. func (s *Store) HasConfig(ctx context.Context) (bool, error) { var present int err := s.ro.GetContext(ctx, &present, `SELECT COUNT(*) FROM jira_configs WHERE id = ?`, singletonID) if err != nil { return false, err } return present > 0, nil } // UpdateAuthHealth records the result of a credential probe on the singleton // config row. errMsg is the empty string when ok is true. If the row no longer // exists (e.g. the user removed the config concurrently with the poller), the // update is a silent no-op rather than an error. func (s *Store) UpdateAuthHealth(ctx context.Context, ok bool, errMsg string, checkedAt time.Time) error { _, err := s.db.ExecContext(ctx, ` UPDATE jira_configs SET last_checked_at = ?, last_ok = ?, last_error = ? WHERE id = ?`, checkedAt, ok, errMsg, singletonID) return err } // --- Issue watch operations --- // issueWatchInsertColumns / issueWatchSelectColumns split insert vs read so // the SELECT path can wrap nullable last_error in COALESCE (older databases // pre-self-heal migration may return NULL). const issueWatchInsertColumns = `id, workspace_id, workflow_id, workflow_step_id, jql, agent_profile_id, executor_profile_id, prompt, enabled, poll_interval_seconds, max_inflight_tasks, last_polled_at, last_error, last_error_at, created_at, updated_at` // nullableInt converts a *int into a value suitable for a nullable SQL column. // A nil pointer becomes a SQL NULL; a non-nil pointer becomes the underlying // int. Used for max_inflight_tasks where NULL means "uncapped". func nullableInt(v *int) interface{} { if v == nil { return nil } return *v } const issueWatchSelectColumns = `id, workspace_id, workflow_id, workflow_step_id, jql, agent_profile_id, executor_profile_id, prompt, enabled, poll_interval_seconds, max_inflight_tasks, last_polled_at, COALESCE(last_error, '') AS last_error, last_error_at, created_at, updated_at` // CreateIssueWatch persists a new issue watch row. ID and timestamps are // assigned here so callers can pass a partially-populated struct. func (s *Store) CreateIssueWatch(ctx context.Context, w *IssueWatch) error { if w.ID == "" { w.ID = uuid.New().String() } now := time.Now().UTC() w.CreatedAt = now w.UpdatedAt = now if w.PollIntervalSeconds <= 0 { w.PollIntervalSeconds = DefaultIssueWatchPollInterval } _, err := s.db.ExecContext(ctx, ` INSERT INTO jira_issue_watches (`+issueWatchInsertColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, w.ID, w.WorkspaceID, w.WorkflowID, w.WorkflowStepID, w.JQL, w.AgentProfileID, w.ExecutorProfileID, w.Prompt, w.Enabled, w.PollIntervalSeconds, nullableInt(w.MaxInflightTasks), w.LastPolledAt, w.LastError, w.LastErrorAt, w.CreatedAt, w.UpdatedAt) return err } // GetIssueWatch returns a single watch by ID, or nil when no row matches. func (s *Store) GetIssueWatch(ctx context.Context, id string) (*IssueWatch, error) { var w IssueWatch err := s.ro.GetContext(ctx, &w, `SELECT `+issueWatchSelectColumns+` FROM jira_issue_watches WHERE id = ?`, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &w, nil } // ListIssueWatches returns all watches configured for a workspace, in // insertion order. The UI uses this to render the watcher table. func (s *Store) ListIssueWatches(ctx context.Context, workspaceID string) ([]*IssueWatch, error) { var watches []*IssueWatch err := s.ro.SelectContext(ctx, &watches, `SELECT `+issueWatchSelectColumns+` FROM jira_issue_watches WHERE workspace_id = ? ORDER BY created_at`, workspaceID) if err != nil { return nil, err } return watches, nil } // ListAllIssueWatches returns every watch across all workspaces, in insertion // order. Used by the install-wide settings UI when no workspace filter is // supplied — the table renders a Workspace column so the user can manage // watches without first picking a workspace context. func (s *Store) ListAllIssueWatches(ctx context.Context) ([]*IssueWatch, error) { var watches []*IssueWatch err := s.ro.SelectContext(ctx, &watches, `SELECT `+issueWatchSelectColumns+` FROM jira_issue_watches ORDER BY workspace_id, created_at`) if err != nil { return nil, err } return watches, nil } // ListEnabledIssueWatches returns every enabled watch across all workspaces, // used by the poller to decide what to query each tick. func (s *Store) ListEnabledIssueWatches(ctx context.Context) ([]*IssueWatch, error) { var watches []*IssueWatch err := s.ro.SelectContext(ctx, &watches, `SELECT `+issueWatchSelectColumns+` FROM jira_issue_watches WHERE enabled = 1 ORDER BY created_at`) if err != nil { return nil, err } return watches, nil } // UpdateIssueWatch overwrites the mutable fields of an existing watch row. // updated_at is bumped automatically; last_polled_at is preserved unless the // caller explicitly sets it. func (s *Store) UpdateIssueWatch(ctx context.Context, w *IssueWatch) error { w.UpdatedAt = time.Now().UTC() if w.PollIntervalSeconds <= 0 { w.PollIntervalSeconds = DefaultIssueWatchPollInterval } _, err := s.db.ExecContext(ctx, ` UPDATE jira_issue_watches SET workflow_id = ?, workflow_step_id = ?, jql = ?, agent_profile_id = ?, executor_profile_id = ?, prompt = ?, enabled = ?, poll_interval_seconds = ?, max_inflight_tasks = ?, last_polled_at = ?, updated_at = ? WHERE id = ?`, w.WorkflowID, w.WorkflowStepID, w.JQL, w.AgentProfileID, w.ExecutorProfileID, w.Prompt, w.Enabled, w.PollIntervalSeconds, nullableInt(w.MaxInflightTasks), w.LastPolledAt, w.UpdatedAt, w.ID) return err } // UpdateIssueWatchLastPolled stamps the last-polled timestamp without touching // the rest of the row. The poller calls this after every check so the UI can // show "polled X seconds ago". func (s *Store) UpdateIssueWatchLastPolled(ctx context.Context, id string, t time.Time) error { _, err := s.db.ExecContext(ctx, `UPDATE jira_issue_watches SET last_polled_at = ?, updated_at = ? WHERE id = ?`, t, time.Now().UTC(), id) return err } // DisableIssueWatchWithError is the self-heal write: it disables the watch // and stamps a human-readable cause + timestamp so the settings UI can show // a "disabled because ..." banner. Called by the orchestrator dispatch // pipeline when the watcher's bound agent profile is detected as // soft-deleted. func (s *Store) DisableIssueWatchWithError(ctx context.Context, id, cause string) error { now := time.Now().UTC() _, err := s.db.ExecContext(ctx, `UPDATE jira_issue_watches SET enabled = 0, last_error = ?, last_error_at = ?, updated_at = ? WHERE id = ?`, cause, now, now, id) return err } // DeleteIssueWatch removes a watch and (via FK ON DELETE CASCADE) its dedup // rows in a single transaction. The explicit DELETE on the child table guards // older databases where foreign_keys may not have been enabled at attach time. func (s *Store) DeleteIssueWatch(ctx context.Context, id string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, id); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM jira_issue_watches WHERE id = ?`, id); err != nil { return err } return tx.Commit() } // ReserveIssueWatchTask atomically claims a slot for a (watch, ticket) pair via // INSERT OR IGNORE. Returns true when this caller won the race and should // proceed to create the task. False either means another handler already // reserved the same ticket or the row already exists from a prior run. func (s *Store) ReserveIssueWatchTask(ctx context.Context, watchID, issueKey, issueURL string) (bool, error) { res, err := s.db.ExecContext(ctx, ` INSERT OR IGNORE INTO jira_issue_watch_tasks (id, issue_watch_id, issue_key, issue_url, task_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`, uuid.New().String(), watchID, issueKey, issueURL, "", time.Now().UTC()) if err != nil { return false, err } rows, err := res.RowsAffected() if err != nil { return false, err } return rows == 1, nil } // AssignIssueWatchTaskID stamps the created task ID onto a previously-reserved // dedup row. Returns an error if no reservation matches — callers should treat // that as a programming bug since they only call this after a successful // reservation. func (s *Store) AssignIssueWatchTaskID(ctx context.Context, watchID, issueKey, taskID string) error { res, err := s.db.ExecContext(ctx, ` UPDATE jira_issue_watch_tasks SET task_id = ? WHERE issue_watch_id = ? AND issue_key = ?`, taskID, watchID, issueKey) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return fmt.Errorf("assign task ID: reservation row not found for watch=%s issue=%s", watchID, issueKey) } return nil } // ReleaseIssueWatchTask drops a reservation so the next poll can retry. Used // when task creation fails after a successful reserve. func (s *Store) ReleaseIssueWatchTask(ctx context.Context, watchID, issueKey string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM jira_issue_watch_tasks WHERE issue_watch_id = ? AND issue_key = ?`, watchID, issueKey) return err } // ListSeenIssueKeys returns the set of ticket keys already reserved against a // watch. Returning a set in one query is cheaper than per-ticket existence // checks — a single JQL search can return up to 50 tickets per tick, so the // per-call savings scale with the workspace's watch count. func (s *Store) ListSeenIssueKeys(ctx context.Context, watchID string) (map[string]struct{}, error) { var keys []string err := s.ro.SelectContext(ctx, &keys, `SELECT issue_key FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, watchID) if err != nil { return nil, err } out := make(map[string]struct{}, len(keys)) for _, k := range keys { out[k] = struct{}{} } return out, nil } // ListIssueWatchTaskIDs returns every task_id recorded against a watch, // including the empty-string sentinel reservations that never completed. // Used by the reset flow to enumerate the tasks to cascade-delete. func (s *Store) ListIssueWatchTaskIDs(ctx context.Context, watchID string) ([]string, error) { var ids []string err := s.ro.SelectContext(ctx, &ids, `SELECT task_id FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, watchID) return ids, err } // ResetIssueWatchState wipes the watch's dedup rows and nulls its // last_polled_at in one transaction. Used by the reset flow after the // cascade-delete loop so the next poll re-imports every currently-matching // ticket as if the watch were freshly created. func (s *Store) ResetIssueWatchState(ctx context.Context, watchID string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM jira_issue_watch_tasks WHERE issue_watch_id = ?`, watchID); err != nil { return err } if _, err := tx.ExecContext(ctx, `UPDATE jira_issue_watches SET last_polled_at = NULL, updated_at = ? WHERE id = ?`, time.Now().UTC(), watchID); err != nil { return err } return tx.Commit() }