feat: bind repository to Linear/Jira/Sentry issue watchers (#1491)

This commit is contained in:
Nicolas Lenepveu
2026-06-25 19:33:15 +02:00
committed by GitHub
parent cb7aa3fa71
commit 5021066d78
52 changed files with 2985 additions and 151 deletions
@@ -0,0 +1,491 @@
---
spec: none — self-contained PR plan (see "Problem" and "Design" below)
created: 2026-06-24
status: draft
area: backend, frontend
related_prs: ["#1094 (watcher self-heal)", "#1124 (use-step-default reset pattern)", "ADR 0013 (multi-branch tasks)", "ADR 0008 (DB upgrade safety)"]
---
# Implementation Plan: Issue-Watcher Repository Binding (Linear / Jira / Sentry)
## Problem
The Linear, Jira, and Sentry issue-watchers create tasks with **no repository
associated**. As a result their agents launch into a blank scratch git repo
instead of the target codebase ("started with no repository checkout").
Validated end-to-end against the current source:
- The watch models carry no repository field:
- Linear `IssueWatch` — `apps/backend/internal/linear/models.go:190-213`
- Jira `IssueWatch` — `apps/backend/internal/jira/models.go:162-185`
- Sentry `IssueWatch` — `apps/backend/internal/sentry/models.go:133-156`
All three carry only workspace/workflow/step, the per-integration filter
(`Filter SearchFilter` for Linear/Sentry, `JQL string` for Jira),
agent/executor profile, prompt, enabled, poll interval, max-inflight, and
error stamps. (The models even carry an explicit comment: *"Linear/JIRA
issues have no repository affinity"* — `linear/models.go:188-189`,
`jira/models.go:159-161`.)
- The orchestrator sources build an `IssueTaskRequest` with no `Repositories`:
- `LinearWatcherSource.BuildTaskRequest` — `apps/backend/internal/orchestrator/source_linear.go:60-81`
- `JiraWatcherSource.BuildTaskRequest` — `apps/backend/internal/orchestrator/source_jira.go:57-80`
- `SentryWatcherSource.BuildTaskRequest` — `apps/backend/internal/orchestrator/source_sentry.go:96-118`
- At launch, the worktree env-preparer branches on whether the launch request
carries repositories. `EnvPrepareRequest.RepoSpecs()`
(`apps/backend/internal/agent/runtime/lifecycle/env_preparer.go:86-91`) and
`manager_launch.go:650-677` take the worktree path only when
`len(req.Repositories) > 0`. With zero repositories the launch falls to
`initGitRepo` (`apps/backend/internal/agent/runtime/lifecycle/manager_launch.go:1250-1300`,
invoked at `manager_launch.go:305`), which `git init`s a blank scratch repo
with an empty `.gitkeep` and an "Initial commit" authored by
`Kandev Quick Chat <quickchat@kandev.local>`. The agent then runs with no
source checkout.
The `workspace_path` override is **not** a fix:
- `MetaKeyWorkspacePath` (`apps/backend/internal/task/models/models.go:66`) is
only written from `CreateTaskRequest.WorkspacePath`
(`apps/backend/internal/task/service/service_requests.go:47`, applied at
`service_tasks.go:250-254`) — the UI "starting folder" picker. Watchers never
set it.
- When set, it runs the agent **directly in that folder with no per-task
isolation** (`apps/backend/internal/orchestrator/executor/executor_execute.go:271`)
— unacceptable for a fan-out watcher creating many concurrent tasks
(e.g. a Linear watch with `maxInflightTasks: 10`).
## Goal & Non-Goals
**Goal.** Let each Linear / Jira / Sentry issue-watch optionally bind a
**repository** (`repository_id`) and a **base branch** (`base_branch`), so
watcher-created tasks carry `Repositories` and take the existing
`len(req.Repositories) > 0` path → an **isolated git worktree per task** cut
from the bound repo at the base branch. This mirrors the GitHub watcher.
**Non-Goals.**
- No change to GitHub watcher behavior — it is the reference template and is
out of scope to modify.
- No multi-repo / multi-branch binding for watchers. A watch binds **at most
one** `(repository_id, base_branch)`. (Multi-branch tasks — ADR 0013 — remain
a manual/agent-driven flow.)
- No automatic re-pointing of existing repo-less watches; they keep working
exactly as today.
- Deleted-repository self-heal is *scoped* here (see "Self-heal interplay") but
its full implementation may land as a follow-up.
## Design
### Reference template — the GitHub watcher
The transport already exists. `IssueTaskRequest`
(`apps/backend/internal/orchestrator/event_handlers_github.go:83-91`) already
has `Repositories []IssueTaskRepository`, and `IssueTaskRepository`
(`event_handlers_github.go:94-97`) is exactly:
```go
type IssueTaskRepository struct {
RepositoryID string
BaseBranch string
}
```
**No change to `IssueTaskRequest` or `IssueTaskRepository` is required** — the
three watcher sources simply need to populate the existing slice. The GitHub
source resolves owner/name → repo via a resolver; our watchers store a kandev
`repository_id` directly, so they can build the slice with no resolution step.
### Core change & the invariant
Each watch gains two optional columns/fields: `repository_id` and
`base_branch`. The single behavioural switch is in each source's
`BuildTaskRequest`:
```go
// only when bound; otherwise leave Repositories nil (today's behaviour)
if e.RepositoryID != "" {
req.Repositories = []IssueTaskRepository{{
RepositoryID: e.RepositoryID,
BaseBranch: e.BaseBranch,
}}
}
```
> **Backward-compatibility invariant (mandatory).** An empty `repository_id`
> produces `Repositories == nil`, which preserves today's exact behaviour
> (repo-less → blank scratch via `initGitRepo`). Only a non-empty
> `repository_id` switches a watch to the worktree path. This invariant is
> covered by a dedicated test per integration (see Tests).
### Where `base_branch` is resolved
Resolve at **create/update time**, store the concrete branch:
- If `repository_id` is set and `base_branch` is empty, default it to the bound
repository's `DefaultBranch` and persist that concrete value.
- This keeps `BuildTaskRequest` a pure projection of stored state (no I/O in the
orchestrator hot path) and matches how the watcher already pre-resolves
agent/executor at config time.
- Trade-off: if the repo's default branch later changes, the stored value is
stale until the watch is edited. Acceptable and explicit; an empty stored
`base_branch` also remains valid — the worktree manager already falls back to
the repo default branch when `BaseBranch` is empty
(`apps/backend/internal/worktree/manager_base_branch_fallback_test.go` exercises
this), so a stale/blank value degrades safely rather than failing.
### Repository lookup dependency (validation + default-branch resolution)
Repositories live in the **task** package, not in the integration packages:
`models.Repository` (`apps/backend/internal/task/models/models.go:757`) carries
`WorkspaceID` and `DefaultBranch`; the store exposes
`GetRepository(ctx, id)` (`apps/backend/internal/task/repository/interface.go:200`)
and `ListRepositories(ctx, workspaceID)` (`interface.go:203`).
The Linear/Jira/Sentry services must **not** import the task service directly
(import-cycle risk). Mirror the existing post-construction wiring used for
`watchreset.TaskDeleter`:
- Each service already declares a `taskDeleter` field wired *after*
construction via `SetTaskDeleter` to dodge the cycle
(`linear/service.go:37-53`, `jira/service.go:40-53`, `sentry/service.go:49-57`),
called from `apps/backend/internal/backendapp/helpers.go:520-526`.
- Add a parallel minimal interface, e.g.:
```go
// package linear (and jira, sentry)
type RepositoryLookup interface {
GetRepository(ctx context.Context, id string) (workspaceID, defaultBranch string, ok bool)
}
func (s *Service) SetRepositoryLookup(rl RepositoryLookup) { s.repoLookup = rl }
```
- Wire it in `backendapp/helpers.go` (next to `SetTaskDeleter`) with a thin
adapter over `services.Task` (the same `services.Task` already used by the
`taskDeleterAdapter` in `backendapp/main.go:493`).
- When `repoLookup` is nil (e.g. unit tests that don't wire it), validation
skips the membership/default-branch step — preserves testability and
fail-open behaviour.
## Backend — file-by-file
The three integrations are near-identical; each bullet applies to all three
unless noted. Per-integration divergences: Jira stores its filter as
`jql TEXT`; Linear & Sentry store `filter_json TEXT`. Sentry's profile fields in
the UI don't use the `STEP_DEFAULT` sentinel (frontend note below). None of
these affect the repository columns.
### 1. Schema + migration (expand-only, nullable-by-default)
Add two columns to each watch table, matching the house style of the existing
optional id columns (`agent_profile_id`, `executor_profile_id`), which are
`TEXT NOT NULL DEFAULT ''` — **empty string = unbound**. This satisfies the
"nullable / optional / expand-only" requirement (the column is always present,
defaults to "no binding") while keeping scans `string`-typed (no `*string`).
- **Linear** — `apps/backend/internal/linear/store.go`
- Add `repository_id TEXT NOT NULL DEFAULT ''` and
`base_branch TEXT NOT NULL DEFAULT ''` to `createTablesSQL`
(table block at `store.go:55-75`).
- Add migration helper `addIssueWatchRepositoryColumns(db)` mirroring
`addMaxInflightTasksColumn` (`store.go:114-129`) and
`addIssueWatchLastErrorColumns` (`store.go:136-152`): use the `tableColumns`
helper (`store.go:262-284`, `PRAGMA table_info`) to add each column only if
missing.
- Register it in `initSchema` (`store.go:94-108`) after the existing
column-add steps.
- **Jira** — `apps/backend/internal/jira/store.go` (table block `59-93`): same
two columns + a `addIssueWatchRepositoryColumns` migration in `initSchema`.
- **Sentry** — `apps/backend/internal/sentry/store.go` (table block `41-71`):
same; Sentry already keeps its incremental column-adds in a dedicated
migration method — extend it.
Rationale: this is the established Kandev migration pattern (idempotent
`PRAGMA table_info` guard + `ALTER TABLE ADD COLUMN`), and it is observed/logged
through the boot-time `MigrateLogger` snapshot path per ADR 0008. New databases
get the columns straight from `createTablesSQL`.
### 2. Store read/write columns
- **INSERT** lists: add `repository_id, base_branch`
- Linear `store.go:80-84` (currently 16 placeholders → 18) and `CreateIssueWatch` (`store.go:94-117`).
- Jira `store.go:400-404`.
- Sentry `store_issue_watch.go:90-94`.
- **SELECT** lists / scan structs: add both columns
- Linear `store.go:86-90` (keep the `COALESCE(...,'')` style for old DBs).
- Jira / Sentry equivalents.
- **UPDATE**: add `repository_id = ?, base_branch = ?`
- Linear `UpdateIssueWatch` (`store.go:194-214`); Jira/Sentry equivalents.
`workspace_id` stays immutable (unchanged).
### 3. Model + request DTOs + patch
- Add `RepositoryID string` and `BaseBranch string` to each `IssueWatch`
(`linear/models.go:190-213`, `jira/models.go:162-185`,
`sentry/models.go:133-156`).
- Add the same to `CreateIssueWatchRequest`
(`linear/models.go:248-259`, `jira/models.go:218-229`, `sentry/models.go:189-200`)
and `UpdateIssueWatchRequest`
(`linear/models.go:265-278`, `jira/models.go:235-248`, `sentry/models.go:204-217`)
as pointers (`*string`) for tri-state PATCH, mirroring the existing optional
fields.
- Patch them in `applyIssueWatchPatch`
(`linear/service_issue_watch.go:367-398`, `jira/service.go:772-803`,
`sentry/service_issue_watch.go:324-355`).
### 4. Validation + default-branch resolution
Extend `validateIssueWatchCreate`
(`linear/service_issue_watch.go:242-264`, `jira/service.go:729-750`,
`sentry/service_issue_watch.go:244-263`) and the update path:
- If `repository_id` is non-empty and `repoLookup` is wired:
- Look up the repo; reject (`ErrInvalidConfig`) if it doesn't exist or its
`workspaceID != req.WorkspaceID` (workspace-scoping / IDOR guard).
- If `base_branch` is empty, set it to the repo's `DefaultBranch` before
persisting.
- If `repository_id` is empty: `base_branch` is forced empty (no orphan branch
without a repo) and all checks are skipped — the repo-less invariant.
### 5. Event payload
Add `RepositoryID string` and `BaseBranch string` to:
- `NewLinearIssueEvent` (`linear/models.go:232-245`)
- `NewJiraIssueEvent` (`jira/models.go:202-215`)
- `NewSentryIssueEvent` (`sentry/models.go:173-186`)
Populate them from the watch in the publishers:
- `publishNewLinearIssueEvent` (`linear/service_issue_watch.go:206-228`)
- `publishNewJiraIssueEvent` (`jira/service.go:691-713`)
- `publishNewSentryIssueEvent` (`sentry/service_issue_watch.go:220-242`)
### 6. Source `BuildTaskRequest`
In each source, after building the existing request, conditionally set
`Repositories` from the event (the snippet under "Core change"):
- `source_linear.go:60-81`
- `source_jira.go:57-80`
- `source_sentry.go:96-118`
No change to the `WatcherSource` interface
(`apps/backend/internal/orchestrator/watcher_dispatch.go:107-164`) — its
`BuildTaskRequest(evt any) (*IssueTaskRequest, error)` contract already returns
the struct that carries `Repositories`.
### 7. HTTP API
The create/update handlers already bind JSON into the request DTOs, so adding
the fields to the DTOs is most of the work:
- Linear `httpCreateIssueWatch` / `httpUpdateIssueWatch`
(`linear/handlers_issue_watch.go:31-61`).
- Jira routes (`jira/handlers.go:41-48`), Sentry routes
(`sentry/handlers_issue_watch.go:12-21`).
No new endpoints; existing workspace-ownership guards
(`assertWatchInWorkspace` / `workspaceMatches`) are unchanged.
## Frontend — file-by-file
### Types & API client (add `repositoryId?: string`, `baseBranch?: string`)
- Linear: `apps/web/lib/types/linear.ts:122-171` (`LinearIssueWatch`,
`CreateLinearIssueWatchInput`, `UpdateLinearIssueWatchInput`);
client `apps/web/lib/api/domains/linear-api.ts:136-196`.
- Jira: `apps/web/lib/types/jira.ts:101-150`; client
`apps/web/lib/api/domains/jira-api.ts`.
- Sentry: `apps/web/lib/types/sentry.ts:85-125`; client
`apps/web/lib/api/domains/sentry-api.ts`.
### Watcher dialogs — add a Repository + Base-branch picker
Place a new optional "Repository" subsection right after the Workflow/Step
("Automation") block in each dialog:
- Linear: `apps/web/components/linear/linear-issue-watch-dialog.tsx`
(after the workflow/step + profile fields, ~`413-457`).
- Jira: `apps/web/components/jira/jira-issue-watch-dialog.tsx` (~`308-352`).
- Sentry: `apps/web/components/sentry/sentry-issue-watch-dialog.tsx` (~`341-375`).
**Recommended control:** a pair of `SelectField`s — a repository select and a
base-branch select — following the existing **"(use step default)"** reset
pattern from PR #1124 (`apps/web/lib/watcher-profile-default.ts`:
`STEP_DEFAULT`, `STEP_DEFAULT_LABEL`, `resolveProfileId`). A "(no repository —
use step default)" sentinel option maps back to `""`, preserving repo-less
watches. The branch select is disabled until a repository is chosen and
defaults to the repo's default branch.
- Source the workspace repositories from the workspace store slice
(`apps/web/lib/state/slices/workspace/`) already available to these dialogs
(they render a workspace picker).
- Source branches from the existing
`apps/web/hooks/domains/workspace/use-repository-branches.ts` hook (the same
hook the task-create repo picker uses).
- The heavier multi-repo chip component
(`apps/web/components/task-create-dialog-repo-chips.tsx`, `RepoChipsRow`) is
**not** reused wholesale — it is built for N-repo/N-branch task creation with
on-machine discovery and fresh-branch toggles. A single repo+branch pair is
the right fit; reuse only the `use-repository-branches` hook.
> Sentry's profile selects currently do **not** use the `STEP_DEFAULT` sentinel
> (they store the id/empty directly). Keep the new repository control consistent
> *within* the Sentry dialog (sentinel optional); unifying Sentry's profile
> fields is out of scope.
## Backward-compatibility invariant
Existing repo-less watches must behave exactly as today:
- Migration adds columns defaulting to `''` → every existing row is "unbound".
- Empty `repository_id` ⇒ `Repositories == nil` in `BuildTaskRequest` ⇒
`initGitRepo` blank-scratch path, unchanged.
- A dedicated regression test per integration asserts an unbound watch yields a
request with empty `Repositories` (Tests §).
## Self-heal interplay (PR #1094)
The dispatch coordinator self-heals when the bound **agent profile** is
soft-deleted: `preflightDeletedProfile`
(`apps/backend/internal/orchestrator/watcher_dispatch.go:170-206`) calls
`src.SelfHeal(...)` → `service.DisableIssueWatchWithError`
(`source_linear.go:136`, `source_jira.go:134`, `source_sentry.go:64`). There is
**no repository existence check** in the coordinator today.
**Decision for this PR:** *fail-open at dispatch, validate at config time.*
- Create/update validation rejects a non-existent or cross-workspace repo, so a
bound repo is valid when stored.
- If a bound repo is later soft-deleted, the launch path already degrades
safely: a missing/empty checkout falls back to repo-default/blank behaviour
rather than crashing the dispatch loop.
- A symmetric "disable watch + stamp `last_error` when the bound repository is
deleted" preflight (mirroring `preflightDeletedProfile`) is **documented as a
follow-up**, not implemented here, to keep this PR focused. Tracked as an open
question below.
## Tests
Backend (`*_test.go` beside each source; all three integrations):
- **Store CRUD round-trip** — bound watch persists/loads `repository_id` +
`base_branch`. *Files:* `linear/store_issue_watch_test.go` (extend the
existing `newTestIssueWatch` helper at `:9-18`), Jira/Sentry equivalents.
*How:* SQLite-backed, table-driven.
- **Migration** — open a DB created from the *old* schema (no repo columns),
run `initSchema`, assert both columns exist and existing rows default to `''`.
*How:* `PRAGMA table_info` assertion, mirroring the existing
`addIssueWatchLastErrorColumns` coverage.
- **Validation** — (a) bound repo in another workspace is rejected;
(b) empty `base_branch` is filled from the repo's `DefaultBranch`;
(c) empty `repository_id` skips all repo checks and forces empty branch.
*How:* service test with a fake `RepositoryLookup`.
- **Source `BuildTaskRequest`** — bound event ⇒
`Repositories == [{repoID, baseBranch}]`; **unbound event ⇒
`Repositories == nil`** (the invariant). *Files:* `source_linear_test.go`,
`source_jira_test.go`, `source_sentry_test.go`.
- **Dispatch parity / self-heal unchanged** — extend
`watcher_dispatch_selfheal_test.go:81-113` coverage so a bound repo flows
through `Dispatch` without disturbing the deleted-profile short-circuit.
- **Disable parity** — existing `store_issue_watch_disable_test.go` continues to
pass with the new columns present.
Frontend:
- Type/compile parity (`pnpm --filter @kandev/web typecheck`); a small unit test
on the create-payload builder asserting `repositoryId`/`baseBranch` are sent
and that the "(no repository)" sentinel maps to `""`.
## E2E Tests
- **Scenario:** GIVEN a Linear issue-watch bound to repository R at branch B,
WHEN the watcher creates a task, THEN the task launches in an isolated
worktree of R at B (not a blank scratch repo).
*File:* `apps/web/e2e/tests/integrations/linear-watch-repository.spec.ts`
(mock Linear provider, `KANDEV_MOCK_LINEAR`). *Verify:* created task has a
repository chip / worktree path under the task dir, not the `.gitkeep`
scratch.
- **Scenario (invariant):** GIVEN an unbound watch, WHEN it creates a task,
THEN behaviour is unchanged (repo-less task). Can be asserted at the
source/service layer instead of full E2E if cheaper.
- Jira/Sentry mirror the Linear E2E (mock providers) — at least the bound-repo
happy path for one of them plus unit parity for the others.
## Rollout
- **No feature flag.** This is additive and behind an empty default
(`repository_id = ''` = today's behaviour); a flag would only gate a strictly
opt-in field. (Per the repo's flag guidance in `CLAUDE.md`, flags are for
toggling behaviour for *all* users — not the case here.)
- **Migration** is expand-only and idempotent; runs on first boot of the new
binary and is captured by the pre-migration `VACUUM INTO` snapshot (ADR 0008).
- **Order:** ship backend (schema → model/DTO → store → validation/lookup wiring
→ event → source → API) and frontend in the same release; the UI degrades
gracefully if pointed at an older backend (extra fields ignored), and an older
UI simply never sends the new fields.
## Risks
- **Stale default branch.** Stored `base_branch` can drift from the repo's
default. Mitigated by the worktree manager's empty-branch fallback and by
re-resolution on edit. Low impact.
- **Deleted bound repository.** Until the follow-up preflight lands, a deleted
repo degrades to fallback/blank rather than a clean disable+error. Documented;
fail-open.
- **Cross-package coupling.** New `RepositoryLookup` dependency in three
integration services. Mitigated by the established post-construction
`Set...` wiring pattern (no import cycle) and nil-safe validation.
- **Three-integration drift.** The change is mechanical but triplicated; a
shared test matrix and identical helper signatures reduce the chance of one
integration diverging.
- **Sentry sentinel inconsistency.** Sentry profile fields don't use
`STEP_DEFAULT`; keep the new control self-consistent and avoid scope creep.
## Implementation Waves
> Single self-contained plan (no sibling task files). Backend packages are
> independent and can be parallelised per integration; frontend is sequential
> (shared build/types); E2E last.
```
Wave 1 — Backend contracts & persistence (parallel per integration):
- [ ] Linear: schema+migration, model/DTO, store, validation+lookup, event, source
- [ ] Jira: schema+migration, model/DTO, store, validation+lookup, event, source
- [ ] Sentry: schema+migration, model/DTO, store, validation+lookup, event, source
- [ ] backendapp wiring: SetRepositoryLookup adapter over services.Task (helpers.go)
Wave 2 — Frontend (sequential):
- [ ] types + api clients (linear/jira/sentry)
- [ ] repository + base-branch picker in the three watcher dialogs
Wave 3 — E2E:
- [ ] bound-repo worktree happy path (+ unbound invariant)
```
## Verification Commands
Format first (formatters may split lines and trip the complexity linter):
```bash
make fmt
```
Targeted backend tests:
```bash
cd apps/backend && go test ./internal/linear/... ./internal/jira/... ./internal/sentry/... ./internal/orchestrator/...
```
Frontend:
```bash
cd apps && pnpm --filter @kandev/web typecheck
cd apps/web && pnpm e2e linear-watch-repository.spec.ts
```
Full gate:
```bash
make typecheck test lint
```
## Open Questions
1. **Deleted-repository self-heal** — implement the symmetric
`preflightDeletedRepository` (disable + `last_error`) in this PR, or ship it
as the documented follow-up? Plan assumes follow-up.
2. **Sentry profile sentinel** — leave Sentry's profile selects without the
`STEP_DEFAULT` sentinel (and add the repo control consistently within that
style), or unify Sentry with Linear/Jira in a separate cleanup? Plan keeps it
out of scope.