23 KiB
spec, created, status, area, related_prs
| spec | created | status | area | related_prs | ||||
|---|---|---|---|---|---|---|---|---|
| none — self-contained PR plan (see "Problem" and "Design" below) | 2026-06-24 | draft | backend, frontend |
|
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-156All three carry only workspace/workflow/step, the per-integration filter (Filter SearchFilterfor Linear/Sentry,JQL stringfor 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.)
- Linear
-
The orchestrator sources build an
IssueTaskRequestwith noRepositories:LinearWatcherSource.BuildTaskRequest—apps/backend/internal/orchestrator/source_linear.go:60-81JiraWatcherSource.BuildTaskRequest—apps/backend/internal/orchestrator/source_jira.go:57-80SentryWatcherSource.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) andmanager_launch.go:650-677take the worktree path only whenlen(req.Repositories) > 0. With zero repositories the launch falls toinitGitRepo(apps/backend/internal/agent/runtime/lifecycle/manager_launch.go:1250-1300, invoked atmanager_launch.go:305), whichgit inits a blank scratch repo with an empty.gitkeepand an "Initial commit" authored byKandev 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 fromCreateTaskRequest.WorkspacePath(apps/backend/internal/task/service/service_requests.go:47, applied atservice_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 withmaxInflightTasks: 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:
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:
// 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_idproducesRepositories == nil, which preserves today's exact behaviour (repo-less → blank scratch viainitGitRepo). Only a non-emptyrepository_idswitches 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_idis set andbase_branchis empty, default it to the bound repository'sDefaultBranchand persist that concrete value. - This keeps
BuildTaskRequesta 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_branchalso remains valid — the worktree manager already falls back to the repo default branch whenBaseBranchis empty (apps/backend/internal/worktree/manager_base_branch_fallback_test.goexercises 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
taskDeleterfield wired after construction viaSetTaskDeleterto dodge the cycle (linear/service.go:37-53,jira/service.go:40-53,sentry/service.go:49-57), called fromapps/backend/internal/backendapp/helpers.go:520-526. -
Add a parallel minimal interface, e.g.:
// 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 toSetTaskDeleter) with a thin adapter overservices.Task(the sameservices.Taskalready used by thetaskDeleterAdapterinbackendapp/main.go:493). -
When
repoLookupis 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 ''andbase_branch TEXT NOT NULL DEFAULT ''tocreateTablesSQL(table block atstore.go:55-75). - Add migration helper
addIssueWatchRepositoryColumns(db)mirroringaddMaxInflightTasksColumn(store.go:114-129) andaddIssueWatchLastErrorColumns(store.go:136-152): use thetableColumnshelper (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.
- Add
- Jira —
apps/backend/internal/jira/store.go(table block59-93): same two columns + aaddIssueWatchRepositoryColumnsmigration ininitSchema. - Sentry —
apps/backend/internal/sentry/store.go(table block41-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) andCreateIssueWatch(store.go:94-117). - Jira
store.go:400-404. - Sentry
store_issue_watch.go:90-94.
- Linear
- SELECT lists / scan structs: add both columns
- Linear
store.go:86-90(keep theCOALESCE(...,'')style for old DBs). - Jira / Sentry equivalents.
- Linear
- UPDATE: add
repository_id = ?, base_branch = ?- Linear
UpdateIssueWatch(store.go:194-214); Jira/Sentry equivalents.workspace_idstays immutable (unchanged).
- Linear
3. Model + request DTOs + patch
- Add
RepositoryID stringandBaseBranch stringto eachIssueWatch(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) andUpdateIssueWatchRequest(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_idis non-empty andrepoLookupis wired:- Look up the repo; reject (
ErrInvalidConfig) if it doesn't exist or itsworkspaceID != req.WorkspaceID(workspace-scoping / IDOR guard). - If
base_branchis empty, set it to the repo'sDefaultBranchbefore persisting.
- Look up the repo; reject (
- If
repository_idis empty:base_branchis 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-81source_jira.go:57-80source_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); clientapps/web/lib/api/domains/linear-api.ts:136-196. - Jira:
apps/web/lib/types/jira.ts:101-150; clientapps/web/lib/api/domains/jira-api.ts. - Sentry:
apps/web/lib/types/sentry.ts:85-125; clientapps/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 SelectFields — 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.tshook (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 theuse-repository-brancheshook.
Sentry's profile selects currently do not use the
STEP_DEFAULTsentinel (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 == nilinBuildTaskRequest⇒initGitRepoblank-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_errorwhen the bound repository is deleted" preflight (mirroringpreflightDeletedProfile) 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 existingnewTestIssueWatchhelper 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_infoassertion, mirroring the existingaddIssueWatchLastErrorColumnscoverage. - Validation — (a) bound repo in another workspace is rejected;
(b) empty
base_branchis filled from the repo'sDefaultBranch; (c) emptyrepository_idskips all repo checks and forces empty branch. How: service test with a fakeRepositoryLookup. - 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-113coverage so a bound repo flows throughDispatchwithout disturbing the deleted-profile short-circuit. - Disable parity — existing
store_issue_watch_disable_test.gocontinues 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 assertingrepositoryId/baseBranchare 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.gitkeepscratch. - 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 inCLAUDE.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 INTOsnapshot (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_branchcan 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
RepositoryLookupdependency in three integration services. Mitigated by the established post-constructionSet...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):
make fmt
Targeted backend tests:
cd apps/backend && go test ./internal/linear/... ./internal/jira/... ./internal/sentry/... ./internal/orchestrator/...
Frontend:
cd apps && pnpm --filter @kandev/web typecheck
cd apps/web && pnpm e2e linear-watch-repository.spec.ts
Full gate:
make typecheck test lint
Open Questions
- 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. - Sentry profile sentinel — leave Sentry's profile selects without the
STEP_DEFAULTsentinel (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.