From 763154aa797935ef3ee629dfd3a7d7a51a553f03 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 13:32:05 +0100 Subject: [PATCH 01/11] refactor(workspaces): split workspace_repos into git_repos + workspace_projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous schema conflated three concerns inside `workspace_repos`: clone+webhook metadata, workspace membership, and the "owned vs linked" distinction. This made it impossible to talk about a project as a first-class entity — every operation was workspace-coupled, which forced workarounds like the singleton "Personal" workspace for standalone repos and the synthetic `is_local` flag for CLI-indexed projects. The new model matches how operators actually think about the system: - **projects** is the canonical entity (unchanged shape). Local and external projects live here side by side, identified by host_path. - **git_repos** carries clone + webhook metadata (1:1 with projects for external projects only — local projects have no git_repos row). - **workspace_projects** is the many-to-many junction. Adding a project to a workspace = INSERT here; removing = DELETE. The project itself is untouched. Deleting a project cascades to git_repos and workspace_projects via FK ON DELETE CASCADE. ## API rewrite - `POST /api/v1/git-repos` — create an external project (clone + index). - `GET /api/v1/projects/{hash}/git-repo` — read git_repos metadata. - `GET /api/v1/projects/{hash}/webhook-info` — webhook URL + secret. - `POST /api/v1/projects/{hash}/reindex` — re-trigger clone+index. - `GET /api/v1/workspaces/{id}/projects` — list projects in workspace. - `POST /api/v1/workspaces/{id}/projects` — link an indexed project. - `DELETE /api/v1/workspaces/{id}/projects/{hash}` — unlink. - `POST /api/v1/webhooks/github/{hash}` — webhook URL now uses projects.path_hash. Existing GitHub-side hooks need re-registering (clean break). All previous `/workspaces/{id}/repos[/...]` endpoints are gone. ## Migration `migrateSplitWorkspaceRepos` runs once at startup. For every `workspace_repos` row it: (a) pre-seeds the matching projects row if absent, (b) inserts a workspace_projects membership, (c) inserts a git_repos row for owned external rows only, (d) renames the on-disk clone dir from `{DataDir}/repos/{workspace_repos.id}` to `{DataDir}/repos/{path_hash}` so existing clones survive. Finally it DROPs the legacy table. ## Code reorg - `internal/gitrepos/` — new service package (Create, GetByPath, GetByHash, SetClone, SetWebhookID, Delete). - `internal/workspaceprojects/` — new service package (Link, Unlink, ListByWorkspace, ListByProject). - `internal/workspacerepos/` — deleted. - `internal/workspacejobs/` — payload identifier is now project_path; clone dir naming uses path_hash; status writes go to projects.status (single source of truth). - `internal/httpapi/gitrepos.go` + `workspaceprojects.go` — new HTTP handlers replacing `workspacerepos.go`. - Webhook lookup uses GitRepos.GetByHash (path_hash) instead of workspace_repos.id. ## Dashboard - `WorkspaceDetailPage` reads projects via `/workspaces/{id}/projects`. - New `WorkspaceProjectRow` replaces `RepoCard`. The row's only action is Unlink (reindex / webhook config / delete live on the project's own detail page). - `AddRepoDialog.workspaceID` is optional. With it: POST `/git-repos` then `POST /workspaces/{id}/projects`. Without it: just create a standalone project (mounted on `/projects`). - `AddExistingProjectDialog` no longer disables local projects — local linkages are a regular workspace_projects row. ## Tests - New gitrepos package tests (Create + UNIQUE + GetByHash + cascade). - New workspaceprojects package tests (Link + duplicate + non-indexed precondition + cascade). - New HTTP tests: TestAddGitRepo_Succeeds, _Duplicate, TestReindexProject_RequiresGitRepo, TestDeleteProject_CascadesGitRepoAndMembership, TestLinkProjectToWorkspace_*, TestUnlinkProject. - Migration test TestMigrate_SplitWorkspaceRepos seeds the legacy table (owned + linked + local rows) plus on-disk clone dirs, opens via OpenWith(DataDir), and asserts the table is dropped, git_repos + workspace_projects populated, and the clone dir renamed. - Existing webhooks_test.go + workspacesearch_test.go ported. Co-Authored-By: Claude Opus 4.7 --- doc/openapi.yaml | 447 +++-- server/cmd/cix-server/main.go | 33 +- .../src/modules/projects/ProjectsListPage.tsx | 31 +- .../workspaces/WorkspaceDetailPage.tsx | 110 +- .../components/AddExistingProjectDialog.tsx | 22 +- .../workspaces/components/AddRepoDialog.tsx | 44 +- .../workspaces/components/RepoCard.tsx | 221 --- .../workspaces/components/WorkspaceCard.tsx | 61 +- .../components/WorkspaceProjectRow.tsx | 138 ++ .../dashboard/src/modules/workspaces/types.ts | 59 +- server/internal/db/db.go | 265 ++- server/internal/db/db_test.go | 158 +- server/internal/db/schema.go | 68 +- server/internal/gitrepos/gitrepos.go | 362 +++++ server/internal/gitrepos/gitrepos_test.go | 187 +++ server/internal/httpapi/gitrepos.go | 306 ++++ server/internal/httpapi/gitrepos_test.go | 155 ++ .../internal/httpapi/openapi/openapi.gen.go | 1447 ++++++++--------- server/internal/httpapi/project_workspaces.go | 43 +- server/internal/httpapi/router.go | 12 +- server/internal/httpapi/webhooks.go | 95 +- server/internal/httpapi/webhooks_test.go | 123 +- .../httpapi/workspace_test_helpers_test.go | 117 ++ server/internal/httpapi/workspaceprojects.go | 151 ++ .../httpapi/workspaceprojects_test.go | 130 ++ server/internal/httpapi/workspacerepos.go | 425 ----- .../internal/httpapi/workspacerepos_test.go | 558 ------- server/internal/httpapi/workspacesearch.go | 54 +- .../internal/httpapi/workspacesearch_test.go | 73 +- .../internal/workspacejobs/workspacejobs.go | 181 +-- .../workspaceprojects/workspaceprojects.go | 170 ++ .../workspaceprojects_test.go | 216 +++ .../internal/workspacerepos/workspacerepos.go | 466 ------ .../workspacerepos/workspacerepos_test.go | 244 --- 34 files changed, 3696 insertions(+), 3476 deletions(-) delete mode 100644 server/dashboard/src/modules/workspaces/components/RepoCard.tsx create mode 100644 server/dashboard/src/modules/workspaces/components/WorkspaceProjectRow.tsx create mode 100644 server/internal/gitrepos/gitrepos.go create mode 100644 server/internal/gitrepos/gitrepos_test.go create mode 100644 server/internal/httpapi/gitrepos.go create mode 100644 server/internal/httpapi/gitrepos_test.go create mode 100644 server/internal/httpapi/workspace_test_helpers_test.go create mode 100644 server/internal/httpapi/workspaceprojects.go create mode 100644 server/internal/httpapi/workspaceprojects_test.go delete mode 100644 server/internal/httpapi/workspacerepos.go delete mode 100644 server/internal/httpapi/workspacerepos_test.go create mode 100644 server/internal/workspaceprojects/workspaceprojects.go create mode 100644 server/internal/workspaceprojects/workspaceprojects_test.go delete mode 100644 server/internal/workspacerepos/workspacerepos.go delete mode 100644 server/internal/workspacerepos/workspacerepos_test.go diff --git a/doc/openapi.yaml b/doc/openapi.yaml index 4990567..b8d73e4 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -1250,66 +1250,38 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/workspaces/{id}/repos: - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Workspace ID. - get: - operationId: listWorkspaceRepos - tags: [workspaces] - summary: List repositories attached to a workspace - responses: - "200": - description: Repo list - content: - application/json: - schema: - $ref: "#/components/schemas/WorkspaceRepoListResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "503": - $ref: "#/components/responses/WorkspacesDisabled" + /api/v1/git-repos: post: - operationId: addWorkspaceRepo - tags: [workspaces] - summary: Attach a GitHub repository to a workspace + operationId: addGitRepo + tags: [projects] + summary: Clone + index a GitHub repository as a standalone project description: | - Inserts a workspace_repos row in status `pending` and enqueues a - `clone_repo` background job. The clone job is followed by an - `index_repo` job on success; the dashboard polls - `/api/v1/workspaces/{id}/repos` to surface status transitions. - - Provide `token_id` to clone a private repository. The - `auto_webhook` flag is accepted in PR2 but not yet acted upon — - PR3 wires the auto-register path against the GitHub API. - - The response includes a one-shot `webhook_url` + `webhook_secret` - so an operator can manually register the webhook in GitHub if - `auto_webhook` is false. The secret is also returned by the - webhook-info endpoint added in PR3. + Inserts a projects row (status=pending), a matching git_repos + row, and enqueues a `clone_repo` background job that is chained + to an `index_repo` job on success. The resulting project lives + in `/api/v1/projects` and is initially attached to no + workspaces — link it into specific workspaces via + `/api/v1/workspaces/{id}/projects` if desired. + + `token_id` is required for private repos; `webhook_mode` + defaults to `manual`. The response carries a one-shot + `webhook_url` + `webhook_secret` so the operator can register + the webhook in GitHub by hand (`auto` mode does it for them). requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AddWorkspaceRepoRequest" + $ref: "#/components/schemas/AddGitRepoRequest" responses: "201": - description: Repo attached + clone enqueued + description: Project created + clone enqueued content: application/json: schema: - $ref: "#/components/schemas/WorkspaceRepoCreated" + $ref: "#/components/schemas/GitRepoCreated" "401": $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "422": @@ -1317,47 +1289,53 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/workspaces/{id}/repos/link: + /api/v1/workspaces/{id}/projects: parameters: - name: id in: path required: true schema: type: string + description: Workspace ID. + get: + operationId: listWorkspaceProjects + tags: [workspaces] + summary: List projects currently linked to a workspace + responses: + "200": + description: Workspace project list + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceProjectListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "503": + $ref: "#/components/responses/WorkspacesDisabled" post: - operationId: linkExistingProject + operationId: linkProjectToWorkspace tags: [workspaces] - summary: Attach an already-indexed project to a workspace + summary: Link an existing project into this workspace description: | - Inserts a workspace_repos row marked `is_linked=true` pointing at - an existing indexed project. No clone happens, no index job is - enqueued, no GitHub webhook is registered — the row is a - lightweight membership pointer so workspace-level features - (search, communities, the repo list) include the project. - - The project's `host_path` must be of the form - "github.com/owner/repo@branch" (i.e. created from a GitHub - source) and the project must be in `status='indexed'`. Per- - workspace uniqueness is enforced via the same composite UNIQUE - as the regular Add Repo flow — a project already in this - workspace (owned or linked) returns 409. - - Use this when the user wants to bring an existing repo from - workspace A into workspace B without paying the clone+index - cost twice. + Inserts a (workspace_id, project_path) row into + `workspace_projects`. The project must already exist and be in + `status='indexed'`. Duplicates return 409. The project itself + is untouched — workspaces are pure membership collections. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/LinkExistingProjectRequest" + $ref: "#/components/schemas/LinkProjectRequest" responses: "201": description: Linked content: application/json: schema: - $ref: "#/components/schemas/WorkspaceRepoCreated" + $ref: "#/components/schemas/WorkspaceProjectMembership" "401": $ref: "#/components/responses/Unauthorized" "404": @@ -1369,30 +1347,30 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/workspaces/{id}/repos/{repo_id}: + /api/v1/workspaces/{id}/projects/{hash}: parameters: - name: id in: path required: true schema: type: string - - name: repo_id + - name: hash in: path required: true schema: type: string + description: Project's path_hash. delete: - operationId: deleteWorkspaceRepo + operationId: unlinkProjectFromWorkspace tags: [workspaces] - summary: Detach a repository from a workspace + summary: Remove a project from this workspace (does not delete the project) description: | - Removes the workspace_repos row. The cloned directory on disk and - the indexed project rows remain — a follow-up cleanup job lands - in a later release. PR3 will also de-register the GitHub webhook - when auto_webhook=true. + Drops the (workspace_id, project_path) row. The project itself, + its clone on disk, its indexed content, and its memberships in + other workspaces are all untouched. responses: "204": - description: Detached + description: Unlinked "401": $ref: "#/components/responses/Unauthorized" "404": @@ -1487,27 +1465,50 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/workspaces/{id}/repos/{repo_id}/webhook-info: + /api/v1/projects/{hash}/git-repo: parameters: - - name: id + - name: hash in: path required: true schema: type: string - - name: repo_id + get: + operationId: getProjectGitRepo + tags: [projects] + summary: Read the git_repos metadata for an external project + description: | + Returns clone + webhook metadata. 404 when the project is local + (has no git_repos row). The webhook_secret is included so the + operator can paste it into GitHub Settings → Webhooks. + responses: + "200": + description: git_repos row + content: + application/json: + schema: + $ref: "#/components/schemas/GitRepo" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "503": + $ref: "#/components/responses/WorkspacesDisabled" + + /api/v1/projects/{hash}/webhook-info: + parameters: + - name: hash in: path required: true schema: type: string get: - operationId: getWorkspaceRepoWebhookInfo - tags: [workspaces] - summary: Get the webhook URL + secret for manual GitHub setup + operationId: getProjectWebhookInfo + tags: [projects] + summary: Webhook URL + secret for manual GitHub setup description: | - Returns the publicly-reachable webhook URL and the HMAC secret. - Pair this with GitHub Settings → Webhooks when `auto_webhook` is - false. The secret rotates if the workspace_repo is deleted and - re-attached. + Returns the publicly-reachable webhook URL and the HMAC secret + for an external project. 404 when the project is local (no + webhook lifecycle). responses: "200": description: Webhook coordinates @@ -1522,28 +1523,60 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/webhooks/github/{repo_id}: + /api/v1/projects/{hash}/reindex: + parameters: + - name: hash + in: path + required: true + schema: + type: string + post: + operationId: reindexProject + tags: [projects] + summary: Manually re-trigger the clone + index pipeline + description: | + Enqueues a fresh `clone_repo` job for the matching git_repos + row. 422 when the project is local (no clone pipeline — local + projects reindex via the CLI). Dedupe collapses repeated + triggers into the existing in-flight job. + responses: + "202": + description: Reindex enqueued (or already running — dedupe) + content: + application/json: + schema: + $ref: "#/components/schemas/ReindexEnqueuedResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/Unprocessable" + "503": + $ref: "#/components/responses/WorkspacesDisabled" + + /api/v1/webhooks/github/{hash}: parameters: - - name: repo_id + - name: hash in: path required: true schema: type: string + description: Project's path_hash (16 hex chars). post: operationId: receiveGithubWebhook - tags: [workspaces] + tags: [projects] summary: Receive a GitHub webhook delivery (public, HMAC-authenticated) description: | - Public endpoint — `requireAuth` is bypassed. Authentication is - per-row via the `X-Hub-Signature-256` header which must be - HMAC-SHA256 of the request body keyed by the workspace_repo's - `webhook_secret`. Mismatched signatures return 401; unknown - `repo_id` returns 404. On a valid `push` for the tracked branch - the server enqueues a `fetch_repo` job (dedupe collapses burst - deliveries). - - GitHub `ping` deliveries return 200 with no side effects so the - setup confirmation flow works. + Public endpoint — auth is bypassed. The `X-Hub-Signature-256` + header must be HMAC-SHA256 of the request body keyed by the + project's `git_repos.webhook_secret`. Mismatched signatures + return 401; an unknown `hash` returns 404. On a valid `push` + for the tracked branch the server enqueues a `clone_repo` job + (dedupe collapses burst deliveries). + + GitHub `ping` deliveries return 200 with no side effects so + the setup confirmation flow works. security: [] parameters: - name: X-Hub-Signature-256 @@ -1584,7 +1617,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: Unknown workspace_repo id + description: Unknown project hash content: application/json: schema: @@ -1592,40 +1625,6 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/workspaces/{id}/repos/{repo_id}/reindex: - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: repo_id - in: path - required: true - schema: - type: string - post: - operationId: reindexWorkspaceRepo - tags: [workspaces] - summary: Manually re-trigger the clone + index pipeline - description: | - Enqueues a fresh `clone_repo` job for the repo. Dedupe collapses - repeated triggers into the existing in-flight job — only one - clone is ever active per repo at a time. - responses: - "202": - description: Reindex enqueued (or already running — dedupe) - content: - application/json: - schema: - $ref: "#/components/schemas/ReindexEnqueuedResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "503": - $ref: "#/components/responses/WorkspacesDisabled" - /api/v1/jobs: get: operationId: listJobs @@ -3212,79 +3211,49 @@ components: consulted. Kept for backwards compatibility with older clients that still send it. - WorkspaceRepo: + GitRepo: type: object required: - - id - - workspace_id + - project_path + - path_hash - github_url - branch - - project_path - - status - auto_webhook - webhook_mode - - is_linked - created_at - updated_at + description: | + Clone + webhook metadata for an external (git-cloned) project. + Exactly 1:1 with the matching projects row; local projects have + no GitRepo row. properties: - id: + project_path: type: string - workspace_id: + description: | + Matches projects.host_path — canonical + "github.com/owner/repo@branch" string. + path_hash: type: string + description: 16-hex SHA1 prefix of project_path, used in URLs. github_url: type: string - description: Canonical https://github.com/owner/repo URL. branch: type: string - project_path: - type: string - description: | - Indexed project's host_path — "github.com/owner/repo@branch". - Use this with the existing /api/v1/projects/{path}/* endpoints - (path = first 16 hex chars of SHA1). token_id: type: string nullable: true - description: | - GitHub token used for clone+webhook calls. Null when the - repo is public. auto_webhook: type: boolean - description: | - Legacy alias for `webhook_mode == "auto"`. Always present so - old clients keep working; new clients should consult - `webhook_mode` instead. + description: Legacy alias for `webhook_mode == "auto"`. webhook_mode: type: string enum: [manual, auto, disabled] - description: | - Operator's intent for how this repo gets kept fresh. `auto` - asks the server to register the GitHub webhook; `manual` - means the operator pastes the URL+secret into GitHub - themselves; `disabled` skips auto-sync entirely — reindex - via the dashboard button only. - status: - type: string - enum: [pending, cloning, indexing, indexed, failed] last_sha: type: string nullable: true - description: HEAD SHA at last successful clone. last_error: type: string nullable: true - last_indexed_at: - type: string - format: date-time - nullable: true - is_linked: - type: boolean - description: | - True when this row is a lightweight pointer to a project - already owned by another workspace_repo — added via the - "Add Existing Project" flow. Linked rows have no clone on - disk, no webhook, and no token; reindex is a no-op (must - be triggered from the canonical owning row). created_at: type: string format: date-time @@ -3292,18 +3261,7 @@ components: type: string format: date-time - WorkspaceRepoListResponse: - type: object - required: [repos, total] - properties: - repos: - type: array - items: - $ref: "#/components/schemas/WorkspaceRepo" - total: - type: integer - - AddWorkspaceRepoRequest: + AddGitRepoRequest: type: object required: [github_url, branch] properties: @@ -3315,28 +3273,11 @@ components: minLength: 1 token_id: type: string - description: | - Optional id of a stored GitHub PAT. Required for private repos. - auto_webhook: - type: boolean - default: false - deprecated: true - description: | - Legacy field. New clients should send `webhook_mode` instead. - When both are provided, `webhook_mode` wins; when only the - bool is set, `true` is mapped to `webhook_mode = "auto"`. + description: Optional id of a stored GitHub PAT (required for private repos). webhook_mode: type: string enum: [manual, auto, disabled] default: manual - description: | - How the server should keep this repo fresh: - - `auto` — server registers the webhook in GitHub on your - behalf (requires admin:repo_hook on the PAT). - - `manual` — server stores a webhook_secret and returns it - once; you paste the URL + secret into GitHub yourself. - - `disabled` — no auto-sync at all; reindex via the - dashboard button only. GithubRepo: type: object @@ -3379,27 +3320,72 @@ components: avatar_url: type: string - WorkspaceRepoCreated: + GitRepoCreated: type: object - required: [repo, webhook_url, webhook_secret] + required: [project, git_repo, webhook_url, webhook_secret] properties: - repo: - $ref: "#/components/schemas/WorkspaceRepo" + project: + $ref: "#/components/schemas/Project" + git_repo: + $ref: "#/components/schemas/GitRepo" webhook_url: type: string description: | Publicly-reachable POST endpoint to register in GitHub when - doing the webhook setup manually. Includes the workspace_repo - id segment. Empty string for linked rows (no webhook). + doing webhook setup manually. webhook_secret: type: string description: | HMAC secret. **Returned once on create + once via - webhook-info.** Use as the "Secret" field in GitHub's webhook - UI; deliveries are validated by HMAC-SHA256 over the body. - Empty string for linked rows (no webhook). + /projects/{hash}/webhook-info.** + auto_registered: + type: boolean + description: | + True when webhook_mode was 'auto' AND the server + successfully registered the hook with GitHub. + auto_register_note: + type: string + description: Human-readable reason when auto_registered is false. + + WorkspaceProjectMembership: + type: object + required: [workspace_id, project_path, added_at] + properties: + workspace_id: + type: string + project_path: + type: string + added_at: + type: string + format: date-time + + WorkspaceProject: + type: object + required: [project, added_at] + description: | + A project listed under a workspace, decorated with the membership + timestamp. The embedded Project carries the full project info + (status, languages, last_indexed_at) so the dashboard doesn't + need a second roundtrip. + properties: + project: + $ref: "#/components/schemas/Project" + added_at: + type: string + format: date-time - LinkExistingProjectRequest: + WorkspaceProjectListResponse: + type: object + required: [projects, total] + properties: + projects: + type: array + items: + $ref: "#/components/schemas/WorkspaceProject" + total: + type: integer + + LinkProjectRequest: type: object required: [project_hash] properties: @@ -3408,11 +3394,9 @@ components: minLength: 16 maxLength: 16 description: | - The 16-hex `path_hash` of an indexed project — the same value - used in /api/v1/projects/{path}. The server resolves it to - the canonical `host_path` and inserts a linked workspace_repo - row. The project must already be in status='indexed' and have - a host_path of the form "github.com/owner/repo@branch". + The 16-hex `path_hash` of an indexed project. The server + resolves it to host_path and inserts the (workspace_id, + project_path) row. The project must be in status='indexed'. ProjectWorkspaceList: type: object @@ -3425,22 +3409,15 @@ components: ProjectWorkspaceEntry: type: object - required: [workspace_id, workspace_name, repo_id, branch, status, is_linked] + required: [workspace_id, workspace_name, added_at] properties: workspace_id: type: string workspace_name: type: string - repo_id: + added_at: type: string - description: workspace_repos.id — same value used in /repos endpoints. - branch: - type: string - status: - type: string - enum: [pending, cloning, indexing, indexed, failed] - is_linked: - type: boolean + format: date-time ReindexEnqueuedResponse: type: object @@ -3449,8 +3426,8 @@ components: status: type: string enum: [enqueued, already_running] - repo: - $ref: "#/components/schemas/WorkspaceRepo" + project: + $ref: "#/components/schemas/Project" Job: type: object diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index 7fe85fd..8ea2d0e 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -21,6 +21,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/db" "github.com/dvcdsys/code-index/server/internal/embeddings" "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/httpapi" "github.com/dvcdsys/code-index/server/internal/indexer" "github.com/dvcdsys/code-index/server/internal/jobs" @@ -31,7 +32,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/vectorstore" "github.com/dvcdsys/code-index/server/internal/versioncheck" "github.com/dvcdsys/code-index/server/internal/workspacejobs" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" + "github.com/dvcdsys/code-index/server/internal/workspaceprojects" "github.com/dvcdsys/code-index/server/internal/workspaces" ) @@ -94,7 +95,10 @@ func run() error { dbPath := cfg.DynamicSQLitePath() logger.Info("opening database", "path", dbPath) - database, err := db.Open(dbPath) + database, err := db.OpenWith(db.OpenOptions{ + Path: dbPath, + DataDir: cfg.WorkspacesDataDir, + }) if err != nil { return fmt.Errorf("open db: %w", err) } @@ -206,7 +210,8 @@ func run() error { var ( wsSvc *workspaces.Service ghSvc *githubtokens.Service - wrSvc *workspacerepos.Service + grSvc *gitrepos.Service + wpSvc *workspaceprojects.Service jobsSvc *jobs.Service ) if cfg.WorkspacesEnabled { @@ -249,7 +254,8 @@ func run() error { logger.Info("workspaces: encryption key loaded", "source", secSvc.Source()) } wsSvc = workspaces.New(database) - wrSvc = workspacerepos.New(database) + grSvc = gitrepos.New(database) + wpSvc = workspaceprojects.New(database) // Persistent job queue + worker pool. Worker concurrency comes // from CIX_WORKER_CONCURRENCY (default 2). Handlers are registered @@ -259,14 +265,14 @@ func run() error { Logger: logger, }) workspacejobs.Register(workspacejobs.Deps{ - DB: database, - Jobs: jobsSvc, - WorkspaceRepos: wrSvc, - GithubTokens: ghSvc, - Indexer: idx, - VectorStore: vs, - DataDir: cfg.WorkspacesDataDir, - Logger: logger, + DB: database, + Jobs: jobsSvc, + GitRepos: grSvc, + GithubTokens: ghSvc, + Indexer: idx, + VectorStore: vs, + DataDir: cfg.WorkspacesDataDir, + Logger: logger, }) jobsSvc.Start(context.Background()) // Defer shutdown — stop new claims, drain in-flight work. @@ -317,7 +323,8 @@ func run() error { WorkspacesEnabled: cfg.WorkspacesEnabled, Workspaces: wsSvc, GithubTokens: ghSvc, - WorkspaceRepos: wrSvc, + GitRepos: grSvc, + WorkspaceProjects: wpSvc, Jobs: jobsSvc, PublicBaseURL: cfg.PublicBaseURL, }) diff --git a/server/dashboard/src/modules/projects/ProjectsListPage.tsx b/server/dashboard/src/modules/projects/ProjectsListPage.tsx index 97bfc5d..cb881f0 100644 --- a/server/dashboard/src/modules/projects/ProjectsListPage.tsx +++ b/server/dashboard/src/modules/projects/ProjectsListPage.tsx @@ -2,19 +2,29 @@ import { AlertCircle, FolderPlus } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert'; import { Skeleton } from '@/ui/skeleton'; import { ApiError } from '@/api/client'; +import { AddRepoDialog } from '@/modules/workspaces/components/AddRepoDialog'; import { ProjectCard } from './components/ProjectCard'; import { useProjects } from './hooks'; export function ProjectsListPage() { - const { data, error, isLoading } = useProjects(); + const { data, error, isLoading, refetch } = useProjects(); return (
-
-

Projects

-

- {data ? `${data.total} indexed ${data.total === 1 ? 'project' : 'projects'}` : ' '} -

+
+
+

Projects

+

+ {data + ? `${data.total} indexed ${data.total === 1 ? 'project' : 'projects'}` + : ' '} +

+
+ {/* Add repo here clones + indexes a GitHub repository as a + standalone project. The new project lives in /projects with + no workspace attachment — link it into specific workspaces + from the workspace detail page if you want. */} + void refetch()} />
{isLoading ? ( @@ -51,9 +61,12 @@ function EmptyState() {

No projects yet

- Register a project from the CLI with{' '} - cix init <path>. - A GitHub source will land here in a future PR. + Use Add repo above to clone + index a GitHub + repository, or register a local project from the CLI with{' '} + + cix init <path> + + .

diff --git a/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx b/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx index 0efd737..99c3bf4 100644 --- a/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx +++ b/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx @@ -7,47 +7,38 @@ import { Button } from '@/ui/button'; import { Skeleton } from '@/ui/skeleton'; import { AddExistingProjectDialog } from './components/AddExistingProjectDialog'; import { AddRepoDialog } from './components/AddRepoDialog'; -import { RepoCard } from './components/RepoCard'; +import { WorkspaceProjectRow } from './components/WorkspaceProjectRow'; import { WorkspaceSearchDialog } from './components/WorkspaceSearchDialog'; import { isInFlight } from './types'; import type { Workspace, - WorkspaceRepo, - WorkspaceRepoListResponse, + WorkspaceProject, + WorkspaceProjectListResponse, } from './types'; -// Auto-dismiss the "indexing finished" toast after this many ms. Long -// enough to read, short enough not to linger past when the user has -// likely moved on. const INDEX_DONE_TOAST_MS = 5000; - -// Background polling cadence. Three seconds is short enough that the -// "indexing" → "indexed" transition is visible while you watch the -// dashboard, long enough that the cost of polling for a workspace -// with many repos stays modest. Only runs while at least one repo is -// in flight. const POLL_MS = 3000; export function WorkspaceDetailPage() { const { id = '' } = useParams<{ id: string }>(); const navigate = useNavigate(); const [workspace, setWorkspace] = useState(null); - const [repos, setRepos] = useState(null); + const [projects, setProjects] = useState(null); const [error, setError] = useState(null); const [notFound, setNotFound] = useState(false); const [indexDoneMsg, setIndexDoneMsg] = useState(null); - const loadRepos = useCallback(async () => { + const loadProjects = useCallback(async () => { try { - const r = await api.get(`/workspaces/${id}/repos`); - setRepos(r.repos); + const r = await api.get( + `/workspaces/${id}/projects`, + ); + setProjects(r.projects); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); + setError(e instanceof Error ? e.message : String(e)); } }, [id]); - // Initial workspace + repo fetch. useEffect(() => { let cancelled = false; api @@ -63,37 +54,27 @@ export function WorkspaceDetailPage() { } setError(e instanceof Error ? e.message : String(e)); }); - void loadRepos(); + void loadProjects(); return () => { cancelled = true; }; - }, [id, loadRepos]); + }, [id, loadProjects]); - // Live progress polling. Active only while at least one repo is - // in pending/cloning/indexing — terminal states stop the tick so we - // don't burn CPU on an idle workspace. + // Poll while any project is still being cloned/indexed. useEffect(() => { - if (!repos || repos.length === 0) return; - const anyBusy = repos.some((r) => isInFlight(r.status)); + if (!projects || projects.length === 0) return; + const anyBusy = projects.some((p) => isInFlight(p.project.status)); if (!anyBusy) return; const handle = setInterval(() => { - void loadRepos(); + void loadProjects(); }, POLL_MS); return () => clearInterval(handle); - }, [repos, loadRepos]); + }, [projects, loadProjects]); - // Detect the "last in-flight repo just finished" transition. Workspace - // search is live (no centroid rebuild step) so we just confirm to - // the user that the new repo is now searchable. - // - // wasInflightRef is the gate: we only fire the toast on a - // true → false transition, not on the initial page load where - // everything was already indexed. Reset back to false after firing - // so a second indexing wave (add another repo later) re-arms it. const wasInflightRef = useRef(false); useEffect(() => { - if (!repos) return; - const anyBusy = repos.some((r) => isInFlight(r.status)); + if (!projects) return; + const anyBusy = projects.some((p) => isInFlight(p.project.status)); if (anyBusy) { wasInflightRef.current = true; return; @@ -102,9 +83,8 @@ export function WorkspaceDetailPage() { wasInflightRef.current = false; setIndexDoneMsg('Indexing finished — workspace search is ready.'); } - }, [repos]); + }, [projects]); - // Auto-dismiss the toast so it doesn't linger after the user moves on. useEffect(() => { if (!indexDoneMsg) return; const handle = setTimeout(() => setIndexDoneMsg(null), INDEX_DONE_TOAST_MS); @@ -115,7 +95,7 @@ export function WorkspaceDetailPage() { if (!workspace) return; if ( !confirm( - `Delete workspace "${workspace.name}"?\n\nThis removes all attached repos and the indexed projects.`, + `Delete workspace "${workspace.name}"?\n\nThe projects themselves stay — only this workspace is removed.`, ) ) { return; @@ -168,11 +148,15 @@ export function WorkspaceDetailPage() {
- + {/* Add repo here clones + indexes a new external project AND + links it into this workspace in one step. The dialog + accepts an optional workspaceID — when supplied, it + chains POST /git-repos with POST /workspaces/{id}/projects. */} + r.project_path)} - onAdded={loadRepos} + existingProjectPaths={(projects ?? []).map((p) => p.project.host_path)} + onAdded={loadProjects} /> - )} - -
- - -
- - {repo.is_linked ? ( - - linked - - ) : ( - - )} - {repo.last_indexed_at && ( - - · indexed {formatRelative(repo.last_indexed_at)} - - )} -
- - {repo.last_error && ( -
- {repo.last_error} -
- )} - - - ); -} - -// StatusBadge renders the colour-coded status + an elapsed-time read -// while a clone/index job is running. The elapsed counter ticks once a -// second so the user can tell the job hasn't silently stalled. -function StatusBadge({ repo }: { repo: WorkspaceRepo }) { - const inFlight = isInFlight(repo.status); - const elapsed = useElapsedSince(inFlight ? repo.updated_at : null); - - if (repo.status === 'indexed') { - return ( - - indexed - - ); - } - if (repo.status === 'failed') { - return ( - - failed - - ); - } - return ( - - - {repo.status} - {elapsed !== null && ( - · {formatDuration(elapsed)} - )} - - ); -} - -function WebhookBadge({ repo }: { repo: WorkspaceRepo }) { - switch (repo.webhook_mode) { - case 'auto': - return ( - - auto - - ); - case 'manual': - return ( - - manual - - ); - case 'disabled': - return ( - - disabled - - ); - } -} - -// useElapsedSince ticks once a second so the in-flight badge shows -// elapsed time without re-fetching from the server. -function useElapsedSince(iso: string | null): number | null { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (iso === null) return; - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, [iso]); - if (iso === null) return null; - const ts = Date.parse(iso); - if (Number.isNaN(ts)) return null; - return Math.max(0, Math.floor((now - ts) / 1000)); -} - -function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}m ${s}s`; -} diff --git a/server/dashboard/src/modules/workspaces/components/WorkspaceCard.tsx b/server/dashboard/src/modules/workspaces/components/WorkspaceCard.tsx index 6400b32..7427224 100644 --- a/server/dashboard/src/modules/workspaces/components/WorkspaceCard.tsx +++ b/server/dashboard/src/modules/workspaces/components/WorkspaceCard.tsx @@ -4,33 +4,35 @@ import { Boxes, ChevronRight, Loader2 } from 'lucide-react'; import { api } from '@/api/client'; import { Badge } from '@/ui/badge'; import { Card, CardContent } from '@/ui/card'; -import type { Workspace, WorkspaceRepo, WorkspaceRepoListResponse } from '../types'; +import type { + Workspace, + WorkspaceProject, + WorkspaceProjectListResponse, +} from '../types'; import { isInFlight } from '../types'; import { formatRelative } from '@/lib/formatDate'; // WorkspaceCard mirrors the projects ProjectCard so the dashboard reads -// with one visual language: counts at-a-glance, status badge, "click -// anywhere" surface. Repos are loaded lazily so the list page renders -// instantly and each card fills in as soon as its summary arrives. +// with one visual language. Project memberships load lazily. export function WorkspaceCard({ workspace }: { workspace: Workspace }) { - const [repos, setRepos] = useState(null); + const [projects, setProjects] = useState(null); useEffect(() => { let cancelled = false; api - .get(`/workspaces/${workspace.id}/repos`) + .get(`/workspaces/${workspace.id}/projects`) .then((r) => { - if (!cancelled) setRepos(r.repos); + if (!cancelled) setProjects(r.projects); }) .catch(() => { - if (!cancelled) setRepos([]); + if (!cancelled) setProjects([]); }); return () => { cancelled = true; }; }, [workspace.id]); - const summary = computeSummary(repos); + const summary = computeSummary(projects); return ( @@ -60,30 +62,30 @@ export function WorkspaceCard({ workspace }: { workspace: Workspace }) { {summary.busy === 1 ? '1 in progress' : `${summary.busy} in progress`} - ) : repos === null ? ( + ) : projects === null ? ( Loading… - ) : repos.length === 0 ? ( + ) : projects.length === 0 ? ( - No repos yet + No projects yet ) : summary.failed > 0 ? ( {summary.failed} failed ) : ( Ready )} - {repos !== null && repos.length > 0 && ( + {projects !== null && projects.length > 0 && ( - {summary.indexed}/{repos.length} indexed + {summary.indexed}/{projects.length} indexed )}
- {repos !== null && repos.length > 0 - ? `Updated ${formatRelative(latestUpdate(repos))}` + {projects !== null && projects.length > 0 + ? `Updated ${formatRelative(latestUpdate(projects))}` : `Created ${formatRelative(workspace.created_at)}`}
@@ -93,33 +95,28 @@ export function WorkspaceCard({ workspace }: { workspace: Workspace }) { ); } -// computeSummary turns the repo list into the three numbers the card -// surface needs. Lives in this file because no other view computes the -// same shape. -function computeSummary(repos: WorkspaceRepo[] | null): { +function computeSummary(projects: WorkspaceProject[] | null): { indexed: number; busy: number; failed: number; } { - if (!repos) return { indexed: 0, busy: 0, failed: 0 }; + if (!projects) return { indexed: 0, busy: 0, failed: 0 }; let indexed = 0; let busy = 0; let failed = 0; - for (const r of repos) { - if (r.status === 'indexed') indexed++; - else if (r.status === 'failed') failed++; - else if (isInFlight(r.status)) busy++; + for (const p of projects) { + const s = p.project.status; + if (s === 'indexed') indexed++; + else if (s === 'failed' || s === 'error') failed++; + else if (isInFlight(s)) busy++; } return { indexed, busy, failed }; } -// latestUpdate returns the most recent updated_at across a repo list. -// Used so the card's "Updated …" footer tracks the freshest signal -// rather than the workspace row's stale updated_at. -function latestUpdate(repos: WorkspaceRepo[]): string { - let best = repos[0]?.updated_at ?? ''; - for (const r of repos) { - if (r.updated_at > best) best = r.updated_at; +function latestUpdate(projects: WorkspaceProject[]): string { + let best = projects[0]?.added_at ?? ''; + for (const p of projects) { + if (p.added_at > best) best = p.added_at; } return best; } diff --git a/server/dashboard/src/modules/workspaces/components/WorkspaceProjectRow.tsx b/server/dashboard/src/modules/workspaces/components/WorkspaceProjectRow.tsx new file mode 100644 index 0000000..834002d --- /dev/null +++ b/server/dashboard/src/modules/workspaces/components/WorkspaceProjectRow.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { + AlertTriangle, + CheckCircle2, + Folder, + Github, + Loader2, + Trash2, +} from 'lucide-react'; +import { api } from '@/api/client'; +import { Badge } from '@/ui/badge'; +import { Button } from '@/ui/button'; +import { Card, CardContent } from '@/ui/card'; +import { formatRelative } from '@/lib/formatDate'; +import type { ProjectStatus, WorkspaceProject } from '../types'; +import { isInFlight } from '../types'; + +// WorkspaceProjectRow renders one project as it appears inside a +// workspace. Unlink is the only action — the project itself is +// managed on its own detail page (reindex, webhook config, delete +// all live there). Click anywhere on the row to navigate to that +// page; the Unlink button stops propagation so it doesn't trigger +// the link. +export function WorkspaceProjectRow({ + workspaceID, + wp, + onUnlinked, +}: { + workspaceID: string; + wp: WorkspaceProject; + onUnlinked: () => void; +}) { + const [busy, setBusy] = useState(false); + const inFlight = isInFlight(wp.project.status); + const isExternal = wp.project.host_path.startsWith('github.com/'); + const hash = wp.project.path_hash ?? ''; + + async function handleUnlink(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if ( + !confirm( + `Remove "${wp.project.host_path}" from this workspace?\n\nThe project itself stays — only this workspace's link is removed.`, + ) + ) { + return; + } + setBusy(true); + try { + await api.delete(`/workspaces/${workspaceID}/projects/${hash}`); + onUnlinked(); + } catch (err) { + alert(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + + +
+ +
+ {isExternal ? ( + + ) : ( + + )} + {wp.project.host_path} +
+
+ linked {formatRelative(wp.added_at)} +
+
+ +
+ +
+ + {wp.project.last_indexed_at && ( + + · indexed {formatRelative(wp.project.last_indexed_at)} + + )} + {wp.project.languages.slice(0, 4).map((l) => ( + + {l} + + ))} +
+
+
+ ); +} + +function StatusBadge({ status }: { status: ProjectStatus }) { + if (status === 'indexed') { + return ( + + indexed + + ); + } + if (status === 'error' || status === 'failed') { + return ( + + {status} + + ); + } + return ( + + + {status} + + ); +} diff --git a/server/dashboard/src/modules/workspaces/types.ts b/server/dashboard/src/modules/workspaces/types.ts index 6000cb8..7b72222 100644 --- a/server/dashboard/src/modules/workspaces/types.ts +++ b/server/dashboard/src/modules/workspaces/types.ts @@ -18,33 +18,53 @@ export type WorkspaceListResponse = { export type WebhookMode = 'manual' | 'auto' | 'disabled'; -export type RepoStatus = +// Project lifecycle status — single source of truth lives on the +// projects row. Surfaces directly on Project (from the projects module) +// and on the WorkspaceProject decorator below. +export type ProjectStatus = + | 'created' | 'pending' | 'cloning' | 'indexing' | 'indexed' + | 'error' | 'failed'; -export type WorkspaceRepo = { - id: string; - workspace_id: string; +// GitRepo carries the clone + webhook metadata for an external project. +// Local projects have no GitRepo row at all — that's how the dashboard +// tells them apart from cloneable repos. +export type GitRepo = { + project_path: string; + path_hash: string; github_url: string; branch: string; - project_path: string; token_id: string | null; auto_webhook: boolean; webhook_mode: WebhookMode; - status: RepoStatus; last_sha: string | null; last_error: string | null; - last_indexed_at: string | null; - is_linked: boolean; created_at: string; updated_at: string; }; -export type WorkspaceRepoListResponse = { - repos: WorkspaceRepo[]; +// WorkspaceProject is what /workspaces/{id}/projects returns: the +// embedded Project plus the membership timestamp. +export type WorkspaceProject = { + project: { + host_path: string; + container_path: string; + languages: string[]; + status: ProjectStatus; + path_hash?: string; + last_indexed_at?: string | null; + created_at?: string; + updated_at?: string; + }; + added_at: string; +}; + +export type WorkspaceProjectListResponse = { + projects: WorkspaceProject[]; total: number; }; @@ -87,16 +107,23 @@ export type GithubAccountListResponse = { total: number; }; -export type WorkspaceRepoCreated = { - repo: WorkspaceRepo; +// GitRepoCreated is the POST /git-repos response shape. +export type GitRepoCreated = { + project: WorkspaceProject['project']; + git_repo: GitRepo; webhook_url: string; webhook_secret: string; auto_registered?: boolean; auto_register_note?: string; }; -// Whether the repo's status counts as "still doing something". Polling -// stops as soon as every repo in the workspace is in a terminal state. -export function isInFlight(status: RepoStatus): boolean { - return status === 'pending' || status === 'cloning' || status === 'indexing'; +// Whether a project's status counts as "still doing something". The +// workspace detail page polls until every linked project is in a +// terminal state. +export function isInFlight(status: ProjectStatus): boolean { + return ( + status === 'pending' || + status === 'cloning' || + status === 'indexing' + ); } diff --git a/server/internal/db/db.go b/server/internal/db/db.go index c696718..2d475ff 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -19,10 +19,28 @@ import ( // DriverName is the registered database/sql driver name for modernc.org/sqlite. const DriverName = "sqlite" -// Open opens (and creates if necessary) the SQLite database at path, sets the -// required PRAGMAs via the DSN, and runs the schema migration. Pass ":memory:" -// for an in-memory DB (used by tests). +// OpenOptions configures Open. DataDir is only consulted by migrations +// that need to rename on-disk artefacts (e.g. the split of workspace_repos +// into git_repos renamed clone directories from {workspace_repos.id} to +// {path_hash}). Empty DataDir means "skip filesystem-touching migrations +// in this call" — tests use this. +type OpenOptions struct { + Path string + DataDir string +} + +// Open is the conventional entry point used everywhere except main.go. +// It defers to OpenWith with an empty DataDir, so any migration that +// wants to rename on-disk files becomes a no-op for tests + in-memory DBs. func Open(path string) (*sql.DB, error) { + return OpenWith(OpenOptions{Path: path}) +} + +// OpenWith opens (and creates if necessary) the SQLite database at +// opts.Path, sets the required PRAGMAs via the DSN, and runs the +// schema migrations. +func OpenWith(opts OpenOptions) (*sql.DB, error) { + path := opts.Path dsn, err := buildDSN(path) if err != nil { return nil, err @@ -78,24 +96,27 @@ func Open(path string) (*sql.DB, error) { return nil, fmt.Errorf("migrate indexed_with_model: %w", err) } - // PR10 — extend workspace_repos with webhook_mode so the dashboard - // can distinguish manual/auto/disabled intents. Older databases get - // the column with a sensible default; rows where auto_webhook=1 are - // retro-fitted to 'auto' so they keep the same effective behaviour. + // Up-level pre-PR10 / pre-PR13 workspace_repos shapes to the richest + // pre-split form (webhook_mode column present, is_linked column + // present, no inline-UNIQUE on project_path). On already-current or + // already-split DBs these are no-ops. if err := migrateWebhookMode(db); err != nil { _ = db.Close() return nil, fmt.Errorf("migrate webhook_mode: %w", err) } - - // PR13 — workspace_repos.is_linked + drop the legacy global UNIQUE - // on project_path. The rebuild path is taken only when the old - // constraint is still present; freshly-created DBs hit the new - // CREATE TABLE shape via Schema and the rebuild becomes a no-op. if err := migrateWorkspaceReposLinked(db); err != nil { _ = db.Close() return nil, fmt.Errorf("migrate workspace_repos is_linked: %w", err) } + // Split the legacy workspace_repos table into git_repos + + // workspace_projects. Idempotent — when the table is already gone + // (post-split DBs) the migration returns immediately. + if err := migrateSplitWorkspaceRepos(db, opts.DataDir); err != nil { + _ = db.Close() + return nil, fmt.Errorf("migrate workspace_repos → git_repos: %w", err) + } + // PR14 — workspace search switched from the Louvain-centroid two- // stage pipeline to a weighted fan-out. The communities + // community_members tables stop being written; drop them on @@ -436,6 +457,226 @@ func migrateWebhookMode(db *sql.DB) error { return nil } +// migrateSplitWorkspaceRepos converts a legacy workspace_repos table +// into two new tables: git_repos (clone + webhook metadata, 1:1 with +// projects for external repos) and workspace_projects (workspace ↔ +// project junction). After the table is consumed it is dropped, so +// re-running the migration on already-migrated DBs is a fast no-op. +// +// dataDir, when non-empty, points at the on-disk workspace data root. +// External (owned, non-linked) workspace_repos rows used to keep their +// clone in {dataDir}/repos/{workspace_repos.id}; we rename those dirs +// to {dataDir}/repos/{path_hash} so the new gitrepos service finds +// them. Failures are logged-and-ignored — the next clone job will +// regenerate the directory from scratch. +// +// Pre-conditions on the legacy table: the earlier migrateWebhookMode + +// migrateWorkspaceReposLinked passes brought it up to the richest +// shape (webhook_mode + is_linked columns present, no global UNIQUE +// on project_path). +func migrateSplitWorkspaceRepos(db *sql.DB, dataDir string) error { + exists, err := tableExists(db, "workspace_repos") + if err != nil { + return err + } + if !exists { + return nil + } + + type rowSnapshot struct { + id string + workspaceID string + githubURL string + branch string + projectPath string + tokenID sql.NullString + webhookSecret string + webhookID sql.NullInt64 + autoWebhook int + webhookMode string + lastSHA sql.NullString + lastError sql.NullString + isLinked int + createdAt string + updatedAt string + } + + rows, err := db.Query(` + SELECT id, workspace_id, github_url, branch, project_path, + token_id, webhook_secret, webhook_id, auto_webhook, + webhook_mode, last_sha, last_error, is_linked, + created_at, updated_at + FROM workspace_repos`) + if err != nil { + return fmt.Errorf("select workspace_repos: %w", err) + } + var legacy []rowSnapshot + for rows.Next() { + var s rowSnapshot + if err := rows.Scan( + &s.id, &s.workspaceID, &s.githubURL, &s.branch, &s.projectPath, + &s.tokenID, &s.webhookSecret, &s.webhookID, &s.autoWebhook, + &s.webhookMode, &s.lastSHA, &s.lastError, &s.isLinked, + &s.createdAt, &s.updatedAt, + ); err != nil { + rows.Close() + return fmt.Errorf("scan workspace_repos row: %w", err) + } + legacy = append(legacy, s) + } + rows.Close() + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate workspace_repos: %w", err) + } + + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("begin split tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Pre-seed projects rows for any project_path that's referenced by + // the legacy workspace_repos but doesn't yet exist in projects + // (typical state for rows still in clone/index lifecycle when the + // upgrade boots). Both workspace_projects and git_repos FK to + // projects(host_path), so the membership + clone-metadata inserts + // below would fail without this. + for _, s := range legacy { + if _, err := tx.Exec(` + INSERT OR IGNORE INTO projects ( + host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash + ) VALUES (?, ?, '[]', '{}', '{}', 'pending', ?, ?, ?)`, + s.projectPath, s.projectPath, + s.createdAt, s.updatedAt, HashHostPath(s.projectPath), + ); err != nil { + return fmt.Errorf("pre-seed projects row for %s: %w", s.projectPath, err) + } + } + + // Track rename targets so the filesystem step can run after the tx + // commits — we don't want to half-rename then roll back the SQL. + type renamePair struct{ oldID, newHash string } + var renames []renamePair + + for _, s := range legacy { + // Every legacy row becomes a workspace_projects membership. + if _, err := tx.Exec(` + INSERT OR IGNORE INTO workspace_projects + (workspace_id, project_path, added_at) + VALUES (?, ?, ?)`, + s.workspaceID, s.projectPath, s.createdAt, + ); err != nil { + return fmt.Errorf("insert workspace_projects: %w", err) + } + + // Owned + external rows additionally seed a git_repos row. + // Linked rows reuse the canonical owner's git_repos row, so we + // skip them here. Local rows (project_path doesn't look like + // github.com/owner/repo@branch) have no git_repos representation. + if s.isLinked != 0 || !looksLikeGitHubProjectPath(s.projectPath) { + continue + } + webhookMode := s.webhookMode + if webhookMode == "" { + webhookMode = "manual" + } + if _, err := tx.Exec(` + INSERT OR IGNORE INTO git_repos ( + project_path, github_url, branch, + token_id, webhook_secret, webhook_id, + webhook_mode, auto_webhook, + last_sha, last_error, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + s.projectPath, s.githubURL, s.branch, + nullableSQL(s.tokenID), s.webhookSecret, nullableSQLInt(s.webhookID), + webhookMode, s.autoWebhook, + nullableSQL(s.lastSHA), nullableSQL(s.lastError), + s.createdAt, s.updatedAt, + ); err != nil { + return fmt.Errorf("insert git_repos for %s: %w", s.projectPath, err) + } + renames = append(renames, renamePair{ + oldID: s.id, + newHash: HashHostPath(s.projectPath), + }) + } + + if _, err := tx.Exec(`DROP TABLE workspace_repos`); err != nil { + return fmt.Errorf("drop workspace_repos: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit split tx: %w", err) + } + + // Filesystem rename — best effort. Failure is non-fatal; the next + // clone job will recreate the directory. + if dataDir != "" { + base := filepath.Join(dataDir, "repos") + for _, rp := range renames { + oldPath := filepath.Join(base, rp.oldID) + newPath := filepath.Join(base, rp.newHash) + if _, statErr := os.Stat(oldPath); statErr != nil { + continue + } + if _, statErr := os.Stat(newPath); statErr == nil { + continue + } + if err := os.Rename(oldPath, newPath); err != nil { + // Log via stderr — the db package has no logger + // dependency, and a single warning per stuck dir is + // enough for an operator to find and clean up. + fmt.Fprintf(os.Stderr, + "db: warning: could not rename clone dir %s → %s: %v\n", + oldPath, newPath, err) + } + } + } + return nil +} + +// looksLikeGitHubProjectPath decides whether a workspace_repos.project_path +// follows the canonical "github.com/owner/repo@branch" shape used for +// external repos. Local-path projects (absolute filesystem paths) fail this +// check and are handled as workspace-only memberships during the split. +func looksLikeGitHubProjectPath(projectPath string) bool { + s := strings.TrimSpace(projectPath) + if !strings.HasPrefix(s, "github.com/") { + return false + } + return strings.LastIndex(s, "@") > 0 +} + +// tableExists returns whether a table with the given name is registered in +// sqlite_master. Used by migrations to short-circuit on already-migrated DBs. +func tableExists(db *sql.DB, name string) (bool, error) { + row := db.QueryRow( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, name) + var dummy int + if err := row.Scan(&dummy); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("sqlite_master lookup for %q: %w", name, err) + } + return true, nil +} + +func nullableSQL(s sql.NullString) any { + if !s.Valid { + return nil + } + return s.String +} + +func nullableSQLInt(n sql.NullInt64) any { + if !n.Valid { + return nil + } + return n.Int64 +} + // HashHostPath returns the 16-char SHA1 prefix used as the URL segment for // projects. Exported so projects.Create and the migration share one // implementation (keep it byte-identical to projects.HashPath). diff --git a/server/internal/db/db_test.go b/server/internal/db/db_test.go index f0cfba9..42a8999 100644 --- a/server/internal/db/db_test.go +++ b/server/internal/db/db_test.go @@ -227,30 +227,37 @@ func TestSymbolsIndexExists(t *testing.T) { } } -// TestMigrate_DropsGlobalUniqueOnProjectPath verifies that opening a -// pre-PR13 database (workspace_repos with `project_path TEXT NOT NULL UNIQUE`) -// migrates it to the current shape, dropping the global UNIQUE so the -// same indexed project can live in multiple workspaces. +// TestMigrate_SplitWorkspaceRepos verifies the conversion from the +// legacy workspace_repos table into the new git_repos + workspace_projects +// shape. Three legacy rows are seeded covering all three flavours that +// existed before the split: an owned external repo, a linked external +// repo (is_linked=1), and a local-path repo (host_path doesn't match +// github.com/owner/repo@branch). After Open(): // -// Strategy: create a fresh file-backed DB, manually lay down the -// legacy table shape + seed one row, close, reopen via Open() so the -// migration runs, then try inserting a second row with the same -// project_path in a different workspace_id — pre-migration this would -// fail with UNIQUE constraint failed; post-migration it must succeed. -func TestMigrate_DropsGlobalUniqueOnProjectPath(t *testing.T) { +// - workspace_repos is gone. +// - git_repos has exactly one row — for the owned external; linked + +// local rows must not seed git_repos. +// - workspace_projects has three rows — every legacy membership is +// preserved, regardless of flavour. +// - The on-disk clone directory for the owned row is renamed from +// {workspace_repos.id} to {path_hash}; the linked + local IDs have +// no on-disk artifacts to begin with so the migration leaves the +// filesystem alone for them. +func TestMigrate_SplitWorkspaceRepos(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "test.db") + dbPath := filepath.Join(dir, "test.db") + dataDir := filepath.Join(dir, "data") + if err := os.MkdirAll(filepath.Join(dataDir, "repos", "owned-id"), 0o755); err != nil { + t.Fatalf("mkdir owned clone: %v", err) + } - // Open with the regular driver (bypass Schema by hand-rolling DDL). - raw, err := sql.Open(DriverName, "file:"+path+"?_pragma=foreign_keys(ON)") + raw, err := sql.Open(DriverName, "file:"+dbPath+"?_pragma=foreign_keys(ON)") if err != nil { t.Fatalf("raw open: %v", err) } - // Lay down only the minimum tables the legacy workspace_repos needs: - // workspaces (FK target) + the OLD workspace_repos shape with the - // inline UNIQUE on project_path. github_tokens is FK-referenced but - // nullable, so we can skip it for this test. + // Lay down the post-PR13 / pre-split shape (matches what an upgraded + // prod DB looks like just before this migration ran for the first time). legacy := ` CREATE TABLE workspaces ( id TEXT PRIMARY KEY, @@ -264,7 +271,7 @@ func TestMigrate_DropsGlobalUniqueOnProjectPath(t *testing.T) { workspace_id TEXT NOT NULL, github_url TEXT NOT NULL, branch TEXT NOT NULL, - project_path TEXT NOT NULL UNIQUE, + project_path TEXT NOT NULL, token_id TEXT, webhook_secret TEXT NOT NULL, webhook_id INTEGER, @@ -274,73 +281,94 @@ func TestMigrate_DropsGlobalUniqueOnProjectPath(t *testing.T) { last_sha TEXT, last_error TEXT, last_indexed_at TEXT, + is_linked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(workspace_id, github_url, branch), FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ); - INSERT INTO workspaces (id, name, created_at, updated_at) - VALUES ('ws-a', 'alpha', '2026-05-11T00:00:00Z', '2026-05-11T00:00:00Z'); - INSERT INTO workspaces (id, name, created_at, updated_at) - VALUES ('ws-b', 'beta', '2026-05-11T00:00:00Z', '2026-05-11T00:00:00Z'); - INSERT INTO workspace_repos (id, workspace_id, github_url, branch, project_path, - webhook_secret, created_at, updated_at) - VALUES ('repo-1', 'ws-a', 'https://github.com/x/y', 'main', - 'github.com/x/y@main', 's', '2026-05-11T00:00:00Z', '2026-05-11T00:00:00Z'); + INSERT INTO workspaces (id, name, created_at, updated_at) VALUES + ('ws-a', 'alpha', '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'), + ('ws-b', 'beta', '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'); + INSERT INTO workspace_repos + (id, workspace_id, github_url, branch, project_path, + webhook_secret, status, is_linked, webhook_mode, + created_at, updated_at) + VALUES + ('owned-id', 'ws-a', 'https://github.com/x/y', 'main', + 'github.com/x/y@main', 's-owned', 'indexed', 0, 'manual', + '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'), + ('linked-id', 'ws-b', 'https://github.com/x/y', 'main', + 'github.com/x/y@main', 's-linked', 'indexed', 1, 'disabled', + '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'), + ('local-id', 'ws-a', '/Users/x/local-proj', '', + '/Users/x/local-proj', 's-local', 'indexed', 1, 'disabled', + '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'); ` if _, err := raw.Exec(legacy); err != nil { - t.Fatalf("seed legacy: %v", err) - } - - // Confirm pre-migration: the second insert with same project_path - // would fail. We catch the error so the test is honest about the - // invariant we're removing. - _, err = raw.Exec(`INSERT INTO workspace_repos (id, workspace_id, github_url, branch, project_path, - webhook_secret, created_at, updated_at) VALUES ('repo-bad', 'ws-b', 'https://github.com/x/y', 'main', - 'github.com/x/y@main', 's', '2026-05-11T00:00:00Z', '2026-05-11T00:00:00Z')`) - if err == nil { _ = raw.Close() - t.Fatalf("pre-migration insert should fail UNIQUE — test setup is wrong") + t.Fatalf("seed legacy: %v", err) } _ = raw.Close() - // Now reopen via the real Open() so the migration runs. - migrated, err := Open(path) + migrated, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) if err != nil { - t.Fatalf("Open: %v", err) + t.Fatalf("OpenWith: %v", err) } defer migrated.Close() - // is_linked column should be present and default to 0 on the - // migrated row. - var isLinked int + // workspace_repos is gone. + var n int if err := migrated.QueryRow( - `SELECT is_linked FROM workspace_repos WHERE id = 'repo-1'`, - ).Scan(&isLinked); err != nil { - t.Fatalf("read is_linked: %v", err) + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='workspace_repos'`, + ).Scan(&n); err != nil { + t.Fatalf("count workspace_repos: %v", err) } - if isLinked != 0 { - t.Fatalf("pre-existing rows must keep is_linked=0, got %d", isLinked) + if n != 0 { + t.Fatalf("workspace_repos should be dropped after migration, count=%d", n) } - // And the post-migration invariant we care about: same project_path - // in a different workspace now succeeds. - if _, err := migrated.Exec(`INSERT INTO workspace_repos (id, workspace_id, github_url, branch, project_path, - webhook_secret, status, is_linked, created_at, updated_at) - VALUES ('repo-2', 'ws-b', 'https://github.com/x/y', 'main', - 'github.com/x/y@main', 's', 'indexed', 1, - '2026-05-11T00:00:00Z', '2026-05-11T00:00:00Z')`); err != nil { - t.Fatalf("post-migration cross-workspace insert should succeed: %v", err) + // git_repos has exactly the one owned-external row. + if err := migrated.QueryRow(`SELECT COUNT(*) FROM git_repos`).Scan(&n); err != nil { + t.Fatalf("count git_repos: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 git_repos row, got %d", n) + } + var gh, branch, secret string + if err := migrated.QueryRow( + `SELECT github_url, branch, webhook_secret FROM git_repos WHERE project_path = ?`, + "github.com/x/y@main", + ).Scan(&gh, &branch, &secret); err != nil { + t.Fatalf("read git_repos: %v", err) + } + if gh != "https://github.com/x/y" || branch != "main" || secret != "s-owned" { + t.Fatalf("git_repos row mismatch: gh=%q branch=%q secret=%q", gh, branch, secret) + } + + // workspace_projects holds three rows — one per legacy workspace_repos. + if err := migrated.QueryRow(`SELECT COUNT(*) FROM workspace_projects`).Scan(&n); err != nil { + t.Fatalf("count workspace_projects: %v", err) + } + if n != 3 { + t.Fatalf("expected 3 workspace_projects rows, got %d", n) } - // Per-workspace UNIQUE must still bite — adding the same repo+branch - // to ws-b a second time should fail. - _, err = migrated.Exec(`INSERT INTO workspace_repos (id, workspace_id, github_url, branch, project_path, - webhook_secret, status, is_linked, created_at, updated_at) - VALUES ('repo-3', 'ws-b', 'https://github.com/x/y', 'main', - 'github.com/x/y@main', 's', 'indexed', 1, - '2026-05-11T00:00:00Z', '2026-05-11T00:00:00Z')`) - if err == nil { - t.Fatalf("per-workspace UNIQUE should still reject duplicate (workspace_id, github_url, branch)") + // On-disk clone for the owned row was renamed from {old id} → {path_hash}. + expectedPath := filepath.Join(dataDir, "repos", HashHostPath("github.com/x/y@main")) + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("clone dir was not renamed to %s: %v", expectedPath, err) + } + if _, err := os.Stat(filepath.Join(dataDir, "repos", "owned-id")); err == nil { + t.Fatalf("legacy clone dir still exists after rename") + } + + // Re-running Open() must be a no-op — workspace_repos is already + // gone so the migration short-circuits at tableExists(). + migrated.Close() + again, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err != nil { + t.Fatalf("second OpenWith: %v", err) } + defer again.Close() } diff --git a/server/internal/db/schema.go b/server/internal/db/schema.go index d55f788..188f87d 100644 --- a/server/internal/db/schema.go +++ b/server/internal/db/schema.go @@ -177,51 +177,52 @@ CREATE TABLE IF NOT EXISTS github_tokens ( last_used_at TEXT ); --- Workspaces feature PR2 — workspace_repos + jobs. +-- git_repos holds clone + webhook metadata for projects that come from a +-- git remote (currently GitHub-only). Exactly 1:1 with the corresponding +-- projects row — keyed by project_path = projects.host_path. Local +-- projects (indexed via the CLI) have no git_repos row at all, which +-- is how the server tells them apart from cloneable repos. -- --- One workspace_repos row per (repo, branch). project_path is the canonical --- "github.com/owner/repo@branch" string used as host_path in projects, so --- existing per-project SQL stays uniform across local + remote sources. --- webhook_secret is generated server-side at create time and shown exactly --- once to the operator (or used by the auto-register flow added in PR3). --- token_id stays nullable so public repos can be added without storing a PAT. --- last_sha / last_indexed_at survive across reindexes so an incremental --- fetch_repo job can short-circuit when HEAD hasn't moved. --- is_linked discriminates owned rows (the canonical Add Repo flow that --- clones + indexes + owns a webhook) from linked rows (a lightweight --- membership pointer to an already-indexed project — no clone, no --- webhook). Uniqueness is per-workspace; the same project_path may live --- in many workspaces as long as it appears at most once in each. -CREATE TABLE IF NOT EXISTS workspace_repos ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, +-- webhook_secret is generated server-side at create time and shown +-- exactly once to the operator (or consumed by the auto-register flow). +-- token_id stays nullable so public repos can be cloned without a PAT. +-- last_sha lets an incremental fetch short-circuit when HEAD hasn't +-- moved; status lives on the projects row (single source of truth). +CREATE TABLE IF NOT EXISTS git_repos ( + project_path TEXT PRIMARY KEY, github_url TEXT NOT NULL, branch TEXT NOT NULL, - project_path TEXT NOT NULL, token_id TEXT, webhook_secret TEXT NOT NULL, webhook_id INTEGER, - auto_webhook INTEGER NOT NULL DEFAULT 0, - -- webhook_mode is the operator's stated intent for how this repo gets - -- kept fresh: 'auto' (server calls GitHub to register the hook), - -- 'manual' (operator pastes the URL+secret into GitHub themselves), - -- 'disabled' (no auto-sync, reindex via the dashboard button only). - -- Stored separately from auto_webhook so the dashboard can distinguish - -- "manual, still pending operator action" from "deliberately disabled". + -- webhook_mode = 'auto' | 'manual' | 'disabled'. See workspaces docs. webhook_mode TEXT NOT NULL DEFAULT 'manual', - status TEXT NOT NULL DEFAULT 'pending', + auto_webhook INTEGER NOT NULL DEFAULT 0, last_sha TEXT, last_error TEXT, - last_indexed_at TEXT, - is_linked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - UNIQUE(workspace_id, github_url, branch), - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + UNIQUE (github_url, branch), + FOREIGN KEY (project_path) REFERENCES projects(host_path) ON DELETE CASCADE, FOREIGN KEY (token_id) REFERENCES github_tokens(id) ON DELETE SET NULL ); -CREATE INDEX IF NOT EXISTS idx_workspace_repos_workspace ON workspace_repos(workspace_id); -CREATE INDEX IF NOT EXISTS idx_workspace_repos_project ON workspace_repos(project_path); + +-- workspace_projects is the many-to-many junction between workspaces +-- and projects. A workspace is just a labelled collection — adding a +-- project = INSERT here, removing = DELETE here. The project itself +-- is untouched. The same project can live in any number of workspaces. +-- ON DELETE CASCADE on both FKs keeps memberships consistent: deleting +-- a workspace or a project automatically clears the rows that name it. +CREATE TABLE IF NOT EXISTS workspace_projects ( + workspace_id TEXT NOT NULL, + project_path TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, project_path), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (project_path) REFERENCES projects(host_path) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_workspace_projects_project + ON workspace_projects(project_path); -- jobs is the persistent worker queue. Survives process restarts; one -- worker pool drains it. dedupe_key is the partial-unique mechanism that @@ -341,7 +342,8 @@ var ExpectedTables = []string{ "runtime_settings", "workspaces", "github_tokens", - "workspace_repos", + "git_repos", + "workspace_projects", "jobs", "call_edges", "chunks_meta", diff --git a/server/internal/gitrepos/gitrepos.go b/server/internal/gitrepos/gitrepos.go new file mode 100644 index 0000000..4b91d79 --- /dev/null +++ b/server/internal/gitrepos/gitrepos.go @@ -0,0 +1,362 @@ +// Package gitrepos is the service layer for the git_repos table — +// clone + webhook metadata for projects that come from a git remote +// (currently GitHub-only). A row exists exactly 1:1 with the projects +// row whose host_path matches project_path; local projects (CLI-indexed +// filesystem paths) have no git_repos row, which is how the rest of the +// system tells them apart from cloneable repos. +// +// Workspace membership lives in a separate junction table — +// workspace_projects — owned by the workspaceprojects package. This +// package knows nothing about workspaces. +package gitrepos + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/dvcdsys/code-index/server/internal/db" +) + +// Webhook modes. Stored verbatim in the webhook_mode column so the +// dashboard renders the operator's stated intent. +const ( + WebhookModeManual = "manual" + WebhookModeAuto = "auto" + WebhookModeDisabled = "disabled" +) + +// Errors. +var ( + ErrNotFound = errors.New("git repo not found") + ErrDuplicate = errors.New("a git repo with this (github_url, branch) already exists") + ErrInvalidURL = errors.New("github_url must be an https://github.com/owner/repo URL") + ErrBranchEmpty = errors.New("branch is required") + ErrInvalidWebhookMode = errors.New("webhook_mode must be one of manual, auto, disabled") +) + +// GitRepo is the wire view. The webhook_secret is in the response of +// Create only — subsequent reads must call WebhookInfo to fetch it +// (kept out of bulk lists so secrets don't fan out unnecessarily). +type GitRepo struct { + ProjectPath string + PathHash string + GitHubURL string + Branch string + TokenID string + WebhookSecret string + WebhookID *int64 + WebhookMode string + AutoWebhook bool + LastSHA string + LastError string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Service wraps the git_repos table. +type Service struct { + DB *sql.DB +} + +func New(db *sql.DB) *Service { return &Service{DB: db} } + +// CreateRequest is what handlers pass in. ProjectPath is computed from +// GitHubURL + Branch; callers don't supply it directly. +type CreateRequest struct { + GitHubURL string + Branch string + TokenID string // optional + WebhookMode string // empty → manual +} + +// Create inserts a git_repos row. The caller is responsible for +// ensuring the matching projects row exists (FK target). The resulting +// ProjectPath is "github.com/owner/repo@branch". +func (s *Service) Create(ctx context.Context, req CreateRequest) (GitRepo, error) { + owner, repo, err := parseGitHubURL(req.GitHubURL) + if err != nil { + return GitRepo{}, err + } + if strings.TrimSpace(req.Branch) == "" { + return GitRepo{}, ErrBranchEmpty + } + mode, merr := NormaliseWebhookMode(req.WebhookMode) + if merr != nil { + return GitRepo{}, merr + } + + projectPath := fmt.Sprintf("github.com/%s/%s@%s", owner, repo, req.Branch) + githubURL := canonicaliseURL(req.GitHubURL) + secret, err := generateWebhookSecret() + if err != nil { + return GitRepo{}, fmt.Errorf("generate webhook secret: %w", err) + } + auto := 0 + if mode == WebhookModeAuto { + auto = 1 + } + tokenID := nullableString(req.TokenID) + now := time.Now().UTC().Format(time.RFC3339Nano) + + if _, err := s.DB.ExecContext(ctx, ` + INSERT INTO git_repos ( + project_path, github_url, branch, + token_id, webhook_secret, + webhook_mode, auto_webhook, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + projectPath, githubURL, req.Branch, + tokenID, secret, mode, auto, + now, now, + ); err != nil { + if isUniqueConstraintViolation(err) { + return GitRepo{}, ErrDuplicate + } + return GitRepo{}, fmt.Errorf("insert git_repo: %w", err) + } + return s.GetByPath(ctx, projectPath) +} + +// GetByPath returns the git_repos row for the given project_path +// (= projects.host_path). +func (s *Service) GetByPath(ctx context.Context, projectPath string) (GitRepo, error) { + row := s.DB.QueryRowContext(ctx, selectColumns+` WHERE project_path = ?`, projectPath) + return scanRow(row) +} + +// GetByHash resolves a git_repos row by the 16-char SHA1 prefix of +// project_path (= projects.path_hash). The webhook endpoint uses this +// — it's stable across the system and doubles as the on-disk clone dir +// identifier. +func (s *Service) GetByHash(ctx context.Context, pathHash string) (GitRepo, error) { + var path string + if err := s.DB.QueryRowContext(ctx, + `SELECT host_path FROM projects WHERE path_hash = ?`, pathHash, + ).Scan(&path); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return GitRepo{}, ErrNotFound + } + return GitRepo{}, fmt.Errorf("lookup by path_hash: %w", err) + } + return s.GetByPath(ctx, path) +} + +// ListAll returns every git_repos row, newest first. Local projects do +// not appear here — they have no git_repos representation. +func (s *Service) ListAll(ctx context.Context) ([]GitRepo, error) { + rows, err := s.DB.QueryContext(ctx, selectColumns+` ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("list git_repos: %w", err) + } + defer rows.Close() + return scanRows(rows) +} + +// SetWebhookID persists the GitHub-side hook id after the auto-register +// flow registers the webhook. ErrNotFound when the row is gone. +func (s *Service) SetWebhookID(ctx context.Context, projectPath string, hookID int64) error { + res, err := s.DB.ExecContext(ctx, ` + UPDATE git_repos SET webhook_id = ?, updated_at = ? + WHERE project_path = ?`, + hookID, time.Now().UTC().Format(time.RFC3339Nano), projectPath) + if err != nil { + return fmt.Errorf("set webhook_id: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// SetClone updates last_sha / last_error after a clone job completes. +// Pass empty strings to leave the corresponding field unchanged (NULL +// to clear last_error explicitly is not supported — callers should +// pass "" for "no error" which CASE-clears it). +func (s *Service) SetClone(ctx context.Context, projectPath, lastSHA, lastError string) error { + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.DB.ExecContext(ctx, ` + UPDATE git_repos + SET last_sha = COALESCE(NULLIF(?, ''), last_sha), + last_error = CASE WHEN ? = '' THEN NULL ELSE ? END, + updated_at = ? + WHERE project_path = ?`, + lastSHA, lastError, lastError, now, projectPath) + if err != nil { + return fmt.Errorf("set clone: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// Delete removes a git_repos row. Idempotent — re-deleting returns +// ErrNotFound. The matching projects row + on-disk clone are NOT +// cleaned up here; that's the project-delete handler's job. +func (s *Service) Delete(ctx context.Context, projectPath string) error { + res, err := s.DB.ExecContext(ctx, `DELETE FROM git_repos WHERE project_path = ?`, projectPath) + if err != nil { + return fmt.Errorf("delete git_repo: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// --- helpers --- + +const selectColumns = ` + SELECT project_path, github_url, branch, + token_id, webhook_secret, webhook_id, + webhook_mode, auto_webhook, + last_sha, last_error, + created_at, updated_at + FROM git_repos` + +func scanRow(r interface{ Scan(dest ...any) error }) (GitRepo, error) { + var ( + g GitRepo + tokenID sql.NullString + webhookID sql.NullInt64 + webhookMode string + autoWebhook int + lastSHA sql.NullString + lastError sql.NullString + createdAt string + updatedAt string + ) + err := r.Scan(&g.ProjectPath, &g.GitHubURL, &g.Branch, + &tokenID, &g.WebhookSecret, &webhookID, + &webhookMode, &autoWebhook, + &lastSHA, &lastError, + &createdAt, &updatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return GitRepo{}, ErrNotFound + } + return GitRepo{}, fmt.Errorf("scan git_repo: %w", err) + } + g.PathHash = HashHostPath(g.ProjectPath) + g.TokenID = tokenID.String + if webhookID.Valid { + v := webhookID.Int64 + g.WebhookID = &v + } + g.WebhookMode = webhookMode + if g.WebhookMode == "" { + g.WebhookMode = WebhookModeManual + } + g.AutoWebhook = autoWebhook == 1 + g.LastSHA = lastSHA.String + g.LastError = lastError.String + g.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + g.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + return g, nil +} + +func scanRows(rows *sql.Rows) ([]GitRepo, error) { + out := []GitRepo{} + for rows.Next() { + g, err := scanRow(rows) + if err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +// NormaliseWebhookMode rejects unknown values up front so the DB only +// ever stores one of the three documented states. Empty input maps to +// the default 'manual'. +func NormaliseWebhookMode(s string) (string, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "": + return WebhookModeManual, nil + case WebhookModeManual: + return WebhookModeManual, nil + case WebhookModeAuto: + return WebhookModeAuto, nil + case WebhookModeDisabled: + return WebhookModeDisabled, nil + default: + return "", ErrInvalidWebhookMode + } +} + +// ParseGitHubURL extracts owner + repo from an HTTPS GitHub URL. +// Accepts trailing slash and ".git" suffix; rejects anything not on +// github.com. Exported so the HTTP handler can resolve the canonical +// project_path before staging the projects row. +func ParseGitHubURL(s string) (owner, repo string, err error) { + return parseGitHubURL(s) +} + +// parseGitHubURL extracts owner + repo from an HTTPS GitHub URL. Accepts +// trailing slash and ".git" suffix. Rejects anything not on github.com. +func parseGitHubURL(s string) (owner, repo string, err error) { + s = strings.TrimSpace(s) + if s == "" { + return "", "", ErrInvalidURL + } + u, perr := url.Parse(s) + if perr != nil { + return "", "", ErrInvalidURL + } + if !strings.EqualFold(u.Host, "github.com") { + return "", "", ErrInvalidURL + } + p := strings.Trim(u.Path, "/") + p = strings.TrimSuffix(p, ".git") + parts := strings.Split(p, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", ErrInvalidURL + } + return parts[0], parts[1], nil +} + +func canonicaliseURL(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, "/") + s = strings.TrimSuffix(s, ".git") + return s +} + +func generateWebhookSecret() (string, error) { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf[:]), nil +} + +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} + +func isUniqueConstraintViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "constraint failed: UNIQUE") +} + +// HashHostPath is a thin re-export of db.HashHostPath so callers within +// the gitrepos package don't need a separate import. +func HashHostPath(path string) string { return db.HashHostPath(path) } diff --git a/server/internal/gitrepos/gitrepos_test.go b/server/internal/gitrepos/gitrepos_test.go new file mode 100644 index 0000000..8ec5fba --- /dev/null +++ b/server/internal/gitrepos/gitrepos_test.go @@ -0,0 +1,187 @@ +package gitrepos + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/dvcdsys/code-index/server/internal/db" +) + +// seedProject inserts the minimum projects row the git_repos FK requires. +// We don't go through the projects service to keep the test focused on +// gitrepos and free of any indirect coupling. +func seedProject(t *testing.T, d *sql.DB, hostPath string) { + t.Helper() + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := d.Exec(` + INSERT INTO projects ( + host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash + ) VALUES (?, ?, '[]', '{}', '{}', 'pending', ?, ?, ?)`, + hostPath, hostPath, now, now, db.HashHostPath(hostPath), + ); err != nil { + t.Fatalf("seed project %s: %v", hostPath, err) + } +} + +func mustOpen(t *testing.T) (*sql.DB, *Service) { + t.Helper() + d, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + return d, New(d) +} + +func TestCreate_HappyPath(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + seedProject(t, d, "github.com/spf13/cobra@main") + + g, err := svc.Create(ctx, CreateRequest{ + GitHubURL: "https://github.com/spf13/cobra", + Branch: "main", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if g.ProjectPath != "github.com/spf13/cobra@main" { + t.Errorf("ProjectPath = %q", g.ProjectPath) + } + if g.GitHubURL != "https://github.com/spf13/cobra" { + t.Errorf("GitHubURL = %q", g.GitHubURL) + } + if g.WebhookMode != WebhookModeManual { + t.Errorf("default WebhookMode = %q, want manual", g.WebhookMode) + } + if g.WebhookSecret == "" { + t.Error("WebhookSecret was not generated") + } + if g.PathHash != db.HashHostPath(g.ProjectPath) { + t.Errorf("PathHash mismatch: %q", g.PathHash) + } +} + +func TestCreate_RejectsBadURL(t *testing.T) { + _, svc := mustOpen(t) + cases := []struct { + name string + body CreateRequest + want error + }{ + {"empty url", CreateRequest{GitHubURL: "", Branch: "main"}, ErrInvalidURL}, + {"non-github host", CreateRequest{GitHubURL: "https://gitlab.com/x/y", Branch: "main"}, ErrInvalidURL}, + {"missing branch", CreateRequest{GitHubURL: "https://github.com/x/y", Branch: ""}, ErrBranchEmpty}, + {"missing repo", CreateRequest{GitHubURL: "https://github.com/x", Branch: "main"}, ErrInvalidURL}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := svc.Create(context.Background(), tc.body); !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) + } + }) + } +} + +// TestCreate_Duplicate guards the UNIQUE(github_url, branch) constraint +// — two git_repos rows for the same upstream + branch must not coexist +// even if the operator typed slightly different casings. +func TestCreate_Duplicate(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + seedProject(t, d, "github.com/foo/bar@main") + + if _, err := svc.Create(ctx, CreateRequest{ + GitHubURL: "https://github.com/foo/bar", + Branch: "main", + }); err != nil { + t.Fatalf("first create: %v", err) + } + if _, err := svc.Create(ctx, CreateRequest{ + GitHubURL: "https://github.com/foo/bar", + Branch: "main", + }); !errors.Is(err, ErrDuplicate) { + t.Fatalf("second create: got %v, want ErrDuplicate", err) + } +} + +func TestGetByHash(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + seedProject(t, d, "github.com/x/y@main") + if _, err := svc.Create(ctx, CreateRequest{ + GitHubURL: "https://github.com/x/y", + Branch: "main", + }); err != nil { + t.Fatalf("Create: %v", err) + } + + hash := db.HashHostPath("github.com/x/y@main") + g, err := svc.GetByHash(ctx, hash) + if err != nil { + t.Fatalf("GetByHash: %v", err) + } + if g.ProjectPath != "github.com/x/y@main" { + t.Errorf("ProjectPath via hash mismatch: %q", g.ProjectPath) + } + if _, err := svc.GetByHash(ctx, "0000000000000000"); !errors.Is(err, ErrNotFound) { + t.Errorf("unknown hash: got %v, want ErrNotFound", err) + } +} + +func TestSetClone_UpdatesLastSHA(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + seedProject(t, d, "github.com/x/y@main") + if _, err := svc.Create(ctx, CreateRequest{GitHubURL: "https://github.com/x/y", Branch: "main"}); err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.SetClone(ctx, "github.com/x/y@main", "deadbeef", ""); err != nil { + t.Fatalf("SetClone: %v", err) + } + g, err := svc.GetByPath(ctx, "github.com/x/y@main") + if err != nil { + t.Fatalf("GetByPath: %v", err) + } + if g.LastSHA != "deadbeef" { + t.Errorf("LastSHA = %q, want deadbeef", g.LastSHA) + } +} + +func TestDelete_Idempotent(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + seedProject(t, d, "github.com/x/y@main") + if _, err := svc.Create(ctx, CreateRequest{GitHubURL: "https://github.com/x/y", Branch: "main"}); err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.Delete(ctx, "github.com/x/y@main"); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := svc.Delete(ctx, "github.com/x/y@main"); !errors.Is(err, ErrNotFound) { + t.Errorf("second Delete: got %v, want ErrNotFound", err) + } +} + +// TestDeletingProject_CascadesToGitRepo guards the schema FK behaviour: +// removing a row from projects must drop the matching git_repos row via +// ON DELETE CASCADE so the project-level delete handler can stay simple. +func TestDeletingProject_CascadesToGitRepo(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + seedProject(t, d, "github.com/x/y@main") + if _, err := svc.Create(ctx, CreateRequest{GitHubURL: "https://github.com/x/y", Branch: "main"}); err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := d.ExecContext(ctx, `DELETE FROM projects WHERE host_path = ?`, "github.com/x/y@main"); err != nil { + t.Fatalf("delete project: %v", err) + } + if _, err := svc.GetByPath(ctx, "github.com/x/y@main"); !errors.Is(err, ErrNotFound) { + t.Fatalf("git_repos row should have cascade-deleted, got %v", err) + } +} diff --git a/server/internal/httpapi/gitrepos.go b/server/internal/httpapi/gitrepos.go new file mode 100644 index 0000000..0c5770f --- /dev/null +++ b/server/internal/httpapi/gitrepos.go @@ -0,0 +1,306 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/dvcdsys/code-index/server/internal/githubapi" + "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/gitrepos" + "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" + "github.com/dvcdsys/code-index/server/internal/jobs" + "github.com/dvcdsys/code-index/server/internal/projects" + "github.com/dvcdsys/code-index/server/internal/workspacejobs" +) + +// gitReposUnavailable returns 503 when the workspaces feature flag is +// off OR any required service is nil. Single source for the message so +// the dashboard's "feature off" UI key is stable. +func (s *Server) gitReposUnavailable(w http.ResponseWriter) bool { + if !s.Deps.WorkspacesEnabled || s.Deps.GitRepos == nil || s.Deps.Jobs == nil { + writeError(w, http.StatusServiceUnavailable, "workspaces feature is disabled (set CIX_WORKSPACES_ENABLED=true and restart)") + return true + } + return false +} + +// gitRepoPayload mirrors the OpenAPI GitRepo schema. +type gitRepoPayload struct { + ProjectPath string `json:"project_path"` + PathHash string `json:"path_hash"` + GitHubURL string `json:"github_url"` + Branch string `json:"branch"` + TokenID *string `json:"token_id"` + AutoWebhook bool `json:"auto_webhook"` + WebhookMode string `json:"webhook_mode"` + LastSHA *string `json:"last_sha"` + LastError *string `json:"last_error"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func gitRepoToPayload(g gitrepos.GitRepo) gitRepoPayload { + var tokenID *string + if g.TokenID != "" { + v := g.TokenID + tokenID = &v + } + var lastSHA *string + if g.LastSHA != "" { + v := g.LastSHA + lastSHA = &v + } + var lastErr *string + if g.LastError != "" { + v := g.LastError + lastErr = &v + } + return gitRepoPayload{ + ProjectPath: g.ProjectPath, + PathHash: g.PathHash, + GitHubURL: g.GitHubURL, + Branch: g.Branch, + TokenID: tokenID, + AutoWebhook: g.AutoWebhook, + WebhookMode: g.WebhookMode, + LastSHA: lastSHA, + LastError: lastErr, + CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), + UpdatedAt: g.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), + } +} + +// AddGitRepo — POST /api/v1/git-repos. +// +// Creates a projects row (status='pending'), the matching git_repos row, +// and enqueues a clone_repo job. The resulting project belongs to no +// workspace — the caller can attach it via POST /workspaces/{id}/projects. +func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) { + if s.gitReposUnavailable(w) { + return + } + var body openapi.AddGitRepoRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return + } + + mode := "" + if body.WebhookMode != nil { + mode = string(*body.WebhookMode) + } + tokenID := "" + if body.TokenId != nil { + tokenID = *body.TokenId + } + + // Parse the URL up front so we know the canonical project_path and + // can stage the projects row before gitrepos.Create runs (the FK + // from git_repos.project_path → projects.host_path needs it). + owner, repo, perr := gitrepos.ParseGitHubURL(body.GithubUrl) + if perr != nil { + writeError(w, http.StatusUnprocessableEntity, "github_url must be an https://github.com/owner/repo URL") + return + } + branch := strings.TrimSpace(body.Branch) + if branch == "" { + writeError(w, http.StatusUnprocessableEntity, "branch is required") + return + } + projectPath := "github.com/" + owner + "/" + repo + "@" + branch + + // Pre-stage the projects row so the git_repos FK can attach. + // ErrConflict on a re-add is fine — somebody else (or a previous + // half-failed attempt) already wrote it; the gitrepos.Create + // below will surface the real duplicate via UNIQUE on (github_url, + // branch). ErrOverlap is a hard reject. + if _, perr := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{HostPath: projectPath}); perr != nil { + if !errors.Is(perr, projects.ErrConflict) { + writeError(w, http.StatusUnprocessableEntity, perr.Error()) + return + } + } + + g, err := s.Deps.GitRepos.Create(r.Context(), gitrepos.CreateRequest{ + GitHubURL: body.GithubUrl, + Branch: body.Branch, + TokenID: tokenID, + WebhookMode: mode, + }) + if err != nil { + switch { + case errors.Is(err, gitrepos.ErrInvalidURL): + writeError(w, http.StatusUnprocessableEntity, "github_url must be an https://github.com/owner/repo URL") + case errors.Is(err, gitrepos.ErrBranchEmpty): + writeError(w, http.StatusUnprocessableEntity, "branch is required") + case errors.Is(err, gitrepos.ErrInvalidWebhookMode): + writeError(w, http.StatusUnprocessableEntity, "webhook_mode must be one of manual, auto, disabled") + case errors.Is(err, gitrepos.ErrDuplicate): + writeError(w, http.StatusConflict, "a project for this github_url + branch already exists") + default: + writeError(w, http.StatusInternalServerError, "could not register git repo: "+err.Error()) + } + return + } + + if err := workspacejobs.EnqueueClone(r.Context(), s.Deps.Jobs, g.ProjectPath); err != nil { + writeError(w, http.StatusInternalServerError, "git repo registered but clone could not be enqueued: "+err.Error()) + return + } + + webhookURL := s.buildWebhookURL(g.PathHash) + autoRegistered := false + autoNote := "" + if g.WebhookMode == gitrepos.WebhookModeAuto { + ok, note := s.tryAutoRegisterWebhook(r.Context(), g, webhookURL) + autoRegistered = ok + autoNote = note + if ok { + // Reload so the response reflects the persisted webhook_id. + if fresh, ferr := s.Deps.GitRepos.GetByPath(r.Context(), g.ProjectPath); ferr == nil { + g = fresh + } + } + } + + proj, perr := projects.Get(r.Context(), s.Deps.DB, g.ProjectPath) + if perr != nil { + writeError(w, http.StatusInternalServerError, "could not reload project: "+perr.Error()) + return + } + resp := map[string]any{ + "project": projectToOpenAPI(proj), + "git_repo": gitRepoToPayload(g), + "webhook_url": webhookURL, + "webhook_secret": g.WebhookSecret, + "auto_registered": autoRegistered, + } + if autoNote != "" { + resp["auto_register_note"] = autoNote + } + writeJSON(w, http.StatusCreated, resp) +} + +// GetProjectGitRepo — GET /api/v1/projects/{hash}/git-repo. +func (s *Server) GetProjectGitRepo(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) { + if s.gitReposUnavailable(w) { + return + } + g, err := s.Deps.GitRepos.GetByHash(r.Context(), string(hash)) + if err != nil { + if errors.Is(err, gitrepos.ErrNotFound) { + writeError(w, http.StatusNotFound, "no git_repos row for this project (likely a local project)") + return + } + writeError(w, http.StatusInternalServerError, "could not load git_repo: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, gitRepoToPayload(g)) +} + +// ReindexProject — POST /api/v1/projects/{hash}/reindex. +// +// Looks up the matching git_repos row and enqueues a clone_repo job +// (which chains into index_repo on success). 422 for local projects +// — they have no clone pipeline and must be reindexed via the CLI. +func (s *Server) ReindexProject(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) { + if s.gitReposUnavailable(w) { + return + } + g, err := s.Deps.GitRepos.GetByHash(r.Context(), string(hash)) + if err != nil { + if errors.Is(err, gitrepos.ErrNotFound) { + writeError(w, http.StatusUnprocessableEntity, "this project has no git_repos row — reindex via `cix reindex ` for local projects") + return + } + writeError(w, http.StatusInternalServerError, "could not load git_repo: "+err.Error()) + return + } + + enqueued := true + if _, eerr := s.Deps.Jobs.Enqueue(r.Context(), jobs.EnqueueRequest{ + Type: workspacejobs.TypeCloneRepo, + DedupeKey: "clone:" + g.PathHash, + Payload: workspacejobs.ClonePayload{ProjectPath: g.ProjectPath}, + }); eerr != nil { + if errors.Is(eerr, jobs.ErrDuplicate) { + enqueued = false + } else { + writeError(w, http.StatusInternalServerError, "could not enqueue reindex") + return + } + } + status := "enqueued" + if !enqueued { + status = "already_running" + } + proj, _ := projects.Get(r.Context(), s.Deps.DB, g.ProjectPath) + resp := map[string]any{"status": status} + if proj != nil { + resp["project"] = projectToOpenAPI(proj) + } + writeJSON(w, http.StatusAccepted, resp) +} + +// tryAutoRegisterWebhook calls the GitHub API to register a push hook +// for the given git_repo. Best-effort — failure does NOT roll back the +// git_repos row; the operator can rerun manually via webhook-info. +func (s *Server) tryAutoRegisterWebhook(ctx context.Context, g gitrepos.GitRepo, deliveryURL string) (bool, string) { + logger := s.Deps.Logger + if !strings.HasPrefix(deliveryURL, "http") { + return false, "CIX_PUBLIC_URL is not set — register the webhook manually" + } + if g.TokenID == "" { + return false, "auto webhook_mode requires a token_id with admin:repo_hook scope" + } + pat, err := s.Deps.GithubTokens.Reveal(ctx, g.TokenID) + if err != nil { + if errors.Is(err, githubtokens.ErrNotFound) { + return false, "token_id not found" + } + return false, "could not decrypt the GitHub token" + } + _ = s.Deps.GithubTokens.Touch(ctx, g.TokenID) + + owner, repo, perr := githubapi.ParseOwnerRepo(g.GitHubURL) + if perr != nil { + return false, "github_url is not a parseable owner/repo URL" + } + hr, herr := githubapi.New().CreateWebhook(ctx, githubapi.CreateWebhookOptions{ + Owner: owner, + Repo: repo, + PAT: pat, + URL: deliveryURL, + Secret: g.WebhookSecret, + }) + if herr != nil { + if logger != nil { + logger.Warn("workspaces: auto-register webhook failed", + "project", g.ProjectPath, "owner", owner, "repo", repo, "err", herr) + } + if errors.Is(herr, githubapi.ErrUnauthorized) { + return false, "GitHub rejected the token — add admin:repo_hook scope or register manually" + } + return false, "GitHub API rejected the call: " + herr.Error() + } + if uerr := s.Deps.GitRepos.SetWebhookID(ctx, g.ProjectPath, hr.ID); uerr != nil && logger != nil { + logger.Warn("workspaces: could not persist webhook id", "project", g.ProjectPath, "err", uerr) + } + return true, "" +} + +// buildWebhookURL constructs the publicly-reachable webhook delivery URL +// for a project's path_hash. When PublicBaseURL is empty, returns the +// path only so the dashboard can render with a helper note. +func (s *Server) buildWebhookURL(pathHash string) string { + path := "/api/v1/webhooks/github/" + pathHash + base := strings.TrimRight(s.Deps.PublicBaseURL, "/") + if base == "" { + return path + } + return base + path +} + diff --git a/server/internal/httpapi/gitrepos_test.go b/server/internal/httpapi/gitrepos_test.go new file mode 100644 index 0000000..9d05a83 --- /dev/null +++ b/server/internal/httpapi/gitrepos_test.go @@ -0,0 +1,155 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/dvcdsys/code-index/server/internal/jobs" +) + +func TestAddGitRepo_Succeeds(t *testing.T) { + router, jobsSvc := reposRouter(t) + + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ + "github_url": "https://github.com/spf13/cobra", + "branch": "main", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("add: %d (%s)", rr.Code, rr.Body.String()) + } + + var resp struct { + Project struct { + HostPath string `json:"host_path"` + Status string `json:"status"` + } `json:"project"` + GitRepo struct { + ProjectPath string `json:"project_path"` + PathHash string `json:"path_hash"` + GitHubURL string `json:"github_url"` + Branch string `json:"branch"` + WebhookMode string `json:"webhook_mode"` + } `json:"git_repo"` + WebhookURL string `json:"webhook_url"` + WebhookSecret string `json:"webhook_secret"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + want := "github.com/spf13/cobra@main" + if resp.GitRepo.ProjectPath != want { + t.Fatalf("project_path = %q, want %q", resp.GitRepo.ProjectPath, want) + } + if resp.Project.HostPath != want { + t.Fatalf("project.host_path = %q, want %q", resp.Project.HostPath, want) + } + // Fresh projects.Create returns status='created'; the clone job + // flips through 'cloning' → 'indexing' → 'indexed' from there. + if resp.Project.Status != "created" { + t.Fatalf("project.status = %q, want created", resp.Project.Status) + } + if resp.WebhookSecret == "" { + t.Errorf("webhook_secret was not populated") + } + if resp.GitRepo.WebhookMode != "manual" { + t.Errorf("default webhook_mode = %q, want manual", resp.GitRepo.WebhookMode) + } + + // clone_repo job enqueued. + jobList, err := jobsSvc.List(context.Background(), jobs.StatusPending, "clone_repo", 10) + if err != nil { + t.Fatalf("jobs list: %v", err) + } + if len(jobList) != 1 { + t.Fatalf("expected 1 clone_repo job, got %d", len(jobList)) + } + if jobList[0].DedupeKey != "clone:"+resp.GitRepo.PathHash { + t.Errorf("dedupe_key = %q, want clone:%s", jobList[0].DedupeKey, resp.GitRepo.PathHash) + } +} + +// TestAddGitRepo_Duplicate confirms the UNIQUE(github_url, branch) +// constraint on git_repos surfaces as 409 from the HTTP handler. Used +// to be a workspace-scoped duplicate; with the split the same upstream +// can only be registered once across the whole server. +func TestAddGitRepo_Duplicate(t *testing.T) { + router, _ := reposRouter(t) + body := map[string]any{ + "github_url": "https://github.com/a/b", + "branch": "main", + } + if rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", body); rr.Code != http.StatusCreated { + t.Fatalf("first: %d", rr.Code) + } + if rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", body); rr.Code != http.StatusConflict { + t.Fatalf("duplicate should 409, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestReindexProject_RequiresGitRepo(t *testing.T) { + router, _ := reposRouter(t) + + // CLI-indexed local project — has a projects row but no git_repos. + rr := doJSON(t, router, http.MethodPost, "/api/v1/projects", map[string]any{ + "host_path": "/Users/x/local-proj", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("seed local project: %d (%s)", rr.Code, rr.Body.String()) + } + var p struct { + PathHash string `json:"path_hash"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &p) + if p.PathHash == "" { + t.Fatalf("local project missing path_hash") + } + + rr = doJSON(t, router, http.MethodPost, "/api/v1/projects/"+p.PathHash+"/reindex", nil) + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422 for local-project reindex, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +// TestDeleteProject_CascadesGitRepoAndMembership exercises the chained +// FK ON DELETE CASCADE: removing the project deletes the git_repos row +// AND every workspace_projects row referencing it. Used to be a +// manual cleanup in projects.Delete; now the FKs do the work. +func TestDeleteProject_CascadesGitRepoAndMembership(t *testing.T) { + router, _ := reposRouter(t) + + // Add an external project and attach it to a workspace. + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ + "github_url": "https://github.com/a/b", + "branch": "main", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("add: %d (%s)", rr.Code, rr.Body.String()) + } + var created struct { + GitRepo struct { + PathHash string `json:"path_hash"` + } `json:"git_repo"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &created) + hash := created.GitRepo.PathHash + + // Delete the project directly — the cascade should clear both + // git_repos and any workspace memberships (there are none here, + // but the SQL exercises the FK trigger regardless). + rr = doJSON(t, router, http.MethodDelete, "/api/v1/projects/"+hash, nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("delete: %d (%s)", rr.Code, rr.Body.String()) + } + // Re-adding the exact same upstream must succeed — proves the + // git_repos row was actually removed (otherwise UNIQUE(github_url, + // branch) would 409 here, which is the bug a previous patch fixed). + rr = doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ + "github_url": "https://github.com/a/b", + "branch": "main", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("re-add after delete: %d (%s)", rr.Code, rr.Body.String()) + } +} diff --git a/server/internal/httpapi/openapi/openapi.gen.go b/server/internal/httpapi/openapi/openapi.gen.go index 94436b0..909d32f 100644 --- a/server/internal/httpapi/openapi/openapi.gen.go +++ b/server/internal/httpapi/openapi/openapi.gen.go @@ -26,21 +26,21 @@ const ( BearerAuthScopes bearerAuthContextKey = "bearerAuth.Scopes" ) -// Defines values for AddWorkspaceRepoRequestWebhookMode. +// Defines values for AddGitRepoRequestWebhookMode. const ( - AddWorkspaceRepoRequestWebhookModeAuto AddWorkspaceRepoRequestWebhookMode = "auto" - AddWorkspaceRepoRequestWebhookModeDisabled AddWorkspaceRepoRequestWebhookMode = "disabled" - AddWorkspaceRepoRequestWebhookModeManual AddWorkspaceRepoRequestWebhookMode = "manual" + AddGitRepoRequestWebhookModeAuto AddGitRepoRequestWebhookMode = "auto" + AddGitRepoRequestWebhookModeDisabled AddGitRepoRequestWebhookMode = "disabled" + AddGitRepoRequestWebhookModeManual AddGitRepoRequestWebhookMode = "manual" ) -// Valid indicates whether the value is a known member of the AddWorkspaceRepoRequestWebhookMode enum. -func (e AddWorkspaceRepoRequestWebhookMode) Valid() bool { +// Valid indicates whether the value is a known member of the AddGitRepoRequestWebhookMode enum. +func (e AddGitRepoRequestWebhookMode) Valid() bool { switch e { - case AddWorkspaceRepoRequestWebhookModeAuto: + case AddGitRepoRequestWebhookModeAuto: return true - case AddWorkspaceRepoRequestWebhookModeDisabled: + case AddGitRepoRequestWebhookModeDisabled: return true - case AddWorkspaceRepoRequestWebhookModeManual: + case AddGitRepoRequestWebhookModeManual: return true default: return false @@ -65,6 +65,27 @@ func (e CreateUserRequestRole) Valid() bool { } } +// Defines values for GitRepoWebhookMode. +const ( + GitRepoWebhookModeAuto GitRepoWebhookMode = "auto" + GitRepoWebhookModeDisabled GitRepoWebhookMode = "disabled" + GitRepoWebhookModeManual GitRepoWebhookMode = "manual" +) + +// Valid indicates whether the value is a known member of the GitRepoWebhookMode enum. +func (e GitRepoWebhookMode) Valid() bool { + switch e { + case GitRepoWebhookModeAuto: + return true + case GitRepoWebhookModeDisabled: + return true + case GitRepoWebhookModeManual: + return true + default: + return false + } +} + // Defines values for GithubAccountType. const ( GithubAccountTypeOrg GithubAccountType = "org" @@ -266,33 +287,6 @@ func (e ProjectStatus) Valid() bool { } } -// Defines values for ProjectWorkspaceEntryStatus. -const ( - ProjectWorkspaceEntryStatusCloning ProjectWorkspaceEntryStatus = "cloning" - ProjectWorkspaceEntryStatusFailed ProjectWorkspaceEntryStatus = "failed" - ProjectWorkspaceEntryStatusIndexed ProjectWorkspaceEntryStatus = "indexed" - ProjectWorkspaceEntryStatusIndexing ProjectWorkspaceEntryStatus = "indexing" - ProjectWorkspaceEntryStatusPending ProjectWorkspaceEntryStatus = "pending" -) - -// Valid indicates whether the value is a known member of the ProjectWorkspaceEntryStatus enum. -func (e ProjectWorkspaceEntryStatus) Valid() bool { - switch e { - case ProjectWorkspaceEntryStatusCloning: - return true - case ProjectWorkspaceEntryStatusFailed: - return true - case ProjectWorkspaceEntryStatusIndexed: - return true - case ProjectWorkspaceEntryStatusIndexing: - return true - case ProjectWorkspaceEntryStatusPending: - return true - default: - return false - } -} - // Defines values for ReferenceItemChunkType. const ( Reference ReferenceItemChunkType = "reference" @@ -467,54 +461,6 @@ func (e WebhookAcceptedStatus) Valid() bool { } } -// Defines values for WorkspaceRepoStatus. -const ( - WorkspaceRepoStatusCloning WorkspaceRepoStatus = "cloning" - WorkspaceRepoStatusFailed WorkspaceRepoStatus = "failed" - WorkspaceRepoStatusIndexed WorkspaceRepoStatus = "indexed" - WorkspaceRepoStatusIndexing WorkspaceRepoStatus = "indexing" - WorkspaceRepoStatusPending WorkspaceRepoStatus = "pending" -) - -// Valid indicates whether the value is a known member of the WorkspaceRepoStatus enum. -func (e WorkspaceRepoStatus) Valid() bool { - switch e { - case WorkspaceRepoStatusCloning: - return true - case WorkspaceRepoStatusFailed: - return true - case WorkspaceRepoStatusIndexed: - return true - case WorkspaceRepoStatusIndexing: - return true - case WorkspaceRepoStatusPending: - return true - default: - return false - } -} - -// Defines values for WorkspaceRepoWebhookMode. -const ( - Auto WorkspaceRepoWebhookMode = "auto" - Disabled WorkspaceRepoWebhookMode = "disabled" - Manual WorkspaceRepoWebhookMode = "manual" -) - -// Valid indicates whether the value is a known member of the WorkspaceRepoWebhookMode enum. -func (e WorkspaceRepoWebhookMode) Valid() bool { - switch e { - case Auto: - return true - case Disabled: - return true - case Manual: - return true - default: - return false - } -} - // Defines values for WorkspaceSearchPendingRepoStatus. const ( WorkspaceSearchPendingRepoStatusCloning WorkspaceSearchPendingRepoStatus = "cloning" @@ -595,22 +541,22 @@ func (e ListTokenReposParamsAccountType) Valid() bool { // Defines values for ListJobsParamsStatus. const ( - ListJobsParamsStatusCompleted ListJobsParamsStatus = "completed" - ListJobsParamsStatusFailed ListJobsParamsStatus = "failed" - ListJobsParamsStatusPending ListJobsParamsStatus = "pending" - ListJobsParamsStatusRunning ListJobsParamsStatus = "running" + Completed ListJobsParamsStatus = "completed" + Failed ListJobsParamsStatus = "failed" + Pending ListJobsParamsStatus = "pending" + Running ListJobsParamsStatus = "running" ) // Valid indicates whether the value is a known member of the ListJobsParamsStatus enum. func (e ListJobsParamsStatus) Valid() bool { switch e { - case ListJobsParamsStatusCompleted: + case Completed: return true - case ListJobsParamsStatusFailed: + case Failed: return true - case ListJobsParamsStatusPending: + case Pending: return true - case ListJobsParamsStatusRunning: + case Running: return true default: return false @@ -635,39 +581,20 @@ func (e IndexFilesParamsAccept) Valid() bool { } } -// AddWorkspaceRepoRequest defines model for AddWorkspaceRepoRequest. -type AddWorkspaceRepoRequest struct { - // AutoWebhook Legacy field. New clients should send `webhook_mode` instead. - // When both are provided, `webhook_mode` wins; when only the - // bool is set, `true` is mapped to `webhook_mode = "auto"`. - // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set - AutoWebhook *bool `json:"auto_webhook,omitempty"` - Branch string `json:"branch"` +// AddGitRepoRequest defines model for AddGitRepoRequest. +type AddGitRepoRequest struct { + Branch string `json:"branch"` // GithubUrl https://github.com/owner/repo URL. GithubUrl string `json:"github_url"` - // TokenId Optional id of a stored GitHub PAT. Required for private repos. - TokenId *string `json:"token_id,omitempty"` - - // WebhookMode How the server should keep this repo fresh: - // - `auto` — server registers the webhook in GitHub on your - // behalf (requires admin:repo_hook on the PAT). - // - `manual` — server stores a webhook_secret and returns it - // once; you paste the URL + secret into GitHub yourself. - // - `disabled` — no auto-sync at all; reindex via the - // dashboard button only. - WebhookMode *AddWorkspaceRepoRequestWebhookMode `json:"webhook_mode,omitempty"` + // TokenId Optional id of a stored GitHub PAT (required for private repos). + TokenId *string `json:"token_id,omitempty"` + WebhookMode *AddGitRepoRequestWebhookMode `json:"webhook_mode,omitempty"` } -// AddWorkspaceRepoRequestWebhookMode How the server should keep this repo fresh: -// - `auto` — server registers the webhook in GitHub on your -// behalf (requires admin:repo_hook on the PAT). -// - `manual` — server stores a webhook_secret and returns it -// once; you paste the URL + secret into GitHub yourself. -// - `disabled` — no auto-sync at all; reindex via the -// dashboard button only. -type AddWorkspaceRepoRequestWebhookMode string +// AddGitRepoRequestWebhookMode defines model for AddGitRepoRequest.WebhookMode. +type AddGitRepoRequestWebhookMode string // ApiKey defines model for ApiKey. type ApiKey struct { @@ -857,6 +784,56 @@ type FileSearchResponse struct { Total int `json:"total"` } +// GitRepo Clone + webhook metadata for an external (git-cloned) project. +// Exactly 1:1 with the matching projects row; local projects have +// no GitRepo row. +type GitRepo struct { + // AutoWebhook Legacy alias for `webhook_mode == "auto"`. + AutoWebhook bool `json:"auto_webhook"` + Branch string `json:"branch"` + CreatedAt time.Time `json:"created_at"` + GithubUrl string `json:"github_url"` + LastError *string `json:"last_error,omitempty"` + LastSha *string `json:"last_sha,omitempty"` + + // PathHash 16-hex SHA1 prefix of project_path, used in URLs. + PathHash string `json:"path_hash"` + + // ProjectPath Matches projects.host_path — canonical + // "github.com/owner/repo@branch" string. + ProjectPath string `json:"project_path"` + TokenId *string `json:"token_id,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + WebhookMode GitRepoWebhookMode `json:"webhook_mode"` +} + +// GitRepoWebhookMode defines model for GitRepo.WebhookMode. +type GitRepoWebhookMode string + +// GitRepoCreated defines model for GitRepoCreated. +type GitRepoCreated struct { + // AutoRegisterNote Human-readable reason when auto_registered is false. + AutoRegisterNote *string `json:"auto_register_note,omitempty"` + + // AutoRegistered True when webhook_mode was 'auto' AND the server + // successfully registered the hook with GitHub. + AutoRegistered *bool `json:"auto_registered,omitempty"` + + // GitRepo Clone + webhook metadata for an external (git-cloned) project. + // Exactly 1:1 with the matching projects row; local projects have + // no GitRepo row. + GitRepo GitRepo `json:"git_repo"` + Project Project `json:"project"` + + // WebhookSecret HMAC secret. **Returned once on create + once via + // /projects/{hash}/webhook-info.** + WebhookSecret string `json:"webhook_secret"` + + // WebhookUrl Publicly-reachable POST endpoint to register in GitHub when + // doing webhook setup manually. + WebhookUrl string `json:"webhook_url"` +} + // GithubAccount A GitHub account the PAT can see. The user owning the PAT is // returned first, followed by every org accessible via /user/orgs. // The dashboard's add-repo flow shows these in a Select before @@ -1051,13 +1028,11 @@ type JobListResponse struct { Total int `json:"total"` } -// LinkExistingProjectRequest defines model for LinkExistingProjectRequest. -type LinkExistingProjectRequest struct { - // ProjectHash The 16-hex `path_hash` of an indexed project — the same value - // used in /api/v1/projects/{path}. The server resolves it to - // the canonical `host_path` and inserts a linked workspace_repo - // row. The project must already be in status='indexed' and have - // a host_path of the form "github.com/owner/repo@branch". +// LinkProjectRequest defines model for LinkProjectRequest. +type LinkProjectRequest struct { + // ProjectHash The 16-hex `path_hash` of an indexed project. The server + // resolves it to host_path and inserts the (workspace_id, + // project_path) row. The project must be in status='indexed'. ProjectHash string `json:"project_hash"` } @@ -1182,19 +1157,11 @@ type ProjectSummary struct { // ProjectWorkspaceEntry defines model for ProjectWorkspaceEntry. type ProjectWorkspaceEntry struct { - Branch string `json:"branch"` - IsLinked bool `json:"is_linked"` - - // RepoId workspace_repos.id — same value used in /repos endpoints. - RepoId string `json:"repo_id"` - Status ProjectWorkspaceEntryStatus `json:"status"` - WorkspaceId string `json:"workspace_id"` - WorkspaceName string `json:"workspace_name"` + AddedAt time.Time `json:"added_at"` + WorkspaceId string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name"` } -// ProjectWorkspaceEntryStatus defines model for ProjectWorkspaceEntry.Status. -type ProjectWorkspaceEntryStatus string - // ProjectWorkspaceList defines model for ProjectWorkspaceList. type ProjectWorkspaceList struct { Workspaces []ProjectWorkspaceEntry `json:"workspaces"` @@ -1233,8 +1200,8 @@ type ReferenceResponse struct { // ReindexEnqueuedResponse defines model for ReindexEnqueuedResponse. type ReindexEnqueuedResponse struct { - Repo *WorkspaceRepo `json:"repo,omitempty"` - Status ReindexEnqueuedResponseStatus `json:"status"` + Project *Project `json:"project,omitempty"` + Status ReindexEnqueuedResponseStatus `json:"status"` } // ReindexEnqueuedResponseStatus defines model for ReindexEnqueuedResponse.Status. @@ -1308,8 +1275,10 @@ type SemanticSearchRequest struct { // Limit Maximum number of FILE groups (not chunks) to return. Limit *int `json:"limit,omitempty"` - // MinScore Minimum cosine similarity. Omit for server default (0.4 for - // CodeRankEmbed-Q8). Send `0` explicitly to disable the floor. + // MinScore Minimum cosine similarity. Omit for server default (0.2 — + // light floor that keeps abstract NL queries non-empty). Send + // `0` to disable; pass `0.4+` for strict code-symbol searches + // calibrated for CodeRankEmbed-Q8. MinScore *float32 `json:"min_score,omitempty"` // Paths Whitelist — keep only results whose path matches any prefix or substring. @@ -1572,81 +1541,26 @@ type WorkspaceListResponse struct { Workspaces []Workspace `json:"workspaces"` } -// WorkspaceRepo defines model for WorkspaceRepo. -type WorkspaceRepo struct { - // AutoWebhook Legacy alias for `webhook_mode == "auto"`. Always present so - // old clients keep working; new clients should consult - // `webhook_mode` instead. - AutoWebhook bool `json:"auto_webhook"` - Branch string `json:"branch"` - CreatedAt time.Time `json:"created_at"` - - // GithubUrl Canonical https://github.com/owner/repo URL. - GithubUrl string `json:"github_url"` - Id string `json:"id"` - - // IsLinked True when this row is a lightweight pointer to a project - // already owned by another workspace_repo — added via the - // "Add Existing Project" flow. Linked rows have no clone on - // disk, no webhook, and no token; reindex is a no-op (must - // be triggered from the canonical owning row). - IsLinked bool `json:"is_linked"` - LastError *string `json:"last_error,omitempty"` - LastIndexedAt *time.Time `json:"last_indexed_at,omitempty"` - - // LastSha HEAD SHA at last successful clone. - LastSha *string `json:"last_sha,omitempty"` - - // ProjectPath Indexed project's host_path — "github.com/owner/repo@branch". - // Use this with the existing /api/v1/projects/{path}/* endpoints - // (path = first 16 hex chars of SHA1). - ProjectPath string `json:"project_path"` - Status WorkspaceRepoStatus `json:"status"` - - // TokenId GitHub token used for clone+webhook calls. Null when the - // repo is public. - TokenId *string `json:"token_id,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - - // WebhookMode Operator's intent for how this repo gets kept fresh. `auto` - // asks the server to register the GitHub webhook; `manual` - // means the operator pastes the URL+secret into GitHub - // themselves; `disabled` skips auto-sync entirely — reindex - // via the dashboard button only. - WebhookMode WorkspaceRepoWebhookMode `json:"webhook_mode"` - WorkspaceId string `json:"workspace_id"` +// WorkspaceProject A project listed under a workspace, decorated with the membership +// timestamp. The embedded Project carries the full project info +// (status, languages, last_indexed_at) so the dashboard doesn't +// need a second roundtrip. +type WorkspaceProject struct { + AddedAt time.Time `json:"added_at"` + Project Project `json:"project"` } -// WorkspaceRepoStatus defines model for WorkspaceRepo.Status. -type WorkspaceRepoStatus string - -// WorkspaceRepoWebhookMode Operator's intent for how this repo gets kept fresh. `auto` -// asks the server to register the GitHub webhook; `manual` -// means the operator pastes the URL+secret into GitHub -// themselves; `disabled` skips auto-sync entirely — reindex -// via the dashboard button only. -type WorkspaceRepoWebhookMode string - -// WorkspaceRepoCreated defines model for WorkspaceRepoCreated. -type WorkspaceRepoCreated struct { - Repo WorkspaceRepo `json:"repo"` - - // WebhookSecret HMAC secret. **Returned once on create + once via - // webhook-info.** Use as the "Secret" field in GitHub's webhook - // UI; deliveries are validated by HMAC-SHA256 over the body. - // Empty string for linked rows (no webhook). - WebhookSecret string `json:"webhook_secret"` - - // WebhookUrl Publicly-reachable POST endpoint to register in GitHub when - // doing the webhook setup manually. Includes the workspace_repo - // id segment. Empty string for linked rows (no webhook). - WebhookUrl string `json:"webhook_url"` +// WorkspaceProjectListResponse defines model for WorkspaceProjectListResponse. +type WorkspaceProjectListResponse struct { + Projects []WorkspaceProject `json:"projects"` + Total int `json:"total"` } -// WorkspaceRepoListResponse defines model for WorkspaceRepoListResponse. -type WorkspaceRepoListResponse struct { - Repos []WorkspaceRepo `json:"repos"` - Total int `json:"total"` +// WorkspaceProjectMembership defines model for WorkspaceProjectMembership. +type WorkspaceProjectMembership struct { + AddedAt time.Time `json:"added_at"` + ProjectPath string `json:"project_path"` + WorkspaceId string `json:"workspace_id"` } // WorkspaceSearchChunk defines model for WorkspaceSearchChunk. @@ -1863,11 +1777,13 @@ type WorkspaceSearchParams struct { TopChunks *int `form:"top_chunks,omitempty" json:"top_chunks,omitempty"` // MinScore Floor on raw cosine similarity. Chunks below this are - // dropped before aggregation. Default 0 — relies on - // chromem's natural ordering. Set higher (e.g. 0.3) to cut - // noise when querying long natural-language sentences; - // keep at 0 for short tokens / acronyms where embedding - // magnitudes are inherently smaller. + // dropped before aggregation. Default 0.4 — symmetric with + // per-project search default so an unfiltered workspace + // query doesn't return cross-repo noise that a single-repo + // query would have rejected. Pass 0 explicitly for + // intentional cross-project sweeps that need long-tail + // recall (e.g. "authentication and authorization" across a + // mixed-domain workspace). MinScore *float32 `form:"min_score,omitempty" json:"min_score,omitempty"` } @@ -1889,6 +1805,9 @@ type ChangePasswordJSONRequestBody = ChangePasswordRequest // LoginJSONRequestBody defines body for Login for application/json ContentType. type LoginJSONRequestBody = LoginRequest +// AddGitRepoJSONRequestBody defines body for AddGitRepo for application/json ContentType. +type AddGitRepoJSONRequestBody = AddGitRepoRequest + // CreateGithubTokenJSONRequestBody defines body for CreateGithubToken for application/json ContentType. type CreateGithubTokenJSONRequestBody = CreateGithubTokenRequest @@ -1931,11 +1850,8 @@ type CreateWorkspaceJSONRequestBody = CreateWorkspaceRequest // UpdateWorkspaceJSONRequestBody defines body for UpdateWorkspace for application/json ContentType. type UpdateWorkspaceJSONRequestBody = UpdateWorkspaceRequest -// AddWorkspaceRepoJSONRequestBody defines body for AddWorkspaceRepo for application/json ContentType. -type AddWorkspaceRepoJSONRequestBody = AddWorkspaceRepoRequest - -// LinkExistingProjectJSONRequestBody defines body for LinkExistingProject for application/json ContentType. -type LinkExistingProjectJSONRequestBody = LinkExistingProjectRequest +// LinkProjectToWorkspaceJSONRequestBody defines body for LinkProjectToWorkspace for application/json ContentType. +type LinkProjectToWorkspaceJSONRequestBody = LinkProjectRequest // ServerInterface represents all server handlers. type ServerInterface interface { @@ -1996,6 +1912,9 @@ type ServerInterface interface { // End one of my sessions (sign out a single device) // (DELETE /api/v1/auth/sessions/{id}) DeleteMySession(w http.ResponseWriter, r *http.Request, id string) + // Clone + index a GitHub repository as a standalone project + // (POST /api/v1/git-repos) + AddGitRepo(w http.ResponseWriter, r *http.Request) // List stored GitHub PATs (metadata only) // (GET /api/v1/github-tokens) ListGithubTokens(w http.ResponseWriter, r *http.Request) @@ -2020,6 +1939,15 @@ type ServerInterface interface { // Register a new project // (POST /api/v1/projects) CreateProject(w http.ResponseWriter, r *http.Request) + // Read the git_repos metadata for an external project + // (GET /api/v1/projects/{hash}/git-repo) + GetProjectGitRepo(w http.ResponseWriter, r *http.Request, hash string) + // Manually re-trigger the clone + index pipeline + // (POST /api/v1/projects/{hash}/reindex) + ReindexProject(w http.ResponseWriter, r *http.Request, hash string) + // Webhook URL + secret for manual GitHub setup + // (GET /api/v1/projects/{hash}/webhook-info) + GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash string) // Delete a project and all its indexed data (admin only) // (DELETE /api/v1/projects/{path}) DeleteProject(w http.ResponseWriter, r *http.Request, path ProjectHash) @@ -2069,8 +1997,8 @@ type ServerInterface interface { // (GET /api/v1/status) GetStatus(w http.ResponseWriter, r *http.Request) // Receive a GitHub webhook delivery (public, HMAC-authenticated) - // (POST /api/v1/webhooks/github/{repo_id}) - ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, repoId string, params ReceiveGithubWebhookParams) + // (POST /api/v1/webhooks/github/{hash}) + ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, hash string, params ReceiveGithubWebhookParams) // List all workspaces // (GET /api/v1/workspaces) ListWorkspaces(w http.ResponseWriter, r *http.Request) @@ -2086,24 +2014,15 @@ type ServerInterface interface { // Update workspace metadata // (PATCH /api/v1/workspaces/{id}) UpdateWorkspace(w http.ResponseWriter, r *http.Request, id string) - // List repositories attached to a workspace - // (GET /api/v1/workspaces/{id}/repos) - ListWorkspaceRepos(w http.ResponseWriter, r *http.Request, id string) - // Attach a GitHub repository to a workspace - // (POST /api/v1/workspaces/{id}/repos) - AddWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string) - // Attach an already-indexed project to a workspace - // (POST /api/v1/workspaces/{id}/repos/link) - LinkExistingProject(w http.ResponseWriter, r *http.Request, id string) - // Detach a repository from a workspace - // (DELETE /api/v1/workspaces/{id}/repos/{repo_id}) - DeleteWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string, repoId string) - // Manually re-trigger the clone + index pipeline - // (POST /api/v1/workspaces/{id}/repos/{repo_id}/reindex) - ReindexWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string, repoId string) - // Get the webhook URL + secret for manual GitHub setup - // (GET /api/v1/workspaces/{id}/repos/{repo_id}/webhook-info) - GetWorkspaceRepoWebhookInfo(w http.ResponseWriter, r *http.Request, id string, repoId string) + // List projects currently linked to a workspace + // (GET /api/v1/workspaces/{id}/projects) + ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, id string) + // Link an existing project into this workspace + // (POST /api/v1/workspaces/{id}/projects) + LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request, id string) + // Remove a project from this workspace (does not delete the project) + // (DELETE /api/v1/workspaces/{id}/projects/{hash}) + UnlinkProjectFromWorkspace(w http.ResponseWriter, r *http.Request, id string, hash string) // Hybrid BM25+dense search across all repos in a workspace // (GET /api/v1/workspaces/{id}/search) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id string, params WorkspaceSearchParams) @@ -2230,6 +2149,12 @@ func (_ Unimplemented) DeleteMySession(w http.ResponseWriter, r *http.Request, i w.WriteHeader(http.StatusNotImplemented) } +// Clone + index a GitHub repository as a standalone project +// (POST /api/v1/git-repos) +func (_ Unimplemented) AddGitRepo(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // List stored GitHub PATs (metadata only) // (GET /api/v1/github-tokens) func (_ Unimplemented) ListGithubTokens(w http.ResponseWriter, r *http.Request) { @@ -2278,6 +2203,24 @@ func (_ Unimplemented) CreateProject(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Read the git_repos metadata for an external project +// (GET /api/v1/projects/{hash}/git-repo) +func (_ Unimplemented) GetProjectGitRepo(w http.ResponseWriter, r *http.Request, hash string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Manually re-trigger the clone + index pipeline +// (POST /api/v1/projects/{hash}/reindex) +func (_ Unimplemented) ReindexProject(w http.ResponseWriter, r *http.Request, hash string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Webhook URL + secret for manual GitHub setup +// (GET /api/v1/projects/{hash}/webhook-info) +func (_ Unimplemented) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash string) { + w.WriteHeader(http.StatusNotImplemented) +} + // Delete a project and all its indexed data (admin only) // (DELETE /api/v1/projects/{path}) func (_ Unimplemented) DeleteProject(w http.ResponseWriter, r *http.Request, path ProjectHash) { @@ -2375,8 +2318,8 @@ func (_ Unimplemented) GetStatus(w http.ResponseWriter, r *http.Request) { } // Receive a GitHub webhook delivery (public, HMAC-authenticated) -// (POST /api/v1/webhooks/github/{repo_id}) -func (_ Unimplemented) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, repoId string, params ReceiveGithubWebhookParams) { +// (POST /api/v1/webhooks/github/{hash}) +func (_ Unimplemented) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, hash string, params ReceiveGithubWebhookParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -2410,39 +2353,21 @@ func (_ Unimplemented) UpdateWorkspace(w http.ResponseWriter, r *http.Request, i w.WriteHeader(http.StatusNotImplemented) } -// List repositories attached to a workspace -// (GET /api/v1/workspaces/{id}/repos) -func (_ Unimplemented) ListWorkspaceRepos(w http.ResponseWriter, r *http.Request, id string) { - w.WriteHeader(http.StatusNotImplemented) -} - -// Attach a GitHub repository to a workspace -// (POST /api/v1/workspaces/{id}/repos) -func (_ Unimplemented) AddWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string) { +// List projects currently linked to a workspace +// (GET /api/v1/workspaces/{id}/projects) +func (_ Unimplemented) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, id string) { w.WriteHeader(http.StatusNotImplemented) } -// Attach an already-indexed project to a workspace -// (POST /api/v1/workspaces/{id}/repos/link) -func (_ Unimplemented) LinkExistingProject(w http.ResponseWriter, r *http.Request, id string) { +// Link an existing project into this workspace +// (POST /api/v1/workspaces/{id}/projects) +func (_ Unimplemented) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request, id string) { w.WriteHeader(http.StatusNotImplemented) } -// Detach a repository from a workspace -// (DELETE /api/v1/workspaces/{id}/repos/{repo_id}) -func (_ Unimplemented) DeleteWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string, repoId string) { - w.WriteHeader(http.StatusNotImplemented) -} - -// Manually re-trigger the clone + index pipeline -// (POST /api/v1/workspaces/{id}/repos/{repo_id}/reindex) -func (_ Unimplemented) ReindexWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string, repoId string) { - w.WriteHeader(http.StatusNotImplemented) -} - -// Get the webhook URL + secret for manual GitHub setup -// (GET /api/v1/workspaces/{id}/repos/{repo_id}/webhook-info) -func (_ Unimplemented) GetWorkspaceRepoWebhookInfo(w http.ResponseWriter, r *http.Request, id string, repoId string) { +// Remove a project from this workspace (does not delete the project) +// (DELETE /api/v1/workspaces/{id}/projects/{hash}) +func (_ Unimplemented) UnlinkProjectFromWorkspace(w http.ResponseWriter, r *http.Request, id string, hash string) { w.WriteHeader(http.StatusNotImplemented) } @@ -2902,6 +2827,26 @@ func (siw *ServerInterfaceWrapper) DeleteMySession(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// AddGitRepo operation middleware +func (siw *ServerInterfaceWrapper) AddGitRepo(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AddGitRepo(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListGithubTokens operation middleware func (siw *ServerInterfaceWrapper) ListGithubTokens(w http.ResponseWriter, r *http.Request) { @@ -3185,6 +3130,102 @@ func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// GetProjectGitRepo operation middleware +func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetProjectGitRepo(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReindexProject operation middleware +func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReindexProject(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetProjectWebhookInfo operation middleware +func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetProjectWebhookInfo(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DeleteProject operation middleware func (siw *ServerInterfaceWrapper) DeleteProject(w http.ResponseWriter, r *http.Request) { @@ -3715,12 +3756,12 @@ func (siw *ServerInterfaceWrapper) ReceiveGithubWebhook(w http.ResponseWriter, r var err error _ = err - // ------------- Path parameter "repo_id" ------------- - var repoId string + // ------------- Path parameter "hash" ------------- + var hash string - err = runtime.BindStyledParameterWithOptions("simple", "repo_id", chi.URLParam(r, "repo_id"), &repoId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "repo_id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) return } @@ -3768,7 +3809,7 @@ func (siw *ServerInterfaceWrapper) ReceiveGithubWebhook(w http.ResponseWriter, r } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ReceiveGithubWebhook(w, r, repoId, params) + siw.Handler.ReceiveGithubWebhook(w, r, hash, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3914,104 +3955,8 @@ func (siw *ServerInterfaceWrapper) UpdateWorkspace(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } -// ListWorkspaceRepos operation middleware -func (siw *ServerInterfaceWrapper) ListWorkspaceRepos(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id string - - err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) - return - } - - ctx := r.Context() - - ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) - - r = r.WithContext(ctx) - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListWorkspaceRepos(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// AddWorkspaceRepo operation middleware -func (siw *ServerInterfaceWrapper) AddWorkspaceRepo(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id string - - err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) - return - } - - ctx := r.Context() - - ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) - - r = r.WithContext(ctx) - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.AddWorkspaceRepo(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// LinkExistingProject operation middleware -func (siw *ServerInterfaceWrapper) LinkExistingProject(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // ------------- Path parameter "id" ------------- - var id string - - err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) - return - } - - ctx := r.Context() - - ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) - - r = r.WithContext(ctx) - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.LinkExistingProject(w, r, id) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// DeleteWorkspaceRepo operation middleware -func (siw *ServerInterfaceWrapper) DeleteWorkspaceRepo(w http.ResponseWriter, r *http.Request) { +// ListWorkspaceProjects operation middleware +func (siw *ServerInterfaceWrapper) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -4025,15 +3970,6 @@ func (siw *ServerInterfaceWrapper) DeleteWorkspaceRepo(w http.ResponseWriter, r return } - // ------------- Path parameter "repo_id" ------------- - var repoId string - - err = runtime.BindStyledParameterWithOptions("simple", "repo_id", chi.URLParam(r, "repo_id"), &repoId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "repo_id", Err: err}) - return - } - ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) @@ -4041,7 +3977,7 @@ func (siw *ServerInterfaceWrapper) DeleteWorkspaceRepo(w http.ResponseWriter, r r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteWorkspaceRepo(w, r, id, repoId) + siw.Handler.ListWorkspaceProjects(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4051,8 +3987,8 @@ func (siw *ServerInterfaceWrapper) DeleteWorkspaceRepo(w http.ResponseWriter, r handler.ServeHTTP(w, r) } -// ReindexWorkspaceRepo operation middleware -func (siw *ServerInterfaceWrapper) ReindexWorkspaceRepo(w http.ResponseWriter, r *http.Request) { +// LinkProjectToWorkspace operation middleware +func (siw *ServerInterfaceWrapper) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -4066,15 +4002,6 @@ func (siw *ServerInterfaceWrapper) ReindexWorkspaceRepo(w http.ResponseWriter, r return } - // ------------- Path parameter "repo_id" ------------- - var repoId string - - err = runtime.BindStyledParameterWithOptions("simple", "repo_id", chi.URLParam(r, "repo_id"), &repoId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "repo_id", Err: err}) - return - } - ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) @@ -4082,7 +4009,7 @@ func (siw *ServerInterfaceWrapper) ReindexWorkspaceRepo(w http.ResponseWriter, r r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ReindexWorkspaceRepo(w, r, id, repoId) + siw.Handler.LinkProjectToWorkspace(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4092,8 +4019,8 @@ func (siw *ServerInterfaceWrapper) ReindexWorkspaceRepo(w http.ResponseWriter, r handler.ServeHTTP(w, r) } -// GetWorkspaceRepoWebhookInfo operation middleware -func (siw *ServerInterfaceWrapper) GetWorkspaceRepoWebhookInfo(w http.ResponseWriter, r *http.Request) { +// UnlinkProjectFromWorkspace operation middleware +func (siw *ServerInterfaceWrapper) UnlinkProjectFromWorkspace(w http.ResponseWriter, r *http.Request) { var err error _ = err @@ -4107,12 +4034,12 @@ func (siw *ServerInterfaceWrapper) GetWorkspaceRepoWebhookInfo(w http.ResponseWr return } - // ------------- Path parameter "repo_id" ------------- - var repoId string + // ------------- Path parameter "hash" ------------- + var hash string - err = runtime.BindStyledParameterWithOptions("simple", "repo_id", chi.URLParam(r, "repo_id"), &repoId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "repo_id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) return } @@ -4123,7 +4050,7 @@ func (siw *ServerInterfaceWrapper) GetWorkspaceRepoWebhookInfo(w http.ResponseWr r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetWorkspaceRepoWebhookInfo(w, r, id, repoId) + siw.Handler.UnlinkProjectFromWorkspace(w, r, id, hash) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4404,6 +4331,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/api/v1/auth/sessions/{id}", wrapper.DeleteMySession) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/git-repos", wrapper.AddGitRepo) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/github-tokens", wrapper.ListGithubTokens) }) @@ -4428,6 +4358,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/projects", wrapper.CreateProject) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/projects/{hash}/git-repo", wrapper.GetProjectGitRepo) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/projects/{hash}/reindex", wrapper.ReindexProject) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/projects/{hash}/webhook-info", wrapper.GetProjectWebhookInfo) + }) r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/api/v1/projects/{path}", wrapper.DeleteProject) }) @@ -4477,7 +4416,7 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Get(options.BaseURL+"/api/v1/status", wrapper.GetStatus) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/webhooks/github/{repo_id}", wrapper.ReceiveGithubWebhook) + r.Post(options.BaseURL+"/api/v1/webhooks/github/{hash}", wrapper.ReceiveGithubWebhook) }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/workspaces", wrapper.ListWorkspaces) @@ -4495,22 +4434,13 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Patch(options.BaseURL+"/api/v1/workspaces/{id}", wrapper.UpdateWorkspace) }) r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/workspaces/{id}/repos", wrapper.ListWorkspaceRepos) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/workspaces/{id}/repos", wrapper.AddWorkspaceRepo) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/workspaces/{id}/repos/link", wrapper.LinkExistingProject) - }) - r.Group(func(r chi.Router) { - r.Delete(options.BaseURL+"/api/v1/workspaces/{id}/repos/{repo_id}", wrapper.DeleteWorkspaceRepo) + r.Get(options.BaseURL+"/api/v1/workspaces/{id}/projects", wrapper.ListWorkspaceProjects) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/workspaces/{id}/repos/{repo_id}/reindex", wrapper.ReindexWorkspaceRepo) + r.Post(options.BaseURL+"/api/v1/workspaces/{id}/projects", wrapper.LinkProjectToWorkspace) }) r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/workspaces/{id}/repos/{repo_id}/webhook-info", wrapper.GetWorkspaceRepoWebhookInfo) + r.Delete(options.BaseURL+"/api/v1/workspaces/{id}/projects/{hash}", wrapper.UnlinkProjectFromWorkspace) }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/workspaces/{id}/search", wrapper.WorkspaceSearch) @@ -4527,352 +4457,345 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "IPMVPch1a1RzbHzR2Jxm6+pPP3/6/wMAAP//", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/server/internal/httpapi/project_workspaces.go b/server/internal/httpapi/project_workspaces.go index 644d9e6..e87434f 100644 --- a/server/internal/httpapi/project_workspaces.go +++ b/server/internal/httpapi/project_workspaces.go @@ -4,39 +4,28 @@ import ( "database/sql" "errors" "net/http" + "time" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/projects" ) // projectWorkspaceEntryPayload is the wire shape for one membership. -// Kept JSON-tagged here rather than reusing the generated type so the -// handler can set fields by name without aligning to openapi-codegen's -// nullability quirks for the embedded enums. type projectWorkspaceEntryPayload struct { - WorkspaceID string `json:"workspace_id"` - WorkspaceName string `json:"workspace_name"` - RepoID string `json:"repo_id"` - Branch string `json:"branch"` - Status string `json:"status"` - IsLinked bool `json:"is_linked"` + WorkspaceID string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name"` + AddedAt time.Time `json:"added_at"` } // ListProjectWorkspaces — GET /api/v1/projects/{path}/workspaces. // -// Returns every workspace that has this project attached. Used by the -// project detail page to render "Workspaces" chips linking to each -// workspace. Empty list when the project isn't part of any workspace. -// -// The workspaces feature flag is NOT consulted here: even if workspaces -// are disabled, returning an empty membership list is the right -// response — the project page should still render cleanly. +// Returns every workspace that this project is currently linked into. +// Used by the project detail page to render "Workspaces" chips. Empty +// list when the project is in no workspace (true for freshly-added +// standalone projects). The workspaces feature flag is NOT consulted — +// returning an empty list is fine even when the flag is off. func (s *Server) ListProjectWorkspaces(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { hash := string(path) - - // Resolve the project first so 404 vs empty membership are clearly - // distinguishable: unknown hash → 404; known hash with zero - // memberships → 200 with workspaces=[]. proj, err := projects.GetByHash(r.Context(), s.Deps.DB, hash) if err != nil { if errors.Is(err, projects.ErrNotFound) { @@ -48,10 +37,10 @@ func (s *Server) ListProjectWorkspaces(w http.ResponseWriter, r *http.Request, p } rows, err := s.Deps.DB.QueryContext(r.Context(), ` - SELECT w.id, w.name, wr.id, wr.branch, wr.status, wr.is_linked + SELECT w.id, w.name, wp.added_at FROM workspaces w - JOIN workspace_repos wr ON wr.workspace_id = w.id - WHERE wr.project_path = ? + JOIN workspace_projects wp ON wp.workspace_id = w.id + WHERE wp.project_path = ? ORDER BY w.name`, proj.HostPath) if err != nil { writeError(w, http.StatusInternalServerError, "could not list workspaces: "+err.Error()) @@ -62,14 +51,14 @@ func (s *Server) ListProjectWorkspaces(w http.ResponseWriter, r *http.Request, p entries := []projectWorkspaceEntryPayload{} for rows.Next() { var ( - e projectWorkspaceEntryPayload - isLinked int + e projectWorkspaceEntryPayload + addedAt string ) - if scanErr := rows.Scan(&e.WorkspaceID, &e.WorkspaceName, &e.RepoID, &e.Branch, &e.Status, &isLinked); scanErr != nil { + if scanErr := rows.Scan(&e.WorkspaceID, &e.WorkspaceName, &addedAt); scanErr != nil { writeError(w, http.StatusInternalServerError, "could not read row: "+scanErr.Error()) return } - e.IsLinked = isLinked == 1 + e.AddedAt, _ = time.Parse(time.RFC3339Nano, addedAt) entries = append(entries, e) } if err := rows.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) { diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 444cf0b..6391a65 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -13,6 +13,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/apikeys" "github.com/dvcdsys/code-index/server/internal/embeddings" + "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/githubtokens" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/indexer" @@ -22,7 +23,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/users" "github.com/dvcdsys/code-index/server/internal/vectorstore" "github.com/dvcdsys/code-index/server/internal/versioncheck" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" + "github.com/dvcdsys/code-index/server/internal/workspaceprojects" "github.com/dvcdsys/code-index/server/internal/workspaces" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -86,9 +87,12 @@ type Deps struct { WorkspacesEnabled bool Workspaces *workspaces.Service GithubTokens *githubtokens.Service - // PR2 additions — repo attachment + the persistent job queue. - WorkspaceRepos *workspacerepos.Service - Jobs *jobs.Service + // GitRepos owns clone + webhook metadata for external projects; + // WorkspaceProjects owns the workspace ↔ project junction. Jobs is + // the persistent background queue. + GitRepos *gitrepos.Service + WorkspaceProjects *workspaceprojects.Service + Jobs *jobs.Service // PublicBaseURL is the operator-set externally-reachable URL of the // server. Used to build the webhook URL surfaced when adding a repo // — when empty, handlers return the path-only form and rely on the diff --git a/server/internal/httpapi/webhooks.go b/server/internal/httpapi/webhooks.go index aa664a7..76ac916 100644 --- a/server/internal/httpapi/webhooks.go +++ b/server/internal/httpapi/webhooks.go @@ -10,73 +10,64 @@ import ( "net/http" "strings" + "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/jobs" + "github.com/dvcdsys/code-index/server/internal/projects" "github.com/dvcdsys/code-index/server/internal/secrets" "github.com/dvcdsys/code-index/server/internal/workspacejobs" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" ) -// GetWorkspaceRepoWebhookInfo — GET /workspaces/{id}/repos/{repo_id}/webhook-info. +// GetProjectWebhookInfo — GET /api/v1/projects/{hash}/webhook-info. // // Authenticated. Returns the publicly-reachable webhook URL + the HMAC // secret. Operators copy these into GitHub's webhook config when -// auto_webhook=false. -func (s *Server) GetWorkspaceRepoWebhookInfo(w http.ResponseWriter, r *http.Request, id, repoID string) { - if s.workspaceReposUnavailable(w) { +// webhook_mode is not 'auto'. 404 when the project is local (no +// git_repos row). +func (s *Server) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) { + if s.gitReposUnavailable(w) { return } - if !s.requireWorkspace(w, r, id) { - return - } - wr, err := s.Deps.WorkspaceRepos.GetByID(r.Context(), repoID) + g, err := s.Deps.GitRepos.GetByHash(r.Context(), string(hash)) if err != nil { - if errors.Is(err, workspacerepos.ErrNotFound) { - writeError(w, http.StatusNotFound, "repo not found") + if errors.Is(err, gitrepos.ErrNotFound) { + writeError(w, http.StatusNotFound, "no git_repos row for this project (local projects have no webhook)") return } - writeError(w, http.StatusInternalServerError, "could not load repo") - return - } - if wr.WorkspaceID != id { - writeError(w, http.StatusNotFound, "repo not found") + writeError(w, http.StatusInternalServerError, "could not load git_repo: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ - "webhook_url": s.buildWebhookURL(wr.ID), - "webhook_secret": wr.WebhookSecret, - "auto_registered": wr.WebhookID != nil, + "webhook_url": s.buildWebhookURL(g.PathHash), + "webhook_secret": g.WebhookSecret, + "auto_registered": g.WebhookID != nil, }) } // pushEvent is the minimal subset of GitHub's push webhook body we care -// about. We don't bind to go-github here because we only need two fields -// — the ref and the head SHA — and pulling in the dependency for that -// would be heavyweight. +// about — the ref and the head SHA. type pushEvent struct { - Ref string `json:"ref"` // "refs/heads/main" - After string `json:"after"` // post-push HEAD SHA + Ref string `json:"ref"` // "refs/heads/main" + After string `json:"after"` // post-push HEAD SHA } -// ReceiveGithubWebhook — POST /api/v1/webhooks/github/{repo_id}. +// ReceiveGithubWebhook — POST /api/v1/webhooks/github/{hash}. // -// Public endpoint (added to publicPaths in middleware.go). Authenticated -// per-row by HMAC-SHA256 over the body keyed by workspace_repos.webhook_secret. -func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, repoID string, params openapi.ReceiveGithubWebhookParams) { - if !s.Deps.WorkspacesEnabled || s.Deps.WorkspaceRepos == nil || s.Deps.Jobs == nil { +// Public endpoint. Authenticated per-row by HMAC-SHA256 over the body +// keyed by git_repos.webhook_secret (looked up via projects.path_hash). +func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, hash string, params openapi.ReceiveGithubWebhookParams) { + if !s.Deps.WorkspacesEnabled || s.Deps.GitRepos == nil || s.Deps.Jobs == nil { writeError(w, http.StatusServiceUnavailable, "workspaces feature is disabled (set CIX_WORKSPACES_ENABLED=true and restart)") return } - // Read the raw body BEFORE any JSON parsing so we can compute HMAC - // against the exact byte sequence GitHub signed. body, err := io.ReadAll(r.Body) if err != nil { writeError(w, http.StatusBadRequest, "could not read body") return } - wr, err := s.Deps.WorkspaceRepos.GetByID(r.Context(), repoID) + g, err := s.Deps.GitRepos.GetByHash(r.Context(), hash) if err != nil { // Don't leak existence — both unknown and bad-HMAC look like 404 // to a probing attacker. @@ -89,12 +80,10 @@ func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, re sigHeader = *params.XHubSignature256 } if sigHeader == "" { - // Fall back to direct header read in case oapi-codegen casing - // differs from what GitHub sends. sigHeader = r.Header.Get("X-Hub-Signature-256") } - if !validHMAC(body, []byte(wr.WebhookSecret), sigHeader) { - s.Deps.Logger.Warn("workspaces webhook: HMAC mismatch", "repo_id", repoID) + if !validHMAC(body, []byte(g.WebhookSecret), sigHeader) { + s.Deps.Logger.Warn("workspaces webhook: HMAC mismatch", "path_hash", hash) writeError(w, http.StatusUnauthorized, "invalid signature") return } @@ -109,17 +98,13 @@ func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, re switch event { case "ping": - // GitHub sends ping on webhook creation — return 200 so the UI - // confirms the setup is wired. writeJSON(w, http.StatusOK, map[string]any{"status": "ping"}) return case "push": // fall through default: - // Unknown / unsupported events are ack'd quietly so GitHub stops - // retrying. We log so operators can see what arrived. s.Deps.Logger.Info("workspaces webhook: ignored event", - "repo_id", repoID, + "path_hash", hash, "event", event) writeJSON(w, http.StatusOK, map[string]any{"status": "ignored"}) return @@ -131,29 +116,36 @@ func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, re return } - // Only react to pushes on the tracked branch. GitHub sends one delivery - // per ref; deletes have After=000…000 and we treat those as ignored. - wantRef := "refs/heads/" + wr.Branch + wantRef := "refs/heads/" + g.Branch if p.Ref != wantRef { writeJSON(w, http.StatusOK, map[string]any{"status": "ignored"}) return } if strings.Trim(p.After, "0") == "" { - // Branch deletion → ignore (cleanup story lives in PR4+). writeJSON(w, http.StatusOK, map[string]any{"status": "ignored"}) return } + // Sanity-check the projects row still exists so the job has + // something to work against. We don't require any particular + // status — re-clone is the normal recovery path. + if _, perr := projects.Get(r.Context(), s.Deps.DB, g.ProjectPath); perr != nil { + s.Deps.Logger.Error("webhook: project missing for git_repo", + "path_hash", hash, "project", g.ProjectPath, "err", perr) + writeError(w, http.StatusInternalServerError, "project row missing") + return + } + enqueued := true if _, eerr := s.Deps.Jobs.Enqueue(r.Context(), jobs.EnqueueRequest{ Type: workspacejobs.TypeCloneRepo, - DedupeKey: "clone:" + wr.ID, - Payload: workspacejobs.ClonePayload{RepoID: wr.ID}, + DedupeKey: "clone:" + g.PathHash, + Payload: workspacejobs.ClonePayload{ProjectPath: g.ProjectPath}, }); eerr != nil { if errors.Is(eerr, jobs.ErrDuplicate) { enqueued = false } else { - s.Deps.Logger.Error("workspaces webhook: enqueue failed", "repo_id", repoID, "err", eerr) + s.Deps.Logger.Error("workspaces webhook: enqueue failed", "path_hash", hash, "err", eerr) writeError(w, http.StatusInternalServerError, "could not enqueue reindex") return } @@ -162,12 +154,11 @@ func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, re if !enqueued { status = "already_running" } - writeJSON(w, http.StatusAccepted, map[string]any{"status": status, "repo_id": wr.ID}) + writeJSON(w, http.StatusAccepted, map[string]any{"status": status, "path_hash": g.PathHash}) } // validHMAC returns true when the given header matches HMAC-SHA256(body, secret). -// Header format is "sha256=" per GitHub's spec. Constant-time compare -// against the expected value to avoid leaking timing signals. +// Header format is "sha256=" per GitHub's spec. func validHMAC(body, secret []byte, header string) bool { header = strings.TrimSpace(header) const prefix = "sha256=" @@ -181,7 +172,5 @@ func validHMAC(body, secret []byte, header string) bool { mac := hmac.New(sha256.New, secret) mac.Write(body) want := mac.Sum(nil) - // Use the secrets package's constant-time helper — same byte semantics - // as hmac.Equal, kept in one place across the codebase. return secrets.ConstantTimeEqual(got, want) } diff --git a/server/internal/httpapi/webhooks_test.go b/server/internal/httpapi/webhooks_test.go index db16662..89922a3 100644 --- a/server/internal/httpapi/webhooks_test.go +++ b/server/internal/httpapi/webhooks_test.go @@ -13,51 +13,33 @@ import ( "time" "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/jobs" + "github.com/dvcdsys/code-index/server/internal/projects" "github.com/dvcdsys/code-index/server/internal/secrets" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" + "github.com/dvcdsys/code-index/server/internal/workspaceprojects" "github.com/dvcdsys/code-index/server/internal/workspaces" ) -// addRepo helper — creates a workspace + repo and returns the repo -// payload so tests can lift webhook_secret/id directly. -func addRepo(t *testing.T, router http.Handler, wsName, githubURL, branch string) workspaceRepoPayload { +// addGitRepo helper — POSTs /git-repos and returns (path_hash, webhook_secret) +// so individual webhook tests can post against the new URL shape. +func addGitRepo(t *testing.T, router http.Handler, githubURL, branch string) (string, string) { t.Helper() - wsID := createWS(t, router, wsName) - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ "github_url": githubURL, "branch": branch, }) if rr.Code != http.StatusCreated { - t.Fatalf("add repo: %d (%s)", rr.Code, rr.Body.String()) + t.Fatalf("add git_repo: %d (%s)", rr.Code, rr.Body.String()) } var got struct { - Repo workspaceRepoPayload `json:"repo"` - WebhookSecret string `json:"webhook_secret"` + GitRepo struct { + PathHash string `json:"path_hash"` + } `json:"git_repo"` + WebhookSecret string `json:"webhook_secret"` } _ = json.Unmarshal(rr.Body.Bytes(), &got) - // Stash the secret onto the payload via the URL — tests pluck it - // from the response body directly when needed; this helper just - // returns the repo. Test bodies that need the secret call addRepoWithSecret. - return got.Repo -} - -func addRepoWithSecret(t *testing.T, router http.Handler, wsName, githubURL, branch string) (workspaceRepoPayload, string) { - t.Helper() - wsID := createWS(t, router, wsName) - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ - "github_url": githubURL, - "branch": branch, - }) - if rr.Code != http.StatusCreated { - t.Fatalf("add repo: %d (%s)", rr.Code, rr.Body.String()) - } - var got struct { - Repo workspaceRepoPayload `json:"repo"` - WebhookSecret string `json:"webhook_secret"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &got) - return got.Repo, got.WebhookSecret + return got.GitRepo.PathHash, got.WebhookSecret } func signBody(body []byte, secret string) string { @@ -66,9 +48,9 @@ func signBody(body []byte, secret string) string { return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } -func postWebhook(t *testing.T, router http.Handler, repoID string, body []byte, sig, event string) *httptest.ResponseRecorder { +func postWebhook(t *testing.T, router http.Handler, hash string, body []byte, sig, event string) *httptest.ResponseRecorder { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github/"+repoID, bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github/"+hash, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") if sig != "" { req.Header.Set("X-Hub-Signature-256", sig) @@ -83,9 +65,9 @@ func postWebhook(t *testing.T, router http.Handler, repoID string, body []byte, func TestWebhook_PingReturns200(t *testing.T) { router, _ := reposRouter(t) - repo, secret := addRepoWithSecret(t, router, "platform", "https://github.com/x/y", "main") + hash, secret := addGitRepo(t, router, "https://github.com/x/y", "main") body := []byte(`{"zen":"Speak like a human."}`) - rr := postWebhook(t, router, repo.ID, body, signBody(body, secret), "ping") + rr := postWebhook(t, router, hash, body, signBody(body, secret), "ping") if rr.Code != http.StatusOK { t.Fatalf("ping: expected 200, got %d (%s)", rr.Code, rr.Body.String()) } @@ -93,10 +75,8 @@ func TestWebhook_PingReturns200(t *testing.T) { func TestWebhook_PushEnqueuesCloneJob(t *testing.T) { router, jobsSvc := reposRouter(t) - repo, secret := addRepoWithSecret(t, router, "platform", "https://github.com/x/y", "main") + hash, secret := addGitRepo(t, router, "https://github.com/x/y", "main") - // Drain the initial clone job from the add-repo call so we can see the - // webhook's own dedupe behaviour clearly. ctx := context.Background() initial, _ := jobsSvc.List(ctx, jobs.StatusPending, "clone_repo", 10) if len(initial) != 1 { @@ -104,8 +84,7 @@ func TestWebhook_PushEnqueuesCloneJob(t *testing.T) { } body := []byte(`{"ref":"refs/heads/main","after":"abc123def456"}`) - rr := postWebhook(t, router, repo.ID, body, signBody(body, secret), "push") - // Dedupe with the in-flight initial clone → 202 already_running. + rr := postWebhook(t, router, hash, body, signBody(body, secret), "push") if rr.Code != http.StatusAccepted { t.Fatalf("push: expected 202, got %d (%s)", rr.Code, rr.Body.String()) } @@ -120,9 +99,9 @@ func TestWebhook_PushEnqueuesCloneJob(t *testing.T) { func TestWebhook_PushOnDifferentBranchIgnored(t *testing.T) { router, _ := reposRouter(t) - repo, secret := addRepoWithSecret(t, router, "platform", "https://github.com/x/y", "main") + hash, secret := addGitRepo(t, router, "https://github.com/x/y", "main") body := []byte(`{"ref":"refs/heads/develop","after":"abc123"}`) - rr := postWebhook(t, router, repo.ID, body, signBody(body, secret), "push") + rr := postWebhook(t, router, hash, body, signBody(body, secret), "push") if rr.Code != http.StatusOK { t.Fatalf("ignored: expected 200, got %d", rr.Code) } @@ -137,10 +116,9 @@ func TestWebhook_PushOnDifferentBranchIgnored(t *testing.T) { func TestWebhook_BadSignatureRejected(t *testing.T) { router, _ := reposRouter(t) - repo, _ := addRepoWithSecret(t, router, "platform", "https://github.com/x/y", "main") + hash, _ := addGitRepo(t, router, "https://github.com/x/y", "main") body := []byte(`{"ref":"refs/heads/main","after":"abc"}`) - // Sign with the wrong secret. - rr := postWebhook(t, router, repo.ID, body, signBody(body, "wrong"), "push") + rr := postWebhook(t, router, hash, body, signBody(body, "wrong"), "push") if rr.Code != http.StatusUnauthorized { t.Fatalf("bad sig: expected 401, got %d (%s)", rr.Code, rr.Body.String()) } @@ -148,28 +126,24 @@ func TestWebhook_BadSignatureRejected(t *testing.T) { func TestWebhook_MissingSignatureRejected(t *testing.T) { router, _ := reposRouter(t) - repo, _ := addRepoWithSecret(t, router, "platform", "https://github.com/x/y", "main") + hash, _ := addGitRepo(t, router, "https://github.com/x/y", "main") body := []byte(`{"ref":"refs/heads/main","after":"abc"}`) - rr := postWebhook(t, router, repo.ID, body, "", "push") + rr := postWebhook(t, router, hash, body, "", "push") if rr.Code != http.StatusUnauthorized { t.Fatalf("no sig: expected 401, got %d", rr.Code) } } -func TestWebhook_UnknownRepoReturns404(t *testing.T) { +func TestWebhook_UnknownHashReturns404(t *testing.T) { router, _ := reposRouter(t) body := []byte(`{}`) - // Use the right HMAC math but a bogus repo id — must still 404 (we - // short-circuit before HMAC since there's no secret to compare against). - rr := postWebhook(t, router, "no-such-repo", body, signBody(body, "anything"), "push") + rr := postWebhook(t, router, "0000000000000000", body, signBody(body, "anything"), "push") if rr.Code != http.StatusNotFound { - t.Fatalf("unknown repo: expected 404, got %d", rr.Code) + t.Fatalf("unknown hash: expected 404, got %d", rr.Code) } } func TestWebhook_PathIsPublic(t *testing.T) { - // Spin up a router with auth ENABLED (not the test-default) to verify - // the webhook path is reachable without credentials. d, err := dbOpenMemory(t) if err != nil { t.Fatalf("open db: %v", err) @@ -186,18 +160,12 @@ func TestWebhook_PathIsPublic(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github/anything", bytes.NewReader([]byte(`{}`))) router.ServeHTTP(rr, req) - // We expect either 503 (feature off) or 404, NOT 401 — the public-path - // gate should let us through the auth middleware. if rr.Code == http.StatusUnauthorized { t.Fatalf("webhook path leaked into auth-gated set, got 401") } } -func TestAddRepo_AutoRegisterFailsCleanlyWithoutPublicURL(t *testing.T) { - // reposRouter sets PublicBaseURL=https://cix.example.test, but the - // auto-register flow tries a real github.com call which the test - // can't allow. So skip when wired with a real URL — this test - // exercises the empty-URL branch by building a separate router. +func TestAddGitRepo_AutoRegisterFailsCleanlyWithoutPublicURL(t *testing.T) { d, err := dbOpenMemory(t) if err != nil { t.Fatalf("open db: %v", err) @@ -210,7 +178,8 @@ func TestAddRepo_AutoRegisterFailsCleanlyWithoutPublicURL(t *testing.T) { } wsSvc := workspaces.New(d) ghSvc := githubtokens.New(d, sec) - wrSvc := workspacerepos.New(d) + grSvc := gitrepos.New(d) + wpSvc := workspaceprojects.New(d) jobsSvc := jobs.New(d, jobs.Options{Concurrency: 1, PollEvery: time.Hour}) router := NewRouter(Deps{ @@ -222,16 +191,16 @@ func TestAddRepo_AutoRegisterFailsCleanlyWithoutPublicURL(t *testing.T) { WorkspacesEnabled: true, Workspaces: wsSvc, GithubTokens: ghSvc, - WorkspaceRepos: wrSvc, + GitRepos: grSvc, + WorkspaceProjects: wpSvc, Jobs: jobsSvc, // PublicBaseURL deliberately unset. }) - wsID := createWS(t, router, "platform") - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ "github_url": "https://github.com/x/y", "branch": "main", - "auto_webhook": true, + "webhook_mode": "auto", }) if rr.Code != http.StatusCreated { t.Fatalf("create: %d (%s)", rr.Code, rr.Body.String()) @@ -251,23 +220,27 @@ func TestAddRepo_AutoRegisterFailsCleanlyWithoutPublicURL(t *testing.T) { func TestWebhookInfo_ReturnsURLAndSecret(t *testing.T) { router, _ := reposRouter(t) - wsID := createWS(t, router, "platform") - // Manual add — we want the wsID + repo for the URL construction. - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ + // AddGitRepo includes the secret in the create response. + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ "github_url": "https://github.com/a/b", "branch": "main", }) if rr.Code != http.StatusCreated { - t.Fatalf("add: %d", rr.Code) + t.Fatalf("add: %d (%s)", rr.Code, rr.Body.String()) } var created struct { - Repo workspaceRepoPayload `json:"repo"` - WebhookSecret string `json:"webhook_secret"` + GitRepo struct { + PathHash string `json:"path_hash"` + } `json:"git_repo"` + WebhookSecret string `json:"webhook_secret"` } _ = json.Unmarshal(rr.Body.Bytes(), &created) - rr = doJSON(t, router, http.MethodGet, - "/api/v1/workspaces/"+wsID+"/repos/"+created.Repo.ID+"/webhook-info", nil) + hash := projects.HashPath("github.com/a/b@main") + if hash != created.GitRepo.PathHash { + t.Fatalf("path_hash mismatch: %q vs %q", hash, created.GitRepo.PathHash) + } + rr = doJSON(t, router, http.MethodGet, "/api/v1/projects/"+hash+"/webhook-info", nil) if rr.Code != http.StatusOK { t.Fatalf("webhook-info: %d (%s)", rr.Code, rr.Body.String()) } @@ -280,7 +253,7 @@ func TestWebhookInfo_ReturnsURLAndSecret(t *testing.T) { if info.WebhookSecret != created.WebhookSecret { t.Fatalf("secret mismatch between create and info") } - if info.WebhookURL != "https://cix.example.test/api/v1/webhooks/github/"+created.Repo.ID { + if info.WebhookURL != "https://cix.example.test/api/v1/webhooks/github/"+hash { t.Fatalf("URL wrong: %q", info.WebhookURL) } if info.AutoRegistered { diff --git a/server/internal/httpapi/workspace_test_helpers_test.go b/server/internal/httpapi/workspace_test_helpers_test.go new file mode 100644 index 0000000..3b2ea9d --- /dev/null +++ b/server/internal/httpapi/workspace_test_helpers_test.go @@ -0,0 +1,117 @@ +package httpapi + +import ( + "database/sql" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/gitrepos" + "github.com/dvcdsys/code-index/server/internal/jobs" + "github.com/dvcdsys/code-index/server/internal/secrets" + "github.com/dvcdsys/code-index/server/internal/workspaceprojects" + "github.com/dvcdsys/code-index/server/internal/workspaces" +) + +// reposRouter spins up a router with the full workspaces + git_repos + +// workspace_projects surface wired against an in-memory DB. Auth is +// disabled — the focus is the persistence + enqueue paths. +// +// The jobs worker pool is created but NOT started: tests only assert +// jobs landed in the right state. End-to-end clone+index runs against +// real git remotes — out of scope for unit tests. +// reposRouterDB is the explicit form returning the DB handle too. +// Tests that need to manipulate projects.status (a state normally owned +// by the indexer) call this variant. +func reposRouterDB(t *testing.T) (http.Handler, *jobs.Service, *sql.DB) { + t.Helper() + d, err := dbOpenMemory(t) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Setenv("CIX_SECRET_KEY", "") + t.Setenv("CIX_SECRET_KEYFILE", "") + sec, err := secrets.Open(secrets.OpenOptions{DataDir: t.TempDir(), AllowGenerate: true}) + if err != nil { + t.Fatalf("open secrets: %v", err) + } + wsSvc := workspaces.New(d) + ghSvc := githubtokens.New(d, sec) + grSvc := gitrepos.New(d) + wpSvc := workspaceprojects.New(d) + jobsSvc := jobs.New(d, jobs.Options{Concurrency: 1, PollEvery: time.Hour}) + + router := NewRouter(Deps{ + DB: d, + ServerVersion: "test", + APIVersion: "v1", + Backend: "go", + AuthDisabled: true, + Users: seedlessUsers(d), + Sessions: seedlessSessions(d), + APIKeys: seedlessAPIKeys(d), + WorkspacesEnabled: true, + Workspaces: wsSvc, + GithubTokens: ghSvc, + GitRepos: grSvc, + WorkspaceProjects: wpSvc, + Jobs: jobsSvc, + PublicBaseURL: "https://cix.example.test", + }) + return router, jobsSvc, d +} + +func reposRouter(t *testing.T) (http.Handler, *jobs.Service) { + t.Helper() + d, err := dbOpenMemory(t) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Setenv("CIX_SECRET_KEY", "") + t.Setenv("CIX_SECRET_KEYFILE", "") + sec, err := secrets.Open(secrets.OpenOptions{DataDir: t.TempDir(), AllowGenerate: true}) + if err != nil { + t.Fatalf("open secrets: %v", err) + } + wsSvc := workspaces.New(d) + ghSvc := githubtokens.New(d, sec) + grSvc := gitrepos.New(d) + wpSvc := workspaceprojects.New(d) + jobsSvc := jobs.New(d, jobs.Options{Concurrency: 1, PollEvery: time.Hour}) + + router := NewRouter(Deps{ + DB: d, + ServerVersion: "test", + APIVersion: "v1", + Backend: "go", + AuthDisabled: true, + Users: seedlessUsers(d), + Sessions: seedlessSessions(d), + APIKeys: seedlessAPIKeys(d), + WorkspacesEnabled: true, + Workspaces: wsSvc, + GithubTokens: ghSvc, + GitRepos: grSvc, + WorkspaceProjects: wpSvc, + Jobs: jobsSvc, + PublicBaseURL: "https://cix.example.test", + }) + return router, jobsSvc +} + +// createWS calls POST /api/v1/workspaces with the given name and returns +// the new workspace id. +func createWS(t *testing.T, router http.Handler, name string) string { + t.Helper() + rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces", map[string]any{ + "name": name, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("create workspace: %d (%s)", rr.Code, rr.Body.String()) + } + var got workspacePayload + _ = json.Unmarshal(rr.Body.Bytes(), &got) + return got.ID +} diff --git a/server/internal/httpapi/workspaceprojects.go b/server/internal/httpapi/workspaceprojects.go new file mode 100644 index 0000000..96d81f6 --- /dev/null +++ b/server/internal/httpapi/workspaceprojects.go @@ -0,0 +1,151 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" + "github.com/dvcdsys/code-index/server/internal/projects" + "github.com/dvcdsys/code-index/server/internal/workspaceprojects" + "github.com/dvcdsys/code-index/server/internal/workspaces" +) + +// workspaceProjectsUnavailable returns 503 when the feature flag is +// off OR any required service is nil. +func (s *Server) workspaceProjectsUnavailable(w http.ResponseWriter) bool { + if !s.Deps.WorkspacesEnabled || s.Deps.WorkspaceProjects == nil || s.Deps.Workspaces == nil { + writeError(w, http.StatusServiceUnavailable, "workspaces feature is disabled (set CIX_WORKSPACES_ENABLED=true and restart)") + return true + } + return false +} + +// requireWorkspace loads the parent workspace and returns 404 if missing. +func (s *Server) requireWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool { + _, err := s.Deps.Workspaces.GetByID(r.Context(), workspaceID) + if err != nil { + if errors.Is(err, workspaces.ErrNotFound) { + writeError(w, http.StatusNotFound, "workspace not found") + } else { + writeError(w, http.StatusInternalServerError, "could not load workspace") + } + return false + } + return true +} + +// ListWorkspaceProjects — GET /api/v1/workspaces/{id}/projects. +func (s *Server) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, id string) { + if s.workspaceProjectsUnavailable(w) { + return + } + if !s.requireWorkspace(w, r, id) { + return + } + memberships, err := s.Deps.WorkspaceProjects.ListByWorkspace(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list workspace projects: "+err.Error()) + return + } + out := make([]map[string]any, 0, len(memberships)) + for _, m := range memberships { + proj, perr := projects.Get(r.Context(), s.Deps.DB, m.ProjectPath) + if perr != nil { + // The FK should prevent dangling rows, but if the project + // disappeared between SELECT and Get we just skip it. + s.Deps.Logger.Warn("workspaceprojects: project missing for membership", + "workspace_id", id, "project_path", m.ProjectPath, "err", perr) + continue + } + out = append(out, map[string]any{ + "project": projectToOpenAPI(proj), + "added_at": m.AddedAt, + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "projects": out, + "total": len(out), + }) +} + +// LinkProjectToWorkspace — POST /api/v1/workspaces/{id}/projects. +// +// Adds an existing indexed project to a workspace. The project must be +// in status='indexed'; pending/cloning projects return 422 so the +// dashboard can prompt the user to wait. +func (s *Server) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request, id string) { + if s.workspaceProjectsUnavailable(w) { + return + } + if !s.requireWorkspace(w, r, id) { + return + } + var body openapi.LinkProjectRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return + } + proj, perr := projects.GetByHash(r.Context(), s.Deps.DB, body.ProjectHash) + if perr != nil { + if errors.Is(perr, projects.ErrNotFound) { + writeError(w, http.StatusNotFound, "project not found") + return + } + writeError(w, http.StatusInternalServerError, "could not load project: "+perr.Error()) + return + } + m, err := s.Deps.WorkspaceProjects.Link(r.Context(), id, proj.HostPath) + if err != nil { + switch { + case errors.Is(err, workspaceprojects.ErrProjectNotIndexed): + writeError(w, http.StatusUnprocessableEntity, + "project is not yet indexed — wait for indexing to complete before linking") + case errors.Is(err, workspaceprojects.ErrProjectMissing): + writeError(w, http.StatusNotFound, "project not found") + case errors.Is(err, workspaceprojects.ErrWorkspaceMissing): + writeError(w, http.StatusNotFound, "workspace not found") + case errors.Is(err, workspaceprojects.ErrDuplicate): + writeError(w, http.StatusConflict, "project is already linked to this workspace") + default: + writeError(w, http.StatusInternalServerError, "could not link project: "+err.Error()) + } + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "workspace_id": m.WorkspaceID, + "project_path": m.ProjectPath, + "added_at": m.AddedAt, + }) +} + +// UnlinkProjectFromWorkspace — DELETE /api/v1/workspaces/{id}/projects/{hash}. +// +// Removes the workspace_projects row. The project itself is untouched. +// 204 on success, 404 when the project (or the membership) doesn't exist. +func (s *Server) UnlinkProjectFromWorkspace(w http.ResponseWriter, r *http.Request, id string, hash openapi.ProjectHash) { + if s.workspaceProjectsUnavailable(w) { + return + } + if !s.requireWorkspace(w, r, id) { + return + } + proj, perr := projects.GetByHash(r.Context(), s.Deps.DB, string(hash)) + if perr != nil { + if errors.Is(perr, projects.ErrNotFound) { + writeError(w, http.StatusNotFound, "project not found") + return + } + writeError(w, http.StatusInternalServerError, "could not load project: "+perr.Error()) + return + } + if err := s.Deps.WorkspaceProjects.Unlink(r.Context(), id, proj.HostPath); err != nil { + if errors.Is(err, workspaceprojects.ErrNotFound) { + writeError(w, http.StatusNotFound, "project is not linked to this workspace") + return + } + writeError(w, http.StatusInternalServerError, "could not unlink project: "+err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/httpapi/workspaceprojects_test.go b/server/internal/httpapi/workspaceprojects_test.go new file mode 100644 index 0000000..3285574 --- /dev/null +++ b/server/internal/httpapi/workspaceprojects_test.go @@ -0,0 +1,130 @@ +package httpapi + +import ( + "database/sql" + "encoding/json" + "net/http" + "testing" + + "github.com/dvcdsys/code-index/server/internal/projects" +) + +// markIndexed flips a project's status to 'indexed' so workspaceprojects.Link +// passes its precondition. Production code does this through the indexer's +// finish step; tests don't run the indexer and write the row directly. +func markIndexed(t *testing.T, d *sql.DB, hostPath string) { + t.Helper() + if _, err := d.Exec( + `UPDATE projects SET status = 'indexed' WHERE host_path = ?`, hostPath, + ); err != nil { + t.Fatalf("flip status to indexed for %s: %v", hostPath, err) + } +} + +func TestLinkProjectToWorkspace_RejectsNotIndexed(t *testing.T) { + router, _ := reposRouter(t) + wsID := createWS(t, router, "platform") + + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ + "github_url": "https://github.com/x/y", + "branch": "main", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("add git_repo: %d (%s)", rr.Code, rr.Body.String()) + } + hash := projects.HashPath("github.com/x/y@main") + + rr = doJSON(t, router, http.MethodPost, + "/api/v1/workspaces/"+wsID+"/projects", + map[string]any{"project_hash": hash}) + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("link before indexed: expected 422, got %d (%s)", rr.Code, rr.Body.String()) + } +} + +func TestLinkProjectToWorkspace_AfterIndexed(t *testing.T) { + router, _, d := reposRouterDB(t) + wsID := createWS(t, router, "platform") + hostPath := "/Users/x/local-proj" + rr := doJSON(t, router, http.MethodPost, "/api/v1/projects", map[string]any{ + "host_path": hostPath, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("create project: %d (%s)", rr.Code, rr.Body.String()) + } + markIndexed(t, d, hostPath) + hash := projects.HashPath(hostPath) + + rr = doJSON(t, router, http.MethodPost, + "/api/v1/workspaces/"+wsID+"/projects", + map[string]any{"project_hash": hash}) + if rr.Code != http.StatusCreated { + t.Fatalf("link: %d (%s)", rr.Code, rr.Body.String()) + } + + rr = doJSON(t, router, http.MethodGet, "/api/v1/workspaces/"+wsID+"/projects", nil) + if rr.Code != http.StatusOK { + t.Fatalf("list: %d (%s)", rr.Code, rr.Body.String()) + } + var list struct { + Projects []map[string]any `json:"projects"` + Total int `json:"total"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &list) + if list.Total != 1 { + t.Fatalf("expected 1 project in workspace, got %d", list.Total) + } +} + +// TestLink_Duplicate confirms the workspace_projects PRIMARY KEY +// catches a re-link as 409. Same project, same workspace, twice. +func TestLink_Duplicate(t *testing.T) { + router, _, d := reposRouterDB(t) + wsID := createWS(t, router, "platform") + hostPath := "/Users/x/p" + _ = doJSON(t, router, http.MethodPost, "/api/v1/projects", + map[string]any{"host_path": hostPath}) + markIndexed(t, d, hostPath) + hash := projects.HashPath(hostPath) + + body := map[string]any{"project_hash": hash} + if rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/projects", body); rr.Code != http.StatusCreated { + t.Fatalf("first link: %d", rr.Code) + } + if rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/projects", body); rr.Code != http.StatusConflict { + t.Fatalf("second link should 409, got %d", rr.Code) + } +} + +// TestUnlinkProject removes a membership without touching the project. +func TestUnlinkProject(t *testing.T) { + router, _, d := reposRouterDB(t) + wsID := createWS(t, router, "platform") + hostPath := "/Users/x/local-proj" + _ = doJSON(t, router, http.MethodPost, "/api/v1/projects", + map[string]any{"host_path": hostPath}) + markIndexed(t, d, hostPath) + hash := projects.HashPath(hostPath) + + if rr := doJSON(t, router, http.MethodPost, + "/api/v1/workspaces/"+wsID+"/projects", + map[string]any{"project_hash": hash}); rr.Code != http.StatusCreated { + t.Fatalf("link: %d (%s)", rr.Code, rr.Body.String()) + } + + rr := doJSON(t, router, http.MethodDelete, + "/api/v1/workspaces/"+wsID+"/projects/"+hash, nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("unlink: expected 204, got %d (%s)", rr.Code, rr.Body.String()) + } + rr = doJSON(t, router, http.MethodDelete, + "/api/v1/workspaces/"+wsID+"/projects/"+hash, nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("repeat unlink: expected 404, got %d", rr.Code) + } + // Project still exists. + rr = doJSON(t, router, http.MethodGet, "/api/v1/projects/"+hash, nil) + if rr.Code != http.StatusOK { + t.Fatalf("project should survive unlink, got %d", rr.Code) + } +} diff --git a/server/internal/httpapi/workspacerepos.go b/server/internal/httpapi/workspacerepos.go deleted file mode 100644 index d135149..0000000 --- a/server/internal/httpapi/workspacerepos.go +++ /dev/null @@ -1,425 +0,0 @@ -package httpapi - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "strings" - "time" - - "github.com/dvcdsys/code-index/server/internal/githubapi" - "github.com/dvcdsys/code-index/server/internal/githubtokens" - "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" - "github.com/dvcdsys/code-index/server/internal/jobs" - "github.com/dvcdsys/code-index/server/internal/projects" - "github.com/dvcdsys/code-index/server/internal/workspacejobs" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" - "github.com/dvcdsys/code-index/server/internal/workspaces" -) - -type workspaceRepoPayload struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - GitHubURL string `json:"github_url"` - Branch string `json:"branch"` - ProjectPath string `json:"project_path"` - TokenID *string `json:"token_id"` - AutoWebhook bool `json:"auto_webhook"` - WebhookMode string `json:"webhook_mode"` - Status string `json:"status"` - LastSHA *string `json:"last_sha"` - LastError *string `json:"last_error"` - LastIndexedAt *time.Time `json:"last_indexed_at"` - IsLinked bool `json:"is_linked"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func workspaceRepoToPayload(wr workspacerepos.WorkspaceRepo) workspaceRepoPayload { - var tokenID *string - if wr.TokenID != "" { - v := wr.TokenID - tokenID = &v - } - var lastSHA *string - if wr.LastSHA != "" { - v := wr.LastSHA - lastSHA = &v - } - var lastErr *string - if wr.LastError != "" { - v := wr.LastError - lastErr = &v - } - mode := wr.WebhookMode - if mode == "" { - mode = workspacerepos.WebhookModeManual - } - return workspaceRepoPayload{ - ID: wr.ID, - WorkspaceID: wr.WorkspaceID, - GitHubURL: wr.GitHubURL, - Branch: wr.Branch, - ProjectPath: wr.ProjectPath, - TokenID: tokenID, - AutoWebhook: wr.AutoWebhook, - WebhookMode: mode, - Status: wr.Status, - LastSHA: lastSHA, - LastError: lastErr, - LastIndexedAt: wr.LastIndexedAt, - IsLinked: wr.IsLinked, - CreatedAt: wr.CreatedAt, - UpdatedAt: wr.UpdatedAt, - } -} - -// workspaceReposUnavailable returns 503 when the feature flag is off OR -// any required service is nil. -func (s *Server) workspaceReposUnavailable(w http.ResponseWriter) bool { - if !s.Deps.WorkspacesEnabled || s.Deps.WorkspaceRepos == nil || s.Deps.Workspaces == nil || s.Deps.Jobs == nil { - writeError(w, http.StatusServiceUnavailable, "workspaces feature is disabled (set CIX_WORKSPACES_ENABLED=true and restart)") - return true - } - return false -} - -// requireWorkspace loads the parent workspace and returns 404 if missing. -// Used by every workspace-scoped endpoint to make "wrong workspace id" -// vs "wrong repo id" distinguishable in error responses. -func (s *Server) requireWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool { - _, err := s.Deps.Workspaces.GetByID(r.Context(), workspaceID) - if err != nil { - if errors.Is(err, workspaces.ErrNotFound) { - writeError(w, http.StatusNotFound, "workspace not found") - } else { - writeError(w, http.StatusInternalServerError, "could not load workspace") - } - return false - } - return true -} - -// ListWorkspaceRepos — GET /api/v1/workspaces/{id}/repos. -func (s *Server) ListWorkspaceRepos(w http.ResponseWriter, r *http.Request, id string) { - if s.workspaceReposUnavailable(w) { - return - } - if !s.requireWorkspace(w, r, id) { - return - } - list, err := s.Deps.WorkspaceRepos.ListByWorkspace(r.Context(), id) - if err != nil { - writeError(w, http.StatusInternalServerError, "could not list repos") - return - } - out := make([]workspaceRepoPayload, 0, len(list)) - for _, wr := range list { - out = append(out, workspaceRepoToPayload(wr)) - } - writeJSON(w, http.StatusOK, map[string]any{ - "repos": out, - "total": len(out), - }) -} - -// AddWorkspaceRepo — POST /api/v1/workspaces/{id}/repos. -// -// Creates the workspace_repos row + enqueues the clone_repo job (which -// chains to index_repo on success). Response carries the freshly-minted -// webhook_secret + a constructed webhook_url so the operator can set up -// the GitHub webhook manually (or wait for PR3's auto-register flow). -func (s *Server) AddWorkspaceRepo(w http.ResponseWriter, r *http.Request, id string) { - if s.workspaceReposUnavailable(w) { - return - } - if !s.requireWorkspace(w, r, id) { - return - } - var body openapi.AddWorkspaceRepoRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") - return - } - req := workspacerepos.CreateRequest{ - WorkspaceID: id, - GitHubURL: body.GithubUrl, - Branch: body.Branch, - } - if body.TokenId != nil { - req.TokenID = *body.TokenId - } - if body.AutoWebhook != nil { - req.AutoWebhook = *body.AutoWebhook - } - if body.WebhookMode != nil { - req.WebhookMode = string(*body.WebhookMode) - } - wr, err := s.Deps.WorkspaceRepos.Create(r.Context(), req) - if err != nil { - switch { - case errors.Is(err, workspacerepos.ErrInvalidURL): - writeError(w, http.StatusUnprocessableEntity, "github_url must be an https://github.com/owner/repo URL") - case errors.Is(err, workspacerepos.ErrBranchEmpty): - writeError(w, http.StatusUnprocessableEntity, "branch is required") - case errors.Is(err, workspacerepos.ErrInvalidWebhookMode): - writeError(w, http.StatusUnprocessableEntity, "webhook_mode must be one of manual, auto, disabled") - case errors.Is(err, workspacerepos.ErrDuplicate): - writeError(w, http.StatusConflict, "this repo+branch is already attached to the workspace") - default: - writeError(w, http.StatusInternalServerError, "could not attach repo") - } - return - } - - if err := workspacejobs.EnqueueClone(r.Context(), s.Deps.Jobs, wr.ID); err != nil { - // Row created, job not — surface the error but leave the row. - // A manual reindex will retry the clone. - writeError(w, http.StatusInternalServerError, "repo attached but clone could not be enqueued: "+err.Error()) - return - } - - webhookURL := s.buildWebhookURL(wr.ID) - autoRegistered := false - autoNote := "" - if wr.AutoWebhook { - ok, note := s.tryAutoRegisterWebhook(r.Context(), wr, webhookURL) - autoRegistered = ok - autoNote = note - if ok { - // Reload so the response reflects the persisted webhook_id. - if fresh, ferr := s.Deps.WorkspaceRepos.GetByID(r.Context(), wr.ID); ferr == nil { - wr = fresh - } - } - } - - resp := map[string]any{ - "repo": workspaceRepoToPayload(wr), - "webhook_url": webhookURL, - "webhook_secret": wr.WebhookSecret, - "auto_registered": autoRegistered, - } - if autoNote != "" { - resp["auto_register_note"] = autoNote - } - writeJSON(w, http.StatusCreated, resp) -} - -// tryAutoRegisterWebhook calls the GitHub API to register a push hook for -// the given repo. Best-effort — failure does NOT roll back the -// workspace_repos row; the operator can rerun manually via the -// webhook-info endpoint. Returns (success, human-readable note). -// -// Public URL is required — without it GitHub would deliver to a path -// that's not reachable. We refuse to attempt the call when PublicBaseURL -// is empty and surface that as the note. -func (s *Server) tryAutoRegisterWebhook(ctx context.Context, wr workspacerepos.WorkspaceRepo, deliveryURL string) (bool, string) { - logger := s.Deps.Logger - if !strings.HasPrefix(deliveryURL, "http") { - return false, "CIX_PUBLIC_URL is not set — register the webhook manually" - } - if wr.TokenID == "" { - return false, "auto_webhook=true requires a token_id with admin:repo_hook scope" - } - pat, err := s.Deps.GithubTokens.Reveal(ctx, wr.TokenID) - if err != nil { - if errors.Is(err, githubtokens.ErrNotFound) { - return false, "token_id not found" - } - return false, "could not decrypt the GitHub token" - } - _ = s.Deps.GithubTokens.Touch(ctx, wr.TokenID) - - owner, repo, perr := githubapi.ParseOwnerRepo(wr.GitHubURL) - if perr != nil { - return false, "github_url is not a parseable owner/repo URL" - } - hr, herr := githubapi.New().CreateWebhook(ctx, githubapi.CreateWebhookOptions{ - Owner: owner, - Repo: repo, - PAT: pat, - URL: deliveryURL, - Secret: wr.WebhookSecret, - }) - if herr != nil { - if logger != nil { - logger.Warn("workspaces: auto-register webhook failed", - "repo_id", wr.ID, "owner", owner, "repo", repo, "err", herr) - } - if errors.Is(herr, githubapi.ErrUnauthorized) { - return false, "GitHub rejected the token — add admin:repo_hook scope or register manually" - } - return false, "GitHub API rejected the call: " + herr.Error() - } - if uerr := s.Deps.WorkspaceRepos.SetWebhookID(ctx, wr.ID, hr.ID); uerr != nil && logger != nil { - logger.Warn("workspaces: could not persist webhook id", "repo_id", wr.ID, "err", uerr) - } - return true, "" -} - -// DeleteWorkspaceRepo — DELETE /api/v1/workspaces/{id}/repos/{repo_id}. -func (s *Server) DeleteWorkspaceRepo(w http.ResponseWriter, r *http.Request, id, repoID string) { - if s.workspaceReposUnavailable(w) { - return - } - if !s.requireWorkspace(w, r, id) { - return - } - // Authorisation: a repo only belongs to its workspace, so we also - // require the repo's workspace_id to match. Otherwise users could - // detach repos across workspaces by guessing ids. - existing, err := s.Deps.WorkspaceRepos.GetByID(r.Context(), repoID) - if err != nil { - if errors.Is(err, workspacerepos.ErrNotFound) { - writeError(w, http.StatusNotFound, "repo not found") - return - } - writeError(w, http.StatusInternalServerError, "could not load repo") - return - } - if existing.WorkspaceID != id { - writeError(w, http.StatusNotFound, "repo not found") - return - } - if err := s.Deps.WorkspaceRepos.Delete(r.Context(), repoID); err != nil { - if errors.Is(err, workspacerepos.ErrNotFound) { - writeError(w, http.StatusNotFound, "repo not found") - return - } - writeError(w, http.StatusInternalServerError, "could not delete repo") - return - } - w.WriteHeader(http.StatusNoContent) -} - -// ReindexWorkspaceRepo — POST /api/v1/workspaces/{id}/repos/{repo_id}/reindex. -func (s *Server) ReindexWorkspaceRepo(w http.ResponseWriter, r *http.Request, id, repoID string) { - if s.workspaceReposUnavailable(w) { - return - } - if !s.requireWorkspace(w, r, id) { - return - } - wr, err := s.Deps.WorkspaceRepos.GetByID(r.Context(), repoID) - if err != nil { - if errors.Is(err, workspacerepos.ErrNotFound) { - writeError(w, http.StatusNotFound, "repo not found") - return - } - writeError(w, http.StatusInternalServerError, "could not load repo") - return - } - if wr.WorkspaceID != id { - writeError(w, http.StatusNotFound, "repo not found") - return - } - - enqueued := true - if _, eerr := s.Deps.Jobs.Enqueue(r.Context(), jobs.EnqueueRequest{ - Type: workspacejobs.TypeCloneRepo, - DedupeKey: "clone:" + wr.ID, - Payload: workspacejobs.ClonePayload{RepoID: wr.ID}, - }); eerr != nil { - if errors.Is(eerr, jobs.ErrDuplicate) { - enqueued = false - } else { - writeError(w, http.StatusInternalServerError, "could not enqueue reindex") - return - } - } - - status := "enqueued" - if !enqueued { - status = "already_running" - } - writeJSON(w, http.StatusAccepted, map[string]any{ - "status": status, - "repo": workspaceRepoToPayload(wr), - }) -} - -// buildWebhookURL constructs the publicly-reachable webhook delivery URL -// for a workspace_repo. When PublicBaseURL is empty (no operator-set -// origin), returns only the path so the dashboard can render it with a -// helper note. -func (s *Server) buildWebhookURL(repoID string) string { - path := "/api/v1/webhooks/github/" + repoID - base := strings.TrimRight(s.Deps.PublicBaseURL, "/") - if base == "" { - return path - } - return base + path -} - -// LinkExistingProject — POST /api/v1/workspaces/{id}/repos/link. -// -// Attaches an already-indexed project to the workspace as a lightweight -// linked row. No clone, no index job, no webhook. The response mirrors -// AddWorkspaceRepo's shape so the dashboard can reuse the same refresh -// pattern; webhook_url + webhook_secret are empty because linked rows -// have no webhook to register. -func (s *Server) LinkExistingProject(w http.ResponseWriter, r *http.Request, id string) { - if s.workspaceReposUnavailable(w) { - return - } - if !s.requireWorkspace(w, r, id) { - return - } - var body openapi.LinkExistingProjectRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") - return - } - hash := strings.TrimSpace(body.ProjectHash) - if hash == "" { - writeError(w, http.StatusUnprocessableEntity, "project_hash is required") - return - } - - // Resolve the project by hash so we can validate status + extract - // host_path. The same lookup is used by /projects/{path} so the - // behaviour is consistent — 404 for unknown hashes, 422 for known - // but not-yet-indexed projects. - proj, perr := projects.GetByHash(r.Context(), s.Deps.DB, hash) - if perr != nil { - if errors.Is(perr, projects.ErrNotFound) { - writeError(w, http.StatusNotFound, "project not found") - return - } - writeError(w, http.StatusInternalServerError, "could not load project") - return - } - if proj.Status != "indexed" { - writeError(w, http.StatusUnprocessableEntity, - "project is not yet indexed (status="+proj.Status+") — wait for indexing to complete before linking") - return - } - - wr, err := s.Deps.WorkspaceRepos.CreateLink(r.Context(), id, proj.HostPath) - if err != nil { - switch { - case errors.Is(err, workspacerepos.ErrInvalidURL): - writeError(w, http.StatusUnprocessableEntity, - "project host_path is not a github.com/owner/repo@branch — local-path projects cannot be linked") - case errors.Is(err, workspacerepos.ErrBranchEmpty): - writeError(w, http.StatusUnprocessableEntity, "project host_path has no branch suffix") - case errors.Is(err, workspacerepos.ErrDuplicate): - writeError(w, http.StatusConflict, "this repo+branch is already attached to the workspace") - default: - writeError(w, http.StatusInternalServerError, "could not link project") - } - return - } - - // Mirror AddWorkspaceRepo's envelope so the dashboard can decode one - // shape regardless of which create path it called. Linked rows have - // no webhook, so URL/secret are empty. - writeJSON(w, http.StatusCreated, map[string]any{ - "repo": workspaceRepoToPayload(wr), - "webhook_url": "", - "webhook_secret": "", - "auto_registered": false, - }) -} diff --git a/server/internal/httpapi/workspacerepos_test.go b/server/internal/httpapi/workspacerepos_test.go deleted file mode 100644 index cf137ee..0000000 --- a/server/internal/httpapi/workspacerepos_test.go +++ /dev/null @@ -1,558 +0,0 @@ -package httpapi - -import ( - "context" - "database/sql" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/dvcdsys/code-index/server/internal/githubtokens" - "github.com/dvcdsys/code-index/server/internal/jobs" - "github.com/dvcdsys/code-index/server/internal/projects" - "github.com/dvcdsys/code-index/server/internal/secrets" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" - "github.com/dvcdsys/code-index/server/internal/workspaces" -) - -// reposRouter spins up a router with the full workspaces+repos surface -// wired against an in-memory DB. Auth is disabled — the focus here is -// the persistence + enqueue paths. -// -// We deliberately do NOT start the jobs worker pool: we only assert the -// job row landed in the right state. End-to-end clone+index runs against -// real git remotes and the embeddings sidecar — out of scope for unit -// tests. -func reposRouter(t *testing.T) (http.Handler, *jobs.Service) { - t.Helper() - d, err := dbOpenMemory(t) - if err != nil { - t.Fatalf("open db: %v", err) - } - - t.Setenv("CIX_SECRET_KEY", "") - t.Setenv("CIX_SECRET_KEYFILE", "") - sec, err := secrets.Open(secrets.OpenOptions{DataDir: t.TempDir(), AllowGenerate: true}) - if err != nil { - t.Fatalf("open secrets: %v", err) - } - wsSvc := workspaces.New(d) - ghSvc := githubtokens.New(d, sec) - wrSvc := workspacerepos.New(d) - jobsSvc := jobs.New(d, jobs.Options{Concurrency: 1, PollEvery: time.Hour}) // never poll in tests - - router := NewRouter(Deps{ - DB: d, - ServerVersion: "test", - APIVersion: "v1", - Backend: "go", - AuthDisabled: true, - Users: seedlessUsers(d), - Sessions: seedlessSessions(d), - APIKeys: seedlessAPIKeys(d), - WorkspacesEnabled: true, - Workspaces: wsSvc, - GithubTokens: ghSvc, - WorkspaceRepos: wrSvc, - Jobs: jobsSvc, - PublicBaseURL: "https://cix.example.test", - }) - return router, jobsSvc -} - -func createWS(t *testing.T, router http.Handler, name string) string { - t.Helper() - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces", map[string]any{ - "name": name, - }) - if rr.Code != http.StatusCreated { - t.Fatalf("create workspace: %d (%s)", rr.Code, rr.Body.String()) - } - var got workspacePayload - _ = json.Unmarshal(rr.Body.Bytes(), &got) - return got.ID -} - -func TestRepos_AddEnqueuesCloneJob(t *testing.T) { - router, jobsSvc := reposRouter(t) - wsID := createWS(t, router, "platform") - - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ - "github_url": "https://github.com/spf13/cobra", - "branch": "main", - }) - if rr.Code != http.StatusCreated { - t.Fatalf("add repo: %d (%s)", rr.Code, rr.Body.String()) - } - var resp struct { - Repo workspaceRepoPayload `json:"repo"` - WebhookURL string `json:"webhook_url"` - WebhookSecret string `json:"webhook_secret"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Repo.ProjectPath != "github.com/spf13/cobra@main" { - t.Fatalf("unexpected project_path %q", resp.Repo.ProjectPath) - } - if resp.Repo.Status != workspacerepos.StatusPending { - t.Fatalf("expected status=pending, got %q", resp.Repo.Status) - } - if resp.WebhookSecret == "" { - t.Fatalf("webhook secret should be present in response") - } - if resp.WebhookURL != "https://cix.example.test/api/v1/webhooks/github/"+resp.Repo.ID { - t.Fatalf("webhook URL wrong: %q", resp.WebhookURL) - } - - // Verify the job landed on the queue. - jobList, err := jobsSvc.List(context.Background(), jobs.StatusPending, "clone_repo", 10) - if err != nil { - t.Fatalf("jobs list: %v", err) - } - if len(jobList) != 1 { - t.Fatalf("expected 1 pending clone_repo job, got %d", len(jobList)) - } - if jobList[0].DedupeKey != "clone:"+resp.Repo.ID { - t.Fatalf("unexpected dedupe_key %q", jobList[0].DedupeKey) - } -} - -func TestRepos_DuplicateRejected(t *testing.T) { - router, _ := reposRouter(t) - wsID := createWS(t, router, "platform") - body := map[string]any{ - "github_url": "https://github.com/a/b", - "branch": "main", - } - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", body) - if rr.Code != http.StatusCreated { - t.Fatalf("first add: %d", rr.Code) - } - rr = doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", body) - if rr.Code != http.StatusConflict { - t.Fatalf("duplicate should 409, got %d", rr.Code) - } -} - -// TestRepos_WebhookModeStored covers the three-state webhook_mode -// introduced for the add-repo UI. The DB column should round-trip the -// chosen mode; the legacy auto_webhook bool is derived (true iff -// mode == "auto") so old API clients keep behaving the same. -func TestRepos_WebhookModeStored(t *testing.T) { - router, _ := reposRouter(t) - wsID := createWS(t, router, "platform") - - cases := []struct { - name string - body map[string]any - wantMode string - wantAutoBool bool - }{ - { - name: "manual explicit", - body: map[string]any{"github_url": "https://github.com/a/manual", "branch": "main", "webhook_mode": "manual"}, - wantMode: "manual", - wantAutoBool: false, - }, - { - name: "auto explicit", - body: map[string]any{"github_url": "https://github.com/a/auto", "branch": "main", "webhook_mode": "auto"}, - wantMode: "auto", - wantAutoBool: true, - }, - { - name: "disabled explicit", - body: map[string]any{"github_url": "https://github.com/a/disabled", "branch": "main", "webhook_mode": "disabled"}, - wantMode: "disabled", - wantAutoBool: false, - }, - { - name: "legacy auto_webhook bool", - body: map[string]any{"github_url": "https://github.com/a/legacy", "branch": "main", "auto_webhook": true}, - wantMode: "auto", - wantAutoBool: true, - }, - { - name: "default when omitted", - body: map[string]any{"github_url": "https://github.com/a/default", "branch": "main"}, - wantMode: "manual", - wantAutoBool: false, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", tc.body) - if rr.Code != http.StatusCreated { - t.Fatalf("add: %d (%s)", rr.Code, rr.Body.String()) - } - var resp struct { - Repo workspaceRepoPayload `json:"repo"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &resp) - if resp.Repo.WebhookMode != tc.wantMode { - t.Fatalf("webhook_mode = %q, want %q", resp.Repo.WebhookMode, tc.wantMode) - } - if resp.Repo.AutoWebhook != tc.wantAutoBool { - t.Fatalf("auto_webhook = %v, want %v (for mode=%q)", - resp.Repo.AutoWebhook, tc.wantAutoBool, tc.wantMode) - } - }) - } -} - -// TestRepos_WebhookModeRejectsUnknown ensures the DB never receives an -// unknown enum value — the dashboard's three radio buttons are the only -// supported inputs. -func TestRepos_WebhookModeRejectsUnknown(t *testing.T) { - router, _ := reposRouter(t) - wsID := createWS(t, router, "platform") - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ - "github_url": "https://github.com/a/b", - "branch": "main", - "webhook_mode": "totally-bogus", - }) - if rr.Code != http.StatusUnprocessableEntity { - t.Fatalf("expected 422 on unknown mode, got %d", rr.Code) - } -} - -func TestRepos_BadURLRejected(t *testing.T) { - router, _ := reposRouter(t) - wsID := createWS(t, router, "platform") - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ - "github_url": "https://gitlab.com/x/y", - "branch": "main", - }) - if rr.Code != http.StatusUnprocessableEntity { - t.Fatalf("expected 422 for non-github URL, got %d", rr.Code) - } -} - -func TestRepos_DeleteCrossWorkspaceForbidden(t *testing.T) { - router, _ := reposRouter(t) - wsA := createWS(t, router, "alpha") - wsB := createWS(t, router, "bravo") - - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsA+"/repos", map[string]any{ - "github_url": "https://github.com/x/y", - "branch": "main", - }) - if rr.Code != http.StatusCreated { - t.Fatalf("add: %d", rr.Code) - } - var resp struct { - Repo workspaceRepoPayload `json:"repo"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &resp) - - // Try to delete repo from workspace B — must 404 (don't leak existence). - rr = doJSON(t, router, http.MethodDelete, "/api/v1/workspaces/"+wsB+"/repos/"+resp.Repo.ID, nil) - if rr.Code != http.StatusNotFound { - t.Fatalf("cross-workspace delete should 404, got %d", rr.Code) - } - - // Correct workspace should succeed. - rr = doJSON(t, router, http.MethodDelete, "/api/v1/workspaces/"+wsA+"/repos/"+resp.Repo.ID, nil) - if rr.Code != http.StatusNoContent { - t.Fatalf("delete: %d", rr.Code) - } -} - -func TestRepos_ReindexDedupeCollapsesInFlightJob(t *testing.T) { - router, jobsSvc := reposRouter(t) - wsID := createWS(t, router, "platform") - - rr := doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos", map[string]any{ - "github_url": "https://github.com/foo/bar", - "branch": "main", - }) - var created struct { - Repo workspaceRepoPayload `json:"repo"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &created) - - // Add-time already enqueued a clone_repo job — reindex should be - // dedup'd and return status="already_running". - rr = doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+wsID+"/repos/"+created.Repo.ID+"/reindex", nil) - if rr.Code != http.StatusAccepted { - t.Fatalf("reindex: %d (%s)", rr.Code, rr.Body.String()) - } - var rresp struct { - Status string `json:"status"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &rresp) - if rresp.Status != "already_running" { - t.Fatalf("expected already_running on dedupe, got %q", rresp.Status) - } - - // Exactly one job on the queue still. - all, _ := jobsSvc.List(context.Background(), jobs.StatusPending, "clone_repo", 10) - if len(all) != 1 { - t.Fatalf("expected dedupe to collapse into 1 job, got %d", len(all)) - } -} - -func TestRepos_DisabledFeatureReturns503(t *testing.T) { - router := workspaceRouter(t, false) - rr := doJSON(t, router, http.MethodGet, "/api/v1/workspaces/any/repos", nil) - if rr.Code != http.StatusServiceUnavailable { - t.Fatalf("expected 503, got %d", rr.Code) - } -} - -func TestJobs_ListEndpointFiltersByStatus(t *testing.T) { - router, jobsSvc := reposRouter(t) - ctx := context.Background() - if _, err := jobsSvc.Enqueue(ctx, jobs.EnqueueRequest{Type: "test_a"}); err != nil { - t.Fatalf("enqueue: %v", err) - } - if _, err := jobsSvc.Enqueue(ctx, jobs.EnqueueRequest{Type: "test_b"}); err != nil { - t.Fatalf("enqueue: %v", err) - } - rr := doJSON(t, router, http.MethodGet, "/api/v1/jobs", nil) - if rr.Code != http.StatusOK { - t.Fatalf("jobs list: %d", rr.Code) - } - var lr struct { - Jobs []jobPayload `json:"jobs"` - Total int `json:"total"` - } - _ = json.Unmarshal(rr.Body.Bytes(), &lr) - if lr.Total != 2 { - t.Fatalf("expected 2 jobs, got %d", lr.Total) - } - rr = doJSON(t, router, http.MethodGet, "/api/v1/jobs?type=test_a", nil) - if rr.Code != http.StatusOK { - t.Fatalf("typed list: %d", rr.Code) - } - _ = json.Unmarshal(rr.Body.Bytes(), &lr) - if lr.Total != 1 { - t.Fatalf("expected 1 typed job, got %d", lr.Total) - } -} - -// reposRouterWithDB is the same router setup as reposRouter but also -// returns the underlying *sql.DB so link-existing tests can seed -// projects directly. We keep reposRouter signature unchanged so the -// existing call sites in webhooks_test.go and elsewhere stay green. -func reposRouterWithDB(t *testing.T) (http.Handler, *jobs.Service, *sql.DB) { - t.Helper() - d, err := dbOpenMemory(t) - if err != nil { - t.Fatalf("open db: %v", err) - } - t.Setenv("CIX_SECRET_KEY", "") - t.Setenv("CIX_SECRET_KEYFILE", "") - sec, err := secrets.Open(secrets.OpenOptions{DataDir: t.TempDir(), AllowGenerate: true}) - if err != nil { - t.Fatalf("open secrets: %v", err) - } - wsSvc := workspaces.New(d) - ghSvc := githubtokens.New(d, sec) - wrSvc := workspacerepos.New(d) - jobsSvc := jobs.New(d, jobs.Options{Concurrency: 1, PollEvery: time.Hour}) - - router := NewRouter(Deps{ - DB: d, - ServerVersion: "test", - APIVersion: "v1", - Backend: "go", - AuthDisabled: true, - Users: seedlessUsers(d), - Sessions: seedlessSessions(d), - APIKeys: seedlessAPIKeys(d), - WorkspacesEnabled: true, - Workspaces: wsSvc, - GithubTokens: ghSvc, - WorkspaceRepos: wrSvc, - Jobs: jobsSvc, - PublicBaseURL: "https://cix.example.test", - }) - return router, jobsSvc, d -} - -// seedIndexedProject creates an indexed project row with the given -// host_path and returns its path_hash. Used by the link-existing tests -// so they don't need to invoke the real cloner+indexer. -func seedIndexedProject(t *testing.T, db *sql.DB, hostPath string) string { - t.Helper() - if _, err := projects.Create(context.Background(), db, projects.CreateRequest{HostPath: hostPath}); err != nil { - t.Fatalf("seed project: %v", err) - } - if _, err := db.Exec( - `UPDATE projects SET status = 'indexed', last_indexed_at = ?, updated_at = ? WHERE host_path = ?`, - time.Now().UTC().Format(time.RFC3339Nano), - time.Now().UTC().Format(time.RFC3339Nano), - hostPath, - ); err != nil { - t.Fatalf("mark indexed: %v", err) - } - return projects.HashPath(hostPath) -} - -func TestLinkExistingProject_SkipsCloneJob(t *testing.T) { - router, jobsSvc, d := reposRouterWithDB(t) - wsID := createWS(t, router, "platform") - hash := seedIndexedProject(t, d, "github.com/spf13/cobra@main") - - rr := doJSON(t, router, http.MethodPost, - "/api/v1/workspaces/"+wsID+"/repos/link", - map[string]any{"project_hash": hash}) - if rr.Code != http.StatusCreated { - t.Fatalf("link: %d (%s)", rr.Code, rr.Body.String()) - } - var resp struct { - Repo workspaceRepoPayload `json:"repo"` - WebhookURL string `json:"webhook_url"` - WebhookSecret string `json:"webhook_secret"` - AutoRegistered bool `json:"auto_registered"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if !resp.Repo.IsLinked { - t.Fatalf("expected IsLinked=true, got %+v", resp.Repo) - } - if resp.Repo.Status != workspacerepos.StatusIndexed { - t.Fatalf("expected status=indexed, got %q", resp.Repo.Status) - } - if resp.Repo.WebhookMode != workspacerepos.WebhookModeDisabled { - t.Fatalf("expected webhook_mode=disabled, got %q", resp.Repo.WebhookMode) - } - if resp.WebhookURL != "" || resp.WebhookSecret != "" { - t.Fatalf("linked rows must not surface webhook info, got url=%q secret-len=%d", - resp.WebhookURL, len(resp.WebhookSecret)) - } - - // Critical: no clone_repo job should have been enqueued. - jobList, err := jobsSvc.List(context.Background(), jobs.StatusPending, "clone_repo", 10) - if err != nil { - t.Fatalf("jobs list: %v", err) - } - if len(jobList) != 0 { - t.Fatalf("expected 0 clone_repo jobs, got %d (linked rows must not clone)", len(jobList)) - } -} - -func TestLinkExistingProject_409OnDuplicate(t *testing.T) { - router, _, d := reposRouterWithDB(t) - wsID := createWS(t, router, "platform") - hash := seedIndexedProject(t, d, "github.com/foo/bar@main") - - rr := doJSON(t, router, http.MethodPost, - "/api/v1/workspaces/"+wsID+"/repos/link", - map[string]any{"project_hash": hash}) - if rr.Code != http.StatusCreated { - t.Fatalf("first link: %d (%s)", rr.Code, rr.Body.String()) - } - rr = doJSON(t, router, http.MethodPost, - "/api/v1/workspaces/"+wsID+"/repos/link", - map[string]any{"project_hash": hash}) - if rr.Code != http.StatusConflict { - t.Fatalf("expected 409 on duplicate link, got %d (%s)", rr.Code, rr.Body.String()) - } -} - -func TestLinkExistingProject_422IfProjectNotIndexed(t *testing.T) { - router, _, d := reposRouterWithDB(t) - wsID := createWS(t, router, "platform") - // Create the project but leave status=created (the default). - hostPath := "github.com/foo/bar@main" - if _, err := projects.Create(context.Background(), d, - projects.CreateRequest{HostPath: hostPath}); err != nil { - t.Fatalf("seed project: %v", err) - } - rr := doJSON(t, router, http.MethodPost, - "/api/v1/workspaces/"+wsID+"/repos/link", - map[string]any{"project_hash": projects.HashPath(hostPath)}) - if rr.Code != http.StatusUnprocessableEntity { - t.Fatalf("expected 422, got %d (%s)", rr.Code, rr.Body.String()) - } -} - -func TestLinkExistingProject_404OnUnknownHash(t *testing.T) { - router, _ := reposRouter(t) - wsID := createWS(t, router, "platform") - rr := doJSON(t, router, http.MethodPost, - "/api/v1/workspaces/"+wsID+"/repos/link", - map[string]any{"project_hash": "0000000000000000"}) - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404 for unknown project_hash, got %d (%s)", rr.Code, rr.Body.String()) - } -} - -func TestListProjectWorkspaces_ReturnsAllMemberships(t *testing.T) { - router, _, d := reposRouterWithDB(t) - hash := seedIndexedProject(t, d, "github.com/foo/bar@main") - wsA := createWS(t, router, "alpha") - wsB := createWS(t, router, "beta") - - for _, ws := range []string{wsA, wsB} { - rr := doJSON(t, router, http.MethodPost, - "/api/v1/workspaces/"+ws+"/repos/link", - map[string]any{"project_hash": hash}) - if rr.Code != http.StatusCreated { - t.Fatalf("link to %s: %d (%s)", ws, rr.Code, rr.Body.String()) - } - } - - rr := doJSON(t, router, http.MethodGet, - "/api/v1/projects/"+hash+"/workspaces", nil) - if rr.Code != http.StatusOK { - t.Fatalf("list workspaces: %d (%s)", rr.Code, rr.Body.String()) - } - var resp struct { - Workspaces []struct { - WorkspaceID string `json:"workspace_id"` - WorkspaceName string `json:"workspace_name"` - RepoID string `json:"repo_id"` - IsLinked bool `json:"is_linked"` - Status string `json:"status"` - } `json:"workspaces"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if len(resp.Workspaces) != 2 { - t.Fatalf("expected 2 memberships, got %d (%+v)", len(resp.Workspaces), resp.Workspaces) - } - for _, m := range resp.Workspaces { - if !m.IsLinked { - t.Fatalf("workspace %s membership should be linked", m.WorkspaceName) - } - if m.Status != workspacerepos.StatusIndexed { - t.Fatalf("status should be indexed, got %q", m.Status) - } - } -} - -func TestListProjectWorkspaces_EmptyWhenUnused(t *testing.T) { - router, _, d := reposRouterWithDB(t) - hash := seedIndexedProject(t, d, "github.com/lonely/project@main") - - rr := doJSON(t, router, http.MethodGet, - "/api/v1/projects/"+hash+"/workspaces", nil) - if rr.Code != http.StatusOK { - t.Fatalf("list workspaces: %d (%s)", rr.Code, rr.Body.String()) - } - var resp struct { - Workspaces []any `json:"workspaces"` - } - if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if len(resp.Workspaces) != 0 { - t.Fatalf("expected empty list, got %d", len(resp.Workspaces)) - } -} - -func TestListProjectWorkspaces_404OnUnknownHash(t *testing.T) { - router, _ := reposRouter(t) - rr := doJSON(t, router, http.MethodGet, - "/api/v1/projects/0000000000000000/workspaces", nil) - if rr.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d (%s)", rr.Code, rr.Body.String()) - } -} diff --git a/server/internal/httpapi/workspacesearch.go b/server/internal/httpapi/workspacesearch.go index 9a2518a..b5175c5 100644 --- a/server/internal/httpapi/workspacesearch.go +++ b/server/internal/httpapi/workspacesearch.go @@ -14,7 +14,6 @@ import ( "github.com/dvcdsys/code-index/server/internal/chunksfts" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" "github.com/dvcdsys/code-index/server/internal/workspaces" ) @@ -137,7 +136,7 @@ type projectHits struct { // project threshold those repos drop out, restoring the cross-project // signal the user needs to scope an agent's follow-up search. func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id string, params openapi.WorkspaceSearchParams) { - if s.workspaceReposUnavailable(w) { + if s.workspaceProjectsUnavailable(w) { return } if s.Deps.VectorStore == nil || s.Deps.EmbeddingSvc == nil { @@ -177,12 +176,39 @@ func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id stri return } - repos, err := s.Deps.WorkspaceRepos.ListByWorkspace(r.Context(), id) + // Pull the workspace's project memberships joined with the projects + // table so we can split into indexed vs pending in one pass. The + // junction lives in workspace_projects; status lives on projects. + rows, err := s.Deps.DB.QueryContext(r.Context(), ` + SELECT p.host_path, p.status + FROM workspace_projects wp + JOIN projects p ON p.host_path = wp.project_path + WHERE wp.workspace_id = ? + ORDER BY wp.added_at DESC`, id) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load workspace repos: "+err.Error()) + writeError(w, http.StatusInternalServerError, "could not load workspace projects: "+err.Error()) return } - if len(repos) == 0 { + type memberRow struct { + ProjectPath string + Status string + } + var members []memberRow + for rows.Next() { + var m memberRow + if scanErr := rows.Scan(&m.ProjectPath, &m.Status); scanErr != nil { + rows.Close() + writeError(w, http.StatusInternalServerError, "scan workspace project row: "+scanErr.Error()) + return + } + members = append(members, m) + } + rows.Close() + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "iterate workspace projects: "+err.Error()) + return + } + if len(members) == 0 { writeJSON(w, http.StatusOK, workspaceSearchResponse( "empty", []workspaceSearchProjectPayload{}, @@ -194,22 +220,22 @@ func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id stri return } - seenProjects := make(map[string]struct{}, len(repos)) - projectPaths := make([]string, 0, len(repos)) + seenProjects := make(map[string]struct{}, len(members)) + projectPaths := make([]string, 0, len(members)) pendingRepos := make([]workspaceSearchPendingRepoPayload, 0) - for _, rp := range repos { - if rp.Status != workspacerepos.StatusIndexed { + for _, m := range members { + if m.Status != "indexed" { pendingRepos = append(pendingRepos, workspaceSearchPendingRepoPayload{ - ProjectPath: rp.ProjectPath, - Status: rp.Status, + ProjectPath: m.ProjectPath, + Status: m.Status, }) continue } - if _, ok := seenProjects[rp.ProjectPath]; ok { + if _, ok := seenProjects[m.ProjectPath]; ok { continue } - seenProjects[rp.ProjectPath] = struct{}{} - projectPaths = append(projectPaths, rp.ProjectPath) + seenProjects[m.ProjectPath] = struct{}{} + projectPaths = append(projectPaths, m.ProjectPath) } if len(projectPaths) == 0 { diff --git a/server/internal/httpapi/workspacesearch_test.go b/server/internal/httpapi/workspacesearch_test.go index 4c74160..7886679 100644 --- a/server/internal/httpapi/workspacesearch_test.go +++ b/server/internal/httpapi/workspacesearch_test.go @@ -15,10 +15,12 @@ import ( "github.com/dvcdsys/code-index/server/internal/chunksfts" "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/jobs" + "github.com/dvcdsys/code-index/server/internal/projects" "github.com/dvcdsys/code-index/server/internal/secrets" "github.com/dvcdsys/code-index/server/internal/vectorstore" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" + "github.com/dvcdsys/code-index/server/internal/workspaceprojects" "github.com/dvcdsys/code-index/server/internal/workspaces" ) @@ -55,7 +57,8 @@ func newSearchRouter(t *testing.T, d *sql.DB, vs *vectorstore.Store, emb fixedEm WorkspacesEnabled: true, Workspaces: workspaces.New(d), GithubTokens: githubtokens.New(d, sec), - WorkspaceRepos: workspacerepos.New(d), + GitRepos: gitrepos.New(d), + WorkspaceProjects: workspaceprojects.New(d), Jobs: jobs.New(d, jobs.Options{Concurrency: 1, PollEvery: time.Hour}), VectorStore: vs, EmbeddingSvc: emb, @@ -79,19 +82,19 @@ func seedRepoWithChunks( now := time.Now().UTC().Format(time.RFC3339Nano) if _, err := d.Exec( `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, path_hash) - VALUES (?, ?, '[]', '{}', '{}', 'created', ?, ?, 'h')`, - projectPath, projectPath, now, now, + VALUES (?, ?, '[]', '{}', '{}', 'indexed', ?, ?, ?)`, + projectPath, projectPath, now, now, projects.HashPath(projectPath), ); err != nil { t.Fatalf("insert project %q: %v", projectPath, err) } if _, err := d.Exec( - `INSERT INTO workspace_repos - (id, workspace_id, github_url, branch, project_path, webhook_secret, status, created_at, updated_at, last_indexed_at) - VALUES (?, ?, ?, 'main', ?, 'sec', 'indexed', ?, ?, ?)`, - uuid.NewString(), wsID, "https://"+projectPath, projectPath, now, now, now, + `INSERT INTO workspace_projects (workspace_id, project_path, added_at) + VALUES (?, ?, ?)`, + wsID, projectPath, now, ); err != nil { - t.Fatalf("insert workspace_repo %q: %v", projectPath, err) + t.Fatalf("insert workspace_project %q: %v", projectPath, err) } + _ = uuid.NewString() // keep import in case future tests need it if err := vs.UpsertChunks(context.Background(), projectPath, chunks, embeddings); err != nil { t.Fatalf("upsert chunks for %q: %v", projectPath, err) } @@ -505,20 +508,19 @@ func TestWorkspaceSearch_FlagsStaleFTSRepos(t *testing.T) { now := time.Now().UTC().Format(time.RFC3339Nano) stalePath := "github.com/o/stale@main" if _, err := d.Exec( - `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, path_hash) - VALUES (?, ?, '[]', '{}', '{}', 'created', ?, ?, 'h')`, - stalePath, stalePath, now, now, + `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, path_hash) + VALUES (?, ?, '[]', '{}', '{}', 'indexed', ?, ?, ?, ?)`, + stalePath, stalePath, now, now, now, projects.HashPath(stalePath), ); err != nil { t.Fatalf("insert stale project: %v", err) } if _, err := d.Exec( - `INSERT INTO workspace_repos - (id, workspace_id, github_url, branch, project_path, webhook_secret, status, created_at, updated_at, last_indexed_at) - VALUES (?, ?, ?, 'main', ?, 'sec', 'indexed', ?, ?, ?)`, - uuid.NewString(), wsID, "https://"+stalePath, stalePath, now, now, now, + `INSERT INTO workspace_projects (workspace_id, project_path, added_at) + VALUES (?, ?, ?)`, wsID, stalePath, now, ); err != nil { - t.Fatalf("insert stale workspace_repo: %v", err) + t.Fatalf("insert stale workspace_projects: %v", err) } + _ = uuid.NewString() if err := vs.UpsertChunks(context.Background(), stalePath, []vectorstore.Chunk{{Content: "stale chunk", FilePath: "s.go", StartLine: 1, EndLine: 9, Language: "go"}}, [][]float32{l2([]float32{0.9, 0.1, 0.0, 0.0})}, @@ -766,20 +768,29 @@ func TestWorkspaceSearch_Disabled(t *testing.T) { } } -// seedPendingRepo inserts a workspace_repos row with a non-`indexed` -// status (no projects row, no chromem collection). Mirrors what the -// DB looks like while clone/index jobs are still in flight. +// seedPendingRepo inserts a projects row with a non-`indexed` status +// AND a workspace_projects membership row. Mirrors what the DB looks +// like while clone/index jobs are still in flight (project registered, +// workspace_projects already attached, status not yet 'indexed'). func seedPendingRepo(t *testing.T, d *sql.DB, wsID, projectPath, status string) { t.Helper() now := time.Now().UTC().Format(time.RFC3339Nano) if _, err := d.Exec( - `INSERT INTO workspace_repos - (id, workspace_id, github_url, branch, project_path, webhook_secret, status, created_at, updated_at) - VALUES (?, ?, ?, 'main', ?, 'sec', ?, ?, ?)`, - uuid.NewString(), wsID, "https://"+projectPath, projectPath, status, now, now, + `INSERT INTO projects ( + host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash + ) VALUES (?, ?, '[]', '{}', '{}', ?, ?, ?, 'h')`, + projectPath, projectPath, status, now, now, + ); err != nil { + t.Fatalf("insert pending project %q: %v", projectPath, err) + } + if _, err := d.Exec( + `INSERT INTO workspace_projects (workspace_id, project_path, added_at) + VALUES (?, ?, ?)`, wsID, projectPath, now, ); err != nil { - t.Fatalf("insert pending workspace_repo %q: %v", projectPath, err) + t.Fatalf("insert workspace_projects %q: %v", projectPath, err) } + _ = uuid.NewString() } // TestWorkspaceSearch_SurfacesPendingRepos verifies that repos whose @@ -808,8 +819,8 @@ func TestWorkspaceSearch_SurfacesPendingRepos(t *testing.T) { // Two repos still in flight — different statuses to make sure // every non-indexed value propagates verbatim. - seedPendingRepo(t, d, wsID, "github.com/o/cloning@main", workspacerepos.StatusCloning) - seedPendingRepo(t, d, wsID, "github.com/o/indexing@main", workspacerepos.StatusIndexing) + seedPendingRepo(t, d, wsID, "github.com/o/cloning@main", "cloning") + seedPendingRepo(t, d, wsID, "github.com/o/indexing@main", "indexing") rr := doJSON(t, router, http.MethodGet, "/api/v1/workspaces/"+wsID+"/search?q=x", nil) if rr.Code != http.StatusOK { @@ -831,10 +842,10 @@ func TestWorkspaceSearch_SurfacesPendingRepos(t *testing.T) { for _, p := range resp.PendingRepos { gotStatuses[p.ProjectPath] = p.Status } - if gotStatuses["github.com/o/cloning@main"] != workspacerepos.StatusCloning { + if gotStatuses["github.com/o/cloning@main"] != "cloning" { t.Fatalf("cloning repo lost its status: %+v", resp.PendingRepos) } - if gotStatuses["github.com/o/indexing@main"] != workspacerepos.StatusIndexing { + if gotStatuses["github.com/o/indexing@main"] != "indexing" { t.Fatalf("indexing repo lost its status: %+v", resp.PendingRepos) } } @@ -853,8 +864,8 @@ func TestWorkspaceSearch_AllPendingReturnsEmpty(t *testing.T) { router := newSearchRouter(t, d, vs, fixedEmbedder{q: l2([]float32{1, 0, 0, 0})}) wsID := createWS(t, router, "all-pending") - seedPendingRepo(t, d, wsID, "github.com/o/p1@main", workspacerepos.StatusPending) - seedPendingRepo(t, d, wsID, "github.com/o/p2@main", workspacerepos.StatusCloning) + seedPendingRepo(t, d, wsID, "github.com/o/p1@main", "pending") + seedPendingRepo(t, d, wsID, "github.com/o/p2@main", "cloning") rr := doJSON(t, router, http.MethodGet, "/api/v1/workspaces/"+wsID+"/search?q=x", nil) if rr.Code != http.StatusOK { diff --git a/server/internal/workspacejobs/workspacejobs.go b/server/internal/workspacejobs/workspacejobs.go index 7b7f2e2..090b853 100644 --- a/server/internal/workspacejobs/workspacejobs.go +++ b/server/internal/workspacejobs/workspacejobs.go @@ -1,29 +1,27 @@ -// Package workspacejobs wires the workspaces feature's job handlers into -// the generic internal/jobs queue. It owns nothing — just composes the -// other workspaces packages (workspacerepos, githubtokens, repocloner, -// repoindexer) behind a thin Register function called from main. +// Package workspacejobs wires the workspaces feature's job handlers +// into the generic internal/jobs queue. It owns nothing — just +// composes gitrepos, githubtokens, repocloner, and repoindexer behind +// a thin Register function called from main. // -// Lifecycle for a repo: +// Lifecycle for an external project: // -// 1. POST /api/v1/workspaces/{id}/repos -// - inserts a workspace_repos row (status=pending) -// - enqueues clone_repo job (dedupe_key="clone:") +// 1. POST /api/v1/git-repos +// - inserts a projects row (status='pending') and a git_repos row +// - enqueues clone_repo job (dedupe_key="clone:") // // 2. clone_repo handler // - reveals PAT via githubtokens.Reveal (if token_id set) -// - calls repocloner.CloneOrFetch into DataDir/repos// -// - registers projects row (host_path = workspace_repos.project_path) -// - flips status → indexing -// - enqueues index_repo job (dedupe_key="index:") +// - calls repocloner.CloneOrFetch into DataDir/repos// +// - flips projects.status → indexing +// - enqueues index_repo job (dedupe_key="index:") // // 3. index_repo handler -// - calls repoindexer.IndexDir with the workspace_repo.project_path -// - flips status → indexed (or failed on error) +// - calls repoindexer.IndexDir with the project_path +// - flips projects.status → indexed (or 'error' on failure) +// - writes last_indexed_at on the projects row // -// Workspace-level search is served straight from the per-project -// chromem collections via a weighted fan-out (see -// internal/httpapi/workspacesearch.go) — there is no background -// "build centroid index" step anymore. +// Workspace-level search is served from per-project chromem +// collections via a weighted fan-out (internal/httpapi/workspacesearch.go). package workspacejobs import ( @@ -36,49 +34,47 @@ import ( "time" "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/indexer" "github.com/dvcdsys/code-index/server/internal/jobs" "github.com/dvcdsys/code-index/server/internal/projects" "github.com/dvcdsys/code-index/server/internal/repocloner" "github.com/dvcdsys/code-index/server/internal/repoindexer" "github.com/dvcdsys/code-index/server/internal/vectorstore" - "github.com/dvcdsys/code-index/server/internal/workspacerepos" ) -// Job type constants. Kept here so handlers and enqueue-sites share one -// string — typos in job types are a notoriously easy source of "why isn't -// this running?" bugs. +// Job type constants. const ( TypeCloneRepo = "clone_repo" TypeIndexRepo = "index_repo" ) -// ClonePayload is the JSON shape stored on a clone_repo job. +// ClonePayload is the JSON shape stored on a clone_repo job. The +// project_path doubles as the lookup key for the matching git_repos +// row; path_hash is derived via db.HashHostPath when needed (e.g. as +// the on-disk clone directory name). type ClonePayload struct { - RepoID string `json:"repo_id"` + ProjectPath string `json:"project_path"` } -// IndexPayload is the JSON shape stored on an index_repo job. +// IndexPayload mirrors ClonePayload. type IndexPayload struct { - RepoID string `json:"repo_id"` + ProjectPath string `json:"project_path"` } -// Deps bundles everything the handlers need. Keeping it explicit makes -// wiring obvious in main and means tests can swap any single piece for a -// fake. +// Deps bundles everything the handlers need. type Deps struct { - DB *sql.DB - Jobs *jobs.Service - WorkspaceRepos *workspacerepos.Service - GithubTokens *githubtokens.Service - Indexer *indexer.Service - VectorStore *vectorstore.Store - DataDir string // root for cloned repos: /repos// - Logger *slog.Logger + DB *sql.DB + Jobs *jobs.Service + GitRepos *gitrepos.Service + GithubTokens *githubtokens.Service + Indexer *indexer.Service + VectorStore *vectorstore.Store + DataDir string // root for cloned repos: /repos// + Logger *slog.Logger } -// Register hooks the workspaces job handlers into a jobs.Service. Call -// once at startup, BEFORE jobs.Start. +// Register hooks the workspaces job handlers into a jobs.Service. func Register(d Deps) { if d.Logger == nil { d.Logger = slog.Default() @@ -93,11 +89,11 @@ func Register(d Deps) { // EnqueueClone inserts a clone_repo job. The index_repo job is chained // on successful clone — callers don't enqueue it directly. -func EnqueueClone(ctx context.Context, j *jobs.Service, repoID string) error { +func EnqueueClone(ctx context.Context, j *jobs.Service, projectPath string) error { _, err := j.Enqueue(ctx, jobs.EnqueueRequest{ Type: TypeCloneRepo, - DedupeKey: "clone:" + repoID, - Payload: ClonePayload{RepoID: repoID}, + DedupeKey: "clone:" + projects.HashPath(projectPath), + Payload: ClonePayload{ProjectPath: projectPath}, }) if errors.Is(err, jobs.ErrDuplicate) { // Already queued — soft no-op. @@ -111,68 +107,55 @@ func handleClone(ctx context.Context, d Deps, job jobs.Job) error { if err := jobs.UnmarshalPayload(job, &p); err != nil { return fmt.Errorf("decode payload: %w", err) } - if p.RepoID == "" { - return errors.New("empty repo_id") + if p.ProjectPath == "" { + return errors.New("empty project_path") } - wr, err := d.WorkspaceRepos.GetByID(ctx, p.RepoID) + g, err := d.GitRepos.GetByPath(ctx, p.ProjectPath) if err != nil { - return fmt.Errorf("load workspace_repo: %w", err) + return fmt.Errorf("load git_repo for %s: %w", p.ProjectPath, err) } + hash := projects.HashPath(g.ProjectPath) - if err := d.WorkspaceRepos.SetStatus(ctx, wr.ID, workspacerepos.StatusCloning, "", "", nil); err != nil { + if err := setProjectStatus(ctx, d.DB, g.ProjectPath, "cloning"); err != nil { return fmt.Errorf("mark cloning: %w", err) } pat := "" - if wr.TokenID != "" { - token, terr := d.GithubTokens.Reveal(ctx, wr.TokenID) + if g.TokenID != "" { + token, terr := d.GithubTokens.Reveal(ctx, g.TokenID) if terr != nil { - d.recordFailure(ctx, wr.ID, fmt.Errorf("reveal token: %w", terr)) + d.recordFailure(ctx, g, fmt.Errorf("reveal token: %w", terr)) return terr } pat = token - // Best-effort last_used bookkeeping; ignore errors. - _ = d.GithubTokens.Touch(ctx, wr.TokenID) + _ = d.GithubTokens.Touch(ctx, g.TokenID) } result, err := repocloner.CloneOrFetch(ctx, repocloner.CloneOptions{ - GitHubURL: wr.GitHubURL, - Branch: wr.Branch, + GitHubURL: g.GitHubURL, + Branch: g.Branch, PAT: pat, - LocalDir: repocloner.LocalDirFor(d.DataDir, wr.ID), + LocalDir: repocloner.LocalDirFor(d.DataDir, hash), }) if err != nil { - d.recordFailure(ctx, wr.ID, fmt.Errorf("clone: %w", err)) + d.recordFailure(ctx, g, fmt.Errorf("clone: %w", err)) return err } - // Register the project row (idempotent — Get-or-Create pattern). Two - // branches: - // a) project already exists → leave it alone (incremental updates - // happen via subsequent index runs) - // b) project missing → create it with the project_path as host_path - if _, gerr := projects.Get(ctx, d.DB, wr.ProjectPath); gerr != nil { - if _, cerr := projects.Create(ctx, d.DB, projects.CreateRequest{ - HostPath: wr.ProjectPath, - }); cerr != nil && !errors.Is(cerr, projects.ErrConflict) { - d.recordFailure(ctx, wr.ID, fmt.Errorf("register project: %w", cerr)) - return cerr - } + if err := d.GitRepos.SetClone(ctx, g.ProjectPath, result.HeadSHA, ""); err != nil { + d.Logger.Warn("workspacejobs: set last_sha failed", "project", g.ProjectPath, "err", err) } - if err := d.WorkspaceRepos.SetStatus(ctx, wr.ID, workspacerepos.StatusIndexing, result.HeadSHA, "", nil); err != nil { - // Non-fatal — still chain the index job. - d.Logger.Warn("workspacejobs: set status indexing failed", "repo_id", wr.ID, "err", err) + if err := setProjectStatus(ctx, d.DB, g.ProjectPath, "indexing"); err != nil { + d.Logger.Warn("workspacejobs: set status indexing failed", "project", g.ProjectPath, "err", err) } - // Chain index_repo. Use the same dedupe pattern so a manual reindex - // fired by the user mid-clone collapses into the natural follow-up. if _, eerr := d.Jobs.Enqueue(ctx, jobs.EnqueueRequest{ Type: TypeIndexRepo, - DedupeKey: "index:" + wr.ID, - Payload: IndexPayload{RepoID: wr.ID}, + DedupeKey: "index:" + hash, + Payload: IndexPayload{ProjectPath: g.ProjectPath}, }); eerr != nil && !errors.Is(eerr, jobs.ErrDuplicate) { - d.recordFailure(ctx, wr.ID, fmt.Errorf("enqueue index: %w", eerr)) + d.recordFailure(ctx, g, fmt.Errorf("enqueue index: %w", eerr)) return eerr } return nil @@ -183,47 +166,57 @@ func handleIndex(ctx context.Context, d Deps, job jobs.Job) error { if err := jobs.UnmarshalPayload(job, &p); err != nil { return fmt.Errorf("decode payload: %w", err) } - if p.RepoID == "" { - return errors.New("empty repo_id") + if p.ProjectPath == "" { + return errors.New("empty project_path") } - wr, err := d.WorkspaceRepos.GetByID(ctx, p.RepoID) + g, err := d.GitRepos.GetByPath(ctx, p.ProjectPath) if err != nil { - return fmt.Errorf("load workspace_repo: %w", err) + return fmt.Errorf("load git_repo for %s: %w", p.ProjectPath, err) } - cloneDir := repocloner.LocalDirFor(d.DataDir, wr.ID) + cloneDir := repocloner.LocalDirFor(d.DataDir, projects.HashPath(g.ProjectPath)) - _, _, err = repoindexer.IndexDir(ctx, d.Indexer, wr.ProjectPath, cloneDir, repoindexer.DefaultFilter(), d.Logger) + _, _, err = repoindexer.IndexDir(ctx, d.Indexer, g.ProjectPath, cloneDir, repoindexer.DefaultFilter(), d.Logger) if err != nil { - d.recordFailure(ctx, wr.ID, fmt.Errorf("index: %w", err)) + d.recordFailure(ctx, g, fmt.Errorf("index: %w", err)) return err } - now := time.Now().UTC() - if err := d.WorkspaceRepos.SetStatus(ctx, wr.ID, workspacerepos.StatusIndexed, "", "", &now); err != nil { + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := d.DB.ExecContext(ctx, + `UPDATE projects SET status = 'indexed', last_indexed_at = ?, updated_at = ? WHERE host_path = ?`, + now, now, g.ProjectPath, + ); err != nil { return fmt.Errorf("mark indexed: %w", err) } return nil } -// recordFailure flips the workspace_repo into status=failed with the -// error message attached. Logs the error too (handler return value also -// gets logged by the jobs service but at a different layer — duplicate -// is fine). -func (d Deps) recordFailure(ctx context.Context, repoID string, err error) { +func setProjectStatus(ctx context.Context, db *sql.DB, projectPath, status string) error { + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := db.ExecContext(ctx, + `UPDATE projects SET status = ?, updated_at = ? WHERE host_path = ?`, + status, now, projectPath) + return err +} + +func (d Deps) recordFailure(ctx context.Context, g gitrepos.GitRepo, err error) { if err == nil { return } - d.Logger.Error("workspacejobs: repo failed", "repo_id", repoID, "err", err) + d.Logger.Error("workspacejobs: repo failed", "project", g.ProjectPath, "err", err) msg := err.Error() if len(msg) > 1024 { msg = msg[:1024] } - if uerr := d.WorkspaceRepos.SetStatus(ctx, repoID, workspacerepos.StatusFailed, "", msg, nil); uerr != nil { - d.Logger.Error("workspacejobs: could not write failed status", "repo_id", repoID, "err", uerr) + if uerr := d.GitRepos.SetClone(ctx, g.ProjectPath, "", msg); uerr != nil { + d.Logger.Error("workspacejobs: could not write last_error", "project", g.ProjectPath, "err", uerr) + } + if uerr := setProjectStatus(ctx, d.DB, g.ProjectPath, "error"); uerr != nil { + d.Logger.Error("workspacejobs: could not write status=error", "project", g.ProjectPath, "err", uerr) } } -// Compile-time guard: ClonePayload / IndexPayload encode cleanly. +// Compile-time guard: payloads encode cleanly. var _ = func() (any, any) { a, _ := json.Marshal(ClonePayload{}) b, _ := json.Marshal(IndexPayload{}) diff --git a/server/internal/workspaceprojects/workspaceprojects.go b/server/internal/workspaceprojects/workspaceprojects.go new file mode 100644 index 0000000..e816331 --- /dev/null +++ b/server/internal/workspaceprojects/workspaceprojects.go @@ -0,0 +1,170 @@ +// Package workspaceprojects is the service layer for the +// workspace_projects junction table — the many-to-many mapping between +// workspaces and projects. A row exists when a project is currently a +// member of a workspace; Link adds one, Unlink removes one. The project +// itself is unaffected by either operation (deletion happens through +// the projects service, which cascades here via FK ON DELETE CASCADE). +// +// This is the only place in the codebase that knows how workspace +// membership is represented. The gitrepos service knows about clone +// metadata; the projects service knows about indexed content; this +// service knows about "which workspace holds which project". +package workspaceprojects + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Errors. +var ( + ErrNotFound = errors.New("workspace project membership not found") + ErrDuplicate = errors.New("project is already linked to this workspace") + ErrProjectNotIndexed = errors.New("project is not yet indexed — wait for indexing before linking") + ErrProjectMissing = errors.New("project does not exist") + ErrWorkspaceMissing = errors.New("workspace does not exist") +) + +// Membership is the wire-friendly row shape. +type Membership struct { + WorkspaceID string + ProjectPath string + AddedAt time.Time +} + +// Service wraps the workspace_projects table. +type Service struct { + DB *sql.DB +} + +func New(db *sql.DB) *Service { return &Service{DB: db} } + +// Link inserts (workspace_id, project_path) into workspace_projects. +// The project must exist and be in status='indexed' so workspace search +// has something to fan out to. Duplicates return ErrDuplicate; missing +// targets return ErrWorkspaceMissing / ErrProjectMissing. +func (s *Service) Link(ctx context.Context, workspaceID, projectPath string) (Membership, error) { + // Existence checks up front so the caller gets a precise error + // instead of a raw FK violation. + var wsCount int + if err := s.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM workspaces WHERE id = ?`, workspaceID, + ).Scan(&wsCount); err != nil { + return Membership{}, fmt.Errorf("check workspace: %w", err) + } + if wsCount == 0 { + return Membership{}, ErrWorkspaceMissing + } + + var status sql.NullString + if err := s.DB.QueryRowContext(ctx, + `SELECT status FROM projects WHERE host_path = ?`, projectPath, + ).Scan(&status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Membership{}, ErrProjectMissing + } + return Membership{}, fmt.Errorf("check project: %w", err) + } + if status.String != "indexed" { + return Membership{}, ErrProjectNotIndexed + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := s.DB.ExecContext(ctx, ` + INSERT INTO workspace_projects (workspace_id, project_path, added_at) + VALUES (?, ?, ?)`, + workspaceID, projectPath, now, + ); err != nil { + if isUniqueConstraintViolation(err) { + return Membership{}, ErrDuplicate + } + return Membership{}, fmt.Errorf("insert workspace_project: %w", err) + } + addedAt, _ := time.Parse(time.RFC3339Nano, now) + return Membership{ + WorkspaceID: workspaceID, + ProjectPath: projectPath, + AddedAt: addedAt, + }, nil +} + +// Unlink removes a (workspace_id, project_path) row. Returns ErrNotFound +// when no such row exists so the handler can distinguish "deleted" from +// "wasn't there" — both can become 204 in the API but tests need the +// signal. +func (s *Service) Unlink(ctx context.Context, workspaceID, projectPath string) error { + res, err := s.DB.ExecContext(ctx, ` + DELETE FROM workspace_projects + WHERE workspace_id = ? AND project_path = ?`, + workspaceID, projectPath) + if err != nil { + return fmt.Errorf("delete workspace_project: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// ListByWorkspace returns every project_path attached to a workspace, +// newest membership first. +func (s *Service) ListByWorkspace(ctx context.Context, workspaceID string) ([]Membership, error) { + rows, err := s.DB.QueryContext(ctx, ` + SELECT workspace_id, project_path, added_at + FROM workspace_projects + WHERE workspace_id = ? + ORDER BY added_at DESC`, workspaceID) + if err != nil { + return nil, fmt.Errorf("list workspace_projects by workspace: %w", err) + } + defer rows.Close() + return scanRows(rows) +} + +// ListByProject returns every workspace this project participates in. +// Used by the project detail page to render "Workspaces" chips. +func (s *Service) ListByProject(ctx context.Context, projectPath string) ([]Membership, error) { + rows, err := s.DB.QueryContext(ctx, ` + SELECT wp.workspace_id, wp.project_path, wp.added_at + FROM workspace_projects wp + WHERE wp.project_path = ? + ORDER BY wp.added_at DESC`, projectPath) + if err != nil { + return nil, fmt.Errorf("list workspace_projects by project: %w", err) + } + defer rows.Close() + return scanRows(rows) +} + +// --- helpers --- + +func scanRows(rows *sql.Rows) ([]Membership, error) { + out := []Membership{} + for rows.Next() { + var ( + m Membership + addedAt string + ) + if err := rows.Scan(&m.WorkspaceID, &m.ProjectPath, &addedAt); err != nil { + return nil, fmt.Errorf("scan workspace_project: %w", err) + } + m.AddedAt, _ = time.Parse(time.RFC3339Nano, addedAt) + out = append(out, m) + } + return out, rows.Err() +} + +func isUniqueConstraintViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "constraint failed: UNIQUE") || + strings.Contains(msg, "constraint failed: PRIMARY KEY") +} diff --git a/server/internal/workspaceprojects/workspaceprojects_test.go b/server/internal/workspaceprojects/workspaceprojects_test.go new file mode 100644 index 0000000..2b87017 --- /dev/null +++ b/server/internal/workspaceprojects/workspaceprojects_test.go @@ -0,0 +1,216 @@ +package workspaceprojects + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "github.com/dvcdsys/code-index/server/internal/db" + "github.com/dvcdsys/code-index/server/internal/workspaces" +) + +func mustOpen(t *testing.T) (*sql.DB, *Service) { + t.Helper() + d, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = d.Close() }) + return d, New(d) +} + +// seedIndexedProject inserts the project row in status='indexed' so +// Link() satisfies its precondition. Avoids the full projects service +// import to keep the test focused on this package. +func seedIndexedProject(t *testing.T, d *sql.DB, hostPath string) { + t.Helper() + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := d.Exec(` + INSERT INTO projects ( + host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash + ) VALUES (?, ?, '[]', '{}', '{}', 'indexed', ?, ?, ?)`, + hostPath, hostPath, now, now, db.HashHostPath(hostPath), + ); err != nil { + t.Fatalf("seed project %s: %v", hostPath, err) + } +} + +func seedWorkspace(t *testing.T, d *sql.DB, name string) string { + t.Helper() + ws, err := workspaces.New(d).Create(context.Background(), name, "") + if err != nil { + t.Fatalf("seed workspace %s: %v", name, err) + } + return ws.ID +} + +func TestLink_HappyPath(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsID := seedWorkspace(t, d, "platform") + seedIndexedProject(t, d, "github.com/x/y@main") + + m, err := svc.Link(ctx, wsID, "github.com/x/y@main") + if err != nil { + t.Fatalf("Link: %v", err) + } + if m.WorkspaceID != wsID || m.ProjectPath != "github.com/x/y@main" { + t.Fatalf("Link returned wrong row: %+v", m) + } + if m.AddedAt.IsZero() { + t.Error("AddedAt was not populated") + } +} + +func TestLink_DuplicateRejected(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsID := seedWorkspace(t, d, "platform") + seedIndexedProject(t, d, "github.com/x/y@main") + + if _, err := svc.Link(ctx, wsID, "github.com/x/y@main"); err != nil { + t.Fatalf("first Link: %v", err) + } + if _, err := svc.Link(ctx, wsID, "github.com/x/y@main"); !errors.Is(err, ErrDuplicate) { + t.Fatalf("second Link: got %v, want ErrDuplicate", err) + } +} + +func TestLink_RejectsNonIndexed(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsID := seedWorkspace(t, d, "platform") + // Seed without forcing status=indexed — default is 'pending'. + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := d.Exec(` + INSERT INTO projects ( + host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash + ) VALUES (?, ?, '[]', '{}', '{}', 'pending', ?, ?, ?)`, + "github.com/x/y@main", "github.com/x/y@main", now, now, + db.HashHostPath("github.com/x/y@main"), + ); err != nil { + t.Fatalf("seed pending project: %v", err) + } + + if _, err := svc.Link(ctx, wsID, "github.com/x/y@main"); !errors.Is(err, ErrProjectNotIndexed) { + t.Fatalf("got %v, want ErrProjectNotIndexed", err) + } +} + +func TestLink_RejectsMissingTargets(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + + // Unknown workspace. + if _, err := svc.Link(ctx, "no-such-ws", "github.com/x/y@main"); !errors.Is(err, ErrWorkspaceMissing) { + t.Fatalf("missing workspace: got %v, want ErrWorkspaceMissing", err) + } + + wsID := seedWorkspace(t, d, "platform") + // Workspace exists but project doesn't. + if _, err := svc.Link(ctx, wsID, "github.com/missing/proj@main"); !errors.Is(err, ErrProjectMissing) { + t.Fatalf("missing project: got %v, want ErrProjectMissing", err) + } +} + +func TestUnlink(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsID := seedWorkspace(t, d, "platform") + seedIndexedProject(t, d, "github.com/x/y@main") + if _, err := svc.Link(ctx, wsID, "github.com/x/y@main"); err != nil { + t.Fatalf("Link: %v", err) + } + + if err := svc.Unlink(ctx, wsID, "github.com/x/y@main"); err != nil { + t.Fatalf("Unlink: %v", err) + } + if err := svc.Unlink(ctx, wsID, "github.com/x/y@main"); !errors.Is(err, ErrNotFound) { + t.Fatalf("second Unlink: got %v, want ErrNotFound", err) + } +} + +// TestListByWorkspace exercises the "newest first" ordering — the +// dashboard uses this to show the most recently linked project at the +// top of the workspace detail page. +func TestListByWorkspace(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsID := seedWorkspace(t, d, "platform") + seedIndexedProject(t, d, "github.com/a/older@main") + seedIndexedProject(t, d, "github.com/b/newer@main") + + // Link older first, sleep enough for distinct ISO seconds, link newer. + if _, err := svc.Link(ctx, wsID, "github.com/a/older@main"); err != nil { + t.Fatalf("first Link: %v", err) + } + time.Sleep(2 * time.Millisecond) + if _, err := svc.Link(ctx, wsID, "github.com/b/newer@main"); err != nil { + t.Fatalf("second Link: %v", err) + } + + list, err := svc.ListByWorkspace(ctx, wsID) + if err != nil { + t.Fatalf("ListByWorkspace: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 memberships, got %d", len(list)) + } + if list[0].ProjectPath != "github.com/b/newer@main" { + t.Errorf("newest-first ordering broken: %+v", list) + } +} + +// TestListByProject covers the reverse lookup — which workspaces +// contain a given project. Used by the project detail page chips. +func TestListByProject(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsA := seedWorkspace(t, d, "alpha") + wsB := seedWorkspace(t, d, "beta") + seedIndexedProject(t, d, "github.com/x/y@main") + + for _, ws := range []string{wsA, wsB} { + if _, err := svc.Link(ctx, ws, "github.com/x/y@main"); err != nil { + t.Fatalf("Link %s: %v", ws, err) + } + } + list, err := svc.ListByProject(ctx, "github.com/x/y@main") + if err != nil { + t.Fatalf("ListByProject: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 memberships, got %d", len(list)) + } +} + +// TestDeletingProject_CascadesMembership confirms the schema's FK +// ON DELETE CASCADE actually fires: removing the projects row drops +// every workspace_projects row referencing it. +func TestDeletingProject_CascadesMembership(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsA := seedWorkspace(t, d, "alpha") + wsB := seedWorkspace(t, d, "beta") + seedIndexedProject(t, d, "github.com/x/y@main") + for _, ws := range []string{wsA, wsB} { + if _, err := svc.Link(ctx, ws, "github.com/x/y@main"); err != nil { + t.Fatalf("Link: %v", err) + } + } + + if _, err := d.ExecContext(ctx, `DELETE FROM projects WHERE host_path = ?`, "github.com/x/y@main"); err != nil { + t.Fatalf("delete project: %v", err) + } + memberships, err := svc.ListByProject(ctx, "github.com/x/y@main") + if err != nil { + t.Fatalf("ListByProject: %v", err) + } + if len(memberships) != 0 { + t.Fatalf("expected memberships cascaded to 0, got %d", len(memberships)) + } +} diff --git a/server/internal/workspacerepos/workspacerepos.go b/server/internal/workspacerepos/workspacerepos.go deleted file mode 100644 index f235fa1..0000000 --- a/server/internal/workspacerepos/workspacerepos.go +++ /dev/null @@ -1,466 +0,0 @@ -// Package workspacerepos is the service layer for the workspace_repos -// table — one row per (workspace, github_url, branch). Each row maps 1:1 -// to an indexed project (host_path = "github.com/owner/repo@branch"). -// -// Lifecycle (PR2): -// -// create row (status=pending) → enqueue clone_repo job → worker clones -// → enqueue index_repo job → worker indexes → status=indexed -// -// PR3 adds webhook delivery → enqueue fetch_repo on push; PR4+ feeds -// call-graph + community recompute. This package stays small — handlers -// own service composition; we just persist rows. -package workspacerepos - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/base64" - "errors" - "fmt" - "net/url" - "strings" - "time" - - "github.com/google/uuid" -) - -// Status values. Kept as bare strings since they map straight to the DB -// column and the JSON wire format. -const ( - StatusPending = "pending" // row created, work not yet scheduled - StatusCloning = "cloning" // clone_repo job running - StatusIndexing = "indexing" // index_repo job running - StatusIndexed = "indexed" // happy path - StatusFailed = "failed" // last attempt errored (see LastError) -) - -// Webhook modes. The legacy AutoWebhook bool stays in the struct for -// backwards compatibility with old API consumers, but new code should -// consult WebhookMode — it carries the operator's stated intent (auto -// vs manual-pending vs deliberately disabled). -const ( - WebhookModeManual = "manual" - WebhookModeAuto = "auto" - WebhookModeDisabled = "disabled" -) - -// NormaliseWebhookMode rejects unknown values up front so the database -// only ever stores one of the three documented states. Empty input maps -// to the default ('manual'), so old API clients that omit the field -// keep working unchanged. -func NormaliseWebhookMode(s string) (string, error) { - switch strings.ToLower(strings.TrimSpace(s)) { - case "": - return WebhookModeManual, nil - case WebhookModeManual: - return WebhookModeManual, nil - case WebhookModeAuto: - return WebhookModeAuto, nil - case WebhookModeDisabled: - return WebhookModeDisabled, nil - default: - return "", ErrInvalidWebhookMode - } -} - -// Errors. -var ( - ErrNotFound = errors.New("workspace repo not found") - ErrDuplicate = errors.New("repo is already in this workspace on that branch") - ErrInvalidURL = errors.New("github_url must be an https://github.com/owner/repo URL") - ErrBranchEmpty = errors.New("branch is required") - ErrInvalidWebhookMode = errors.New("webhook_mode must be one of manual, auto, disabled") -) - -// WorkspaceRepo is the wire view. Tokens themselves are referenced by -// id — Reveal happens server-side via internal/githubtokens. -type WorkspaceRepo struct { - ID string - WorkspaceID string - GitHubURL string - Branch string - ProjectPath string - TokenID string // empty when no PAT is associated (public repo) - WebhookSecret string - WebhookID *int64 // GitHub hook id (set by PR3 auto-register) - AutoWebhook bool - WebhookMode string // 'manual' | 'auto' | 'disabled' - Status string - LastSHA string - LastError string - LastIndexedAt *time.Time - IsLinked bool // true for lightweight references to projects owned by another workspace_repo - CreatedAt time.Time - UpdatedAt time.Time -} - -// Service wraps the workspace_repos table. -type Service struct { - DB *sql.DB -} - -// New returns a Service. -func New(db *sql.DB) *Service { return &Service{DB: db} } - -// CreateRequest is what handlers pass in. -type CreateRequest struct { - WorkspaceID string - GitHubURL string - Branch string - TokenID string // optional - AutoWebhook bool // legacy: kept for old clients; new code uses WebhookMode - WebhookMode string // 'manual' | 'auto' | 'disabled'; empty = manual -} - -// Create inserts a workspace_repo and generates a webhook secret. The -// resulting ProjectPath is "github.com/owner/repo@branch" — the canonical -// id for downstream tables (projects.host_path). -func (s *Service) Create(ctx context.Context, req CreateRequest) (WorkspaceRepo, error) { - owner, repo, err := parseGitHubURL(req.GitHubURL) - if err != nil { - return WorkspaceRepo{}, err - } - if strings.TrimSpace(req.Branch) == "" { - return WorkspaceRepo{}, ErrBranchEmpty - } - projectPath := fmt.Sprintf("github.com/%s/%s@%s", owner, repo, req.Branch) - - secret, err := generateWebhookSecret() - if err != nil { - return WorkspaceRepo{}, fmt.Errorf("generate webhook secret: %w", err) - } - - id := uuid.NewString() - now := time.Now().UTC().Format(time.RFC3339Nano) - githubURL := canonicaliseURL(req.GitHubURL) - - // WebhookMode is the source of truth in the DB; AutoWebhook stays - // derived so the legacy SELECT path keeps working until removed. - mode, merr := NormaliseWebhookMode(req.WebhookMode) - if merr != nil { - return WorkspaceRepo{}, merr - } - // If the caller used the legacy bool but left WebhookMode empty, - // honour the bool — otherwise mode wins. - if req.WebhookMode == "" && req.AutoWebhook { - mode = WebhookModeAuto - } - auto := 0 - if mode == WebhookModeAuto { - auto = 1 - } - tokenID := nullableString(req.TokenID) - - _, err = s.DB.ExecContext(ctx, - `INSERT INTO workspace_repos ( - id, workspace_id, github_url, branch, project_path, - token_id, webhook_secret, auto_webhook, webhook_mode, status, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - id, req.WorkspaceID, githubURL, req.Branch, projectPath, - tokenID, secret, auto, mode, StatusPending, - now, now, - ) - if err != nil { - if isUniqueConstraintViolation(err) { - return WorkspaceRepo{}, ErrDuplicate - } - return WorkspaceRepo{}, fmt.Errorf("insert workspace_repo: %w", err) - } - return s.GetByID(ctx, id) -} - -// CreateLink inserts a workspace_repo with is_linked=1: a lightweight -// pointer to an already-indexed project. Unlike Create, there is no -// clone job, no webhook, no PAT — the row exists only so the project -// participates in workspace-level features (search, communities, -// the repo list UI). The canonical project must already exist in the -// projects table; the caller (HTTP handler) is responsible for that -// check + the status='indexed' precondition before calling here. -// -// projectPath must be the same canonical form Create produces, i.e. -// "github.com/owner/repo@branch" — we round-trip through parseProjectPath -// so the resulting (workspace_id, github_url, branch) triple matches -// what an owned row would produce. This is what makes the -// UNIQUE(workspace_id, github_url, branch) constraint catch an attempt -// to link the same project that's already attached as owned. -// -// webhook_secret is generated but never used — the column is NOT NULL. -// webhook_mode is set to 'disabled' so the dashboard hides the webhook -// UI for linked rows. -func (s *Service) CreateLink(ctx context.Context, workspaceID, projectPath string) (WorkspaceRepo, error) { - githubURL, branch, err := parseProjectPath(projectPath) - if err != nil { - return WorkspaceRepo{}, err - } - - secret, err := generateWebhookSecret() - if err != nil { - return WorkspaceRepo{}, fmt.Errorf("generate webhook secret: %w", err) - } - - id := uuid.NewString() - now := time.Now().UTC().Format(time.RFC3339Nano) - - _, err = s.DB.ExecContext(ctx, - `INSERT INTO workspace_repos ( - id, workspace_id, github_url, branch, project_path, - token_id, webhook_secret, auto_webhook, webhook_mode, status, - is_linked, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, 1, ?, ?)`, - id, workspaceID, githubURL, branch, projectPath, - secret, WebhookModeDisabled, StatusIndexed, - now, now, - ) - if err != nil { - if isUniqueConstraintViolation(err) { - return WorkspaceRepo{}, ErrDuplicate - } - return WorkspaceRepo{}, fmt.Errorf("insert linked workspace_repo: %w", err) - } - return s.GetByID(ctx, id) -} - -// GetByID returns one row. -func (s *Service) GetByID(ctx context.Context, id string) (WorkspaceRepo, error) { - row := s.DB.QueryRowContext(ctx, selectColumns+` WHERE id = ?`, id) - return scanRow(row) -} - -// ListByWorkspace returns every repo in a workspace, newest first. -func (s *Service) ListByWorkspace(ctx context.Context, workspaceID string) ([]WorkspaceRepo, error) { - rows, err := s.DB.QueryContext(ctx, - selectColumns+` WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID) - if err != nil { - return nil, fmt.Errorf("list repos: %w", err) - } - defer rows.Close() - return scanRows(rows) -} - -// SetStatus is the workhorse called from job handlers. lastSHA / lastError -// / lastIndexedAt are optional — pass empty / nil / nil to leave them -// unchanged. -func (s *Service) SetStatus(ctx context.Context, id, status string, lastSHA, lastError string, indexed *time.Time) error { - now := time.Now().UTC().Format(time.RFC3339Nano) - // We use a single UPDATE with COALESCE to keep optional fields atomic. - var indexedStr any - if indexed != nil { - indexedStr = indexed.UTC().Format(time.RFC3339Nano) - } else { - indexedStr = nil - } - res, err := s.DB.ExecContext(ctx, ` - UPDATE workspace_repos - SET status = ?, - last_sha = COALESCE(NULLIF(?, ''), last_sha), - last_error = CASE WHEN ? = '' THEN NULL ELSE ? END, - last_indexed_at = COALESCE(?, last_indexed_at), - updated_at = ? - WHERE id = ?`, - status, lastSHA, lastError, lastError, indexedStr, now, id, - ) - if err != nil { - return fmt.Errorf("set status: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return ErrNotFound - } - return nil -} - -// SetWebhookID persists the GitHub-side hook id returned by the -// auto-register flow. ErrNotFound when the row is gone (race with -// concurrent delete — caller can ignore). -func (s *Service) SetWebhookID(ctx context.Context, id string, hookID int64) error { - res, err := s.DB.ExecContext(ctx, - `UPDATE workspace_repos SET webhook_id = ?, updated_at = ? WHERE id = ?`, - hookID, time.Now().UTC().Format(time.RFC3339Nano), id) - if err != nil { - return fmt.Errorf("set webhook_id: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return ErrNotFound - } - return nil -} - -// Delete removes a workspace_repo. The on-disk clone, indexed project, and -// associated rows are NOT cleaned up here — handlers should enqueue a -// cleanup job (PR3+) or accept the orphan for now. -func (s *Service) Delete(ctx context.Context, id string) error { - res, err := s.DB.ExecContext(ctx, `DELETE FROM workspace_repos WHERE id = ?`, id) - if err != nil { - return fmt.Errorf("delete workspace_repo: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } - if n == 0 { - return ErrNotFound - } - return nil -} - -// --- helpers --- - -const selectColumns = ` - SELECT id, workspace_id, github_url, branch, project_path, - token_id, webhook_secret, webhook_id, auto_webhook, - webhook_mode, status, last_sha, last_error, last_indexed_at, - is_linked, created_at, updated_at - FROM workspace_repos` - -func scanRow(r interface{ Scan(dest ...any) error }) (WorkspaceRepo, error) { - var ( - wr WorkspaceRepo - tokenID sql.NullString - webhookID sql.NullInt64 - autoWebhook int - webhookMode string - lastSHA sql.NullString - lastError sql.NullString - lastIndexed sql.NullString - isLinked int - createdAt string - updatedAt string - ) - err := r.Scan(&wr.ID, &wr.WorkspaceID, &wr.GitHubURL, &wr.Branch, &wr.ProjectPath, - &tokenID, &wr.WebhookSecret, &webhookID, &autoWebhook, - &webhookMode, &wr.Status, &lastSHA, &lastError, &lastIndexed, - &isLinked, &createdAt, &updatedAt) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return WorkspaceRepo{}, ErrNotFound - } - return WorkspaceRepo{}, fmt.Errorf("scan workspace_repo: %w", err) - } - wr.TokenID = tokenID.String - if webhookID.Valid { - v := webhookID.Int64 - wr.WebhookID = &v - } - wr.AutoWebhook = autoWebhook == 1 - wr.WebhookMode = webhookMode - if wr.WebhookMode == "" { - wr.WebhookMode = WebhookModeManual - } - wr.LastSHA = lastSHA.String - wr.LastError = lastError.String - if lastIndexed.Valid { - t, _ := time.Parse(time.RFC3339Nano, lastIndexed.String) - wr.LastIndexedAt = &t - } - wr.IsLinked = isLinked == 1 - wr.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) - wr.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) - return wr, nil -} - -func scanRows(rows *sql.Rows) ([]WorkspaceRepo, error) { - out := []WorkspaceRepo{} - for rows.Next() { - wr, err := scanRow(rows) - if err != nil { - return nil, err - } - out = append(out, wr) - } - return out, rows.Err() -} - -// parseGitHubURL extracts owner + repo from an HTTPS GitHub URL. Accepts -// trailing slash and ".git" suffix. Rejects anything not on github.com so -// we don't accidentally try to clone arbitrary forge URLs (each forge has -// its own quirks — supporting them is out of scope). -func parseGitHubURL(s string) (owner, repo string, err error) { - s = strings.TrimSpace(s) - if s == "" { - return "", "", ErrInvalidURL - } - u, perr := url.Parse(s) - if perr != nil { - return "", "", ErrInvalidURL - } - if !strings.EqualFold(u.Host, "github.com") { - return "", "", ErrInvalidURL - } - path := strings.Trim(u.Path, "/") - path = strings.TrimSuffix(path, ".git") - parts := strings.Split(path, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", ErrInvalidURL - } - return parts[0], parts[1], nil -} - -// parseProjectPath splits a canonical project_path of the form -// "github.com/owner/repo@branch" back into (github_url, branch) so we -// can reuse the per-workspace uniqueness key when creating a linked -// row from a project hash. Inverse of the Sprintf at Create(). -// -// Errors: -// - empty input or missing "@" → ErrInvalidURL -// - prefix not "github.com/" → ErrInvalidURL (linked rows only make -// sense for GitHub-derived projects; local paths can't map to a -// workspace_repo since the schema requires github_url + branch) -// - branch portion empty → ErrBranchEmpty -func parseProjectPath(projectPath string) (githubURL, branch string, err error) { - s := strings.TrimSpace(projectPath) - at := strings.LastIndex(s, "@") - if at <= 0 { - return "", "", ErrInvalidURL - } - left, right := s[:at], s[at+1:] - if right == "" { - return "", "", ErrBranchEmpty - } - const prefix = "github.com/" - if !strings.HasPrefix(left, prefix) { - return "", "", ErrInvalidURL - } - ownerRepo := strings.Trim(left[len(prefix):], "/") - parts := strings.Split(ownerRepo, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", ErrInvalidURL - } - return "https://github.com/" + parts[0] + "/" + parts[1], right, nil -} - -// canonicaliseURL strips trailing slash + ".git" so two forms of the same -// URL aren't treated as distinct repos. -func canonicaliseURL(s string) string { - s = strings.TrimSpace(s) - s = strings.TrimSuffix(s, "/") - s = strings.TrimSuffix(s, ".git") - return s -} - -func generateWebhookSecret() (string, error) { - var buf [32]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buf[:]), nil -} - -func nullableString(s string) any { - if s == "" { - return nil - } - return s -} - -func isUniqueConstraintViolation(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "UNIQUE constraint failed") || - strings.Contains(msg, "constraint failed: UNIQUE") -} diff --git a/server/internal/workspacerepos/workspacerepos_test.go b/server/internal/workspacerepos/workspacerepos_test.go deleted file mode 100644 index aa5437c..0000000 --- a/server/internal/workspacerepos/workspacerepos_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package workspacerepos - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/dvcdsys/code-index/server/internal/db" - "github.com/dvcdsys/code-index/server/internal/workspaces" -) - -// withWorkspace creates a workspaces row and returns its id. Tests need a -// real FK target since workspace_repos.workspace_id has ON DELETE CASCADE. -func withWorkspace(t *testing.T) (*Service, string) { - t.Helper() - d, err := db.Open(":memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { _ = d.Close() }) - ws, err := workspaces.New(d).Create(context.Background(), "ws", "") - if err != nil { - t.Fatalf("seed workspace: %v", err) - } - return New(d), ws.ID -} - -func TestCreateAndGet(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - wr, err := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, - GitHubURL: "https://github.com/spf13/cobra", - Branch: "main", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if wr.ProjectPath != "github.com/spf13/cobra@main" { - t.Fatalf("unexpected project_path %q", wr.ProjectPath) - } - if wr.WebhookSecret == "" { - t.Fatalf("webhook secret should be auto-generated") - } - if wr.Status != StatusPending { - t.Fatalf("expected pending status, got %q", wr.Status) - } - - got, err := svc.GetByID(ctx, wr.ID) - if err != nil { - t.Fatalf("GetByID: %v", err) - } - if got.ProjectPath != wr.ProjectPath { - t.Fatalf("get/create mismatch") - } -} - -func TestURLNormalisation(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - // trailing slash + .git suffix should be collapsed. - wr, err := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, - GitHubURL: "https://github.com/spf13/cobra.git/", - Branch: "main", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - if wr.GitHubURL != "https://github.com/spf13/cobra" { - t.Fatalf("URL not canonicalised, got %q", wr.GitHubURL) - } - if wr.ProjectPath != "github.com/spf13/cobra@main" { - t.Fatalf("project_path wrong: %q", wr.ProjectPath) - } -} - -func TestDuplicateRejected(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - if _, err := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, GitHubURL: "https://github.com/x/y", Branch: "main", - }); err != nil { - t.Fatalf("first: %v", err) - } - if _, err := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, GitHubURL: "https://github.com/x/y", Branch: "main", - }); !errors.Is(err, ErrDuplicate) { - t.Fatalf("expected ErrDuplicate, got %v", err) - } - // Different branch should succeed. - if _, err := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, GitHubURL: "https://github.com/x/y", Branch: "develop", - }); err != nil { - t.Fatalf("different branch should succeed: %v", err) - } -} - -func TestInvalidURL(t *testing.T) { - svc, wsID := withWorkspace(t) - cases := []string{ - "", - "not a url", - "https://gitlab.com/x/y", - "https://github.com", - "https://github.com/onlyowner", - } - for _, c := range cases { - _, err := svc.Create(context.Background(), CreateRequest{ - WorkspaceID: wsID, GitHubURL: c, Branch: "main", - }) - if !errors.Is(err, ErrInvalidURL) { - t.Fatalf("URL %q: expected ErrInvalidURL, got %v", c, err) - } - } -} - -func TestSetStatus(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - wr, _ := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, GitHubURL: "https://github.com/x/y", Branch: "main", - }) - now := time.Now().UTC() - if err := svc.SetStatus(ctx, wr.ID, StatusIndexed, "abc123", "", &now); err != nil { - t.Fatalf("SetStatus: %v", err) - } - got, _ := svc.GetByID(ctx, wr.ID) - if got.Status != StatusIndexed || got.LastSHA != "abc123" { - t.Fatalf("status/sha not persisted: %+v", got) - } - if got.LastIndexedAt == nil { - t.Fatalf("LastIndexedAt should be set") - } -} - -func TestDeleteCascade(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - wr, _ := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, GitHubURL: "https://github.com/a/b", Branch: "main", - }) - if err := svc.Delete(ctx, wr.ID); err != nil { - t.Fatalf("Delete: %v", err) - } - if err := svc.Delete(ctx, wr.ID); !errors.Is(err, ErrNotFound) { - t.Fatalf("expected ErrNotFound, got %v", err) - } -} - -func TestCreateLink_HappyPath(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - wr, err := svc.CreateLink(ctx, wsID, "github.com/spf13/cobra@main") - if err != nil { - t.Fatalf("CreateLink: %v", err) - } - if !wr.IsLinked { - t.Fatalf("expected IsLinked=true, got %v", wr.IsLinked) - } - if wr.Status != StatusIndexed { - t.Fatalf("expected status=indexed, got %q", wr.Status) - } - if wr.WebhookMode != WebhookModeDisabled { - t.Fatalf("expected webhook_mode=disabled, got %q", wr.WebhookMode) - } - if wr.TokenID != "" { - t.Fatalf("linked rows must have empty token_id, got %q", wr.TokenID) - } - if wr.GitHubURL != "https://github.com/spf13/cobra" { - t.Fatalf("github_url derived wrong: %q", wr.GitHubURL) - } - if wr.Branch != "main" { - t.Fatalf("branch derived wrong: %q", wr.Branch) - } - if wr.ProjectPath != "github.com/spf13/cobra@main" { - t.Fatalf("project_path mismatch: %q", wr.ProjectPath) - } -} - -func TestCreateLink_DuplicateInSameWorkspace(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - if _, err := svc.CreateLink(ctx, wsID, "github.com/foo/bar@main"); err != nil { - t.Fatalf("first: %v", err) - } - // Second link with the same (workspace, repo, branch) → ErrDuplicate. - if _, err := svc.CreateLink(ctx, wsID, "github.com/foo/bar@main"); !errors.Is(err, ErrDuplicate) { - t.Fatalf("expected ErrDuplicate, got %v", err) - } - // An owned row in the same workspace conflicts with the linked one too — - // both share the same UNIQUE(workspace_id, github_url, branch) key. - if _, err := svc.Create(ctx, CreateRequest{ - WorkspaceID: wsID, GitHubURL: "https://github.com/foo/bar", Branch: "main", - }); !errors.Is(err, ErrDuplicate) { - t.Fatalf("owned-after-linked: expected ErrDuplicate, got %v", err) - } -} - -func TestCreateLink_AllowedAcrossWorkspaces(t *testing.T) { - svcA, wsA := withWorkspace(t) - // Reuse the same underlying DB — withWorkspace gives us a Service - // bound to a fresh DB; for a cross-workspace test we need two - // workspaces on one DB. Seed a second workspace explicitly. - wsB, err := workspaces.New(svcA.DB).Create(context.Background(), "ws-b", "") - if err != nil { - t.Fatalf("seed second workspace: %v", err) - } - ctx := context.Background() - // Same canonical project_path attaches as owned in A, then linked - // in B without tripping the legacy global UNIQUE. - if _, err := svcA.Create(ctx, CreateRequest{ - WorkspaceID: wsA, GitHubURL: "https://github.com/x/y", Branch: "main", - }); err != nil { - t.Fatalf("owned in A: %v", err) - } - if _, err := svcA.CreateLink(ctx, wsB.ID, "github.com/x/y@main"); err != nil { - t.Fatalf("linked in B (same project): %v", err) - } -} - -func TestCreateLink_InvalidProjectPath(t *testing.T) { - svc, wsID := withWorkspace(t) - ctx := context.Background() - cases := []struct { - name string - path string - want error - }{ - {"empty", "", ErrInvalidURL}, - {"no at", "github.com/foo/bar", ErrInvalidURL}, - {"empty branch", "github.com/foo/bar@", ErrBranchEmpty}, - {"non-github", "gitlab.com/foo/bar@main", ErrInvalidURL}, - {"missing repo", "github.com/foo@main", ErrInvalidURL}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if _, err := svc.CreateLink(ctx, wsID, c.path); !errors.Is(err, c.want) { - t.Fatalf("path=%q: expected %v, got %v", c.path, c.want, err) - } - }) - } -} From 340e48dc34590aa4bac93ec20fdd95012d7eea47 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 13:35:54 +0100 Subject: [PATCH 02/11] chore(dashboard): untrack tsconfig.tsbuildinfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This file is the TypeScript incremental-build cache (`tsc -b`). It mutates on every local build and was producing noise in git status + diffs. Same treatment as the existing `dist/` and `src/api/generated.ts` rules — build artefacts don't belong in source control. Co-Authored-By: Claude Opus 4.7 --- server/dashboard/.gitignore | 4 ++++ server/dashboard/tsconfig.tsbuildinfo | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) delete mode 100644 server/dashboard/tsconfig.tsbuildinfo diff --git a/server/dashboard/.gitignore b/server/dashboard/.gitignore index fd16421..ac7e93e 100644 --- a/server/dashboard/.gitignore +++ b/server/dashboard/.gitignore @@ -3,5 +3,9 @@ node_modules/ .cache/ *.log +# TypeScript incremental-build cache — local build artefact, mutates +# on every `tsc -b` invocation. +*.tsbuildinfo + # Type-gen output is reproducible — never commit src/api/generated.ts diff --git a/server/dashboard/tsconfig.tsbuildinfo b/server/dashboard/tsconfig.tsbuildinfo deleted file mode 100644 index 9a79695..0000000 --- a/server/dashboard/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated.ts","./src/api/types.ts","./src/app/app.tsx","./src/app/footer.tsx","./src/app/shell.tsx","./src/app/sidebar.tsx","./src/app/themeprovider.tsx","./src/app/updatebanner.tsx","./src/app/providers.tsx","./src/auth/authprovider.tsx","./src/auth/bootstrapneededpage.tsx","./src/auth/changepasswordpage.tsx","./src/auth/loginpage.tsx","./src/auth/useauth.ts","./src/lib/cn.ts","./src/lib/editorpreference.ts","./src/lib/formatdate.ts","./src/lib/theme.ts","./src/lib/useserverstatus.ts","./src/modules/registry.ts","./src/modules/types.ts","./src/modules/api-keys/apikeyspage.tsx","./src/modules/api-keys/hooks.ts","./src/modules/api-keys/index.ts","./src/modules/api-keys/components/apikeytable.tsx","./src/modules/api-keys/components/createapikeydialog.tsx","./src/modules/api-keys/components/revokeapikeydialog.tsx","./src/modules/github-tokens/githubtokenspage.tsx","./src/modules/github-tokens/index.ts","./src/modules/home/homepage.tsx","./src/modules/home/index.ts","./src/modules/projects/projectdetailpage.tsx","./src/modules/projects/projectslistpage.tsx","./src/modules/projects/projectspage.tsx","./src/modules/projects/hooks.ts","./src/modules/projects/index.ts","./src/modules/projects/components/deleteprojectdialog.tsx","./src/modules/projects/components/projectcard.tsx","./src/modules/projects/components/projectinfocard.tsx","./src/modules/search/searchpage.tsx","./src/modules/search/hooks.ts","./src/modules/search/index.ts","./src/modules/search/components/filters.tsx","./src/modules/search/components/resultfilecard.tsx","./src/modules/search/components/resultsnippet.tsx","./src/modules/search/components/searchinput.tsx","./src/modules/server/serverpage.tsx","./src/modules/server/hooks.ts","./src/modules/server/index.ts","./src/modules/server/components/saveandrestartdialog.tsx","./src/modules/server/components/sidecarstatebadge.tsx","./src/modules/server/components/sourcepill.tsx","./src/modules/server/sections/advancedsection.tsx","./src/modules/server/sections/embeddingmodelsection.tsx","./src/modules/server/sections/runtimeparamssection.tsx","./src/modules/server/sections/sidecarsection.tsx","./src/modules/settings/settingspage.tsx","./src/modules/settings/hooks.ts","./src/modules/settings/index.ts","./src/modules/settings/components/changepasswordform.tsx","./src/modules/settings/components/sessionrow.tsx","./src/modules/settings/sections/editorsection.tsx","./src/modules/settings/sections/profilesection.tsx","./src/modules/settings/sections/sessionssection.tsx","./src/modules/settings/sections/themesection.tsx","./src/modules/users/userspage.tsx","./src/modules/users/hooks.ts","./src/modules/users/index.ts","./src/modules/users/components/deleteuserdialog.tsx","./src/modules/users/components/disableuserbutton.tsx","./src/modules/users/components/inviteuserdialog.tsx","./src/modules/users/components/userroleselect.tsx","./src/modules/users/components/userstable.tsx","./src/modules/workspaces/workspacedetailpage.tsx","./src/modules/workspaces/workspaceslistpage.tsx","./src/modules/workspaces/workspacespage.tsx","./src/modules/workspaces/index.ts","./src/modules/workspaces/types.ts","./src/modules/workspaces/components/addexistingprojectdialog.tsx","./src/modules/workspaces/components/addrepodialog.tsx","./src/modules/workspaces/components/createworkspacedialog.tsx","./src/modules/workspaces/components/repocard.tsx","./src/modules/workspaces/components/workspacecard.tsx","./src/modules/workspaces/components/workspacesearchdialog.tsx","./src/ui/alert.tsx","./src/ui/badge.tsx","./src/ui/button.tsx","./src/ui/card.tsx","./src/ui/dialog.tsx","./src/ui/input.tsx","./src/ui/label.tsx","./src/ui/radio-group.tsx","./src/ui/scroll-area.tsx","./src/ui/select.tsx","./src/ui/skeleton.tsx","./src/ui/slider.tsx","./src/ui/sonner.tsx","./src/ui/switch.tsx","./src/ui/table.tsx","./src/ui/tabs.tsx","./src/ui/tooltip.tsx"],"version":"5.9.3"} \ No newline at end of file From b5453411f1c21aa6cf2bcdd14080201ac3b127a9 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:56:47 +0100 Subject: [PATCH 03/11] fix(cli): port workspace commands to /workspaces/{id}/projects API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the workspace_repos → git_repos + workspace_projects split (763154a), the CLI client still targeted /api/v1/workspaces/{id}/repos — deleted. Three commands (`cix ws list -v`, `cix ws list`, `cix ws ` describe) returned 404 or silently lost data. - Replace WorkspaceRepo + ListWorkspaceRepos with WorkspaceProject + ListWorkspaceProjects against the new endpoint. - Update three call sites in cli/cmd/workspace.go to use the new payload shape (project_path / status / path_hash instead of github_url / branch / id). - New cli/cmd/workspace_test.go covers status badge formatting, empty-list rendering, and case-insensitive name resolution. Resolves Fix #1 + #17 in docs/code-review-workspaces-link-local-projects.md. Co-Authored-By: Claude Opus 4.7 --- cli/cmd/workspace.go | 113 +++++----- cli/cmd/workspace_test.go | 348 +++++++++++++++++++++++++++++++ cli/internal/client/projects.go | 24 ++- cli/internal/client/workspace.go | 46 ++-- 4 files changed, 447 insertions(+), 84 deletions(-) create mode 100644 cli/cmd/workspace_test.go diff --git a/cli/cmd/workspace.go b/cli/cmd/workspace.go index bacde5d..4733535 100644 --- a/cli/cmd/workspace.go +++ b/cli/cmd/workspace.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/anthropics/code-index/cli/internal/client" "github.com/spf13/cobra" @@ -140,17 +141,17 @@ func cmdListWorkspaces(cli *client.Client) error { } fmt.Println(line) if wsVerbose { - // In verbose mode we follow each workspace with its repo + // In verbose mode we follow each workspace with its project // count + indexed status. Two extra HTTP calls per // workspace; acceptable at typical scale (<10 workspaces). - if reposResp, rerr := cli.ListWorkspaceRepos(w.ID); rerr == nil { + if pr, perr := cli.ListWorkspaceProjects(w.ID); perr == nil { indexed := 0 - for _, r := range reposResp.Repos { - if r.Status == "indexed" { + for _, wp := range pr.Projects { + if wp.Project.Status == "indexed" { indexed++ } } - fmt.Printf(" %d repos (%d indexed)\n", reposResp.Total, indexed) + fmt.Printf(" %d projects (%d indexed)\n", pr.Total, indexed) } } } @@ -166,7 +167,7 @@ func cmdListRepos(cli *client.Client, identifier string) error { if err != nil { return err } - resp, err := cli.ListWorkspaceRepos(id) + resp, err := cli.ListWorkspaceProjects(id) if err != nil { return err } @@ -174,28 +175,21 @@ func cmdListRepos(cli *client.Client, identifier string) error { return emitJSON(resp) } if resp.Total == 0 { - fmt.Fprintln(os.Stderr, "no repos attached — add one at /dashboard/workspaces") + fmt.Fprintln(os.Stderr, "no projects linked — add one at /dashboard/workspaces") return nil } - for _, r := range resp.Repos { - statusBadge := r.Status - switch r.Status { - case "indexed": - statusBadge = "✓ indexed" - case "failed": - statusBadge = "✗ failed" - case "cloning", "indexing", "pending": - statusBadge = "… " + r.Status - } - fmt.Printf("%s %s@%s\n", statusBadge, r.GitHubURL, r.Branch) + for _, wp := range resp.Projects { + p := wp.Project + fmt.Printf("%s %s\n", projectStatusBadge(p.Status), p.HostPath) if wsVerbose { - fmt.Printf(" project: %s\n", r.ProjectPath) - if r.LastIndexedAt != nil { - fmt.Printf(" last indexed: %s\n", *r.LastIndexedAt) + fmt.Printf(" path_hash: %s\n", p.PathHash) + if p.LastIndexedAt != nil { + fmt.Printf(" last indexed: %s\n", p.LastIndexedAt.Format(time.RFC3339)) } - if r.LastError != nil && *r.LastError != "" { - fmt.Printf(" last error: %s\n", *r.LastError) + if len(p.Languages) > 0 { + fmt.Printf(" languages: %s\n", strings.Join(p.Languages, ", ")) } + fmt.Printf(" linked: %s\n", wp.AddedAt.Format(time.RFC3339)) } } return nil @@ -221,7 +215,7 @@ func cmdDescribeWorkspace(cli *client.Client, identifier string) error { if ws == nil { return fmt.Errorf("workspace %q not found (run `cix ws list`)", identifier) } - reposResp, err := cli.ListWorkspaceRepos(ws.ID) + projResp, err := cli.ListWorkspaceProjects(ws.ID) if err != nil { return err } @@ -229,8 +223,8 @@ func cmdDescribeWorkspace(cli *client.Client, identifier string) error { if wsJSON { return emitJSON(map[string]any{ "workspace": ws, - "repos": reposResp.Repos, - "total": reposResp.Total, + "projects": projResp.Projects, + "total": projResp.Total, }) } @@ -240,39 +234,62 @@ func cmdDescribeWorkspace(cli *client.Client, identifier string) error { fmt.Printf(" description: %s\n", ws.Description) } indexed := 0 - for _, r := range reposResp.Repos { - if r.Status == "indexed" { + for _, wp := range projResp.Projects { + if wp.Project.Status == "indexed" { indexed++ } } - fmt.Printf(" repos: %d (%d indexed)\n", reposResp.Total, indexed) - if reposResp.Total == 0 { - fmt.Fprintln(os.Stderr, "\n (no repos attached — add at /dashboard/workspaces)") + fmt.Printf(" projects: %d (%d indexed)\n", projResp.Total, indexed) + if projResp.Total == 0 { + fmt.Fprintln(os.Stderr, "\n (no projects linked — add at /dashboard/workspaces)") return nil } fmt.Println() - for _, r := range reposResp.Repos { - statusBadge := r.Status - switch r.Status { - case "indexed": - statusBadge = "✓" - case "failed": - statusBadge = "✗" - default: - statusBadge = "…" - } - fmt.Printf(" %s %s@%s\n", statusBadge, r.GitHubURL, r.Branch) - fmt.Printf(" project: %s\n", r.ProjectPath) - if r.LastIndexedAt != nil { - fmt.Printf(" last indexed: %s\n", *r.LastIndexedAt) - } - if r.LastError != nil && *r.LastError != "" { - fmt.Printf(" last error: %s\n", *r.LastError) + for _, wp := range projResp.Projects { + p := wp.Project + fmt.Printf(" %s %s\n", projectStatusBadgeShort(p.Status), p.HostPath) + fmt.Printf(" path_hash: %s\n", p.PathHash) + if p.LastIndexedAt != nil { + fmt.Printf(" last indexed: %s\n", p.LastIndexedAt.Format(time.RFC3339)) } + fmt.Printf(" linked: %s\n", wp.AddedAt.Format(time.RFC3339)) } return nil } +// projectStatusBadge renders the long status form used by +// `cix ws list`. The new wire enum (post-split) is: +// +// created | indexing | indexed | error +// +// Unknown values fall through to the literal string so future enum +// additions render readably without crashing the CLI. +func projectStatusBadge(status string) string { + switch status { + case "indexed": + return "✓ indexed" + case "error": + return "✗ error" + case "indexing", "created": + return "… " + status + default: + return status + } +} + +// projectStatusBadgeShort renders the single-glyph badge used by the +// describe view's per-project bullet list. +func projectStatusBadgeShort(status string) string { + switch status { + case "indexed": + return "✓" + case "error": + return "✗" + default: + return "…" + } +} + // --------------------------------------------------------------------------- // `cix ws search ` // --------------------------------------------------------------------------- diff --git a/cli/cmd/workspace_test.go b/cli/cmd/workspace_test.go new file mode 100644 index 0000000..ea0c979 --- /dev/null +++ b/cli/cmd/workspace_test.go @@ -0,0 +1,348 @@ +package cmd + +import ( + "net/http" + "strings" + "testing" +) + +// TestListWorkspaceProjects_DecodesPayload locks the acceptance from +// docs/code-review-workspaces-link-local-projects.md (Fix #1, line 284): +// after the rewrite, `cix ws list` must return 200 and render a +// readable list with status badges. We also assert the absence of the +// literal "@undefined" — the regression that broke the dashboard side +// of this contract per Fix #2. +func TestListWorkspaceProjects_DecodesPayload(t *testing.T) { + srv := mockServer(t, defaultWorkspaceHandler()) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + prevVerbose := wsVerbose + wsVerbose = true + t.Cleanup(func() { wsVerbose = prevVerbose }) + + out, err := captureOutput(func() error { return cmdListRepos(cli, "platform") }) + if err != nil { + t.Fatalf("cmdListRepos: %v", err) + } + + // Status badges per the new enum. + if !strings.Contains(out, "✓ indexed") { + t.Errorf("expected '✓ indexed' badge, got:\n%s", out) + } + if !strings.Contains(out, "… indexing") { + t.Errorf("expected '… indexing' badge, got:\n%s", out) + } + + // Host-paths render directly — github form already carries @branch. + if !strings.Contains(out, "github.com/owner/repo@main") { + t.Errorf("expected github host_path with @branch, got:\n%s", out) + } + if !strings.Contains(out, "/Users/me/local-proj") { + t.Errorf("expected local host_path, got:\n%s", out) + } + + // Verbose extras for the indexed row. + if !strings.Contains(out, "path_hash: a1b2c3d4e5f60718") { + t.Errorf("expected path_hash in verbose output, got:\n%s", out) + } + if !strings.Contains(out, "last indexed: 2026-05-14T12:30:45Z") { + t.Errorf("expected RFC3339 last_indexed in verbose output, got:\n%s", out) + } + if !strings.Contains(out, "languages: go, typescript") { + t.Errorf("expected languages line for indexed row, got:\n%s", out) + } + + // Regression canary — Fix #2 dashboard bug rendered the literal + // "@undefined" because branch came from a missing field. The CLI + // equivalent must never print that. + if strings.Contains(out, "@undefined") || strings.Contains(out, "undefined") { + t.Errorf("unexpected 'undefined' in output:\n%s", out) + } +} + +// TestListWorkspaces_VerboseProjectCount covers the silent-fail path +// that broke `cix ws list -v` — it used to swallow 404s from the deleted +// /repos endpoint and just omit the count row. After the fix the verbose +// row must reappear with the new "projects" terminology. +func TestListWorkspaces_VerboseProjectCount(t *testing.T) { + srv := mockServer(t, defaultWorkspaceHandler()) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + prevVerbose := wsVerbose + wsVerbose = true + t.Cleanup(func() { wsVerbose = prevVerbose }) + + out, err := captureOutput(func() error { return cmdListWorkspaces(cli) }) + if err != nil { + t.Fatalf("cmdListWorkspaces: %v", err) + } + + if !strings.Contains(out, "2 projects (1 indexed)") { + t.Errorf("expected '2 projects (1 indexed)' verbose count, got:\n%s", out) + } + // Sanity: the old wording must not leak back. + if strings.Contains(out, "repos (") { + t.Errorf("unexpected old 'repos (...)' wording in output:\n%s", out) + } +} + +// TestListWorkspaceProjects_ServiceUnavailable locks in the +// CIX_WORKSPACES_ENABLED=false → 503 path. The CLI must surface a +// helpful error rather than crash or hang. +func TestListWorkspaceProjects_ServiceUnavailable(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspaces": + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{{"id": "ws_1", "name": "platform"}}, + "total": 1, + }) + case "/api/v1/workspaces/ws_1/projects": + apiError(w, http.StatusServiceUnavailable, + "workspaces feature is disabled (set CIX_WORKSPACES_ENABLED=true and restart)") + default: + http.NotFound(w, r) + } + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + _, err = captureOutput(func() error { return cmdListRepos(cli, "platform") }) + if err == nil { + t.Fatal("expected error on 503, got nil") + } + if !strings.Contains(err.Error(), "503") || !strings.Contains(err.Error(), "disabled") { + t.Errorf("expected error to mention 503 + 'disabled', got: %v", err) + } +} + +// TestDescribeWorkspace_ByCaseInsensitiveName exercises the +// describe path that lives separately from `resolveWorkspaceID` (it has +// its own inline name-match loop) and confirms mixed-case lookup works. +func TestDescribeWorkspace_ByCaseInsensitiveName(t *testing.T) { + srv := mockServer(t, defaultWorkspaceHandler()) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + out, err := captureOutput(func() error { return cmdDescribeWorkspace(cli, "PLATFORM") }) + if err != nil { + t.Fatalf("cmdDescribeWorkspace: %v", err) + } + + if !strings.Contains(out, "Workspace: platform") { + t.Errorf("expected workspace header, got:\n%s", out) + } + if !strings.Contains(out, "projects: 2 (1 indexed)") { + t.Errorf("expected per-workspace project count line, got:\n%s", out) + } + if !strings.Contains(out, "github.com/owner/repo@main") { + t.Errorf("expected indexed project's host_path in describe output, got:\n%s", out) + } + if !strings.Contains(out, "path_hash: a1b2c3d4e5f60718") { + t.Errorf("expected path_hash in describe output, got:\n%s", out) + } +} + +// TestListWorkspaces_ParsesEmpty pins the empty-server response path — +// the CLI must handle `{"workspaces": [], "total": 0}` cleanly: no +// error, no spurious lines on stdout, and (silently here, on stderr in +// real use) an operator-friendly hint pointing at the dashboard. Fix #17 +// minimum #1. +func TestListWorkspaces_ParsesEmpty(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/workspaces" { + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{}, + "total": 0, + }) + return + } + http.NotFound(w, r) + }) + useAPI(t, srv) + + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + out, err := captureOutput(func() error { return cmdListWorkspaces(cli) }) + if err != nil { + t.Fatalf("cmdListWorkspaces on empty list: %v", err) + } + // captureOutput only watches stdout; the "no workspaces — create one + // at …" hint goes to stderr in the real binary. Stdout must be empty + // so a future regression that accidentally prints a header row (or a + // stray "0 workspaces" line) trips this assertion. + if out != "" { + t.Errorf("expected empty stdout for 0 workspaces, got: %q", out) + } +} + +// TestProjectStatusBadge — exhaustive per-status formatting check for +// the two badge helpers. Fix #17 minimum #2: a future renumber of the +// status enum (e.g. dropping 'created' or adding 'archived') must trip +// at least one of these table rows. Direct unit test bypasses the HTTP +// harness — the two functions are pure mappings. +func TestProjectStatusBadge(t *testing.T) { + cases := []struct { + in string + long string + short string + }{ + {"indexed", "✓ indexed", "✓"}, + {"indexing", "… indexing", "…"}, + {"created", "… created", "…"}, + {"error", "✗ error", "✗"}, + // Default-arm coverage: unknown future statuses must surface + // verbatim (long) and degrade to the "still working" glyph + // (short) rather than crash or panic. This protects forward + // compatibility — the CLI should render whatever the server + // returns, not gate on the enum. + {"archived", "archived", "…"}, + } + for _, c := range cases { + if got := projectStatusBadge(c.in); got != c.long { + t.Errorf("projectStatusBadge(%q) = %q, want %q", c.in, got, c.long) + } + if got := projectStatusBadgeShort(c.in); got != c.short { + t.Errorf("projectStatusBadgeShort(%q) = %q, want %q", c.in, got, c.short) + } + } +} + +// TestResolveWorkspaceID_ByName covers Fix #17 minimum #3. The shared +// resolver supports three ways to address a workspace: exact ID, exact +// name (case-sensitive), and case-insensitive name match. Unknown +// identifiers must return an error mentioning the input so the user +// can correct the typo. Distinct from +// TestDescribeWorkspace_ByCaseInsensitiveName, which exercises the +// describe-command's inline name-match loop — this one hits the +// resolveWorkspaceID function used by `cix ws list/repos`. +func TestResolveWorkspaceID_ByName(t *testing.T) { + srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/workspaces" { + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{ + {"id": "ws_alpha", "name": "platform"}, + {"id": "ws_beta", "name": "ML-Pipeline"}, + }, + "total": 2, + }) + return + } + http.NotFound(w, r) + }) + useAPI(t, srv) + cli, err := getClient() + if err != nil { + t.Fatalf("getClient: %v", err) + } + + cases := []struct { + in string + wantID string + wantErr bool + }{ + {"platform", "ws_alpha", false}, // exact name match + {"PLATFORM", "ws_alpha", false}, // upper-case name match + {"PlatForm", "ws_alpha", false}, // mixed-case name match + {"ml-pipeline", "ws_beta", false}, // case-insensitive on hyphenated name + {"ML-PIPELINE", "ws_beta", false}, // upper-case variant + {"ws_alpha", "ws_alpha", false}, // exact ID match + {"nonexistent", "", true}, // not found → error + } + for _, c := range cases { + got, err := resolveWorkspaceID(cli, c.in) + if c.wantErr { + if err == nil { + t.Errorf("resolveWorkspaceID(%q): expected error, got id=%q", c.in, got) + continue + } + if !strings.Contains(err.Error(), c.in) { + t.Errorf("resolveWorkspaceID(%q): error should mention input, got: %v", c.in, err) + } + continue + } + if err != nil { + t.Errorf("resolveWorkspaceID(%q): unexpected error: %v", c.in, err) + continue + } + if got != c.wantID { + t.Errorf("resolveWorkspaceID(%q) = %q, want %q", c.in, got, c.wantID) + } + } +} + +// defaultWorkspaceHandler returns the standard 2-project fixture used +// by every test in this file. Factored out to avoid copy-pasting the +// JSON literal across handlers. +func defaultWorkspaceHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspaces": + writeJSON(w, 200, map[string]any{ + "workspaces": []map[string]any{ + {"id": "ws_1", "name": "platform", "description": "core platform repos"}, + }, + "total": 1, + }) + case "/api/v1/workspaces/ws_1/projects": + writeJSON(w, 200, map[string]any{ + "projects": []map[string]any{ + { + "added_at": "2026-05-10T08:15:00Z", + "project": map[string]any{ + "path_hash": "a1b2c3d4e5f60718", + "host_path": "github.com/owner/repo@main", + "container_path": "/code/owner/repo", + "languages": []string{"go", "typescript"}, + "settings": map[string]any{"exclude_patterns": []string{}, "max_file_size": 524288}, + "stats": map[string]any{"total_files": 50, "indexed_files": 50, "total_chunks": 200, "total_symbols": 30}, + "status": "indexed", + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-14T12:30:45Z", + "last_indexed_at": "2026-05-14T12:30:45Z", + }, + }, + { + "added_at": "2026-05-11T09:00:00Z", + "project": map[string]any{ + "path_hash": "7f3e2c1a0d4b5e69", + "host_path": "/Users/me/local-proj", + "container_path": "/Users/me/local-proj", + "languages": []string{}, + "settings": map[string]any{"exclude_patterns": []string{}, "max_file_size": 524288}, + "stats": map[string]any{"total_files": 0, "indexed_files": 0, "total_chunks": 0, "total_symbols": 0}, + "status": "indexing", + "created_at": "2026-05-11T08:55:00Z", + "updated_at": "2026-05-11T09:00:00Z", + "last_indexed_at": nil, + }, + }, + }, + "total": 2, + }) + default: + http.NotFound(w, r) + } + } +} diff --git a/cli/internal/client/projects.go b/cli/internal/client/projects.go index af70475..1278236 100644 --- a/cli/internal/client/projects.go +++ b/cli/internal/client/projects.go @@ -7,15 +7,21 @@ import ( // Project represents a code project type Project struct { - HostPath string `json:"host_path"` - ContainerPath string `json:"container_path"` - Languages []string `json:"languages"` - Settings ProjectSettings `json:"settings"` - Stats ProjectStats `json:"stats"` - Status string `json:"status"` // created, indexing, indexed, error - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LastIndexedAt *time.Time `json:"last_indexed_at"` + PathHash string `json:"path_hash"` + HostPath string `json:"host_path"` + ContainerPath string `json:"container_path"` + Languages []string `json:"languages"` + Settings ProjectSettings `json:"settings"` + Stats ProjectStats `json:"stats"` + Status string `json:"status"` // created, indexing, indexed, error + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastIndexedAt *time.Time `json:"last_indexed_at"` + IndexedWithModel *string `json:"indexed_with_model,omitempty"` + SqlitePath *string `json:"sqlite_path,omitempty"` + SqliteSizeBytes *int64 `json:"sqlite_size_bytes,omitempty"` + ChromaPath *string `json:"chroma_path,omitempty"` + ChromaSizeBytes *int64 `json:"chroma_size_bytes,omitempty"` } type ProjectSettings struct { diff --git a/cli/internal/client/workspace.go b/cli/internal/client/workspace.go index a3be475..1d979ec 100644 --- a/cli/internal/client/workspace.go +++ b/cli/internal/client/workspace.go @@ -3,6 +3,7 @@ package client import ( "fmt" "net/url" + "time" ) // WorkspaceSearchProject mirrors the OpenAPI WorkspaceSearchProject @@ -57,28 +58,18 @@ type WorkspaceListResponse struct { Total int `json:"total"` } -// WorkspaceRepo mirrors the server's WorkspaceRepo payload — every -// field the dashboard or `cix ws list` would display. -type WorkspaceRepo struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - GitHubURL string `json:"github_url"` - Branch string `json:"branch"` - ProjectPath string `json:"project_path"` - TokenID *string `json:"token_id,omitempty"` - AutoWebhook bool `json:"auto_webhook"` - Status string `json:"status"` - LastSHA *string `json:"last_sha,omitempty"` - LastError *string `json:"last_error,omitempty"` - LastIndexedAt *string `json:"last_indexed_at,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` +// WorkspaceProject mirrors the server's WorkspaceProject — the embedded +// project (full Project shape, defined in projects.go) plus the +// membership-added timestamp. +type WorkspaceProject struct { + AddedAt time.Time `json:"added_at"` + Project Project `json:"project"` } -// WorkspaceRepoListResponse is the GET /workspaces/{id}/repos shape. -type WorkspaceRepoListResponse struct { - Repos []WorkspaceRepo `json:"repos"` - Total int `json:"total"` +// WorkspaceProjectListResponse is the GET /workspaces/{id}/projects shape. +type WorkspaceProjectListResponse struct { + Projects []WorkspaceProject `json:"projects"` + Total int `json:"total"` } // ListWorkspaces — GET /api/v1/workspaces. Returns @@ -96,16 +87,17 @@ func (c *Client) ListWorkspaces() (*WorkspaceListResponse, error) { return &out, nil } -// ListWorkspaceRepos — GET /api/v1/workspaces/{id}/repos. Returns -// every attached repo with its current status (pending / cloning / -// indexing / indexed / failed) so the CLI can render a readable -// per-repo summary. -func (c *Client) ListWorkspaceRepos(workspaceID string) (*WorkspaceRepoListResponse, error) { - resp, err := c.do("GET", "/api/v1/workspaces/"+url.PathEscape(workspaceID)+"/repos", nil) +// ListWorkspaceProjects — GET /api/v1/workspaces/{id}/projects. Returns +// every linked project with its current status (created / indexing / +// indexed / error), host path, path hash, and membership timestamp so +// the CLI can render a readable per-project summary without a second +// round-trip. +func (c *Client) ListWorkspaceProjects(workspaceID string) (*WorkspaceProjectListResponse, error) { + resp, err := c.do("GET", "/api/v1/workspaces/"+url.PathEscape(workspaceID)+"/projects", nil) if err != nil { return nil, err } - var out WorkspaceRepoListResponse + var out WorkspaceProjectListResponse if err := parseResponse(resp, &out); err != nil { return nil, err } From aa336562d192d3875d62a8924357e4c615b647d6 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:56:54 +0100 Subject: [PATCH 04/11] fix(dashboard): align ProjectWorkspaceEntry type with server payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectDetailPage rendered "@undefined" and warned about duplicate React keys because the TS type still declared repo_id, branch, status, is_linked — fields the server stopped returning after the workspace_repos split (project_workspaces.go now sends only workspace_id, workspace_name, added_at). - Trim ProjectWorkspaceEntry to the three real fields. - Key on workspace_id; drop the "linked vs owned" UI (concept removed — every membership is just a link now). - Tooltip + chip body show workspace name + added_at only. Resolves Fix #2. Co-Authored-By: Claude Opus 4.7 --- .../src/modules/projects/ProjectDetailPage.tsx | 17 ++++------------- server/dashboard/src/modules/projects/hooks.ts | 15 ++++++++------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx index 4b2a604..2ead656 100644 --- a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx +++ b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx @@ -143,27 +143,18 @@ export function ProjectDetailPage() {
{workspaces.data.workspaces.map((w) => ( ))} diff --git a/server/dashboard/src/modules/projects/hooks.ts b/server/dashboard/src/modules/projects/hooks.ts index 38d19be..05510f8 100644 --- a/server/dashboard/src/modules/projects/hooks.ts +++ b/server/dashboard/src/modules/projects/hooks.ts @@ -14,16 +14,17 @@ export const projectKeys = { }; // ProjectWorkspaceEntry mirrors the Go response shape from -// /api/v1/projects/{hash}/workspaces — one row per workspace_repo -// pointing at this project. Defined locally so the hook doesn't -// depend on a regen of generated.ts every time the page renders. +// /api/v1/projects/{hash}/workspaces — one row per workspace_projects +// membership pointing at this project. The server returns just three +// fields: the workspace it's linked into and the timestamp it was +// added. Branch / status / owner-vs-linked concepts no longer exist +// on this endpoint — projects are uniformly first-class members. +// Defined locally so the hook doesn't depend on a regen of +// generated.ts every time the page renders. export type ProjectWorkspaceEntry = { workspace_id: string; workspace_name: string; - repo_id: string; - branch: string; - status: 'pending' | 'cloning' | 'indexing' | 'indexed' | 'failed'; - is_linked: boolean; + added_at: string; }; export type ProjectWorkspaceList = { From f8dc92ab53fa3d8cd91632dba178f37056d9a046 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:57:01 +0100 Subject: [PATCH 05/11] fix(db): crash-safe split migration + schema_migrations versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related migration-safety fixes: 1. migrateSplitWorkspaceRepos used to commit the DB tx first and then rename clone dirs — a kill -9 in that window left old {workspace_repos.id} dirs orphaned and forced a re-clone. Now the rename runs BEFORE the transaction and an error aborts the migration (leaves workspace_repos in place so the next run retries). Counters for renamed / skipped_missing_source / skipped_target_exists / failed are logged on completion. 2. Add schema_migrations(version, name, applied_at). Open() reads MAX(version) and skips already-applied migrations. Existing prod DBs bootstrap by detecting which legacy tables are present. 3. Migration test suite expanded with subtests covering partial rename, pre-existing target, missing source dir, duplicate project_path rows, and idempotent re-runs. Resolves Fix #3, #7, #14. Co-Authored-By: Claude Opus 4.7 --- server/internal/db/db.go | 240 +++++++++---- server/internal/db/db_test.go | 628 ++++++++++++++++++++++++++++++++++ server/internal/db/schema.go | 9 +- 3 files changed, 800 insertions(+), 77 deletions(-) diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 2d475ff..473f46c 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -12,10 +12,55 @@ import ( "os" "path/filepath" "strings" + "time" _ "modernc.org/sqlite" ) +// migrationFn applies a single schema migration. The opts parameter carries +// OpenOptions through to migrations that touch on-disk artefacts (the split +// migration renames clone dirs under opts.DataDir); migrations that only +// touch SQL ignore it. +type migrationFn func(*sql.DB, OpenOptions) error + +// migration is one row in the registeredMigrations slice. version is the +// permanent identifier recorded in schema_migrations; name is a human-readable +// label surfaced in error messages and ops logs. +type migration struct { + version int + name string + fn migrationFn +} + +// registeredMigrations is the canonical migration ledger, in apply order. +// schema_migrations records (version, name) after each successful run; on +// subsequent boots applyMigrations skips every entry whose version is +// <= MAX(schema_migrations.version), so each migration runs at most once +// per database. +// +// Rules for editing this list: +// +// 1. Append new migrations with the next sequential version number. Never +// renumber, never remove — production schema_migrations rows reference +// these version/name tuples and a collision would silently skip work +// that was supposed to run. +// 2. Keep each migration idempotent. Bootstrap (DB exists but +// schema_migrations is empty) runs all of them from scratch, so each +// must detect already-applied state via PRAGMA / sqlite_master / +// IF NOT EXISTS and short-circuit. +// 3. Migrations run outside any wrapping transaction; some take their own +// internal tx (the split migration does). If a migration fails part-way, +// its schema_migrations row is NOT inserted, so the next boot retries +// end-to-end — which is why idempotency is non-negotiable. +var registeredMigrations = []migration{ + {1, "path_hash", func(db *sql.DB, _ OpenOptions) error { return migratePathHash(db) }}, + {2, "indexed_with_model", func(db *sql.DB, _ OpenOptions) error { return migrateIndexedWithModel(db) }}, + {3, "webhook_mode", func(db *sql.DB, _ OpenOptions) error { return migrateWebhookMode(db) }}, + {4, "workspace_repos_linked", func(db *sql.DB, _ OpenOptions) error { return migrateWorkspaceReposLinked(db) }}, + {5, "split_workspace_repos", func(db *sql.DB, opts OpenOptions) error { return migrateSplitWorkspaceRepos(db, opts.DataDir) }}, + {6, "drop_communities", func(db *sql.DB, _ OpenOptions) error { return migrateDropCommunities(db) }}, +} + // DriverName is the registered database/sql driver name for modernc.org/sqlite. const DriverName = "sqlite" @@ -80,53 +125,63 @@ func OpenWith(opts OpenOptions) (*sql.DB, error) { return nil, fmt.Errorf("apply schema: %w", err) } - // m7 — migrate existing databases that pre-date the path_hash column. - // We add the column + index if absent, then backfill in a single pass. - if err := migratePathHash(db); err != nil { + if err := applyMigrations(db, opts); err != nil { _ = db.Close() - return nil, fmt.Errorf("migrate path_hash: %w", err) + return nil, err } - // PR-E — add indexed_with_model to projects on pre-PR-E databases. Same - // PRAGMA-table_info pattern as migratePathHash; no backfill (NULL means - // "indexed before drift tracking landed" — UI renders this as Unknown, - // not as a stale-model warning). - if err := migrateIndexedWithModel(db); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate indexed_with_model: %w", err) - } + return db, nil +} - // Up-level pre-PR10 / pre-PR13 workspace_repos shapes to the richest - // pre-split form (webhook_mode column present, is_linked column - // present, no inline-UNIQUE on project_path). On already-current or - // already-split DBs these are no-ops. - if err := migrateWebhookMode(db); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate webhook_mode: %w", err) - } - if err := migrateWorkspaceReposLinked(db); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate workspace_repos is_linked: %w", err) +// applyMigrations runs every entry in registeredMigrations whose version is +// greater than the current high-water mark in schema_migrations. Each +// successful migration records a (version, name, applied_at) row so the +// same migration never runs twice on the same database. +// +// Bootstrap behaviour: when schema_migrations is empty (fresh DB or any +// production DB that pre-dates this ledger), MAX(version) reads as 0 and +// every registered migration runs. The migrations are individually +// idempotent — they short-circuit on already-current state — so this +// is the same cost as the pre-ledger code path. The benefit kicks in +// from the SECOND boot onwards: applyMigrations sees MAX = N and skips +// every entry <= N, turning warm boots into a single SELECT. +// +// schema_migrations itself is created here, not in Schema, so the bootstrap +// path on a legacy DB (which never ran Schema with the row) still gets a +// ledger. Schema.Exec runs first in OpenWith and uses IF NOT EXISTS, so the +// table is harmlessly recreated by this function on the same boot. +func applyMigrations(db *sql.DB, opts OpenOptions) error { + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) } - // Split the legacy workspace_repos table into git_repos + - // workspace_projects. Idempotent — when the table is already gone - // (post-split DBs) the migration returns immediately. - if err := migrateSplitWorkspaceRepos(db, opts.DataDir); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate workspace_repos → git_repos: %w", err) + var currentMax sql.NullInt64 + if err := db.QueryRow( + `SELECT MAX(version) FROM schema_migrations`, + ).Scan(¤tMax); err != nil { + return fmt.Errorf("read schema_migrations max version: %w", err) } + threshold := currentMax.Int64 - // PR14 — workspace search switched from the Louvain-centroid two- - // stage pipeline to a weighted fan-out. The communities + - // community_members tables stop being written; drop them on - // upgrade so the schema reflects what's actually used. - if err := migrateDropCommunities(db); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate drop communities: %w", err) + for _, m := range registeredMigrations { + if int64(m.version) <= threshold { + continue + } + if err := m.fn(db, opts); err != nil { + return fmt.Errorf("migration %d (%s): %w", m.version, m.name, err) + } + if _, err := db.Exec( + `INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)`, + m.version, m.name, time.Now().UTC().Format(time.RFC3339Nano), + ); err != nil { + return fmt.Errorf("record migration %d (%s): %w", m.version, m.name, err) + } } - - return db, nil + return nil } // migrateDropCommunities removes the PR5–PR12 communities + @@ -467,8 +522,17 @@ func migrateWebhookMode(db *sql.DB) error { // External (owned, non-linked) workspace_repos rows used to keep their // clone in {dataDir}/repos/{workspace_repos.id}; we rename those dirs // to {dataDir}/repos/{path_hash} so the new gitrepos service finds -// them. Failures are logged-and-ignored — the next clone job will -// regenerate the directory from scratch. +// them. +// +// Crash-safety contract — FS renames run BEFORE the DB transaction: +// a kill -9 between commit and rename used to leave the DB split but +// the clone dirs stranded under their old UUID names (causing silent +// re-clones on next start). By running renames first and refusing to +// drop workspace_repos when any rename hard-fails, a retry on next +// start is fully idempotent (INSERT OR IGNORE on workspace_projects / +// git_repos, skip-target-exists on the rename loop). Missing source +// dirs (legacy clone job died before mkdir) and pre-existing targets +// (partial rename from a previous run) are non-fatal skips. // // Pre-conditions on the legacy table: the earlier migrateWebhookMode + // migrateWorkspaceReposLinked passes brought it up to the richest @@ -529,6 +593,69 @@ func migrateSplitWorkspaceRepos(db *sql.DB, dataDir string) error { return fmt.Errorf("iterate workspace_repos: %w", err) } + // Build the rename plan from the legacy snapshot. Only owned + // external rows (not linked, project_path is github.com/owner/repo@branch) + // have a clone directory on disk; linked rows reuse the owner's + // clone, and local projects have no on-disk artifact at all. + type renamePair struct{ oldID, newHash string } + var renames []renamePair + for _, s := range legacy { + if s.isLinked != 0 || !looksLikeGitHubProjectPath(s.projectPath) { + continue + } + renames = append(renames, renamePair{ + oldID: s.id, + newHash: HashHostPath(s.projectPath), + }) + } + + // Filesystem renames run BEFORE the SQL transaction. If a hard + // failure happens (permissions, EROFS, …) we return an error and + // leave workspace_repos intact, so the next process start retries + // the migration end-to-end. The DB-side inserts are idempotent + // via INSERT OR IGNORE, and the rename loop's skip-target-exists + // branch keeps the FS retry idempotent too. + if dataDir != "" && len(renames) > 0 { + base := filepath.Join(dataDir, "repos") + var renamed, skippedMissing, skippedExisting, failed int + for _, rp := range renames { + oldPath := filepath.Join(base, rp.oldID) + newPath := filepath.Join(base, rp.newHash) + if _, statErr := os.Stat(oldPath); statErr != nil { + // Source missing — legacy clone job died before mkdir, + // or a prior run already renamed away. Either way no + // FS work needed and the next clone job will recreate. + skippedMissing++ + continue + } + if _, statErr := os.Stat(newPath); statErr == nil { + // Target already there — prior partial run completed + // this rename. Safe to skip. + skippedExisting++ + continue + } + if err := os.Rename(oldPath, newPath); err != nil { + failed++ + fmt.Fprintf(os.Stderr, + "db: migrateSplitWorkspaceRepos: rename %s → %s failed: %v\n", + oldPath, newPath, err) + continue + } + renamed++ + } + fmt.Fprintf(os.Stderr, + "db: migrateSplitWorkspaceRepos: clone-dir renames "+ + "renamed=%d skipped_missing_source=%d skipped_target_exists=%d failed=%d\n", + renamed, skippedMissing, skippedExisting, failed) + if failed > 0 { + return fmt.Errorf( + "migrateSplitWorkspaceRepos: %d clone-dir rename(s) failed; "+ + "refusing to drop workspace_repos so migration retries on next start", + failed, + ) + } + } + tx, err := db.Begin() if err != nil { return fmt.Errorf("begin split tx: %w", err) @@ -554,11 +681,6 @@ func migrateSplitWorkspaceRepos(db *sql.DB, dataDir string) error { } } - // Track rename targets so the filesystem step can run after the tx - // commits — we don't want to half-rename then roll back the SQL. - type renamePair struct{ oldID, newHash string } - var renames []renamePair - for _, s := range legacy { // Every legacy row becomes a workspace_projects membership. if _, err := tx.Exec(` @@ -597,10 +719,6 @@ func migrateSplitWorkspaceRepos(db *sql.DB, dataDir string) error { ); err != nil { return fmt.Errorf("insert git_repos for %s: %w", s.projectPath, err) } - renames = append(renames, renamePair{ - oldID: s.id, - newHash: HashHostPath(s.projectPath), - }) } if _, err := tx.Exec(`DROP TABLE workspace_repos`); err != nil { @@ -609,30 +727,6 @@ func migrateSplitWorkspaceRepos(db *sql.DB, dataDir string) error { if err := tx.Commit(); err != nil { return fmt.Errorf("commit split tx: %w", err) } - - // Filesystem rename — best effort. Failure is non-fatal; the next - // clone job will recreate the directory. - if dataDir != "" { - base := filepath.Join(dataDir, "repos") - for _, rp := range renames { - oldPath := filepath.Join(base, rp.oldID) - newPath := filepath.Join(base, rp.newHash) - if _, statErr := os.Stat(oldPath); statErr != nil { - continue - } - if _, statErr := os.Stat(newPath); statErr == nil { - continue - } - if err := os.Rename(oldPath, newPath); err != nil { - // Log via stderr — the db package has no logger - // dependency, and a single warning per stuck dir is - // enough for an operator to find and clean up. - fmt.Fprintf(os.Stderr, - "db: warning: could not rename clone dir %s → %s: %v\n", - oldPath, newPath, err) - } - } - } return nil } diff --git a/server/internal/db/db_test.go b/server/internal/db/db_test.go index 42a8999..d690d38 100644 --- a/server/internal/db/db_test.go +++ b/server/internal/db/db_test.go @@ -4,7 +4,9 @@ import ( "database/sql" "os" "path/filepath" + "runtime" "sort" + "strings" "testing" _ "modernc.org/sqlite" @@ -372,3 +374,629 @@ func TestMigrate_SplitWorkspaceRepos(t *testing.T) { } defer again.Close() } + +// TestMigrate_SplitWorkspaceRepos_PartialRename covers Fix #3 of the +// branch review: when a clone-dir rename hard-fails (permissions, EROFS, +// …) the migration must NOT drop workspace_repos. The old code dropped +// the table first and then attempted renames; a kill -9 in the gap +// stranded clone dirs and silently re-cloned on next start. The new +// code does renames before the SQL transaction and returns an error +// on any real failure, so the next process start retries end-to-end. +// +// On Unix we drop write on the repos parent dir to force EACCES from +// rename(2). Skipped on Windows where the permission model differs and +// we can't reliably trigger the same failure mode portably. +func TestMigrate_SplitWorkspaceRepos_PartialRename(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission-based rename failure not portable to Windows") + } + + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + dataDir := filepath.Join(dir, "data") + reposDir := filepath.Join(dataDir, "repos") + if err := os.MkdirAll(filepath.Join(reposDir, "owned-id"), 0o755); err != nil { + t.Fatalf("mkdir owned clone: %v", err) + } + + raw, err := sql.Open(DriverName, "file:"+dbPath+"?_pragma=foreign_keys(ON)") + if err != nil { + t.Fatalf("raw open: %v", err) + } + legacy := ` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE workspace_repos ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + github_url TEXT NOT NULL, + branch TEXT NOT NULL, + project_path TEXT NOT NULL, + token_id TEXT, + webhook_secret TEXT NOT NULL, + webhook_id INTEGER, + auto_webhook INTEGER NOT NULL DEFAULT 0, + webhook_mode TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'pending', + last_sha TEXT, + last_error TEXT, + last_indexed_at TEXT, + is_linked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(workspace_id, github_url, branch), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + INSERT INTO workspaces (id, name, created_at, updated_at) VALUES + ('ws-a', 'alpha', '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'); + INSERT INTO workspace_repos + (id, workspace_id, github_url, branch, project_path, + webhook_secret, status, is_linked, webhook_mode, + created_at, updated_at) + VALUES + ('owned-id', 'ws-a', 'https://github.com/x/y', 'main', + 'github.com/x/y@main', 's-owned', 'indexed', 0, 'manual', + '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'); + ` + if _, err := raw.Exec(legacy); err != nil { + _ = raw.Close() + t.Fatalf("seed legacy: %v", err) + } + _ = raw.Close() + + // Drop write on the parent of source+target. rename(2) needs write + // on both parents (which here are the same dir) so the syscall + // will return EACCES. Restore in cleanup so t.TempDir can run. + if err := os.Chmod(reposDir, 0o500); err != nil { + t.Fatalf("chmod %s to read-only: %v", reposDir, err) + } + t.Cleanup(func() { + _ = os.Chmod(reposDir, 0o755) + }) + + // OpenWith must surface the rename failure and refuse to drop + // workspace_repos. + _, err = OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err == nil { + t.Fatalf("expected OpenWith to fail when rename is denied, got nil") + } + if !strings.Contains(err.Error(), "rename") { + t.Fatalf("expected error to mention rename, got: %v", err) + } + + // Restore perms so we can inspect the DB and run the retry. + if err := os.Chmod(reposDir, 0o755); err != nil { + t.Fatalf("chmod %s restore: %v", reposDir, err) + } + + raw2, err := sql.Open(DriverName, "file:"+dbPath+"?_pragma=foreign_keys(ON)") + if err != nil { + t.Fatalf("raw open #2: %v", err) + } + var n int + if err := raw2.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='workspace_repos'`, + ).Scan(&n); err != nil { + _ = raw2.Close() + t.Fatalf("count workspace_repos: %v", err) + } + _ = raw2.Close() + if n != 1 { + t.Fatalf("workspace_repos should still exist (migration must be retry-safe), got count=%d", n) + } + + // Source dir untouched (still under old id, no target created). + if _, err := os.Stat(filepath.Join(reposDir, "owned-id")); err != nil { + t.Fatalf("source clone dir should still exist: %v", err) + } + if _, err := os.Stat(filepath.Join(reposDir, HashHostPath("github.com/x/y@main"))); err == nil { + t.Fatalf("target clone dir should not have been created on failure") + } + + // Retry — with perms restored, OpenWith finishes the migration + // end-to-end. INSERT OR IGNORE on workspace_projects / git_repos + // and skip-target-exists on rename keep it idempotent. + db, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err != nil { + t.Fatalf("OpenWith retry: %v", err) + } + defer db.Close() + + if err := db.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='workspace_repos'`, + ).Scan(&n); err != nil { + t.Fatalf("count workspace_repos post-retry: %v", err) + } + if n != 0 { + t.Fatalf("workspace_repos should be dropped after successful retry, got count=%d", n) + } + expected := filepath.Join(reposDir, HashHostPath("github.com/x/y@main")) + if _, err := os.Stat(expected); err != nil { + t.Fatalf("clone dir was not renamed on retry: %v", err) + } + if _, err := os.Stat(filepath.Join(reposDir, "owned-id")); err == nil { + t.Fatalf("legacy clone dir still exists after retry") + } +} + +// TestMigrate_SplitWorkspaceRepos_EdgeCases — Fix #14 coverage. Each +// subtest exercises one corner of the migration's rename loop or DB-side +// insert pass: +// +// - PreExistingTarget_Skipped: target dir already exists under the new +// path_hash (prior partial-run did this rename). The migration must +// skip the rename — neither overwriting the existing target nor +// erroring — and the DB split must proceed. +// - MissingSourceDir_Skipped: DB row references a clone that never +// materialised on disk (legacy clone job died before mkdir). The +// migration must treat the missing source as a no-op rename and +// still complete the DB split. +// - DuplicatePathInLegacy: two legacy rows in the same workspace map +// to the same (workspace_id, project_path) tuple. INSERT OR IGNORE +// into workspace_projects must collapse them to a single membership +// row without erroring on the PK collision. +// +// PartialRename_ReturnsError (the fifth review-doc case) is already +// covered by TestMigrate_SplitWorkspaceRepos_PartialRename above; not +// duplicated here. +// +// TokenDeletedConcurrently (review-doc case 5) is intentionally NOT +// implemented — simulating a token row vanishing mid-migration requires +// either a mock of the github_tokens FK or a goroutine deleting from a +// parallel connection, both of which are out of scope for a DB-only +// migration test. Tracked as a TODO; reintroduce when there's a +// workspacejobs integration suite that can drive token lifecycle +// alongside the migration. +func TestMigrate_SplitWorkspaceRepos_EdgeCases(t *testing.T) { + t.Run("PreExistingTarget_Skipped", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + dataDir := filepath.Join(dir, "data") + reposDir := filepath.Join(dataDir, "repos") + + projectPath := "github.com/x/y@main" + targetHash := HashHostPath(projectPath) + + // Legacy source dir with a sentinel so we can tell whether it + // survived (skip path) or got renamed away (would be a regression). + if err := os.MkdirAll(filepath.Join(reposDir, "owned-id"), 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) + } + if err := os.WriteFile( + filepath.Join(reposDir, "owned-id", "src.sentinel"), []byte("src"), 0o644, + ); err != nil { + t.Fatalf("write source sentinel: %v", err) + } + + // Pre-existing TARGET dir under the new path_hash with its own + // sentinel — simulates a partial-run that already finished this + // rename. The migration must preserve this content untouched. + if err := os.MkdirAll(filepath.Join(reposDir, targetHash), 0o755); err != nil { + t.Fatalf("mkdir pre-existing target: %v", err) + } + if err := os.WriteFile( + filepath.Join(reposDir, targetHash, "target.sentinel"), []byte("preserved"), 0o644, + ); err != nil { + t.Fatalf("write target sentinel: %v", err) + } + + seedSplitLegacyDB(t, dbPath, false, []legacyRow{{ + id: "owned-id", workspaceID: "ws-a", + githubURL: "https://github.com/x/y", branch: "main", + projectPath: projectPath, + isLinked: 0, + }}) + + d, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err != nil { + t.Fatalf("OpenWith: %v", err) + } + defer d.Close() + + // Target survived with its original content (rename skipped). + got, err := os.ReadFile(filepath.Join(reposDir, targetHash, "target.sentinel")) + if err != nil { + t.Errorf("target dir / sentinel missing after migration: %v", err) + } else if string(got) != "preserved" { + t.Errorf("target sentinel overwritten: got %q, want %q", got, "preserved") + } + // Source dir still there (the rename was a no-op, didn't move it). + if _, err := os.Stat(filepath.Join(reposDir, "owned-id")); err != nil { + t.Errorf("source dir was unexpectedly removed: %v", err) + } + // DB split happened — workspace_repos dropped, git_repos populated. + assertWorkspaceReposDropped(t, d) + assertGitReposCount(t, d, 1) + assertWorkspaceProjectsCount(t, d, 1) + }) + + t.Run("MissingSourceDir_Skipped", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + dataDir := filepath.Join(dir, "data") + reposDir := filepath.Join(dataDir, "repos") + // reposDir exists but no per-id subdir — simulates a legacy clone + // job that died before mkdir, leaving an orphan workspace_repos + // row pointing at a directory that never materialised. + if err := os.MkdirAll(reposDir, 0o755); err != nil { + t.Fatalf("mkdir reposDir: %v", err) + } + + projectPath := "github.com/x/y@main" + seedSplitLegacyDB(t, dbPath, false, []legacyRow{{ + id: "ghost-id", workspaceID: "ws-a", + githubURL: "https://github.com/x/y", branch: "main", + projectPath: projectPath, + isLinked: 0, + }}) + + d, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err != nil { + t.Fatalf("OpenWith: %v", err) + } + defer d.Close() + + // No target dir got created — the rename was skipped because + // there was no source. A future clone_repo job will mkdir the + // target when it runs against the new path_hash. + targetHash := HashHostPath(projectPath) + if _, err := os.Stat(filepath.Join(reposDir, targetHash)); err == nil { + t.Errorf("target dir created from nothing: %s", filepath.Join(reposDir, targetHash)) + } + // DB split still happened — the absent on-disk clone doesn't + // block the SQL inserts. + assertWorkspaceReposDropped(t, d) + assertGitReposCount(t, d, 1) + assertWorkspaceProjectsCount(t, d, 1) + }) + + t.Run("DuplicatePathInLegacy", func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + dataDir := filepath.Join(dir, "data") + + // Two legacy rows in ws-a that map to the same project_path — + // possible on a pre-PR12/13 schema (no UNIQUE on workspace_id + + // github_url + branch) or on a hand-edited DB. The owned row + // uses the bare URL, the linked row uses the ".git" suffix + // variant; both deterministically produce the same project_path + // `github.com/x/y@main` and so collide on the workspace_projects + // PK (workspace_id, project_path). + seedSplitLegacyDB(t, dbPath, true /* skipUnique */, []legacyRow{ + { + id: "row-owned", workspaceID: "ws-a", + githubURL: "https://github.com/x/y", branch: "main", + projectPath: "github.com/x/y@main", + isLinked: 0, + }, + { + id: "row-linked", workspaceID: "ws-a", + githubURL: "https://github.com/x/y.git", branch: "main", + projectPath: "github.com/x/y@main", + isLinked: 1, + }, + }) + + d, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err != nil { + t.Fatalf("OpenWith: %v", err) + } + defer d.Close() + + // Exactly one workspace_projects row survived the INSERT OR + // IGNORE pass; the second legacy row was suppressed by the + // (workspace_id, project_path) PK collision. + var n int + if err := d.QueryRow( + `SELECT COUNT(*) FROM workspace_projects WHERE workspace_id = ? AND project_path = ?`, + "ws-a", "github.com/x/y@main", + ).Scan(&n); err != nil { + t.Fatalf("count membership: %v", err) + } + if n != 1 { + t.Errorf("workspace_projects membership count for duplicate path: got %d, want 1", n) + } + // git_repos also collapses — only the owned row contributes (linked + // rows skip the git_repos insert), and the project_path PK guards + // against double-insert anyway. + assertGitReposCount(t, d, 1) + assertWorkspaceReposDropped(t, d) + }) +} + +// legacyRow models the minimal subset of pre-split workspace_repos fields +// the migration cares about. Defaults that don't matter to the migration +// (webhook_mode, status, timestamps) are filled in by seedSplitLegacyDB. +type legacyRow struct { + id string + workspaceID string + githubURL string + branch string + projectPath string + isLinked int +} + +// seedSplitLegacyDB lays down the pre-split schema (workspaces + +// workspace_repos in the post-PR13 shape) and inserts the given rows. +// When skipUnique is true the composite UNIQUE on (workspace_id, +// github_url, branch) is omitted so duplicate-path scenarios can be +// constructed; otherwise the schema mirrors what +// migrateWorkspaceReposLinked leaves behind. +func seedSplitLegacyDB(t *testing.T, dbPath string, skipUnique bool, rows []legacyRow) { + t.Helper() + raw, err := sql.Open(DriverName, "file:"+dbPath+"?_pragma=foreign_keys(ON)") + if err != nil { + t.Fatalf("raw open: %v", err) + } + defer raw.Close() + + uniqueClause := "UNIQUE(workspace_id, github_url, branch)," + if skipUnique { + uniqueClause = "" + } + schema := ` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE workspace_repos ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + github_url TEXT NOT NULL, + branch TEXT NOT NULL, + project_path TEXT NOT NULL, + token_id TEXT, + webhook_secret TEXT NOT NULL, + webhook_id INTEGER, + auto_webhook INTEGER NOT NULL DEFAULT 0, + webhook_mode TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'pending', + last_sha TEXT, + last_error TEXT, + last_indexed_at TEXT, + is_linked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + ` + uniqueClause + ` + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); + INSERT INTO workspaces (id, name, created_at, updated_at) VALUES + ('ws-a', 'alpha', '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z'); + ` + if _, err := raw.Exec(schema); err != nil { + t.Fatalf("seed schema: %v", err) + } + for _, r := range rows { + if _, err := raw.Exec(` + INSERT INTO workspace_repos (id, workspace_id, github_url, branch, project_path, + webhook_secret, status, is_linked, webhook_mode, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'indexed', ?, 'manual', + '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z')`, + r.id, r.workspaceID, r.githubURL, r.branch, r.projectPath, + "secret-"+r.id, r.isLinked, + ); err != nil { + t.Fatalf("seed legacy row %s: %v", r.id, err) + } + } +} + +func assertWorkspaceReposDropped(t *testing.T, d *sql.DB) { + t.Helper() + var n int + if err := d.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='workspace_repos'`, + ).Scan(&n); err != nil { + t.Fatalf("check workspace_repos drop: %v", err) + } + if n != 0 { + t.Errorf("workspace_repos should be dropped, count=%d", n) + } +} + +func assertGitReposCount(t *testing.T, d *sql.DB, want int) { + t.Helper() + var n int + if err := d.QueryRow(`SELECT COUNT(*) FROM git_repos`).Scan(&n); err != nil { + t.Fatalf("count git_repos: %v", err) + } + if n != want { + t.Errorf("git_repos count: got %d, want %d", n, want) + } +} + +func assertWorkspaceProjectsCount(t *testing.T, d *sql.DB, want int) { + t.Helper() + var n int + if err := d.QueryRow(`SELECT COUNT(*) FROM workspace_projects`).Scan(&n); err != nil { + t.Fatalf("count workspace_projects: %v", err) + } + if n != want { + t.Errorf("workspace_projects count: got %d, want %d", n, want) + } +} + +// TestApplyMigrations_FreshDBRecordsAll — fresh Open() must record every +// registered migration in schema_migrations. The acceptance criterion for +// Fix #7: `SELECT version FROM schema_migrations` returns the full ledger +// in order. Pins both the count (regression guard against silent skipping) +// and the name sequence (regression guard against accidental renumber). +func TestApplyMigrations_FreshDBRecordsAll(t *testing.T) { + d, err := Open(":memory:") + if err != nil { + t.Fatalf("Open: %v", err) + } + defer d.Close() + + rows, err := d.Query( + `SELECT version, name FROM schema_migrations ORDER BY version`) + if err != nil { + t.Fatalf("query schema_migrations: %v", err) + } + defer rows.Close() + + type entry struct { + version int + name string + } + var got []entry + for rows.Next() { + var e entry + if err := rows.Scan(&e.version, &e.name); err != nil { + t.Fatalf("scan: %v", err) + } + got = append(got, e) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + + if len(got) != len(registeredMigrations) { + t.Fatalf("schema_migrations row count = %d, want %d (got=%+v)", + len(got), len(registeredMigrations), got) + } + for i, m := range registeredMigrations { + if got[i].version != m.version || got[i].name != m.name { + t.Errorf("row %d = {%d, %q}, want {%d, %q}", + i, got[i].version, got[i].name, m.version, m.name) + } + } +} + +// TestApplyMigrations_ReopenIsNoOp — opening the same file-backed DB twice +// must leave schema_migrations untouched: same row count, same applied_at +// strings. If a migration accidentally re-ran on warm boot, applied_at +// would be re-stamped to the second open's time. The acceptance criterion +// from Fix #7: "повторний запуск — no-op". +func TestApplyMigrations_ReopenIsNoOp(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "reopen.db") + + d, err := Open(dbPath) + if err != nil { + t.Fatalf("first Open: %v", err) + } + first := readMigrationLedger(t, d) + if err := d.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + again, err := Open(dbPath) + if err != nil { + t.Fatalf("second Open: %v", err) + } + defer again.Close() + second := readMigrationLedger(t, again) + + if len(first) != len(second) { + t.Fatalf("ledger row count changed: first=%d second=%d", len(first), len(second)) + } + for i := range first { + if first[i] != second[i] { + t.Errorf("ledger row %d changed: first=%+v second=%+v", + i, first[i], second[i]) + } + } +} + +// TestApplyMigrations_BootstrapFromLegacyDB — production DBs created before +// the schema_migrations ledger landed start out with no rows in +// schema_migrations even though their tables are already at the modern +// shape. applyMigrations must bootstrap them by running every registered +// migration (each idempotent, so the SQL is mostly a no-op on already-current +// state) and recording all version rows. Without this, every boot would +// re-run every migration forever. +// +// We simulate the pre-ledger state by opening the DB via Schema.Exec alone +// (skipping applyMigrations), then re-opening via the normal path. +func TestApplyMigrations_BootstrapFromLegacyDB(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + + // Seed: schema applied, but schema_migrations not populated (mimicking + // any prod DB that booted before this ledger existed). + raw, err := sql.Open(DriverName, "file:"+dbPath+"?_pragma=foreign_keys(ON)") + if err != nil { + t.Fatalf("raw open: %v", err) + } + if _, err := raw.Exec(Schema); err != nil { + _ = raw.Close() + t.Fatalf("exec Schema: %v", err) + } + if _, err := raw.Exec(`DROP TABLE IF EXISTS schema_migrations`); err != nil { + _ = raw.Close() + t.Fatalf("drop schema_migrations: %v", err) + } + _ = raw.Close() + + // Confirm the seeded DB really has no ledger before we bootstrap. + seedDB, err := sql.Open(DriverName, "file:"+dbPath) + if err != nil { + t.Fatalf("verify seed open: %v", err) + } + var seedHasLedger int + if err := seedDB.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'`, + ).Scan(&seedHasLedger); err != nil { + _ = seedDB.Close() + t.Fatalf("verify seed: %v", err) + } + _ = seedDB.Close() + if seedHasLedger != 0 { + t.Fatalf("seed precondition: schema_migrations should be absent, found %d", seedHasLedger) + } + + // Now boot through the normal path — applyMigrations should bootstrap. + d, err := Open(dbPath) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer d.Close() + + ledger := readMigrationLedger(t, d) + if len(ledger) != len(registeredMigrations) { + t.Fatalf("post-bootstrap ledger has %d rows, want %d", + len(ledger), len(registeredMigrations)) + } + for i, m := range registeredMigrations { + if ledger[i].version != m.version || ledger[i].name != m.name { + t.Errorf("ledger row %d = {%d, %q}, want {%d, %q}", + i, ledger[i].version, ledger[i].name, m.version, m.name) + } + } +} + +// migrationLedgerRow mirrors a schema_migrations row for test assertions. +type migrationLedgerRow struct { + version int + name string + appliedAt string +} + +// readMigrationLedger snapshots the schema_migrations table in version order. +func readMigrationLedger(t *testing.T, d *sql.DB) []migrationLedgerRow { + t.Helper() + rows, err := d.Query( + `SELECT version, name, applied_at FROM schema_migrations ORDER BY version`) + if err != nil { + t.Fatalf("query schema_migrations: %v", err) + } + defer rows.Close() + var out []migrationLedgerRow + for rows.Next() { + var r migrationLedgerRow + if err := rows.Scan(&r.version, &r.name, &r.appliedAt); err != nil { + t.Fatalf("scan ledger row: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("ledger rows.Err: %v", err) + } + return out +} diff --git a/server/internal/db/schema.go b/server/internal/db/schema.go index 188f87d..abe2ecc 100644 --- a/server/internal/db/schema.go +++ b/server/internal/db/schema.go @@ -150,11 +150,11 @@ CREATE TABLE IF NOT EXISTS runtime_settings ( updated_by TEXT ); --- Workspaces feature (PR1 — skeleton). Workspaces group GitHub repositories --- for cross-project semantic search. Server-wide shared: every authenticated +-- Workspaces group indexed projects (rows in the projects table, +-- optionally with their git_repos peer) for cross-project semantic +-- search. Membership lives in workspace_projects; clone + webhook +-- metadata lives in git_repos. Server-wide shared: every authenticated -- user can see and modify any workspace (per the chosen visibility model). --- The richer workspace_repos / call_edges / communities tables land in --- subsequent PRs of the workspaces feature branch. CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -348,4 +348,5 @@ var ExpectedTables = []string{ "call_edges", "chunks_meta", "chunks_fts", + "schema_migrations", } From 7aa3eda13a9b00cb1c52ae3b5b3180353670ef12 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:57:08 +0100 Subject: [PATCH 06/11] feat(server): startup audit for stale webhook URLs (4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the workspace_repos → git_repos split, webhook URLs moved from /webhooks/github/{uuid} to /webhooks/github/{path_hash}. Existing manual-mode GitHub-side hooks silently 404'd. Nothing signalled the break to operators. - New webhook_audit.go runs after db.Open(): counts manual-mode git_repos rows and logs a WARN with the new URL pattern. - Test covers the count + log emission paths. Resolves Fix #4a. Dashboard banner (4b) and auto-reregister endpoint (4c) remain follow-ups per docs/code-review-workspaces-link-local-projects.md. Co-Authored-By: Claude Opus 4.7 --- server/cmd/cix-server/main.go | 6 + server/cmd/cix-server/webhook_audit.go | 57 +++++++++ server/cmd/cix-server/webhook_audit_test.go | 122 ++++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 server/cmd/cix-server/webhook_audit.go create mode 100644 server/cmd/cix-server/webhook_audit_test.go diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index 8ea2d0e..b4c6661 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -108,6 +108,12 @@ func run() error { } }() + // Webhook clean-break notice (Fix #4a): the migration that split + // workspace_repos into git_repos + workspace_projects also changed + // the webhook URL format. Surface a one-line WARN with row counts + // so the operator knows to re-register in GitHub on upgrade. + auditWebhookCleanBreak(context.Background(), database, logger) + // PR-E — overlay dashboard-saved runtime overrides onto the env-loaded // config before any code path reads its fields. The DB row may not // exist yet (fresh install); resolution falls through to env / recommended diff --git a/server/cmd/cix-server/webhook_audit.go b/server/cmd/cix-server/webhook_audit.go new file mode 100644 index 0000000..c0b6045 --- /dev/null +++ b/server/cmd/cix-server/webhook_audit.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "database/sql" + "log/slog" +) + +// auditWebhookCleanBreak emits a one-line WARN on startup when git_repos +// rows still expose webhook URLs that operators must re-register in +// GitHub. The clean break happened when workspace_repos split into +// git_repos + workspace_projects: the URL went from +// /webhooks/github/{workspace_repos.id} (UUID) to +// /webhooks/github/{path_hash} (16-hex). GitHub will keep POSTing to +// the OLD URL until the operator updates the webhook config, and the +// server now returns 404 — deliveries silently fail. +// +// We can't detect "already re-registered" from the DB (manual webhooks +// have no acknowledgement signal), so the warning persists on every +// startup that still has manual + auto webhooks. That's accepted noise +// — the alternative is silent breakage. Operators who want to silence +// it can switch the rows to webhook_mode='disabled' once they've moved +// off webhook-driven indexing. +// +// Counts are surfaced as structured fields so log scrapers can alert +// on the threshold; the human-readable message points at +// /api/v1/projects/{hash}/webhook-info as the per-project URL source. +func auditWebhookCleanBreak(ctx context.Context, db *sql.DB, logger *slog.Logger) { + var manualCount, autoCount int + if err := db.QueryRowContext(ctx, + // COALESCE wraps SUM because SUM() over an empty table returns + // NULL — which Scan into *int rejects. Empty table is the + // fresh-install case and must scan to (0, 0), not fail. + `SELECT + COALESCE(SUM(CASE WHEN webhook_mode = 'manual' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN webhook_mode = 'auto' THEN 1 ELSE 0 END), 0) + FROM git_repos`, + ).Scan(&manualCount, &autoCount); err != nil { + // git_repos missing only when called against a non-migrated + // DB (pre-Open) — by the time main.go reaches us the schema + // is applied, so any error here is a real surprise. Log and + // move on; this is not load-bearing for boot. + logger.Warn("webhooks: clean-break audit query failed; skipping", "err", err) + return + } + if manualCount == 0 && autoCount == 0 { + return + } + logger.Warn( + "webhooks: git_repos rows still point at the OLD webhook URL (workspace_repos UUID). "+ + "The URL format changed when workspace_repos was split into git_repos + workspace_projects; "+ + "GitHub deliveries to the old URL will 404. Re-register in GitHub — per-project URLs are at "+ + "GET /api/v1/projects/{hash}/webhook-info or in the dashboard.", + "manual_webhooks", manualCount, + "auto_webhooks", autoCount, + ) +} diff --git a/server/cmd/cix-server/webhook_audit_test.go b/server/cmd/cix-server/webhook_audit_test.go new file mode 100644 index 0000000..93eea70 --- /dev/null +++ b/server/cmd/cix-server/webhook_audit_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "context" + "database/sql" + "log/slog" + "strings" + "testing" + + "github.com/dvcdsys/code-index/server/internal/db" +) + +// capturingLogger returns a slog.Logger that writes text-formatted +// records into the supplied buffer so the test can inspect them with +// strings.Contains. +func capturingLogger(buf *bytes.Buffer) *slog.Logger { + return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})) +} + +// seedGitRepo inserts a project + git_repo pair so the audit query has +// real data to count. project_path is the FK target — git_repos.project_path +// references projects.host_path. +func seedGitRepo(t *testing.T, d *sql.DB, hostPath, mode string) { + t.Helper() + if _, err := d.Exec(` + INSERT INTO projects (host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash) + VALUES (?, ?, '[]', '{}', '{}', 'indexed', '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z', ?)`, + hostPath, hostPath, db.HashHostPath(hostPath), + ); err != nil { + t.Fatalf("seed project %s: %v", hostPath, err) + } + if _, err := d.Exec(` + INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, + webhook_mode, created_at, updated_at) + VALUES (?, ?, 'main', 'secret-' || ?, ?, '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z')`, + hostPath, "https://"+hostPath, mode, mode, + ); err != nil { + t.Fatalf("seed git_repo %s/%s: %v", hostPath, mode, err) + } +} + +// TestAuditWebhookCleanBreak_NoRows — fresh DB with no git_repos rows: +// the audit must stay silent. A WARN on an empty DB would spam every +// test run plus every dev boot. +func TestAuditWebhookCleanBreak_NoRows(t *testing.T) { + d, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer d.Close() + + buf := &bytes.Buffer{} + auditWebhookCleanBreak(context.Background(), d, capturingLogger(buf)) + + if buf.Len() != 0 { + t.Errorf("expected silent audit on empty DB, got:\n%s", buf.String()) + } +} + +// TestAuditWebhookCleanBreak_ManualAndAuto — both modes exist + one +// 'disabled' row that must NOT be counted. Single WARN line, two +// structured fields with the right counts. +func TestAuditWebhookCleanBreak_ManualAndAuto(t *testing.T) { + d, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer d.Close() + + seedGitRepo(t, d, "github.com/x/manual1@main", "manual") + seedGitRepo(t, d, "github.com/x/manual2@main", "manual") + seedGitRepo(t, d, "github.com/x/manual3@main", "manual") + seedGitRepo(t, d, "github.com/x/auto1@main", "auto") + seedGitRepo(t, d, "github.com/x/auto2@main", "auto") + seedGitRepo(t, d, "github.com/x/disabled1@main", "disabled") + + buf := &bytes.Buffer{} + auditWebhookCleanBreak(context.Background(), d, capturingLogger(buf)) + + out := buf.String() + if !strings.Contains(out, "level=WARN") { + t.Errorf("expected WARN level, got:\n%s", out) + } + if !strings.Contains(out, "manual_webhooks=3") { + t.Errorf("expected manual_webhooks=3, got:\n%s", out) + } + if !strings.Contains(out, "auto_webhooks=2") { + t.Errorf("expected auto_webhooks=2, got:\n%s", out) + } + // Disabled rows must not be counted as either; presence of any + // "disabled" mention in the log is fine, but the counts must + // match the manual+auto totals exactly. + if strings.Count(out, "webhook_mode=") > 0 { + t.Errorf("audit log should not surface webhook_mode= field, got:\n%s", out) + } + if !strings.Contains(out, "/api/v1/projects/{hash}/webhook-info") { + t.Errorf("expected pointer to per-project webhook-info endpoint, got:\n%s", out) + } +} + +// TestAuditWebhookCleanBreak_OnlyDisabled — webhook_mode='disabled' is +// the operator opting out of webhook delivery entirely. No +// re-registration is needed and the audit must stay silent. +func TestAuditWebhookCleanBreak_OnlyDisabled(t *testing.T) { + d, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer d.Close() + + seedGitRepo(t, d, "github.com/x/d1@main", "disabled") + seedGitRepo(t, d, "github.com/x/d2@main", "disabled") + + buf := &bytes.Buffer{} + auditWebhookCleanBreak(context.Background(), d, capturingLogger(buf)) + + if buf.Len() != 0 { + t.Errorf("expected silent audit on disabled-only rows, got:\n%s", buf.String()) + } +} From 3808925c0587ed1794eb4444ce63080dfaf631f9 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:57:17 +0100 Subject: [PATCH 07/11] fix(server): compensating delete + atomic Link for gitrepos/workspaceprojects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related correctness fixes: 1. POST /git-repos was not transactional — a failed gitrepos insert (e.g. UNIQUE violation under concurrent posts) left an orphan projects row in 'pending' that the dashboard couldn't surface for cleanup. The handler now tracks whether it created the project row and runs a compensating DeleteByHash on gitrepos failure. TestAddGitRepo_ConcurrentDuplicate_NoOrphan asserts the invariant: SELECT COUNT(*) FROM projects WHERE host_path = ? == 1 after two parallel posts. 2. workspaceprojects.Link checked precondition + did INSERT in two separate queries — race window where the project could be deleted between the SELECT and INSERT surfaced as a 500 instead of 404. Rewritten as a single INSERT ... SELECT ... WHERE EXISTS, with a follow-up diagnostic SELECT when RowsAffected == 0 to return the right 404/422 reason. 3. TestDeleteProject_CascadesGitRepoAndMembership now explicitly asserts SELECT COUNT(*) FROM workspace_projects WHERE project_path = ? == 0 (instead of relying on UNIQUE-retry inference). Resolves Fix #5, #6, #15, #16. Co-Authored-By: Claude Opus 4.7 --- server/internal/httpapi/gitrepos.go | 36 ++- server/internal/httpapi/gitrepos_test.go | 219 +++++++++++++++++- .../httpapi/workspaceprojects_test.go | 128 +++++++++- .../internal/httpapi/workspacesearch_test.go | 26 +-- .../workspaceprojects/workspaceprojects.go | 91 +++++--- .../workspaceprojects_test.go | 91 ++++++++ 6 files changed, 528 insertions(+), 63 deletions(-) diff --git a/server/internal/httpapi/gitrepos.go b/server/internal/httpapi/gitrepos.go index 0c5770f..fdb3154 100644 --- a/server/internal/httpapi/gitrepos.go +++ b/server/internal/httpapi/gitrepos.go @@ -117,11 +117,16 @@ func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) { // half-failed attempt) already wrote it; the gitrepos.Create // below will surface the real duplicate via UNIQUE on (github_url, // branch). ErrOverlap is a hard reject. - if _, perr := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{HostPath: projectPath}); perr != nil { - if !errors.Is(perr, projects.ErrConflict) { - writeError(w, http.StatusUnprocessableEntity, perr.Error()) - return - } + // + // Fix #5: track whether THIS request created the projects row so + // we can compensate-delete it on gitrepos.Create failure. Without + // the rollback a failed request leaves an operator-visible + // 'pending' orphan with no git_repos and no workspace_projects. + _, createErr := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{HostPath: projectPath}) + projectCreatedHere := createErr == nil + if createErr != nil && !errors.Is(createErr, projects.ErrConflict) { + writeError(w, http.StatusUnprocessableEntity, createErr.Error()) + return } g, err := s.Deps.GitRepos.Create(r.Context(), gitrepos.CreateRequest{ @@ -131,6 +136,27 @@ func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) { WebhookMode: mode, }) if err != nil { + // Compensating delete (Fix #5): drop the project we staged so + // the failed flow doesn't leave a 'pending' orphan visible in + // /projects. Guarded by two checks: + // (a) projectCreatedHere — never touch a project that + // pre-existed; somebody else owns it. + // (b) no git_repos row currently FK-references this project + // — a concurrent winner may have inserted between our + // projects.Create and our gitrepos.Create. Deleting then + // would cascade away the winner's git_repo row. + if projectCreatedHere { + if _, gerr := s.Deps.GitRepos.GetByPath(r.Context(), projectPath); errors.Is(gerr, gitrepos.ErrNotFound) { + if derr := projects.Delete(r.Context(), s.Deps.DB, projectPath); derr != nil && s.Deps.Logger != nil { + s.Deps.Logger.Warn( + "AddGitRepo: compensating projects.Delete after gitrepos.Create failure failed; an orphan 'pending' project may need manual cleanup", + "project_path", projectPath, + "original_err", err, + "delete_err", derr, + ) + } + } + } switch { case errors.Is(err, gitrepos.ErrInvalidURL): writeError(w, http.StatusUnprocessableEntity, "github_url must be an https://github.com/owner/repo URL") diff --git a/server/internal/httpapi/gitrepos_test.go b/server/internal/httpapi/gitrepos_test.go index 9d05a83..c098f36 100644 --- a/server/internal/httpapi/gitrepos_test.go +++ b/server/internal/httpapi/gitrepos_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + "sort" + "sync" "testing" "github.com/dvcdsys/code-index/server/internal/jobs" @@ -112,14 +114,168 @@ func TestReindexProject_RequiresGitRepo(t *testing.T) { } } +// TestAddGitRepo_FailedGitRepoCreate_RollsBackProject covers Fix #5 of +// the branch review: when gitrepos.Create fails AFTER projects.Create +// succeeded, the handler must compensate-delete the freshly created +// projects row. Without that rollback the operator sees a 'pending' +// orphan in /projects that can't be linked to a workspace (status != +// 'indexed') and can't be reindexed (no git_repos row). +// +// Force the gitrepos.Create failure via an invalid webhook_mode — the +// service-side validation rejects unknown values, by which point the +// handler has already staged the projects row. (URL + branch are +// validated by the handler up front so they don't trigger the same +// orphan window.) +func TestAddGitRepo_FailedGitRepoCreate_RollsBackProject(t *testing.T) { + router, _, d := reposRouterDB(t) + + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ + "github_url": "https://github.com/x/orphan-test", + "branch": "main", + "webhook_mode": "totally-bogus-mode", + }) + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422 for invalid webhook_mode, got %d (%s)", rr.Code, rr.Body.String()) + } + + var n int + if err := d.QueryRow( + `SELECT COUNT(*) FROM projects WHERE host_path = ?`, + "github.com/x/orphan-test@main", + ).Scan(&n); err != nil { + t.Fatalf("count projects: %v", err) + } + if n != 0 { + t.Errorf("expected projects row to be rolled back after gitrepos.Create failure, got count=%d", n) + } + + // Sanity: no git_repos row either (Create rejected before INSERT). + if err := d.QueryRow( + `SELECT COUNT(*) FROM git_repos WHERE project_path = ?`, + "github.com/x/orphan-test@main", + ).Scan(&n); err != nil { + t.Fatalf("count git_repos: %v", err) + } + if n != 0 { + t.Errorf("expected no git_repos row after validation failure, got %d", n) + } +} + +// TestAddGitRepo_FailedGitRepoCreate_PreservesPreExistingProject is the +// negative half of Fix #5: when the project pre-existed (created by a +// different flow earlier), a failing AddGitRepo must NOT delete it. +// projectCreatedHere=false → no compensation, even though gitrepos.Create +// failed. This guards the cascade-delete corruption case where rolling +// back would wipe somebody else's git_repos row via FK CASCADE. +func TestAddGitRepo_FailedGitRepoCreate_PreservesPreExistingProject(t *testing.T) { + router, _, d := reposRouterDB(t) + + // Pre-seed an indexed project for the same path the request would + // derive. Simulates: a previous successful flow created this row, + // and our concurrent request shouldn't blow it away. + hostPath := "github.com/x/keepme@main" + if _, err := d.Exec(` + INSERT INTO projects (host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash) + VALUES (?, ?, '[]', '{}', '{}', 'indexed', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', ?)`, + hostPath, hostPath, "0123456789abcdef", + ); err != nil { + t.Fatalf("seed pre-existing project: %v", err) + } + + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ + "github_url": "https://github.com/x/keepme", + "branch": "main", + "webhook_mode": "totally-bogus-mode", + }) + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d (%s)", rr.Code, rr.Body.String()) + } + + var n int + if err := d.QueryRow( + `SELECT COUNT(*) FROM projects WHERE host_path = ?`, hostPath, + ).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("pre-existing project must be preserved on gitrepos.Create failure, got count=%d", n) + } +} + +// TestAddGitRepo_ConcurrentDuplicate_NoOrphan covers the Fix #5 +// acceptance criterion literally: two concurrent POSTs with the same +// github_url + branch should yield one 201 and one 409, with exactly +// one projects row and one git_repos row at the end. This also covers +// Fix #15 (concurrent race test) from the same review. +// +// The compensating delete guard `if no git_repos row exists` is what +// makes this safe — without it, the loser's rollback could cascade +// away the winner's git_repos row. +func TestAddGitRepo_ConcurrentDuplicate_NoOrphan(t *testing.T) { + router, _, d := reposRouterDB(t) + body := map[string]any{ + "github_url": "https://github.com/x/concurrent", + "branch": "main", + } + + var wg sync.WaitGroup + codes := make(chan int, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", body) + codes <- rr.Code + }() + } + wg.Wait() + close(codes) + + got := []int{<-codes, <-codes} + sort.Ints(got) + // Exactly one 201 (winner) and one 409 (duplicate). + if got[0] != http.StatusCreated || got[1] != http.StatusConflict { + t.Fatalf("expected one 201 + one 409, got %v", got) + } + + // Single projects row. + var n int + if err := d.QueryRow( + `SELECT COUNT(*) FROM projects WHERE host_path = ?`, + "github.com/x/concurrent@main", + ).Scan(&n); err != nil { + t.Fatalf("count projects: %v", err) + } + if n != 1 { + t.Errorf("expected exactly 1 projects row after concurrent race, got %d", n) + } + + // Single git_repos row. + if err := d.QueryRow( + `SELECT COUNT(*) FROM git_repos WHERE project_path = ?`, + "github.com/x/concurrent@main", + ).Scan(&n); err != nil { + t.Fatalf("count git_repos: %v", err) + } + if n != 1 { + t.Errorf("expected exactly 1 git_repos row after concurrent race, got %d", n) + } +} + // TestDeleteProject_CascadesGitRepoAndMembership exercises the chained // FK ON DELETE CASCADE: removing the project deletes the git_repos row // AND every workspace_projects row referencing it. Used to be a // manual cleanup in projects.Delete; now the FKs do the work. +// +// Fix #16 acceptance: explicit COUNT(*) assertions on both child tables +// after the DELETE so anyone who later strips `ON DELETE CASCADE` from +// either FK gets a clear "git_repos should cascade" / "workspace_projects +// should cascade" failure instead of a downstream behavioural surprise. func TestDeleteProject_CascadesGitRepoAndMembership(t *testing.T) { - router, _ := reposRouter(t) + router, _, d := reposRouterDB(t) - // Add an external project and attach it to a workspace. + // Add an external project — kicks off project + git_repos rows. rr := doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ "github_url": "https://github.com/a/b", "branch": "main", @@ -134,17 +290,64 @@ func TestDeleteProject_CascadesGitRepoAndMembership(t *testing.T) { } _ = json.Unmarshal(rr.Body.Bytes(), &created) hash := created.GitRepo.PathHash + projPath := "github.com/a/b@main" - // Delete the project directly — the cascade should clear both - // git_repos and any workspace memberships (there are none here, - // but the SQL exercises the FK trigger regardless). + // Force status=indexed so workspaceprojects.Link's precondition passes — + // the clone+index job chain isn't wired in the test harness so we + // satisfy the invariant by hand. + if _, err := d.Exec(`UPDATE projects SET status = 'indexed' WHERE host_path = ?`, projPath); err != nil { + t.Fatalf("mark indexed: %v", err) + } + + // Create a workspace and link the project so the workspace_projects + // cascade has a real row to verify (the prior version of this test + // asserted nothing on workspace_projects — the FK trigger was untested). + rr = doJSON(t, router, http.MethodPost, "/api/v1/workspaces", map[string]any{ + "name": "platform", + "description": "cascade test", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("create workspace: %d (%s)", rr.Code, rr.Body.String()) + } + var ws struct { + ID string `json:"id"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &ws) + rr = doJSON(t, router, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/projects", map[string]any{ + "project_hash": hash, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("link project: %d (%s)", rr.Code, rr.Body.String()) + } + + // Sanity-pin the pre-delete state — without this, a "0 rows after + // delete" assertion can't distinguish "cascade fired" from "row + // never existed in the first place". + assertCount := func(t *testing.T, q string, want int) { + t.Helper() + var n int + if err := d.QueryRow(q, projPath).Scan(&n); err != nil { + t.Fatalf("count %q: %v", q, err) + } + if n != want { + t.Errorf("count %q: got %d, want %d", q, n, want) + } + } + assertCount(t, `SELECT COUNT(*) FROM git_repos WHERE project_path = ?`, 1) + assertCount(t, `SELECT COUNT(*) FROM workspace_projects WHERE project_path = ?`, 1) + + // Delete the project — both child rows must cascade. rr = doJSON(t, router, http.MethodDelete, "/api/v1/projects/"+hash, nil) if rr.Code != http.StatusNoContent { t.Fatalf("delete: %d (%s)", rr.Code, rr.Body.String()) } - // Re-adding the exact same upstream must succeed — proves the - // git_repos row was actually removed (otherwise UNIQUE(github_url, - // branch) would 409 here, which is the bug a previous patch fixed). + assertCount(t, `SELECT COUNT(*) FROM git_repos WHERE project_path = ?`, 0) + assertCount(t, `SELECT COUNT(*) FROM workspace_projects WHERE project_path = ?`, 0) + + // Re-adding the exact same upstream must succeed — end-to-end check + // that the git_repos row was actually removed (otherwise + // UNIQUE(github_url, branch) would 409 here, which is the bug a + // previous patch fixed). rr = doJSON(t, router, http.MethodPost, "/api/v1/git-repos", map[string]any{ "github_url": "https://github.com/a/b", "branch": "main", diff --git a/server/internal/httpapi/workspaceprojects_test.go b/server/internal/httpapi/workspaceprojects_test.go index 3285574..07abb44 100644 --- a/server/internal/httpapi/workspaceprojects_test.go +++ b/server/internal/httpapi/workspaceprojects_test.go @@ -4,18 +4,28 @@ import ( "database/sql" "encoding/json" "net/http" + "strings" "testing" + "time" "github.com/dvcdsys/code-index/server/internal/projects" ) // markIndexed flips a project's status to 'indexed' so workspaceprojects.Link // passes its precondition. Production code does this through the indexer's -// finish step; tests don't run the indexer and write the row directly. +// finish step (which also stamps last_indexed_at); tests don't run the +// indexer so we mimic both writes here. +// +// The timestamp must be RFC3339Nano because projectToOpenAPI parses +// last_indexed_at with time.RFC3339Nano — SQLite's `datetime('now')` +// emits `2006-01-02 15:04:05` which fails that parse and silently +// becomes NULL on the wire. func markIndexed(t *testing.T, d *sql.DB, hostPath string) { t.Helper() + now := time.Now().UTC().Format(time.RFC3339Nano) if _, err := d.Exec( - `UPDATE projects SET status = 'indexed' WHERE host_path = ?`, hostPath, + `UPDATE projects SET status = 'indexed', last_indexed_at = ? WHERE host_path = ?`, + now, hostPath, ); err != nil { t.Fatalf("flip status to indexed for %s: %v", hostPath, err) } @@ -74,8 +84,122 @@ func TestLinkProjectToWorkspace_AfterIndexed(t *testing.T) { if list.Total != 1 { t.Fatalf("expected 1 project in workspace, got %d", list.Total) } + + // Wire-contract check — the CLI client (cli/internal/client/workspace.go) + // decodes this exact response into Project + WorkspaceProject + Wrapped + // list. Anyone breaking these field names breaks the CLI silently + // (which is how Fix #1 happened in the first place). Mirror the + // CLI's struct shape inline and assert every field the CLI reads. + type cliWire struct { + Projects []struct { + AddedAt time.Time `json:"added_at"` + Project struct { + PathHash string `json:"path_hash"` + HostPath string `json:"host_path"` + ContainerPath string `json:"container_path"` + Status string `json:"status"` + LastIndexedAt *time.Time `json:"last_indexed_at"` + Languages []string `json:"languages"` + } `json:"project"` + } `json:"projects"` + Total int `json:"total"` + } + var wire cliWire + if err := json.Unmarshal(rr.Body.Bytes(), &wire); err != nil { + t.Fatalf("decode wire shape: %v", err) + } + if wire.Total != 1 || len(wire.Projects) != 1 { + t.Fatalf("expected wire to surface 1 project; got total=%d len=%d", wire.Total, len(wire.Projects)) + } + got := wire.Projects[0] + if got.AddedAt.IsZero() { + t.Errorf("expected added_at to be a non-zero timestamp") + } + if got.Project.PathHash != hash { + t.Errorf("path_hash: want %q, got %q", hash, got.Project.PathHash) + } + if got.Project.HostPath != hostPath { + t.Errorf("host_path: want %q, got %q", hostPath, got.Project.HostPath) + } + if got.Project.Status != "indexed" { + t.Errorf("status: want \"indexed\", got %q", got.Project.Status) + } + if got.Project.LastIndexedAt == nil { + t.Errorf("last_indexed_at should be populated for an indexed project") + } + if got.Project.Languages == nil { + t.Errorf("languages should be [], not null — CLI iterates it without nil-check") + } } +// TestListWorkspaceProjects_Empty pins the empty-membership response: +// the handler MUST emit `"projects": []` (not `null`), so the CLI's +// `for _, wp := range resp.Projects` loop is safe. A future refactor +// that drops the `make([]map[string]any, 0, …)` initialisation would +// regress to `null` and this test catches it at server CI. +func TestListWorkspaceProjects_Empty(t *testing.T) { + router, _, _ := reposRouterDB(t) + wsID := createWS(t, router, "platform") + + rr := doJSON(t, router, http.MethodGet, "/api/v1/workspaces/"+wsID+"/projects", nil) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (%s)", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + // `"projects":[]` (no whitespace; encoding/json produces compact JSON). + // Negative form `"projects":null` is the regression we're guarding. + if !strings.Contains(body, `"projects":[]`) { + t.Errorf("expected `\"projects\":[]` in body, got: %s", body) + } + var list struct { + Projects []map[string]any `json:"projects"` + Total int `json:"total"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { + t.Fatalf("decode: %v", err) + } + if list.Total != 0 { + t.Errorf("expected total=0, got %d", list.Total) + } + if list.Projects == nil { + t.Errorf("projects should be empty slice, not nil (CLI iterates it)") + } +} + +// TestListWorkspaceProjects_WorkspaceMissing locks the "unknown id → 404" +// contract for the new endpoint. The CLI surfaces this as a "workspace +// %q not found" error to the user. +func TestListWorkspaceProjects_WorkspaceMissing(t *testing.T) { + router, _, _ := reposRouterDB(t) + bogusID := "ws_does_not_exist_01J5" + + rr := doJSON(t, router, http.MethodGet, "/api/v1/workspaces/"+bogusID+"/projects", nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d (%s)", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "workspace not found") { + t.Errorf("expected 404 body to mention 'workspace not found', got: %s", rr.Body.String()) + } +} + +// TestListWorkspaceProjects_FeatureDisabled — when CIX_WORKSPACES_ENABLED +// is false the handler must short-circuit with 503 *before* touching DB. +// The CLI relies on this so `cix ws list` against a disabled +// server prints a useful "feature is disabled" hint, not a generic +// failure. +func TestListWorkspaceProjects_FeatureDisabled(t *testing.T) { + router := workspaceRouter(t, false) + // Any path id will do — 503 must come before the workspace lookup. + rr := doJSON(t, router, http.MethodGet, "/api/v1/workspaces/ws_any/projects", nil) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 with feature disabled, got %d (%s)", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "workspaces feature is disabled") { + t.Errorf("expected 503 body to mention 'workspaces feature is disabled', got: %s", rr.Body.String()) + } +} + + // TestLink_Duplicate confirms the workspace_projects PRIMARY KEY // catches a re-link as 409. Same project, same workspace, twice. func TestLink_Duplicate(t *testing.T) { diff --git a/server/internal/httpapi/workspacesearch_test.go b/server/internal/httpapi/workspacesearch_test.go index 7886679..621de13 100644 --- a/server/internal/httpapi/workspacesearch_test.go +++ b/server/internal/httpapi/workspacesearch_test.go @@ -38,8 +38,9 @@ func (e fixedEmbedder) EmbedQuery(_ context.Context, _ string) ([]float32, error func (e fixedEmbedder) Ready(_ context.Context) error { return nil } // newSearchRouter wires the minimum surface workspace search needs: -// workspaces, workspace_repos, jobs (unused but in Deps), vectorstore -// (real, on tmpdir), and a query embedder the caller controls. +// workspaces, git_repos, workspace_projects, jobs (unused but in Deps), +// vectorstore (real, on tmpdir), and a query embedder the caller +// controls. func newSearchRouter(t *testing.T, d *sql.DB, vs *vectorstore.Store, emb fixedEmbedder) http.Handler { t.Helper() t.Setenv("CIX_SECRET_KEY", "") @@ -65,11 +66,11 @@ func newSearchRouter(t *testing.T, d *sql.DB, vs *vectorstore.Store, emb fixedEm }) } -// seedRepoWithChunks inserts a projects + workspace_repos row for the -// given project_path inside the workspace, then upserts the supplied -// chunks into chromem so /search has something to retrieve. Bypasses -// the clone+index job chain — those are exercised in workspacerepos -// tests already. +// seedRepoWithChunks inserts a projects row + workspace_projects +// membership for the given project_path inside the workspace, then +// upserts the supplied chunks into chromem so /search has something to +// retrieve. Bypasses the clone+index job chain — those are exercised +// in gitrepos tests already. func seedRepoWithChunks( t *testing.T, d *sql.DB, @@ -793,12 +794,11 @@ func seedPendingRepo(t *testing.T, d *sql.DB, wsID, projectPath, status string) _ = uuid.NewString() } -// TestWorkspaceSearch_SurfacesPendingRepos verifies that repos whose -// workspace_repos.status ≠ 'indexed' are reported back in -// `pending_repos` instead of being silently dropped. The dashboard -// uses this to render a "still indexing" banner — without it the -// operator sees a partial result set with no hint that anything's -// missing. +// TestWorkspaceSearch_SurfacesPendingRepos verifies that projects whose +// projects.status ≠ 'indexed' are reported back in `pending_repos` +// instead of being silently dropped. The dashboard uses this to render +// a "still indexing" banner — without it the operator sees a partial +// result set with no hint that anything's missing. func TestWorkspaceSearch_SurfacesPendingRepos(t *testing.T) { d, err := dbOpenMemory(t) if err != nil { diff --git a/server/internal/workspaceprojects/workspaceprojects.go b/server/internal/workspaceprojects/workspaceprojects.go index e816331..95e7823 100644 --- a/server/internal/workspaceprojects/workspaceprojects.go +++ b/server/internal/workspaceprojects/workspaceprojects.go @@ -47,49 +47,70 @@ func New(db *sql.DB) *Service { return &Service{DB: db} } // The project must exist and be in status='indexed' so workspace search // has something to fan out to. Duplicates return ErrDuplicate; missing // targets return ErrWorkspaceMissing / ErrProjectMissing. +// +// Implementation: a single INSERT…SELECT WHERE EXISTS does the precondition +// check and the write in one statement, eliminating the TOCTOU window +// between separate SELECTs and the INSERT. If the insert produces 0 rows, +// preconditions failed — diagnoseLinkFailure runs one SELECT with three +// COUNT subqueries to map "0 rows" → a precise typed error for the API. func (s *Service) Link(ctx context.Context, workspaceID, projectPath string) (Membership, error) { - // Existence checks up front so the caller gets a precise error - // instead of a raw FK violation. - var wsCount int - if err := s.DB.QueryRowContext(ctx, - `SELECT COUNT(*) FROM workspaces WHERE id = ?`, workspaceID, - ).Scan(&wsCount); err != nil { - return Membership{}, fmt.Errorf("check workspace: %w", err) - } - if wsCount == 0 { - return Membership{}, ErrWorkspaceMissing - } - - var status sql.NullString - if err := s.DB.QueryRowContext(ctx, - `SELECT status FROM projects WHERE host_path = ?`, projectPath, - ).Scan(&status); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return Membership{}, ErrProjectMissing - } - return Membership{}, fmt.Errorf("check project: %w", err) - } - if status.String != "indexed" { - return Membership{}, ErrProjectNotIndexed - } - now := time.Now().UTC().Format(time.RFC3339Nano) - if _, err := s.DB.ExecContext(ctx, ` + res, err := s.DB.ExecContext(ctx, ` INSERT INTO workspace_projects (workspace_id, project_path, added_at) - VALUES (?, ?, ?)`, - workspaceID, projectPath, now, - ); err != nil { + SELECT ?, ?, ? + WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?) + AND EXISTS (SELECT 1 FROM projects WHERE host_path = ? AND status = 'indexed')`, + workspaceID, projectPath, now, workspaceID, projectPath, + ) + if err != nil { if isUniqueConstraintViolation(err) { return Membership{}, ErrDuplicate } return Membership{}, fmt.Errorf("insert workspace_project: %w", err) } - addedAt, _ := time.Parse(time.RFC3339Nano, now) - return Membership{ - WorkspaceID: workspaceID, - ProjectPath: projectPath, - AddedAt: addedAt, - }, nil + n, _ := res.RowsAffected() + if n == 1 { + addedAt, _ := time.Parse(time.RFC3339Nano, now) + return Membership{ + WorkspaceID: workspaceID, + ProjectPath: projectPath, + AddedAt: addedAt, + }, nil + } + return Membership{}, s.diagnoseLinkFailure(ctx, workspaceID, projectPath) +} + +// diagnoseLinkFailure runs after a 0-row INSERT to map the failure to a +// typed error. One round-trip with three COUNT subqueries; cheaper than +// separate queries and the values are mutually independent so subquery +// ordering doesn't matter. +// +// TOCTOU edge case: if state flipped to "all preconditions met" between +// the failed INSERT and this query (rare — would need a concurrent index +// completing or a workspace being created in that window), all three +// counts come back non-zero. We return a generic transient error so the +// caller can retry — better than fabricating a misleading typed error. +func (s *Service) diagnoseLinkFailure(ctx context.Context, workspaceID, projectPath string) error { + var wsExists, projExists, projIndexed int + if err := s.DB.QueryRowContext(ctx, ` + SELECT + (SELECT COUNT(*) FROM workspaces WHERE id = ?), + (SELECT COUNT(*) FROM projects WHERE host_path = ?), + (SELECT COUNT(*) FROM projects WHERE host_path = ? AND status = 'indexed')`, + workspaceID, projectPath, projectPath, + ).Scan(&wsExists, &projExists, &projIndexed); err != nil { + return fmt.Errorf("diagnose link failure: %w", err) + } + switch { + case wsExists == 0: + return ErrWorkspaceMissing + case projExists == 0: + return ErrProjectMissing + case projIndexed == 0: + return ErrProjectNotIndexed + default: + return fmt.Errorf("link failed with no precondition violation (concurrent state change); retry") + } } // Unlink removes a (workspace_id, project_path) row. Returns ErrNotFound diff --git a/server/internal/workspaceprojects/workspaceprojects_test.go b/server/internal/workspaceprojects/workspaceprojects_test.go index 2b87017..b1053f4 100644 --- a/server/internal/workspaceprojects/workspaceprojects_test.go +++ b/server/internal/workspaceprojects/workspaceprojects_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "sync" "testing" "time" @@ -188,6 +189,96 @@ func TestListByProject(t *testing.T) { } } +// TestLink_ConcurrentDistinctWorkspaces — two goroutines link the same +// indexed project to TWO different workspaces in parallel. Both inserts +// hit different primary-key tuples, so the expected outcome is 2 ✓ Link +// returns / 0 spurious errors. Acceptance criterion for Fix #6 +// (one-statement atomic precondition + insert). +func TestLink_ConcurrentDistinctWorkspaces(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsA := seedWorkspace(t, d, "alpha") + wsB := seedWorkspace(t, d, "beta") + seedIndexedProject(t, d, "github.com/x/y@main") + + var ( + wg sync.WaitGroup + errs [2]error + members [2]Membership + ) + wg.Add(2) + go func() { + defer wg.Done() + members[0], errs[0] = svc.Link(ctx, wsA, "github.com/x/y@main") + }() + go func() { + defer wg.Done() + members[1], errs[1] = svc.Link(ctx, wsB, "github.com/x/y@main") + }() + wg.Wait() + + for i, e := range errs { + if e != nil { + t.Errorf("goroutine %d: unexpected error: %v", i, e) + } + if members[i].AddedAt.IsZero() { + t.Errorf("goroutine %d: AddedAt not populated", i) + } + } + // Sanity: both rows landed in the DB. + var n int + if err := d.QueryRowContext(ctx, + `SELECT COUNT(*) FROM workspace_projects WHERE project_path = ?`, + "github.com/x/y@main", + ).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 2 { + t.Errorf("expected 2 rows in workspace_projects, got %d", n) + } +} + +// TestLink_ConcurrentSameWorkspace — two goroutines race to link the +// SAME project to the SAME workspace. Exactly one must win (returns +// ErrDuplicate from the loser) — never 2 successes (would violate PK) +// and never a spurious error mapped to ErrWorkspaceMissing / +// ErrProjectMissing. Pins the diagnostic path's correctness under +// genuine PK contention. +func TestLink_ConcurrentSameWorkspace(t *testing.T) { + d, svc := mustOpen(t) + ctx := context.Background() + wsID := seedWorkspace(t, d, "platform") + seedIndexedProject(t, d, "github.com/x/y@main") + + var ( + wg sync.WaitGroup + errs [2]error + ) + wg.Add(2) + for i := range errs { + go func() { + defer wg.Done() + _, errs[i] = svc.Link(ctx, wsID, "github.com/x/y@main") + }() + } + wg.Wait() + + successes, duplicates := 0, 0 + for _, e := range errs { + switch { + case e == nil: + successes++ + case errors.Is(e, ErrDuplicate): + duplicates++ + default: + t.Errorf("unexpected error %v (want nil or ErrDuplicate)", e) + } + } + if successes != 1 || duplicates != 1 { + t.Errorf("expected 1 success + 1 ErrDuplicate, got %d / %d", successes, duplicates) + } +} + // TestDeletingProject_CascadesMembership confirms the schema's FK // ON DELETE CASCADE actually fires: removing the projects row drops // every workspace_projects row referencing it. From db1b56d2203d258ffac0babb65c07c74ea58b540 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:57:24 +0100 Subject: [PATCH 08/11] fix(server): tighten webhook validation + zero PAT after use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validHMAC now rejects empty-secret signatures upfront. The WebhookSecret column is NOT NULL, but the explicit check guards against accidental empty strings in future regressions. New TestValidHMAC_RejectsEmptySecret. - workspacejobs zeroes the decrypted PAT after the repocloner.CloneOrFetch call via deferred clear. Best-effort documentation of intent — Go GC may still hold copies. - OpenAPI: document that GET /projects/{hash}/webhook-info returns 404 for local projects (no git_repos row), so callers can distinguish "project missing" from "expected — local project". Resolves Fix #11, #12, #13. Co-Authored-By: Claude Opus 4.7 --- doc/openapi.yaml | 46 +- .../internal/httpapi/openapi/openapi.gen.go | 693 +++++++++--------- server/internal/httpapi/webhooks.go | 11 + server/internal/httpapi/webhooks_test.go | 70 ++ .../internal/workspacejobs/workspacejobs.go | 11 + 5 files changed, 472 insertions(+), 359 deletions(-) diff --git a/doc/openapi.yaml b/doc/openapi.yaml index b8d73e4..c4585e1 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -733,8 +733,8 @@ paths: the user can click to jump to the workspace detail page. Empty list when the project isn't part of any workspace yet — - either it was indexed directly via /projects rather than via a - workspace_repo, or all its memberships have been detached. + either it was indexed directly via /projects without ever being + linked, or all its memberships have been detached. responses: "200": description: Memberships @@ -1237,9 +1237,11 @@ paths: tags: [workspaces] summary: Delete a workspace description: | - Removes the workspace row. PR1 has nothing else to cascade - (workspace_repos lands in PR2); future PRs will cascade repos, - communities, and the centroid Chroma collection. + Removes the workspace row. workspace_projects memberships + referencing this workspace are removed via ON DELETE CASCADE; + the underlying projects, git_repos peers, and on-disk clones + are preserved (delete those explicitly via /projects/{path} + when desired). responses: "204": description: Deleted @@ -1507,8 +1509,16 @@ paths: summary: Webhook URL + secret for manual GitHub setup description: | Returns the publicly-reachable webhook URL and the HMAC secret - for an external project. 404 when the project is local (no - webhook lifecycle). + for an external (GitHub-cloned) project. Only projects with a + `git_repos` peer participate in webhook delivery — local-path + projects have no clone lifecycle and no webhook. + + The handler returns 404 in two distinct cases, indistinguishable + on the wire: (a) the `path_hash` doesn't resolve to any project + at all, and (b) the project exists but is local. Callers that + need to disambiguate must query `GET /projects/{hash}` first to + confirm existence, then treat a subsequent webhook-info 404 as + "local project, no webhook to configure". responses: "200": description: Webhook coordinates @@ -1519,7 +1529,14 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - $ref: "#/components/responses/NotFound" + description: | + Either the project does not exist, OR the project exists but + is local (no `git_repos` row, no webhook to surface). + Disambiguate by checking `GET /projects/{hash}` first. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "503": $ref: "#/components/responses/WorkspacesDisabled" @@ -1728,9 +1745,11 @@ paths: summary: Delete a stored GitHub PAT description: | Permanently removes the encrypted blob. Subsequent workspaces - operations that reference this token id will fail. PR1 does not - block deletion when the token is referenced — workspace_repos - landing in PR2 will introduce that FK. + operations that reference this token id will fail. The + git_repos.token_id FK uses ON DELETE SET NULL, so existing + rows survive token revocation but their re-clone / webhook + flows that need GitHub auth will fail until a token is + re-attached. responses: "204": description: Deleted @@ -3585,9 +3604,8 @@ components: type: string enum: [pending, cloning, indexing, failed] description: | - Current row state in `workspace_repos.status`. Anything - other than `indexed` means the repo hasn't contributed to - this response. + Current per-project status. Anything other than `indexed` + means the project hasn't contributed to this response. WorkspaceSearchFailedRepo: type: object diff --git a/server/internal/httpapi/openapi/openapi.gen.go b/server/internal/httpapi/openapi/openapi.gen.go index 909d32f..595ebd4 100644 --- a/server/internal/httpapi/openapi/openapi.gen.go +++ b/server/internal/httpapi/openapi/openapi.gen.go @@ -1596,15 +1596,13 @@ type WorkspaceSearchFailedRepo struct { type WorkspaceSearchPendingRepo struct { ProjectPath string `json:"project_path"` - // Status Current row state in `workspace_repos.status`. Anything - // other than `indexed` means the repo hasn't contributed to - // this response. + // Status Current per-project status. Anything other than `indexed` + // means the project hasn't contributed to this response. Status WorkspaceSearchPendingRepoStatus `json:"status"` } -// WorkspaceSearchPendingRepoStatus Current row state in `workspace_repos.status`. Anything -// other than `indexed` means the repo hasn't contributed to -// this response. +// WorkspaceSearchPendingRepoStatus Current per-project status. Anything other than `indexed` +// means the project hasn't contributed to this response. type WorkspaceSearchPendingRepoStatus string // WorkspaceSearchProject defines model for WorkspaceSearchProject. @@ -4457,345 +4455,350 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", + "AwzX2ubUey5/+fnL/xcAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/server/internal/httpapi/webhooks.go b/server/internal/httpapi/webhooks.go index 76ac916..92cd123 100644 --- a/server/internal/httpapi/webhooks.go +++ b/server/internal/httpapi/webhooks.go @@ -159,7 +159,18 @@ func (s *Server) ReceiveGithubWebhook(w http.ResponseWriter, r *http.Request, ha // validHMAC returns true when the given header matches HMAC-SHA256(body, secret). // Header format is "sha256=" per GitHub's spec. +// +// An empty secret is rejected before any computation: HMAC keyed with the +// empty string is a fixed, well-known value over any body (an attacker +// who never saw our secret can forge a matching header). This case +// should be unreachable — git_repos.webhook_secret is NOT NULL and the +// add-repo handler always populates it with a random 32-byte secret — +// but defending here makes the invariant explicit and removes the +// "what if a future caller passes nil" footgun. func validHMAC(body, secret []byte, header string) bool { + if len(secret) == 0 { + return false + } header = strings.TrimSpace(header) const prefix = "sha256=" if !strings.HasPrefix(header, prefix) { diff --git a/server/internal/httpapi/webhooks_test.go b/server/internal/httpapi/webhooks_test.go index 89922a3..35173a7 100644 --- a/server/internal/httpapi/webhooks_test.go +++ b/server/internal/httpapi/webhooks_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -134,6 +135,38 @@ func TestWebhook_MissingSignatureRejected(t *testing.T) { } } +// TestValidHMAC_RejectsEmptySecret pins the empty-secret guard added in +// Fix #13. HMAC keyed with the empty string returns a fixed, attacker- +// computable digest for any body; if validHMAC accepted that case, a +// caller who forgot to load the secret (or a future bug that passed +// nil) would silently authenticate every delivery. We pre-compute the +// "correct" HMAC with the empty key over a known body and assert the +// function STILL rejects it — the empty-secret short-circuit must fire +// before the constant-time compare, never after. +func TestValidHMAC_RejectsEmptySecret(t *testing.T) { + body := []byte(`{"ref":"refs/heads/main"}`) + // Compute what the header WOULD be if we naively HMAC'd with "". + mac := hmac.New(sha256.New, []byte("")) + mac.Write(body) + forged := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if validHMAC(body, nil, forged) { + t.Error("validHMAC accepted a nil-secret HMAC — guard missing") + } + if validHMAC(body, []byte(""), forged) { + t.Error("validHMAC accepted an empty-byte-slice secret HMAC — guard missing") + } + + // Sanity: with a real secret + matching signature it still works. + real := []byte("real-secret") + mac2 := hmac.New(sha256.New, real) + mac2.Write(body) + good := "sha256=" + hex.EncodeToString(mac2.Sum(nil)) + if !validHMAC(body, real, good) { + t.Error("validHMAC rejected a correctly-signed body with a real secret — guard over-broad") + } +} + func TestWebhook_UnknownHashReturns404(t *testing.T) { router, _ := reposRouter(t) body := []byte(`{}`) @@ -218,6 +251,43 @@ func TestAddGitRepo_AutoRegisterFailsCleanlyWithoutPublicURL(t *testing.T) { } } +// TestGetProjectWebhookInfo_LocalProject_Returns404 verifies that the +// webhook-info endpoint returns 404 when the project exists but has no +// `git_repos` peer (i.e. it's a local-path project tracked only in the +// `projects` table). Local projects don't go through the clone + +// webhook lifecycle, so there's no URL or secret to surface. The +// operation contract documented in doc/openapi.yaml requires callers +// to disambiguate "project missing" vs "project local" by first +// hitting GET /projects/{hash} — this test pins the local-project arm +// of the 404 response so the contract doesn't drift. +func TestGetProjectWebhookInfo_LocalProject_Returns404(t *testing.T) { + router, _, d := reposRouterDB(t) + + // Seed a local project — absolute filesystem path, no git_repos peer. + hostPath := "/Users/x/local-proj" + hash := projects.HashPath(hostPath) + if _, err := d.Exec(` + INSERT INTO projects (host_path, container_path, languages, settings, stats, + status, created_at, updated_at, path_hash) + VALUES (?, ?, '[]', '{}', '{}', 'indexed', '2026-05-14T00:00:00Z', '2026-05-14T00:00:00Z', ?)`, + hostPath, hostPath, hash, + ); err != nil { + t.Fatalf("seed local project: %v", err) + } + + rr := doJSON(t, router, http.MethodGet, "/api/v1/projects/"+hash+"/webhook-info", nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 for local project webhook-info, got %d (%s)", rr.Code, rr.Body.String()) + } + // Body should mention "local" or "no git_repos" so an operator + // reading the API response (or curl output) understands why this + // project doesn't have webhook coordinates. + body := rr.Body.String() + if !strings.Contains(body, "local") && !strings.Contains(body, "git_repos") { + t.Errorf("404 body should hint at the local-project cause, got: %s", body) + } +} + func TestWebhookInfo_ReturnsURLAndSecret(t *testing.T) { router, _ := reposRouter(t) // AddGitRepo includes the secret in the create response. diff --git a/server/internal/workspacejobs/workspacejobs.go b/server/internal/workspacejobs/workspacejobs.go index 090b853..8f2ab95 100644 --- a/server/internal/workspacejobs/workspacejobs.go +++ b/server/internal/workspacejobs/workspacejobs.go @@ -31,6 +31,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "time" "github.com/dvcdsys/code-index/server/internal/githubtokens" @@ -128,6 +129,16 @@ func handleClone(ctx context.Context, d Deps, job jobs.Job) error { return terr } pat = token + // Best-effort intent signal: rebind `pat` to a zero-filled string + // on function exit. Go strings are immutable, so this does NOT + // wipe the underlying bytes — the original allocation from + // Reveal/Decrypt may still be reachable from escape-analyzed + // copies (e.g. inside repocloner's HTTP basic-auth header). The + // gesture matters for readability + intent (PAT is sensitive, + // don't hold it longer than needed), not as a security control. + // Switching PAT to []byte with explicit wipe via crypto/subtle + // would be overkill for this code path. + defer func() { pat = strings.Repeat("\x00", len(pat)) }() _ = d.GithubTokens.Touch(ctx, g.TokenID) } From dd464758c8fd8744089dba2c36bce991a3690684 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 16:57:33 +0100 Subject: [PATCH 09/11] docs(workspaces): align prose with new schema + dedupe SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the workspace_repos → git_repos + workspace_projects split, docs and stale comments still referenced concepts that no longer exist. - workspaces.md + doc/WORKSPACES.md: rewrite lifecycle, REST API reference, webhook URL examples, and clone-dir paths to use projects.path_hash, /git-repos, /workspaces/{id}/projects, and /projects/{hash}/reindex. - Both skills/cix-workspace/SKILL.md and plugins/cix/skills/cix-workspace/SKILL.md: replace /workspaces/{id}/repos/{repo_id}/reindex with /projects/{hash}/reindex. New plugins/cix/scripts/sync-skills.sh keeps the duplicate copies byte-identical going forward. - Code comments updated across config.go, callgraph.go, repocloner.go, githubapi.go, workspaces.go, and AddExistingProjectDialog.tsx to point at gitrepos / workspace_projects / projects.path_hash instead of the dead workspacerepos.* names. Resolves Fix #8, #9, #10, #19. Co-Authored-By: Claude Opus 4.7 --- doc/WORKSPACES.md | 34 +-- plugins/cix/scripts/sync-skills.sh | 93 ++++++++ plugins/cix/skills/cix-workspace/SKILL.md | 4 +- .../components/AddExistingProjectDialog.tsx | 2 +- server/internal/callgraph/callgraph.go | 2 +- server/internal/config/config.go | 3 +- server/internal/githubapi/githubapi.go | 2 +- server/internal/repocloner/repocloner.go | 6 +- server/internal/workspaces/workspaces.go | 15 +- skills/cix-workspace/SKILL.md | 5 +- workspaces.md | 221 +++++++++++------- 11 files changed, 275 insertions(+), 112 deletions(-) create mode 100755 plugins/cix/scripts/sync-skills.sh diff --git a/doc/WORKSPACES.md b/doc/WORKSPACES.md index 1ae0131..72e21a9 100644 --- a/doc/WORKSPACES.md +++ b/doc/WORKSPACES.md @@ -28,9 +28,9 @@ and troubleshoot the feature in production. branch, optional token, and choose **Auto-register webhook** if your PAT carries `admin:repo_hook`. Otherwise check **I'll set it up myself** and copy the displayed URL + secret into GitHub. -6. The server clones the repo into `//` +6. The server clones the repo into `//` and runs the existing indexer pipeline against it. Status transitions - visible on the workspace detail page: `pending → cloning → indexing → indexed`. + visible on the workspace detail page: `created → indexing → indexed`. ## Environment variables @@ -62,11 +62,11 @@ convenience for dev. ## Webhooks -GitHub deliveries hit `POST /api/v1/webhooks/github/`. +GitHub deliveries hit `POST /api/v1/webhooks/github/`. The endpoint is **public** in the auth sense (no Bearer/session check) but every delivery is HMAC-SHA256-validated against the per-row -`webhook_secret`. The secret is shown exactly once on add-repo and on -**Workspaces → Repo → Webhook info**. +`webhook_secret` stored on the matching `git_repos` row. The secret is +shown exactly once on add-repo and on **Project → Webhook info**. Supported events: @@ -115,7 +115,7 @@ production but perfect for the first end-to-end smoke test. ### Manual webhook setup -If `auto_webhook=false` (default) the dashboard surfaces the URL + secret +If `webhook_mode=manual` (default) the dashboard surfaces the URL + secret after add-repo. Paste them into GitHub: 1. Repo → **Settings → Webhooks → Add webhook** @@ -130,12 +130,12 @@ GitHub's webhook page will mark the delivery green. ### Auto-register -When the PAT carries `admin:repo_hook` scope and `auto_webhook=true`, -the server calls `POST /repos/{owner}/{repo}/hooks` on your behalf -during add-repo and persists the resulting hook id (used to -de-register on delete). Failure is non-fatal — the response includes -`auto_registered: false` and an operator-facing note explaining the -specific reason (missing scope, network error, etc.). +When the PAT carries `admin:repo_hook` scope and `webhook_mode=auto`, +the server uses GitHub's hooks API on your behalf during add-repo and +persists the resulting hook id (used to de-register on delete). Failure +is non-fatal — the response includes `auto_registered: false` and an +operator-facing note explaining the specific reason (missing scope, +network error, etc.). ## Background workers @@ -157,17 +157,17 @@ Future PRs add `build_call_graph` and `compute_workspace_communities`. ## Troubleshooting -- **Status stuck at `cloning`** — check `GET /jobs?status=running` and +- **Status stuck at `indexing`** — check `GET /jobs?status=running` and the cix-server logs. Most common cause: PAT missing `repo` scope on a private repo, or network not reaching github.com. -- **Status stuck at `failed`** with `last_error` set — the message - comes directly from go-git or the indexer. Common fixes: rotate the - PAT, confirm the branch name, verify the runtime model is loaded +- **Status stuck at `error`** — the underlying job's error message is + surfaced on the project detail page. Common fixes: rotate the PAT, + confirm the branch name, verify the runtime model is loaded (`GET /api/v1/admin/sidecar/status`). - **Webhook deliveries returning 401** — the secret in GitHub doesn't match what cix stored. Click **Webhook info** in the dashboard to see the canonical value, paste again. Secrets rotate when the - workspace_repo is recreated. + git_repos row is recreated. - **Encryption key mismatch on startup** — operator-readable error in the boot log. Recover the prior `CIX_SECRET_KEY` from your secrets manager or wipe `github_tokens` manually before retrying. diff --git a/plugins/cix/scripts/sync-skills.sh b/plugins/cix/scripts/sync-skills.sh new file mode 100755 index 0000000..1a520d6 --- /dev/null +++ b/plugins/cix/scripts/sync-skills.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# sync-skills.sh — keep plugin-bundled skill files byte-identical with +# the canonical sources under skills/. +# +# Fix #19 acceptance: the plugin ships byte-identical copies of files +# that have a single source of truth elsewhere in the repo. Without +# this script, contributors edit one file and forget the mirror; the +# two diverge silently until someone runs cix-workspace via the plugin +# and gets a stale workflow. +# +# Files in scope (canonical source → plugin bundle destination): +# +# skills/cix-workspace/SKILL.md +# → plugins/cix/skills/cix-workspace/SKILL.md +# +# skills/cix-workspace/agents/cix-workspace-investigator.md +# → plugins/cix/agents/cix-workspace-investigator.md +# +# Out of scope: skills/cix/SKILL.md vs plugins/cix/skills/cix/SKILL.md — +# those are INTENTIONALLY different. The plugin version carries extra +# frontmatter (description, when_to_use, allowed-tools) the standalone +# skill loader doesn't need; treating them as drift would be wrong. +# +# Usage: +# sync-skills.sh # copy source → plugin, print what changed +# sync-skills.sh --check # diff only, exit 1 on drift (for CI / pre-commit) + +set -euo pipefail + +# Resolve repo root from the script's location so the script works no +# matter where it's invoked from (CI, IDE task runner, manual cd). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# (source, destination) pairs. Bash 3.2-compatible parallel arrays so +# this also runs on macOS's default /bin/bash. +SRC=( + "skills/cix-workspace/SKILL.md" + "skills/cix-workspace/agents/cix-workspace-investigator.md" +) +DST=( + "plugins/cix/skills/cix-workspace/SKILL.md" + "plugins/cix/agents/cix-workspace-investigator.md" +) + +MODE="copy" +if [[ "${1:-}" == "--check" ]]; then + MODE="check" +elif [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + sed -n '2,32p' "$0" + exit 0 +elif [[ -n "${1:-}" ]]; then + echo "sync-skills.sh: unknown argument: $1" >&2 + echo "Run with --help for usage." >&2 + exit 2 +fi + +drift=0 +for i in "${!SRC[@]}"; do + src="$REPO_ROOT/${SRC[$i]}" + dst="$REPO_ROOT/${DST[$i]}" + + if [[ ! -f "$src" ]]; then + echo "sync-skills.sh: source missing: $src" >&2 + exit 3 + fi + + if [[ "$MODE" == "check" ]]; then + # Skip the copy; just compare. -q suppresses output, exit code 0 + # = identical, 1 = differs, 2 = error. + if ! diff -q "$src" "$dst" >/dev/null 2>&1; then + echo "drift: ${SRC[$i]} != ${DST[$i]}" >&2 + drift=1 + fi + continue + fi + + # Copy mode — only act when the destination differs, so the log + # only mentions files that actually changed. cmp -s is the standard + # "are these byte-identical" test (returns 0 for identical, 1 for + # different, 2 for I/O error). + if ! cmp -s "$src" "$dst"; then + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + echo "synced: ${SRC[$i]} → ${DST[$i]}" + fi +done + +if [[ "$MODE" == "check" && $drift -ne 0 ]]; then + echo "" >&2 + echo "Run plugins/cix/scripts/sync-skills.sh (no args) to fix." >&2 + exit 1 +fi diff --git a/plugins/cix/skills/cix-workspace/SKILL.md b/plugins/cix/skills/cix-workspace/SKILL.md index e7ce03a..276aa95 100644 --- a/plugins/cix/skills/cix-workspace/SKILL.md +++ b/plugins/cix/skills/cix-workspace/SKILL.md @@ -492,8 +492,8 @@ fan-out — the same algorithm that produces the false-positive failure mode described in the worked example above. The response includes `stale_fts_repos` listing the affected -project_paths. Fix: reindex each repo (dashboard → repo card → -reindex button, or `POST /api/v1/workspaces/{id}/repos/{repo_id}/reindex`). +project_paths. Fix: reindex each project (dashboard → project card → +reindex button, or `POST /api/v1/projects/{hash}/reindex`). After reindex, BM25 populates incrementally per-file as chunks are written. diff --git a/server/dashboard/src/modules/workspaces/components/AddExistingProjectDialog.tsx b/server/dashboard/src/modules/workspaces/components/AddExistingProjectDialog.tsx index 131ced5..8ffaf66 100644 --- a/server/dashboard/src/modules/workspaces/components/AddExistingProjectDialog.tsx +++ b/server/dashboard/src/modules/workspaces/components/AddExistingProjectDialog.tsx @@ -40,7 +40,7 @@ function disabledLabel(r: LinkDisabledReason): string { // rows are rendered as disabled with a short reason so the operator // understands why they can't be picked. // -// Submit fans out N POSTs to /workspaces/{id}/repos/link sequentially. +// Submit fans out N POSTs to /workspaces/{id}/projects sequentially. // We chose sequential over parallel because: (a) it makes per-project // error reporting trivial, (b) the per-call cost is tiny (no clone, no // index), (c) a backend hiccup mid-batch leaves the workspace in a diff --git a/server/internal/callgraph/callgraph.go b/server/internal/callgraph/callgraph.go index 75a9a9d..fd0da66 100644 --- a/server/internal/callgraph/callgraph.go +++ b/server/internal/callgraph/callgraph.go @@ -305,7 +305,7 @@ func loadCallees(ctx context.Context, db *sql.DB, projectPath string) (map[strin } // CountEdges returns the number of rows in call_edges for a project. -// Used by /api/v1/workspaces/{id}/repos to surface graph completion +// Used by /api/v1/workspaces/{id}/projects to surface graph completion // state in the dashboard ("graph: 1234 edges"). func CountEdges(ctx context.Context, db *sql.DB, projectPath string) (int, error) { var n int diff --git a/server/internal/config/config.go b/server/internal/config/config.go index b2e1bb9..a900204 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -122,7 +122,8 @@ type Config struct { SecretsDataDir string // WorkspacesDataDir is the parent directory the worker pool clones - // GitHub repositories under (workspace_repos.{id}/). Defaults to + // GitHub repositories under (each clone lives at + // //). Defaults to // /repos. Source: CIX_WORKSPACES_DATA_DIR. WorkspacesDataDir string diff --git a/server/internal/githubapi/githubapi.go b/server/internal/githubapi/githubapi.go index f30ee4c..b577f5a 100644 --- a/server/internal/githubapi/githubapi.go +++ b/server/internal/githubapi/githubapi.go @@ -554,7 +554,7 @@ func githubMessage(body []byte) string { } // ParseOwnerRepo extracts {owner, repo} from an https://github.com/owner/repo URL. -// Mirrors the same logic as workspacerepos.parseGitHubURL but kept private +// Mirrors the same logic as gitrepos.parseGitHubURL but kept private // to that package — we re-implement here to avoid an import cycle. func ParseOwnerRepo(githubURL string) (owner, repo string, err error) { u, perr := url.Parse(strings.TrimSpace(githubURL)) diff --git a/server/internal/repocloner/repocloner.go b/server/internal/repocloner/repocloner.go index fef0bea..d8286c0 100644 --- a/server/internal/repocloner/repocloner.go +++ b/server/internal/repocloner/repocloner.go @@ -11,7 +11,7 @@ // - Fetch + reset to remote HEAD on subsequent runs // - Report the current HEAD SHA (for last_sha bookkeeping) // - Resolve a "github.com/owner/repo" + branch to a deterministic local -// directory under DataDir/repos/{repo_id}/ +// directory under DataDir/repos/{path_hash}/ // // Errors are deliberately coarse — the worker pool surfaces them in the // job row and the dashboard renders them verbatim. There's no point @@ -62,8 +62,8 @@ type Result struct { // after the operation completes. // // The caller is responsible for choosing a LocalDir that won't collide -// across repos — typically `/repos//` keyed by the -// workspace_repos row id (NOT the github URL, which can change with +// across repos — typically `/repos//` keyed by +// projects.path_hash (NOT the github URL, which can change with // rename + redirect). func CloneOrFetch(ctx context.Context, opts CloneOptions) (Result, error) { if strings.TrimSpace(opts.GitHubURL) == "" { diff --git a/server/internal/workspaces/workspaces.go b/server/internal/workspaces/workspaces.go index efb7cf7..38f5a8a 100644 --- a/server/internal/workspaces/workspaces.go +++ b/server/internal/workspaces/workspaces.go @@ -1,12 +1,15 @@ // Package workspaces is the service layer for the workspaces table — the // top-level entity of the workspaces feature. A workspace groups one or -// more GitHub repos for cross-project semantic search powered by -// community-detection on the call graph (PRs 2–7 of the feature branch). +// more projects (each backed by an indexed `projects` row, optionally +// with a `git_repos` peer for GitHub-cloned projects) for cross-project +// semantic search powered by hybrid BM25 + dense fan-out across the +// memberships in `workspace_projects`. // -// PR1 scope: bare CRUD. workspace_repos / call_edges / communities land in -// later PRs. Visibility model is server-wide shared: every authenticated -// user can list/create/modify any workspace. The decision is captured in -// the workspaces.md plan; revisit if a per-user ACL becomes necessary. +// Scope of this package: bare workspace CRUD. Membership lives in +// `workspaceprojects`; clone + webhook metadata lives in `gitrepos`. +// Visibility model is server-wide shared: every authenticated user can +// list/create/modify any workspace. The decision is captured in the +// workspaces.md plan; revisit if a per-user ACL becomes necessary. package workspaces import ( diff --git a/skills/cix-workspace/SKILL.md b/skills/cix-workspace/SKILL.md index b750f2a..276aa95 100644 --- a/skills/cix-workspace/SKILL.md +++ b/skills/cix-workspace/SKILL.md @@ -2,6 +2,7 @@ name: cix-workspace description: Cross-project research workflow for cix workspaces. Manual-invocation skill — load explicitly via `/cix-workspace ` when a request spans multiple repos and you want the full workflow guidance (which repos? what code? what changes?) plus the trust rules for interpreting workspace search responses. Bundles the cix-workspace-investigator sub-agent for parallel per-repo fan-out. Do not auto-trigger. user-invocable: true +allowed-tools: Bash(cix *), Agent --- # `cix workspace` — Cross-Project Research Workflow @@ -491,8 +492,8 @@ fan-out — the same algorithm that produces the false-positive failure mode described in the worked example above. The response includes `stale_fts_repos` listing the affected -project_paths. Fix: reindex each repo (dashboard → repo card → -reindex button, or `POST /api/v1/workspaces/{id}/repos/{repo_id}/reindex`). +project_paths. Fix: reindex each project (dashboard → project card → +reindex button, or `POST /api/v1/projects/{hash}/reindex`). After reindex, BM25 populates incrementally per-file as chunks are written. diff --git a/workspaces.md b/workspaces.md index 4b26418..72e1ddd 100644 --- a/workspaces.md +++ b/workspaces.md @@ -104,18 +104,27 @@ A user creates a workspace, then attaches repositories to it. A workspace has no built-in access control beyond what the server's auth layer already provides — anyone authenticated can list and search workspaces today. -### Workspace repo - -A row in `workspace_repos` that ties one GitHub repo+branch to a -workspace. Two kinds: - -- **Owned** (`is_linked=0`): the server clones the repo to disk and runs - indexing. Status transitions: `pending → cloning → indexing → indexed` - (or `failed`). These are the "true" workspace repos. -- **Linked** (`is_linked=1`): a lightweight pointer to an *already-indexed* - local project (one that's tracked in the `projects` table because you - `cix init`'d it). No clone, no separate index. Useful for including - your primary repo in a workspace without duplicating data. +### Workspace project (membership) + +A row in `workspace_projects` that ties one indexed project to a +workspace. Both the `projects` row and the workspace must already exist +— linking is the act of declaring "this project participates in this +workspace's cross-project search". Two underlying project kinds make it +into a workspace: + +- **GitHub-cloned project** — backed by a row in the `git_repos` table. + The server cloned the repo to disk and indexed it. `host_path` looks + like `github.com/owner/repo@branch`. Its lifecycle (clone, index, + reindex, webhook) lives in the `projects` row's `status` column. +- **Local-path project** — backed only by the `projects` row, no + `git_repos` peer. Created with `cix init` against an absolute filesystem + path, indexed by the local CLI / file watcher rather than the server's + clone pipeline. Useful for including your primary repo in a workspace + without duplicating data. + +Both kinds are linked into a workspace identically — there is no +`is_linked` column anymore. The distinction is "does a `git_repos` row +exist for this project?". ### GitHub token @@ -178,13 +187,15 @@ must rotate. ### 4. Attach a repo -Dashboard: open the workspace → **Add repository** → walk through the -staged dialog (token → account/org → repo → branch → webhook mode). +Dashboard: open the workspace → **Add GitHub repository** → walk through +the staged dialog (token → account/org → repo → branch → webhook mode). -Or: +Or via the API — register the project + clone metadata, then link it +into the workspace: ```bash -curl -X POST http://localhost:21847/api/v1/workspaces//repos \ +# Step 1 — register the project + git_repos row (kicks off clone + index). +curl -X POST http://localhost:21847/api/v1/git-repos \ -H "Authorization: Bearer $CIX_API_KEY" \ -d '{ "github_url":"https://github.com/acme/api-server", @@ -192,18 +203,23 @@ curl -X POST http://localhost:21847/api/v1/workspaces//repos \ "token_id":"abc-123", "webhook_mode":"manual" }' -# → {"id":"...","status":"pending","project_path":"github.com/acme/api-server@main",...} +# → {"path_hash":"abc1234567890def","project_path":"github.com/acme/api-server@main","status":"created",...} + +# Step 2 — link the new path_hash into the workspace. +curl -X POST http://localhost:21847/api/v1/workspaces//projects \ + -H "Authorization: Bearer $CIX_API_KEY" \ + -d '{"path_hash":"abc1234567890def"}' ``` -Status will transition through `cloning → indexing → indexed` over the +Status will transition through `created → indexing → indexed` over the next minutes (depends on repo size + embedding throughput). ### 5. Watch the indexing progress ```bash curl -H "Authorization: Bearer $CIX_API_KEY" \ - http://localhost:21847/api/v1/workspaces//repos -# Look for `status: "indexed"` per repo. + http://localhost:21847/api/v1/workspaces//projects +# Look for `project.status: "indexed"` per project. ``` ### 6. Search @@ -226,20 +242,25 @@ curl -G -H "Authorization: Bearer $CIX_API_KEY" \ ## Adding repositories -### Owned vs linked +### GitHub-cloned vs local-path projects -| | Owned repo (`is_linked=0`) | Linked project (`is_linked=1`) | +| | GitHub-cloned project | Local-path project | |---|---|---| | Source | GitHub clone | Existing `cix init`'d local project | -| Clone path | `/repos//` | n/a (uses original) | +| Clone path | `` → `repos` → `` | n/a (uses original) | +| Backing tables | `projects` + `git_repos` | `projects` only | | Index lifecycle | Server-managed | Whatever the user runs locally | | Indexed by | Server's index pipeline | `cix init` / `cix watch` | | Webhooks | Supported | Not applicable | -| API | `POST /workspaces/{id}/repos` | `POST /workspaces/{id}/repos/link` | -| Dashboard | **Add repository** button | **Link existing project** button | +| Created via | `POST /api/v1/git-repos` | `POST /api/v1/projects` (or `cix init` locally) | +| Linked into workspace via | `POST /api/v1/workspaces/{id}/projects` | `POST /api/v1/workspaces/{id}/projects` | +| Dashboard | **Add GitHub repository** button | **Link existing project** button | -Use **linked** when the primary project you're working in should appear -in the workspace search but you don't want a second clone. +Both kinds are linked into a workspace through the same membership +endpoint; the only difference is which table owns the project's +clone-and-webhook metadata. Use a local-path project when the primary +project you're working in should appear in the workspace search but you +don't want a second clone. ### From the dashboard @@ -255,13 +276,21 @@ The **Add repository** dialog is staged: 5. **Pick a webhook mode** — `manual` / `auto` / `disabled`. See [Webhooks](#webhooks-auto-reindex-on-push). -The dialog calls `POST /workspaces/{id}/repos` at the end. The clone + -index job runs in the background. +The dialog calls `POST /api/v1/git-repos` to register the project + +clone metadata, then `POST /api/v1/workspaces/{id}/projects` to link +the resulting `path_hash` into the workspace. The clone + index job +runs in the background. ### From the API +Registering a GitHub-cloned project is a two-step flow: create the +project (with its `git_repos` peer) via `POST /git-repos`, then link +the resulting `path_hash` into the workspace via +`POST /workspaces/{id}/projects`. + ```bash -curl -X POST http://localhost:21847/api/v1/workspaces//repos \ +# 1. Register the project + git_repos row (kicks off clone + index). +curl -X POST http://localhost:21847/api/v1/git-repos \ -H "Authorization: Bearer $CIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -270,56 +299,83 @@ curl -X POST http://localhost:21847/api/v1/workspaces//repos \ "token_id": "", "webhook_mode": "manual" }' +# → {"path_hash":"abc1234567890def","project_path":"github.com/owner/repo@main",...} + +# 2. Link the path_hash into the workspace. +curl -X POST http://localhost:21847/api/v1/workspaces//projects \ + -H "Authorization: Bearer $CIX_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"path_hash": "abc1234567890def"}' ``` -Response fields worth knowing: +Response fields worth knowing on the `POST /git-repos` response: -- `id` — workspace_repo UUID (use this for delete / reindex / webhook - endpoints) -- `project_path` — `github.com/owner/repo@branch`, the search identifier -- `status` — starts at `pending`, becomes `indexed` when the pipeline - finishes +- `path_hash` — the 16-hex-char project identifier used by every + per-project endpoint (`/projects/{hash}/reindex`, + `/webhooks/github/{hash}`, etc.). +- `project_path` — `github.com/owner/repo@branch`, the search + identifier the dashboard surfaces. +- `status` — lives on the `projects` row; starts at `created`, + transitions through `indexing` to `indexed` once the pipeline + finishes. - `webhook_secret` — server-generated HMAC secret. Returned exactly once if you set `webhook_mode=manual`. Use it when you configure the webhook on GitHub manually. ### Cloning, indexing, and status transitions -What happens when you add a repo: - -1. **`pending`** — row inserted in `workspace_repos`. Clone job queued. -2. **`cloning`** — server fetches via `git clone` (or `git fetch + - checkout` if the repo is already on disk). Private repos use the - attached token. Result lands at `//`. -3. **`indexing`** — indexer scans the clone with the standard pipeline - (tree-sitter chunking → embeddings → vector store + FTS5 mirror). -4. **`indexed`** — `last_indexed_at` updated, repo is searchable. -5. **`failed`** — clone or index errored out. `last_error` populated. - Common causes: invalid token, repo not found, branch doesn't exist, - embedder unavailable. +`projects.status` tracks the per-project lifecycle. What happens after +`POST /git-repos`: + +1. **`created`** — rows inserted in `projects` and `git_repos`. Clone + job queued. +2. **`indexing`** — server fetches via `git clone` (or `git fetch + + checkout` if the repo is already on disk) into + `//`, then runs the indexer + pipeline (tree-sitter chunking → embeddings → vector store + FTS5 + mirror). Private repos use the attached token. +3. **`indexed`** — `last_indexed_at` updated, project is searchable. +4. **`error`** — clone or index errored out. The dashboard surfaces + the underlying error from the job. Common causes: invalid token, + repo not found, branch doesn't exist, embedder unavailable. Clone + index parallelism: `CIX_WORKER_CONCURRENCY` (default `2`). Increase for fleet onboarding; lower if you saturate disk or GPU. -### Reindexing a single repo +### Reindexing a single project + +Per-project endpoint — the same call reindexes a GitHub-cloned project +or a local-path project, no workspace context needed. ```bash -curl -X POST http://localhost:21847/api/v1/workspaces//repos//reindex \ +curl -X POST http://localhost:21847/api/v1/projects//reindex \ -H "Authorization: Bearer $CIX_API_KEY" ``` Use this after a manual content update, after the embedding model changes, or after the stale-FTS warning (see [Search algorithm](#search-algorithm)). -### Removing a repo +### Unlinking a project from a workspace + +Removes the membership row but leaves the underlying project (and any +clone on disk) intact, so the same project can be re-linked or remain +linked to other workspaces. ```bash -curl -X DELETE http://localhost:21847/api/v1/workspaces//repos/ \ +curl -X DELETE http://localhost:21847/api/v1/workspaces//projects/ \ -H "Authorization: Bearer $CIX_API_KEY" ``` -The clone is deleted from disk; the `projects` row is cleaned up if no -other workspace_repo references it; vectors are removed from chromem. +### Deleting a project entirely + +Removes the `projects` row along with its `git_repos` peer (if any), +the on-disk clone, the vectors, and — via `ON DELETE CASCADE` — every +workspace membership referencing it. + +```bash +curl -X DELETE http://localhost:21847/api/v1/projects/ \ + -H "Authorization: Bearer $CIX_API_KEY" +``` --- @@ -463,8 +519,8 @@ Response shape (abbreviated): }, ... ], - "pending_repos": [...], // repos still cloning / indexing - "failed_repos": [...], // repos that errored out + "pending_repos": [...], // projects still in created / indexing + "failed_repos": [...], // projects in error status "stale_fts_repos": [...] // pre-FTS-mirror repos — reindex } ``` @@ -565,7 +621,7 @@ BM25 will be permanently 0 for it. The response surfaces this via `stale_fts_repos: [{project_path: "..."}]`. Run a reindex on each: ```bash -curl -X POST http://localhost:21847/api/v1/workspaces//repos//reindex \ +curl -X POST http://localhost:21847/api/v1/projects//reindex \ -H "Authorization: Bearer $CIX_API_KEY" ``` @@ -588,11 +644,13 @@ Each workspace repo has a `webhook_mode`: ### Delivery endpoint ``` -POST /api/v1/webhooks/github/{repo_id} +POST /api/v1/webhooks/github/{path_hash} ``` -GitHub's payload is HMAC-SHA256-signed with `webhook_secret`; the -server verifies via the `X-Hub-Signature-256` header. +The `{path_hash}` segment is the same 16-hex-char value returned by +`POST /git-repos` and surfaced on every per-project endpoint. GitHub's +payload is HMAC-SHA256-signed with the matching `git_repos.webhook_secret`; +the server verifies via the `X-Hub-Signature-256` header. Event handling: @@ -609,7 +667,7 @@ When `webhook_mode=manual`, the add-repo response includes a `webhook_secret` (returned once) and a `webhook_url` (always returnable). Configure on GitHub: -- **Payload URL:** `/api/v1/webhooks/github/` +- **Payload URL:** `/api/v1/webhooks/github/` - **Content type:** `application/json` - **Secret:** the returned `webhook_secret` - **Events:** Just `push` (the server ignores everything else) @@ -735,15 +793,22 @@ PATCH /api/v1/workspaces/{id} rename / update description DELETE /api/v1/workspaces/{id} remove (cascades to repos + clones) ``` -### Workspace repos +### Workspace project membership + +``` +GET /api/v1/workspaces/{id}/projects list projects linked to this workspace +POST /api/v1/workspaces/{id}/projects link an existing indexed project (body: {path_hash}) +DELETE /api/v1/workspaces/{id}/projects/{hash} unlink (project + clone preserved) +``` + +### Projects (per-project, workspace-independent) ``` -GET /api/v1/workspaces/{id}/repos list -POST /api/v1/workspaces/{id}/repos add (clones + indexes) -POST /api/v1/workspaces/{id}/repos/link link existing local project -DELETE /api/v1/workspaces/{id}/repos/{repo_id} remove -POST /api/v1/workspaces/{id}/repos/{repo_id}/reindex trigger fresh index -GET /api/v1/workspaces/{id}/repos/{repo_id}/webhook-info dashboard helper +POST /api/v1/git-repos register a new GitHub-cloned project (clones + indexes) +GET /api/v1/projects/{hash}/git-repo git_repos peer of a project (404 for local-path projects) +POST /api/v1/projects/{hash}/reindex trigger a fresh index +GET /api/v1/projects/{hash}/webhook-info dashboard helper — current webhook URL + secret +DELETE /api/v1/projects/{path} delete project + clone + memberships (CASCADE) ``` ### Workspace search @@ -767,7 +832,7 @@ DELETE /api/v1/github-tokens/{id} revoke (server-s ### Webhooks ``` -POST /api/v1/webhooks/github/{repo_id} GitHub delivery endpoint +POST /api/v1/webhooks/github/{hash} GitHub delivery endpoint (HMAC-verified) ``` Full OpenAPI: `doc/openapi.yaml` and `http://:21847/docs`. @@ -780,27 +845,27 @@ Full OpenAPI: `doc/openapi.yaml` and `http://:21847/docs`. → `CIX_WORKSPACES_ENABLED=true` is missing or the server hasn't been restarted. -**`status: "failed"` on a repo, `last_error: "authentication required"`** +**`status: "error"` on a project, dashboard surfaces "authentication required"** → Private repo with no token, or token's scopes are insufficient. Re-create the token with `repo` scope (and `admin:repo_hook` if you -want auto webhooks), then retry by deleting and re-adding the repo. +want auto webhooks), then retry by deleting and re-adding the project. -**`status: "failed"`, `last_error: "branch not found"`** -→ Typo or the branch was deleted upstream. Delete the repo entry and -re-add with the correct branch. +**`status: "error"`, dashboard surfaces "branch not found"** +→ Typo or the branch was deleted upstream. Delete the project and +re-add it via `POST /git-repos` with the correct branch. **Search returns `empty` for a query that should match** → Three likely causes: 1. Default `min_score=0.4` filtered everything. Retry with `min_score=0`. -2. Repo is still indexing (`status: pending|cloning|indexing`). Check - `GET /workspaces/{id}/repos`. +2. Project is still indexing (`status: created|indexing`). Check + `GET /workspaces/{id}/projects`. 3. The literal terms genuinely don't appear in any repo AND dense similarity is below threshold. Re-phrase with the term the code actually uses. **`stale_fts_repos` populated on every search** → These repos were indexed pre-FTS5 mirror. Run -`POST /workspaces/{id}/repos/{repo_id}/reindex` on each. +`POST /api/v1/projects/{hash}/reindex` on each. **`status: "partial_failure"`** → At least one repo's dense search errored (corrupt chromem collection, @@ -810,7 +875,7 @@ fastest fix is usually a reindex of the failed repo. **Webhook isn't triggering reindex** → Verify: 1. GitHub's webhook deliveries page shows 200 OK. -2. Push was to the *tracked* branch (the one in `workspace_repos.branch`). +2. Push was to the *tracked* branch (the one in `git_repos.branch`). 3. Server logs show signature verification succeeding. 4. `CIX_PUBLIC_URL` is set and reachable from GitHub (for `auto` mode). From a616e1ed7284049a32b8354c63d2a3f715a54640 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 17:00:04 +0100 Subject: [PATCH 10/11] chore(server): gitignore the cix-server binary at server root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go build ./cmd/cix-server` run directly from server/ (without -o) writes the binary to server/cix-server, which the existing /cmd/cix-server/cix-server rule did not cover. Add a rooted /cix-server pattern matching only server/cix-server — keeps the same full-path style introduced in c058970 so the cmd/ source dir stays trackable. Co-Authored-By: Claude Opus 4.7 --- server/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/.gitignore b/server/.gitignore index 25d3de5..cdefb84 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,5 +1,7 @@ # Compiled binaries — match only the executable, not the cmd/cix-server/ source directory. /cmd/cix-server/cix-server +# `go build ./cmd/cix-server` run from server/ without -o drops the binary here. +/cix-server /bin/ /dist/ From 78adf753ff17f30c0fbf8f4adfedd6dfd4b68ad4 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Thu, 14 May 2026 17:20:37 +0100 Subject: [PATCH 11/11] feat(dashboard): Reindex button + indexing indicator on project page GitHub-cloned projects had no UI affordance to trigger a reindex even though the server-side POST /projects/{hash}/reindex endpoint already exists. Wire it up: button appears next to Search/Delete for projects whose host_path starts with github.com/ (local projects keep their CLI-driven flow with the existing copy). Also show progress: an "Indexing in progress" alert with a spinner appears whenever status='indexing', and the useProject query polls the server every 3s while in that state so the page auto-updates on completion. The bottom "run cix reindex from terminal" alert now hides for external projects, where it would be misleading. Co-Authored-By: Claude Opus 4.7 --- .../modules/projects/ProjectDetailPage.tsx | 38 ++++++++++++++----- .../components/ReindexProjectButton.tsx | 34 +++++++++++++++++ .../dashboard/src/modules/projects/hooks.ts | 22 ++++++++--- 3 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 server/dashboard/src/modules/projects/components/ReindexProjectButton.tsx diff --git a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx index 2ead656..f18ed4c 100644 --- a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx +++ b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, AlertTriangle, ArrowLeft, Search } from 'lucide-react'; +import { AlertCircle, AlertTriangle, ArrowLeft, Loader2, Search } from 'lucide-react'; import { Link, useParams } from 'react-router-dom'; import { ApiError } from '@/api/client'; import type { Project } from '@/api/types'; @@ -12,6 +12,7 @@ import { formatDateTime, formatRelative } from '@/lib/formatDate'; import { useRuntimeModel } from '@/lib/useServerStatus'; import { DeleteProjectDialog } from './components/DeleteProjectDialog'; import { ProjectInfoCard } from './components/ProjectInfoCard'; +import { ReindexProjectButton } from './components/ReindexProjectButton'; import { useProject, useProjectSummary, useProjectWorkspaces } from './hooks'; const STATUS_VARIANT: Record = { @@ -54,6 +55,7 @@ export function ProjectDetailPage() { const p = project.data; const s = summary.data; const drift = !!p.indexed_with_model && !!currentModel && p.indexed_with_model !== currentModel; + const isExternal = p.host_path.startsWith('github.com/'); return (
@@ -96,12 +98,26 @@ export function ProjectDetailPage() { Search in this project + {isExternal ? ( + + ) : null} {isAdmin ? ( ) : null}
+ {p.status === 'indexing' ? ( + + + Indexing in progress + + This project is being indexed. Stats and search results will be incomplete + until it finishes — this page auto-refreshes when the run completes. + + + ) : null} + {drift ? ( @@ -217,15 +233,17 @@ export function ProjectDetailPage() {
- - Reindexing - - Indexing reads files from the local filesystem and is driven by the CLI. Run{' '} - cix reindex for a one-shot - rescan, or keep cix watch{' '} - running for automatic updates on file change. - - + {!isExternal ? ( + + Reindexing + + Indexing reads files from the local filesystem and is driven by the CLI. Run{' '} + cix reindex for a one-shot + rescan, or keep cix watch{' '} + running for automatic updates on file change. + + + ) : null} ); } diff --git a/server/dashboard/src/modules/projects/components/ReindexProjectButton.tsx b/server/dashboard/src/modules/projects/components/ReindexProjectButton.tsx new file mode 100644 index 0000000..38f1418 --- /dev/null +++ b/server/dashboard/src/modules/projects/components/ReindexProjectButton.tsx @@ -0,0 +1,34 @@ +import { Loader2, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Button } from '@/ui/button'; +import { useReindexProject } from '../hooks'; + +export function ReindexProjectButton({ hash, hostPath }: { hash: string; hostPath: string }) { + const reindex = useReindexProject(); + + async function onClick() { + try { + const res = await reindex.mutateAsync(hash); + if (res.status === 'already_running') { + toast.info('Reindex already running', { description: hostPath }); + } else { + toast.success('Reindex enqueued', { description: hostPath }); + } + } catch (err) { + const detail = err instanceof ApiError ? err.detail : String(err); + toast.error('Failed to enqueue reindex', { description: detail }); + } + } + + return ( + + ); +} diff --git a/server/dashboard/src/modules/projects/hooks.ts b/server/dashboard/src/modules/projects/hooks.ts index 05510f8..73b85a4 100644 --- a/server/dashboard/src/modules/projects/hooks.ts +++ b/server/dashboard/src/modules/projects/hooks.ts @@ -43,6 +43,9 @@ export function useProject(hash: string | undefined) { queryKey: hash ? projectKeys.detail(hash) : ['projects', 'unknown'], queryFn: ({ signal }) => api.get(`/projects/${hash}`, { signal }), enabled: Boolean(hash), + // Poll while the project is mid-index so the page reflects completion + // without a manual refresh. Stops as soon as status flips to indexed/error. + refetchInterval: (q) => (q.state.data?.status === 'indexing' ? 3000 : false), }); } @@ -75,8 +78,17 @@ export function useDeleteProject() { }); } -// NOTE: a "Reindex" button is intentionally absent. The server's three-phase -// indexing protocol (begin → files → finish) requires a producer with filesystem -// access to upload file contents. That is the CLI's job (`cix reindex` / -// `cix watch`). The browser cannot drive this — it has no local filesystem. -// The detail page surfaces this expectation in copy. +// Reindex is only meaningful for GitHub-cloned projects — the server enqueues a +// clone_repo job that chains into index_repo. Local projects must reindex via +// the CLI (`cix reindex` / `cix watch`); the endpoint returns 422 for those. +export function useReindexProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (hash: string) => + api.post<{ status: 'enqueued' | 'already_running' }>(`/projects/${hash}/reindex`, undefined), + onSuccess: (_data, hash) => { + qc.invalidateQueries({ queryKey: projectKeys.detail(hash) }); + qc.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +}