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 } 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/doc/openapi.yaml b/doc/openapi.yaml index 4990567..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 @@ -1250,66 +1252,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 +1291,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 +1349,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 +1467,58 @@ 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 (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 @@ -1517,33 +1528,72 @@ paths: $ref: "#/components/schemas/WebhookInfoResponse" "401": $ref: "#/components/responses/Unauthorized" + "404": + 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" + + /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/{repo_id}: + /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 +1634,7 @@ paths: schema: $ref: "#/components/schemas/Error" "404": - description: Unknown workspace_repo id + description: Unknown project hash content: application/json: schema: @@ -1592,40 +1642,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 @@ -1729,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 @@ -3212,79 +3230,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 +3280,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 +3292,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 +3339,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 + + WorkspaceProjectListResponse: + type: object + required: [projects, total] + properties: + projects: + type: array + items: + $ref: "#/components/schemas/WorkspaceProject" + total: + type: integer - LinkExistingProjectRequest: + LinkProjectRequest: type: object required: [project_hash] properties: @@ -3408,11 +3413,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 +3428,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: - type: string - description: workspace_repos.id — same value used in /repos endpoints. - branch: - type: string - status: + added_at: type: string - enum: [pending, cloning, indexing, indexed, failed] - is_linked: - type: boolean + format: date-time ReindexEnqueuedResponse: type: object @@ -3449,8 +3445,8 @@ components: status: type: string enum: [enqueued, already_running] - repo: - $ref: "#/components/schemas/WorkspaceRepo" + project: + $ref: "#/components/schemas/Project" Job: type: object @@ -3608,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/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/.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/ diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index 7fe85fd..b4c6661 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) } @@ -104,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 @@ -206,7 +216,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 +260,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 +271,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 +329,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/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()) + } +} 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/src/modules/projects/ProjectDetailPage.tsx b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx index 4b2a604..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 ? ( @@ -143,27 +159,18 @@ export function ProjectDetailPage() {
{workspaces.data.workspaces.map((w) => ( ))} @@ -226,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/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/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 38d19be..73b85a4 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 = { @@ -42,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), }); } @@ -74,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 }); + }, + }); +} 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/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 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/db/db.go b/server/internal/db/db.go index c696718..473f46c 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -12,17 +12,80 @@ 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" -// 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 @@ -62,50 +125,63 @@ func Open(path string) (*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 +} - // 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. - if err := migrateWebhookMode(db); err != nil { - _ = db.Close() - return nil, fmt.Errorf("migrate webhook_mode: %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) } - // 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) + 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 + @@ -436,6 +512,265 @@ 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. +// +// 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 +// 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) + } + + // 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) + } + 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) + } + } + + 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) + } + } + + 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) + } + 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..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" @@ -227,30 +229,180 @@ 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, + 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'), + ('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 { + _ = raw.Close() + t.Fatalf("seed legacy: %v", err) + } + _ = raw.Close() + + migrated, err := OpenWith(OpenOptions{Path: dbPath, DataDir: dataDir}) + if err != nil { + t.Fatalf("OpenWith: %v", err) + } + defer migrated.Close() + + // workspace_repos is gone. + var n int + if err := migrated.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='workspace_repos'`, + ).Scan(&n); err != nil { + t.Fatalf("count workspace_repos: %v", err) + } + if n != 0 { + t.Fatalf("workspace_repos should be dropped after migration, count=%d", n) + } + + // 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) + } + + // 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() +} + +// 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, @@ -264,7 +416,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 +426,577 @@ 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'); + 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) + }) - // 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')`) + // 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("pre-migration insert should fail UNIQUE — test setup is wrong") + 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() - // Now reopen via the real Open() so the migration runs. - migrated, err := Open(path) + // 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 migrated.Close() + defer d.Close() - // is_linked column should be present and default to 0 on the - // migrated row. - var isLinked 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) - } - if isLinked != 0 { - t.Fatalf("pre-existing rows must keep is_linked=0, got %d", isLinked) - } - - // 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) - } - - // 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)") + 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 d55f788..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, @@ -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,9 +342,11 @@ var ExpectedTables = []string{ "runtime_settings", "workspaces", "github_tokens", - "workspace_repos", + "git_repos", + "workspace_projects", "jobs", "call_edges", "chunks_meta", "chunks_fts", + "schema_migrations", } 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/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..fdb3154 --- /dev/null +++ b/server/internal/httpapi/gitrepos.go @@ -0,0 +1,332 @@ +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. + // + // 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{ + GitHubURL: body.GithubUrl, + Branch: body.Branch, + TokenID: tokenID, + 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") + 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..c098f36 --- /dev/null +++ b/server/internal/httpapi/gitrepos_test.go @@ -0,0 +1,358 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "sort" + "sync" + "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()) + } +} + +// 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, _, d := reposRouterDB(t) + + // 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", + }) + 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 + projPath := "github.com/a/b@main" + + // 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()) + } + 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", + }) + 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..595ebd4 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. @@ -1682,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. @@ -1863,11 +1775,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 +1803,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 +1848,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 +1910,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 +1937,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 +1995,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 +2012,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 +2147,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 +2201,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 +2316,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 +2351,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 +2825,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 +3128,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 +3754,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 +3807,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 +3953,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 +3968,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 +3975,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 +3985,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 +4000,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 +4007,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 +4017,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 +4032,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 +4048,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 +4329,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 +4356,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 +4414,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 +4432,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 +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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", - "IPMVPch1a1RzbHzR2Jxm6+pPP3/6/wMAAP//", + "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", + "AwzX2ubUey5/+fnL/xcAAP//", } // 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..92cd123 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,13 +154,23 @@ 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. +// +// 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) { @@ -181,7 +183,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..35173a7 100644 --- a/server/internal/httpapi/webhooks_test.go +++ b/server/internal/httpapi/webhooks_test.go @@ -9,55 +9,38 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "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/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 +49,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 +66,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 +76,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 +85,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 +100,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 +117,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 +127,56 @@ 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) { +// 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(`{}`) - // 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 +193,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 +211,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 +224,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()) @@ -249,25 +251,66 @@ func TestAddRepo_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) - 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 +323,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..07abb44 --- /dev/null +++ b/server/internal/httpapi/workspaceprojects_test.go @@ -0,0 +1,254 @@ +package httpapi + +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 (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', last_indexed_at = ? WHERE host_path = ?`, + now, 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) + } + + // 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) { + 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..621de13 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" ) @@ -36,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", "") @@ -55,18 +58,19 @@ 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, }) } -// 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, @@ -79,19 +83,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 +509,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,28 +769,36 @@ 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 -// 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 { @@ -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/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/workspacejobs/workspacejobs.go b/server/internal/workspacejobs/workspacejobs.go index 7b7f2e2..8f2ab95 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 ( @@ -33,52 +31,51 @@ import ( "errors" "fmt" "log/slog" + "strings" "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 +90,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 +108,65 @@ 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) + // 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) } 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 +177,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..95e7823 --- /dev/null +++ b/server/internal/workspaceprojects/workspaceprojects.go @@ -0,0 +1,191 @@ +// 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. +// +// 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) { + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.DB.ExecContext(ctx, ` + INSERT INTO workspace_projects (workspace_id, project_path, added_at) + 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) + } + 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 +// 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..b1053f4 --- /dev/null +++ b/server/internal/workspaceprojects/workspaceprojects_test.go @@ -0,0 +1,307 @@ +package workspaceprojects + +import ( + "context" + "database/sql" + "errors" + "sync" + "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)) + } +} + +// 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. +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) - } - }) - } -} 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).