Project repositories data model and API
Epic 6368 — Connect Multiple Repositories to Project. Child C1 of the Architect's decomposition.
Original requirement (verbatim):
> If the user has, say, twenty repos – one for the backend and another for the UI – they should be able to connect both of them to a project. Implement support for project with multiple repos
Settled inputs: PRD (Task 6372) and Architecture (Task 6373). Read both in full (`task_read`, `task_read_artifact`); they are settled — realize them, do not reopen. Binding decisions: D2 per repo only URL + integration branch, the user picks exactly one primary; gate/stack/ports/.env/merge lane/GHA/CI OIDC stay project-level on the primary. D3 every existing project's repo_url becomes its single primary repo, no behaviour change; several projects may share a repo. D4 any number of repos, no cap. D5 any forge, any org. AS3 the same URL cannot be added twice to one project. AS4 the primary cannot be removed while others remain until another is made primary. AS9 no new permission. AS10 each repo's integration branch is required.
## Scope (Architecture S1, S2 — build exactly these)
**S1 — `project_repos` table**
```
project_repos
id bigint PK (identity); org_id OrgScoped NOT NULL
project_id int NOT NULL FK projects.id ON DELETE CASCADE, indexed
repo_url text NOT NULL -- normalized by routes/projects.py::_validated_repo_url
forge text NULL -- derived via _derived_forge (same rules as today)
forge_base_url text NULL
integration_branch text NOT NULL -- _reject_illegal_git_ref; never blank (AS10)
is_primary bool NOT NULL default false
position int NOT NULL -- display order; primary is not forced to 0
+ entity shape: name (= owner/name path, display), description NULL, created_by/at, updated_by/at
UNIQUE (project_id, lower(btrim(repo_url))) -- AS3
UNIQUE (project_id) WHERE is_primary -- one primary (partial index)
```
Invariant (service-enforced, test-pinned): a project with ≥1 row has exactly one `is_primary`. `has_repo=false` projects keep their rows untouched (re-ticking restores them, AC1.7, N5). No row for `system_kind` projects.
**Projection rule:** `projects.repo_url/forge/forge_base_url/integration_branch` equal the primary row's values; with zero rows `repo_url/forge/forge_base_url` are NULL and `integration_branch` keeps its last value. Only `app/project_repos.py::sync_primary(session, project)` writes those four columns, in the same transaction.
**Migration (one Alembic revision, timestamp id, `SET lock_timeout='3s'`, EXPAND ONLY):** create the table, then `INSERT … SELECT` one `is_primary=true` row per project with a non-NULL `repo_url` (copying forge, forge_base_url, integration_branch). Idempotent on re-run (`ON CONFLICT DO NOTHING`). No column drops (contraction is a deferred follow-up).
**S2 — Project API**
- `ProjectOut.repos: list[ProjectRepoOut]` ordered by `position`; `ProjectRepoOut = {id, repo_url, forge, forge_base_url, integration_branch, is_primary, name, web_url}` (`web_url` from `forges.repo_ref`). `repo_url/forge/forge_base_url/integration_branch` stay on `ProjectOut` (the primary's).
- `ProjectCreate.repos` / `ProjectUpdate.repos: list[ProjectRepoIn] | None`; `ProjectRepoIn = {id?, repo_url, forge?, forge_base_url?, integration_branch, is_primary}`. A list REPLACES THE WHOLE SET atomically: rows with `id` updated, without inserted, missing ids deleted.
- Legacy single-repo writes still work: with `repos` absent, `repo_url`/`integration_branch` upsert the PRIMARY row. Sending both `repos` and `repo_url` is a 422.
- Per-row errors: 422 with `detail[].loc = ["body","repos",<index>,"<field>"]`. Codes: invalid URL (today's repo_url message), `duplicate_repo` (AS3), blank `integration_branch` (AS10), `primary_required` (0 or >1 primary in a non-empty list), `primary_removed` (drops the current primary's id while others remain without naming a new primary — message "Pick another primary first"). Nothing is saved on any error.
- No new permission (AS9): the existing project edit/view keys apply.
- The control-plane `task_project_update` tool accepts `repos` with the same semantics.
- ADR register (`docs/adrs/README.md`): add ADR-0164 recording the decision (project_repos + primary projection, primary at unchanged slot path, non-primary under `_repos/`, lossless worktree-move swap, task_prs + all-PR settle). Take the next free number if 0164 is taken.
## Acceptance criteria (verbatim from PRD; this child owns the API/data half)
- **AC2.1 (data half)** After deployment, every existing project that had a `repo_url` has exactly one repo, marked primary, with the same URL, forge, forge_base_url and integration branch. Projects with Repo unticked or no URL have none.
- **AC2.2** Two projects may still connect the same repo URL. Adding B to a second project succeeds.
- **AC2.3** No API or wire break for current consumers. Anything that reads `repo_url`/`integration_branch`/`forge` on a project today (console pages, `jaah-forge`, merge lane, GHA pool, CI OIDC, compliance page, Slack cards) keeps working against the **primary** repo.
- **AC5.1 (API half)** The feature is ON for every org on dev and prod with no flag, setting or plan term gating it (D4: no cap).
Credentials & reach:
- AC1.x, AC2.x, AC3.x, AC4.x, AC5.2 in the dev console | `jaah/debug/main/operator-login` (operator; covers org switching) on `https://app.dev.jaah.ai` | **verified**: `task_credential_get` succeeded; a browser sign-in on node h6-3 reached `/nodes` (5 Linux nodes listed).
- AC5.1 non-operator check | `gmind/skynet/dev-orgb` on `https://app.dev.jaah.ai` (its stored base_url is stale, mem-7834) | **verified**: credential fetch succeeded; the host answered 200 from h6-3.
- Unit/integration gates | worktree only (`./dc check full`) | needs nothing beyond the worktree
Out of scope: Do NOT build the console UI (child C2), node runtime (C3), runner delivery (C4), task PRs (C5). Do NOT drop or rename any `projects` column (expand only; contraction is a deferred follow-up). N1 Do NOT make gate commands, stack up/down, port blocks, `.env`, merge lane, GHA runners or CI OIDC per repo. N2 Do NOT make `branch_prefix` or `push_enabled` per repo (AS1). N4 Do NOT add a cap, plan term or feature flag on repo count (D4, AC5.1). N5 Do NOT change ADR-0153 folder-slot (no-repo) behaviour or the `has_repo` semantics. N6 Do NOT forbid several projects from sharing a repo, and do NOT add a "merge sibling projects" tool. N9 Do NOT add new permissions or roles for repo management (AS9).
Done means:
- AC2.1 (data) — migration test: projects with repo_url get exactly one primary row with identical url/forge/forge_base_url/integration_branch; no-URL and has_repo=false projects get none; re-running the migration is a no-op. On dev after deploy: open three pre-existing projects via the API (`GET /api/projects/<id>`), observe `repos` as stated.
- AC2.2 — API test + dev: the same URL accepted in two projects (N6).
- AC2.3 — projection test asserting the four columns equal the primary row over EVERY write path (create, update with repos, legacy repo_url update, primary change, removing the last repo); a CI grep (in the gate) forbidding any other assignment to `projects.repo_url/forge/forge_base_url/integration_branch` outside `app/project_repos.py` and the migration. A build without the single writer + grep FAILS this (Architecture §6 "Projection drift"). `./dc check full` green.
- S2 errors — one API test per code (invalid URL, duplicate_repo, blank integration_branch, primary_required, primary_removed, repos+repo_url both sent), each asserting the exact `loc` and that nothing was saved.
- AC5.1 (API) / N4 — API test: 25 repos accepted for a non-operator org; no flag/plan term consulted.
- N5 — test: a has_repo=false project keeps its rows untouched and they return when re-ticked.
- N9 — authz registry test unchanged (no new keys).
- ADR entry present in `docs/adrs/README.md`; `python3 tools/check_context.py` green.
Outcome
Built project_repos (primary projected onto projects, single writer + gate census), repos on project create/update/task_project_update with per-row 422s, expand-only backfill, ADR-0164. Full gate green; PR #5497 merged, dev deploy OK; dev: 65/65 projects backfilled. Follow-up #5498 (snapshot hardening) merged. Deferred: 409 on concurrent repos replace.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)