From e86c46888d52763d799441f29bbcc26ed3921be3 Mon Sep 17 00:00:00 2001 From: SefanFVogel Date: Wed, 17 Jun 2026 20:02:28 +0000 Subject: [PATCH] feat(mantis): add schema and models Walking-skeleton T01 of Mantis Bug Tracker integration. Idempotent CREATE TABLE / CREATE INDEX for mantis_configs, mantis_issue_watches, and mantis_issue_watch_tasks plus Go models (MantisConfig, MantisIssueWatch, MantisFilter, MantisIssue). E2E reset wipes the new per-workspace tables before task deletion so the future Mantis forward poller can't recreate rows mid-reset. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/cmd/kandev/e2e_reset.go | 31 +++ apps/backend/cmd/kandev/e2e_reset_test.go | 152 +++++++++++ apps/backend/internal/mantis/models.go | 241 ++++++++++++++++++ .../task/repository/sqlite/base_migrations.go | 11 + .../task/repository/sqlite/base_schema.go | 67 +++++ .../repository/sqlite/mantis_schema_test.go | 133 ++++++++++ 6 files changed, 635 insertions(+) create mode 100644 apps/backend/cmd/kandev/e2e_reset_test.go create mode 100644 apps/backend/internal/mantis/models.go create mode 100644 apps/backend/internal/task/repository/sqlite/mantis_schema_test.go diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go index e3d09495dc..346e734754 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "net/http" "os" "strings" @@ -96,6 +97,17 @@ func handleE2EReset( log.Warn("e2e reset: agent settings reset failed", zap.Error(err)) } + // Wipe Mantis configs + issue watches (and their dedup rows) so + // per-workspace Mantis state doesn't bleed across specs. Done before + // task deletion so the Mantis forward poller (once wired in T05) + // can't recreate dedup rows / tasks mid-reset — same race the + // GitHub review watch cleanup below was hardened against. + if err := deleteMantisStateForReset(ctx, repo.DB(), workspaceID); err != nil { + log.Error("e2e reset: mantis cleanup failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + // Wipe GitHub review watches (and their dedup rows + owned tasks) so // review-watch specs stay isolated. seedData/backend are worker-scoped, // so a watch an earlier test created stays enabled; the global review @@ -185,6 +197,25 @@ func deleteAutomationsForReset( return automationSvc.Store().DeleteAutomationsByWorkspace(ctx, workspaceID) } +// deleteMantisStateForReset wipes per-workspace Mantis state in the order +// required by the foreign-key topology: dedup rows first (so the FK to +// mantis_issue_watches doesn't fire after the parent is gone), then watches, +// then the workspace config. Callers must invoke this BEFORE the task +// deletion phase so the Mantis forward poller can't recreate rows mid-reset +// — see apps/backend/CLAUDE.md "E2E reset invariant". +func deleteMantisStateForReset(ctx context.Context, db *sql.DB, workspaceID string) error { + for _, q := range []string{ + `DELETE FROM mantis_issue_watch_tasks WHERE issue_watch_id IN (SELECT id FROM mantis_issue_watches WHERE workspace_id = ?)`, + `DELETE FROM mantis_issue_watches WHERE workspace_id = ?`, + `DELETE FROM mantis_configs WHERE workspace_id = ?`, + } { + if _, err := db.ExecContext(ctx, q, workspaceID); err != nil { + return err + } + } + return nil +} + type e2eHiddenWorkflowRequest struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` diff --git a/apps/backend/cmd/kandev/e2e_reset_test.go b/apps/backend/cmd/kandev/e2e_reset_test.go new file mode 100644 index 0000000000..f1a45330b5 --- /dev/null +++ b/apps/backend/cmd/kandev/e2e_reset_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// TestDeleteMantisStateForReset_OnlyTargetWorkspace locks in the workspace +// isolation contract: the cleanup deletes Mantis state for one workspace and +// leaves every other workspace untouched. Catches accidental over-broad +// DELETEs (e.g. dropping the WHERE clause) before they leak into specs where +// two workspaces share a Playwright worker's database. +func TestDeleteMantisStateForReset_OnlyTargetWorkspace(t *testing.T) { + repo, sqlxDB := newE2EResetTestRepo(t) + _ = repo + + target := uuid.New().String() + bystander := uuid.New().String() + seedMantisFixtures(t, sqlxDB, target) + seedMantisFixtures(t, sqlxDB, bystander) + + if err := deleteMantisStateForReset(context.Background(), sqlxDB.DB, target); err != nil { + t.Fatalf("deleteMantisStateForReset: %v", err) + } + + if got := countMantisConfigs(t, sqlxDB, target); got != 0 { + t.Errorf("target mantis_configs still has %d rows, want 0", got) + } + if got := countMantisWatches(t, sqlxDB, target); got != 0 { + t.Errorf("target mantis_issue_watches still has %d rows, want 0", got) + } + if got := countMantisWatchTasks(t, sqlxDB, target); got != 0 { + t.Errorf("target mantis_issue_watch_tasks still has %d rows, want 0", got) + } + + if got := countMantisConfigs(t, sqlxDB, bystander); got != 1 { + t.Errorf("bystander mantis_configs has %d rows, want 1 (cleanup leaked across workspaces)", got) + } + if got := countMantisWatches(t, sqlxDB, bystander); got != 1 { + t.Errorf("bystander mantis_issue_watches has %d rows, want 1", got) + } + if got := countMantisWatchTasks(t, sqlxDB, bystander); got != 1 { + t.Errorf("bystander mantis_issue_watch_tasks has %d rows, want 1", got) + } +} + +// TestDeleteMantisStateForReset_Idempotent locks in the no-op-safe contract: +// running the cleanup against a workspace that has no Mantis rows must not +// error. Reset endpoints are hit between every spec, so the empty-state path +// is hot. +func TestDeleteMantisStateForReset_Idempotent(t *testing.T) { + _, sqlxDB := newE2EResetTestRepo(t) + ws := uuid.New().String() + if err := deleteMantisStateForReset(context.Background(), sqlxDB.DB, ws); err != nil { + t.Fatalf("first call: %v", err) + } + if err := deleteMantisStateForReset(context.Background(), sqlxDB.DB, ws); err != nil { + t.Fatalf("second call: %v", err) + } +} + +// newE2EResetTestRepo opens an on-disk SQLite repo so initSchema runs through +// the same code path the live binary uses on boot. +func newE2EResetTestRepo(t *testing.T) (*sqliterepo.Repository, *sqlx.DB) { + t.Helper() + tmpDir := t.TempDir() + dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := sqliterepo.NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + t.Cleanup(func() { _ = sqlxDB.Close() }) + return repo, sqlxDB +} + +// seedMantisFixtures writes one row into each of the three Mantis tables for +// the given workspace, with a deterministic watch ID chain so the watch_tasks +// row's FK to mantis_issue_watches is satisfied. +func seedMantisFixtures(t *testing.T, db *sqlx.DB, workspaceID string) { + t.Helper() + now := time.Now().UTC() + if _, err := db.Exec(` + INSERT INTO mantis_configs ( + workspace_id, base_url, username, auth_method, default_project_id, + last_ok, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspaceID, "https://example.mantis", "user", "api_token", 0, 0, "", now, now, + ); err != nil { + t.Fatalf("seed mantis_configs: %v", err) + } + watchID := uuid.New().String() + if _, err := db.Exec(` + INSERT INTO mantis_issue_watches ( + id, workspace_id, workflow_id, workflow_step_id, filter, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + watchID, workspaceID, "wf", "step", "{}", "", "", "", 1, 300, "", now, now, + ); err != nil { + t.Fatalf("seed mantis_issue_watches: %v", err) + } + if _, err := db.Exec(` + INSERT INTO mantis_issue_watch_tasks ( + id, issue_watch_id, issue_id, issue_url, task_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + uuid.New().String(), watchID, "42", "https://example.mantis/view.php?id=42", "", now, + ); err != nil { + t.Fatalf("seed mantis_issue_watch_tasks: %v", err) + } +} + +func countMantisConfigs(t *testing.T, db *sqlx.DB, workspaceID string) int { + t.Helper() + var n int + if err := db.Get(&n, `SELECT COUNT(*) FROM mantis_configs WHERE workspace_id = ?`, workspaceID); err != nil { + t.Fatalf("count mantis_configs: %v", err) + } + return n +} + +func countMantisWatches(t *testing.T, db *sqlx.DB, workspaceID string) int { + t.Helper() + var n int + if err := db.Get(&n, `SELECT COUNT(*) FROM mantis_issue_watches WHERE workspace_id = ?`, workspaceID); err != nil { + t.Fatalf("count mantis_issue_watches: %v", err) + } + return n +} + +func countMantisWatchTasks(t *testing.T, db *sqlx.DB, workspaceID string) int { + t.Helper() + var n int + if err := db.Get(&n, ` + SELECT COUNT(*) FROM mantis_issue_watch_tasks + WHERE issue_watch_id IN (SELECT id FROM mantis_issue_watches WHERE workspace_id = ?) + `, workspaceID); err != nil { + t.Fatalf("count mantis_issue_watch_tasks: %v", err) + } + return n +} diff --git a/apps/backend/internal/mantis/models.go b/apps/backend/internal/mantis/models.go new file mode 100644 index 0000000000..71e8d82171 --- /dev/null +++ b/apps/backend/internal/mantis/models.go @@ -0,0 +1,241 @@ +// Package mantis implements the Mantis Bug Tracker integration: a per-workspace +// configuration, a REST client for issues and transitions, and the HTTP and +// WebSocket handlers that expose these capabilities to the frontend. +// +// Unlike the Jira and Linear integrations — which are install-wide singletons — +// Mantis is configured per workspace because deployments tend to be team- or +// project-specific (self-hosted instances, on-prem behind a VPN, etc.). Each +// workspace can point at a different Mantis endpoint with its own API token. +package mantis + +import ( + "time" + + "github.com/kandev/kandev/internal/integrations/optional" +) + +// AuthMethodAPIToken is the only auth method Mantis supports today: a Personal +// API Token created from the user's Mantis account page and sent in the +// `Authorization` header. The constant exists so the wire format mirrors the +// Jira/Linear `authMethod` field and leaves room for HTTP Basic in the future. +const AuthMethodAPIToken = "api_token" + +// MantisConfig is the per-workspace configuration for the Mantis integration. +// The API token is stored separately in the encrypted secret store under +// SecretKeyForWorkspace(WorkspaceID). +type MantisConfig struct { + WorkspaceID string `json:"workspaceId" db:"workspace_id"` + BaseURL string `json:"baseUrl" db:"base_url"` + Username string `json:"username" db:"username"` + AuthMethod string `json:"authMethod" db:"auth_method"` + DefaultProjectID int `json:"defaultProjectId" db:"default_project_id"` + HasSecret bool `json:"hasSecret" db:"-"` + // LastCheckedAt / LastOk / LastError are written by the background auth + // poller. They let the UI 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"` +} + +// SecretKeyForWorkspace returns the secret-store key used for a workspace's +// Mantis API token. Unlike Jira/Linear (which are install-wide singletons), +// Mantis is configured per workspace so each row gets its own secret key. +func SecretKeyForWorkspace(workspaceID string) string { + return "mantis:" + workspaceID + ":token" +} + +// MantisFilter is a structured search filter used by issue watches and the +// search UI. Mantis exposes a `filter_search` REST endpoint that takes the +// same dimensions; the poller serializes the filter to JSON for storage and +// translates it to query parameters at request time. +// +// All fields are optional; an empty filter matches every issue the API token +// can see. Watch creation rejects fully-empty filters via filterIsEmpty. +type MantisFilter struct { + // ProjectID restricts to a single Mantis project. 0 means "all projects + // the API token can read". + ProjectID int `json:"projectId,omitempty"` + // CategoryName filters by category label (Mantis categories are + // per-project strings, not stable IDs across projects). + CategoryName string `json:"categoryName,omitempty"` + // StatusIDs is the set of Mantis status numerics to include. Empty + // matches every status. Standard Mantis values: 10=new, 20=feedback, + // 30=acknowledged, 40=confirmed, 50=assigned, 80=resolved, 90=closed. + StatusIDs []int `json:"statusIds,omitempty"` + // HandlerUsername restricts to issues assigned to this Mantis user. + // Empty means any handler (including unassigned). + HandlerUsername string `json:"handlerUsername,omitempty"` + // ReporterUsername restricts to issues reported by this Mantis user. + // Empty means any reporter. + ReporterUsername string `json:"reporterUsername,omitempty"` + // SearchText is a free-text match against summary and description. + SearchText string `json:"searchText,omitempty"` + // PriorityIDs is the set of Mantis priority numerics to include. Empty + // matches every priority. Standard values: 10=none, 20=low, 30=normal, + // 40=high, 50=urgent, 60=immediate. + PriorityIDs []int `json:"priorityIds,omitempty"` +} + +// MantisIssue is the subset of Mantis's bug payload that Kandev consumes. +// Kept small intentionally: the UI needs enough to prefill a task, show the +// current status, and surface a few familiar fields (handler, reporter, +// priority) in the popover so users don't have to switch tabs to Mantis. +type MantisIssue struct { + // ID is the Mantis bug number — exposed as a string for wire-format + // parity with Jira (key) and Linear (identifier). The numeric form is + // canonical inside Mantis URLs (view.php?id=NNNN). + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + // Status mirrors Jira's status tuple (id, name, category) so frontend + // code styling status pills can branch on Category without per-integration + // switches. Mantis status numerics map onto: 10/20/30 → "new", + // 40/50/60/70 → "indeterminate", 80/90 → "done". + StatusID int `json:"statusId"` + StatusName string `json:"statusName"` + StatusCategory string `json:"statusCategory"` + ProjectID int `json:"projectId"` + ProjectName string `json:"projectName"` + CategoryName string `json:"categoryName,omitempty"` + Priority string `json:"priority,omitempty"` + Severity string `json:"severity,omitempty"` + HandlerName string `json:"handlerName,omitempty"` + HandlerEmail string `json:"handlerEmail,omitempty"` + ReporterName string `json:"reporterName,omitempty"` + ReporterEmail string `json:"reporterEmail,omitempty"` + Updated string `json:"updated,omitempty"` + URL string `json:"url"` +} + +// SecretKey is the secret-store key prefix used for Mantis API tokens. The +// per-workspace key is built via SecretKeyForWorkspace; this constant exists +// for symmetry with Jira/Linear and as a sentinel for migration tooling. +const SecretKey = "mantis" + +// DefaultIssueWatchPollInterval is the polling cadence assigned to a watcher +// when the caller does not specify one. Five minutes balances freshness against +// the load self-hosted Mantis instances can absorb when many workspaces have +// watches configured. +const DefaultIssueWatchPollInterval = 300 + +// MantisIssueWatch configures periodic Mantis polling: a workspace-scoped +// watcher runs the filter on a schedule and emits a NewMantisIssueEvent for +// each matching bug the orchestrator hasn't already turned into a Kandev task. +// +// As with Jira and Linear, Mantis issues have no repository affinity — the +// target workflow step's defaults determine where the resulting task runs. +type MantisIssueWatch 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"` + Filter MantisFilter `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. + // See docs/specs/throttle-watcher-fanout/spec.md for the open-task definition. + 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 pipeline self- + // heals the watcher (e.g. the bound agent profile was soft-deleted). + // Empty for a healthy watcher. + 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"` +} + +// MantisIssueWatchTask deduplicates task creation per (watch, issue) tuple. The +// UNIQUE constraint on (issue_watch_id, issue_id) prevents two concurrent +// pollers from racing to create duplicate tasks for the same bug. +type MantisIssueWatchTask struct { + ID string `json:"id" db:"id"` + IssueWatchID string `json:"issueWatchId" db:"issue_watch_id"` + IssueID string `json:"issueId" db:"issue_id"` + IssueURL string `json:"issueUrl" db:"issue_url"` + TaskID string `json:"taskId" db:"task_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` +} + +// NewMantisIssueEvent is published on the bus whenever the poller observes a +// bug matching a watch that has no existing dedup row. The orchestrator +// consumes this to create (and optionally auto-start) a Kandev task. +type NewMantisIssueEvent struct { + IssueWatchID string `json:"issueWatchId"` + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + 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 *MantisIssue `json:"issue"` +} + +// SetConfigRequest is the payload sent by the UI to create or update the +// Mantis configuration for a workspace. When Secret is empty on update, the +// existing secret is retained; when non-empty it replaces the stored value. +type SetConfigRequest struct { + WorkspaceID string `json:"workspaceId"` + BaseURL string `json:"baseUrl"` + Username string `json:"username"` + AuthMethod string `json:"authMethod"` + DefaultProjectID int `json:"defaultProjectId"` + Secret string `json:"secret"` +} + +// TestConnectionResult reports what the backend learned when pinging Mantis +// with the supplied credentials. It is used both to verify newly-entered +// credentials and to surface a meaningful error to the UI when they fail. +type TestConnectionResult struct { + OK bool `json:"ok"` + UserID int `json:"userId,omitempty"` + Username string `json:"username,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"email,omitempty"` + Error string `json:"error,omitempty"` +} + +// CreateIssueWatchRequest is the payload for POST /api/v1/mantis/watches/issue. +type CreateIssueWatchRequest struct { + WorkspaceID string `json:"workspaceId"` + WorkflowID string `json:"workflowId"` + WorkflowStepID string `json:"workflowStepId"` + Filter MantisFilter `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/mantis/watches/issue/:id. Most fields are pointers so callers can +// omit the ones they don't want to change. MaxInflightTasks uses optional.Int +// for tri-state PATCH semantics (absent = unchanged, null = uncapped, positive +// int = cap). +type UpdateIssueWatchRequest struct { + WorkflowID *string `json:"workflowId,omitempty"` + WorkflowStepID *string `json:"workflowStepId,omitempty"` + Filter *MantisFilter `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 (a plain *int can't tell "omitted" from + // "null"). Absent = unchanged, null = uncapped, positive int = cap. + MaxInflightTasks optional.Int `json:"maxInflightTasks"` +} diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 520ba48d87..976d7055fc 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -141,6 +141,17 @@ func (r *Repository) runMigrations() error { // repo hasn't run yet. r.ensureRunnerProjectionTables() + // Mantis integration schema. initSchema runs this first via the + // initMantisSchema step on fresh databases; the second call here + // is a no-op on fresh DBs (CREATE TABLE IF NOT EXISTS) and the + // canonical path for installs that boot past this release without + // having gone through initMantisSchema. Future column additions + // must use ADD COLUMN migrations above, never by mutating the + // CREATE TABLE block. + if err := r.initMantisSchema(); err != nil { + return err + } + return nil } diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index f0cc844199..a558ee84bf 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -21,6 +21,7 @@ func (r *Repository) initSchema() error { r.initSessionSchema, r.initGitSchema, r.initReviewSchema, + r.initMantisSchema, r.migrateExecutorProfiles, r.migrateTaskSessions, r.ensureDefaultWorkspace, @@ -684,3 +685,69 @@ func (r *Repository) initReviewSchema() error { `) return err } + +// mantisSchemaDDL groups mantis_configs, mantis_issue_watches, and +// mantis_issue_watch_tasks DDL. Unlike Jira/Linear — which are install-wide +// singletons — Mantis is configured per workspace, so mantis_configs has a +// (workspace_id) primary key with no singleton CHECK constraint. +// +// All statements use CREATE TABLE / CREATE INDEX IF NOT EXISTS so the block +// is safe to run on both fresh and existing databases. Per the schema rule +// in apps/backend/CLAUDE.md, evolving columns later must happen via ADD +// COLUMN inside runMigrations(), not by mutating this DDL. +const mantisSchemaDDL = ` + CREATE TABLE IF NOT EXISTS mantis_configs ( + workspace_id TEXT PRIMARY KEY, + base_url TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + auth_method TEXT NOT NULL DEFAULT 'api_token', + default_project_id INTEGER NOT NULL DEFAULT 0, + 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 mantis_issue_watches ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + workflow_step_id TEXT NOT NULL, + filter TEXT NOT NULL DEFAULT '{}', + agent_profile_id TEXT NOT NULL DEFAULT '', + executor_profile_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + poll_interval_seconds INTEGER NOT NULL DEFAULT 300, + 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_mantis_issue_watches_workspace + ON mantis_issue_watches(workspace_id); + + CREATE TABLE IF NOT EXISTS mantis_issue_watch_tasks ( + id TEXT PRIMARY KEY, + issue_watch_id TEXT NOT NULL, + issue_id TEXT NOT NULL, + issue_url TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + UNIQUE(issue_watch_id, issue_id), + FOREIGN KEY(issue_watch_id) REFERENCES mantis_issue_watches(id) ON DELETE CASCADE + ); +` + +// initMantisSchema creates the Mantis integration tables on fresh databases. +// The DDL is idempotent (CREATE TABLE / CREATE INDEX IF NOT EXISTS) so the +// step is also safe to re-invoke on existing databases — runMigrations calls +// it a second time as a belt-and-suspenders for installs upgrading past this +// release. +func (r *Repository) initMantisSchema() error { + _, err := r.db.Exec(mantisSchemaDDL) + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/mantis_schema_test.go b/apps/backend/internal/task/repository/sqlite/mantis_schema_test.go new file mode 100644 index 0000000000..969549f83d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/mantis_schema_test.go @@ -0,0 +1,133 @@ +package sqlite + +import ( + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +// newRepoForMantisSchemaTests opens a fresh on-disk SQLite repo. On-disk +// (rather than :memory:) because the migration regression we care about — +// running initSchema twice in the same process — exercises the persistence +// layer the same way a backend restart does. +func newRepoForMantisSchemaTests(t *testing.T) (*Repository, *sqlx.DB) { + t.Helper() + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + t.Cleanup(func() { _ = sqlxDB.Close() }) + return repo, sqlxDB +} + +// TestInitMantisSchema_FreshDBCreatesTables locks in the contract that a fresh +// install gets all three Mantis tables plus the workspace lookup index. If a +// later refactor accidentally removes a CREATE TABLE / CREATE INDEX, this test +// catches it before the orchestrator wiring in T03 hits a "no such table" +// error at runtime. +func TestInitMantisSchema_FreshDBCreatesTables(t *testing.T) { + _, db := newRepoForMantisSchemaTests(t) + + expectTable(t, db, "mantis_configs") + expectTable(t, db, "mantis_issue_watches") + expectTable(t, db, "mantis_issue_watch_tasks") + expectIndex(t, db, "idx_mantis_issue_watches_workspace") +} + +// TestInitMantisSchema_Idempotent locks in the no-op-safe contract: calling +// initMantisSchema twice (the boot path + the runMigrations re-application) +// must not fail on a database that already has the tables. Acceptance +// criterion: "Migration ist auf frischer und auf existierender DB no-op-safe; +// zweimal hintereinander migrieren wirft keinen Fehler". +func TestInitMantisSchema_Idempotent(t *testing.T) { + repo, _ := newRepoForMantisSchemaTests(t) + + if err := repo.initMantisSchema(); err != nil { + t.Fatalf("second initMantisSchema call: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations re-invocation: %v", err) + } +} + +// TestInitMantisSchema_InsertRoundTrip proves the tables aren't just empty +// shells: a real insert against mantis_configs and mantis_issue_watches +// succeeds with the columns declared in mantisSchemaDDL. Catches column-name +// drift between models.go and the DDL before the service layer in T03 starts +// depending on the schema. +func TestInitMantisSchema_InsertRoundTrip(t *testing.T) { + _, db := newRepoForMantisSchemaTests(t) + now := time.Now().UTC() + workspaceID := uuid.New().String() + + if _, err := db.Exec(` + INSERT INTO mantis_configs ( + workspace_id, base_url, username, auth_method, default_project_id, + last_ok, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspaceID, "https://example.mantis", "user", "api_token", 0, 0, "", now, now, + ); err != nil { + t.Fatalf("insert mantis_configs: %v", err) + } + + watchID := uuid.New().String() + if _, err := db.Exec(` + INSERT INTO mantis_issue_watches ( + id, workspace_id, workflow_id, workflow_step_id, filter, + agent_profile_id, executor_profile_id, prompt, enabled, + poll_interval_seconds, last_error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + watchID, workspaceID, "wf", "step", "{}", "", "", "", 1, 300, "", now, now, + ); err != nil { + t.Fatalf("insert mantis_issue_watches: %v", err) + } + + if _, err := db.Exec(` + INSERT INTO mantis_issue_watch_tasks ( + id, issue_watch_id, issue_id, issue_url, task_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + uuid.New().String(), watchID, "1234", "https://example.mantis/view.php?id=1234", "", now, + ); err != nil { + t.Fatalf("insert mantis_issue_watch_tasks: %v", err) + } +} + +// expectTable fails the test when the named table is not in sqlite_master. +func expectTable(t *testing.T, db *sqlx.DB, name string) { + t.Helper() + var found string + err := db.Get(&found, + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name) + if err != nil { + t.Fatalf("query sqlite_master for %s: %v", name, err) + } + if found != name { + t.Fatalf("expected table %q, got %q", name, found) + } +} + +// expectIndex fails the test when the named index is not in sqlite_master. +func expectIndex(t *testing.T, db *sqlx.DB, name string) { + t.Helper() + var found string + err := db.Get(&found, + `SELECT name FROM sqlite_master WHERE type='index' AND name=?`, name) + if err != nil { + t.Fatalf("query sqlite_master for index %s: %v", name, err) + } + if found != name { + t.Fatalf("expected index %q, got %q", name, found) + } +}