Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion doc/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,45 @@ paths:
"503":
$ref: "#/components/responses/WorkspacesDisabled"

/api/v1/git-repos:
post:
operationId: addStandaloneGitRepo
tags: [workspaces]
summary: Clone + index a GitHub repository outside any specific workspace
description: |
Convenience endpoint for the dashboard's `/projects` → Add repo
button. Internally resolves the singleton "default" workspace
(see `workspaces.is_default`) and proxies to the same code path
as POST `/api/v1/workspaces/{id}/repos`. The resulting project
appears in `/api/v1/projects` and can later be linked into any
regular workspace via `/api/v1/workspaces/{id}/repos/link`.

Request and response shapes are identical to the per-workspace
Add repo endpoint, with one exception: the response's `repo`
field carries the default workspace's id in `workspace_id` so
the dashboard can show the resulting project in context.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AddWorkspaceRepoRequest"
responses:
"201":
description: Repo attached to default workspace + clone enqueued
content:
application/json:
schema:
$ref: "#/components/schemas/WorkspaceRepoCreated"
"401":
$ref: "#/components/responses/Unauthorized"
"409":
$ref: "#/components/responses/Conflict"
"422":
$ref: "#/components/responses/Unprocessable"
"503":
$ref: "#/components/responses/WorkspacesDisabled"

/api/v1/workspaces/{id}/repos:
parameters:
- name: id
Expand Down Expand Up @@ -3097,7 +3136,7 @@ components:

Workspace:
type: object
required: [id, name, description, created_at, updated_at]
required: [id, name, description, is_default, created_at, updated_at]
properties:
id:
type: string
Expand All @@ -3108,6 +3147,13 @@ components:
description:
type: string
description: Free-form description. Empty string when absent.
is_default:
type: boolean
description: |
True for the singleton workspace that receives repositories
added via the standalone `/api/v1/git-repos` endpoint (the
dashboard's `/projects` → Add repo flow). Exactly one
workspace is flagged; deleting it returns 409.
created_at:
type: string
format: date-time
Expand Down
9 changes: 9 additions & 0 deletions server/cmd/cix-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@ func run() error {
logger.Info("workspaces: encryption key loaded", "source", secSvc.Source())
}
wsSvc = workspaces.New(database)
// Make sure the singleton default workspace exists so the
// /git-repos endpoint and the dashboard's /projects "Add repo"
// button always have a target. Idempotent — boots after the
// first one are no-ops.
if def, err := wsSvc.EnsureDefault(context.Background()); err != nil {
return fmt.Errorf("ensure default workspace: %w", err)
} else {
logger.Info("workspaces: default workspace ready", "id", def.ID, "name", def.Name)
}
wrSvc = workspacerepos.New(database)

// Persistent job queue + worker pool. Worker concurrency comes
Expand Down
24 changes: 16 additions & 8 deletions server/dashboard/src/modules/projects/ProjectsListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@ 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 (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-sm text-muted-foreground">
{data ? `${data.total} indexed ${data.total === 1 ? 'project' : 'projects'}` : ' '}
</p>
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-sm text-muted-foreground">
{data ? `${data.total} indexed ${data.total === 1 ? 'project' : 'projects'}` : ' '}
</p>
</div>
{/* Add repo here clones + indexes a GitHub repository into the
default workspace so the resulting project shows up in this
list. Standalone projects can later be linked into specific
workspaces from the Workspaces page. */}
<AddRepoDialog onAdded={() => void refetch()} />
</header>

{isLoading ? (
Expand Down Expand Up @@ -51,9 +59,9 @@ function EmptyState() {
<div className="space-y-1">
<p className="text-base font-medium">No projects yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Register a project from the CLI with{' '}
Use <strong>Add repo</strong> above to clone + index a GitHub
repository, or register a local project from the CLI with{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">cix init &lt;path&gt;</code>.
A GitHub source will land here in a future PR.
</p>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ const NO_TOKEN = '__none__';
// can't pick a repository before choosing a token, and can't submit
// before pinning down a branch + webhook mode. The shape mirrors how
// people actually fill it in: PAT → repo → branch → webhook policy.
//
// Two scopes:
// - workspaceID provided → POST /workspaces/{id}/repos (standard flow).
// - workspaceID omitted → POST /git-repos (server resolves
// the singleton default workspace; surfaced from /projects so users
// can add a repo without picking a workspace first).
export function AddRepoDialog({
workspaceID,
onAdded,
}: {
workspaceID: string;
workspaceID?: string;
onAdded: () => void;
}) {
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -193,10 +199,10 @@ export function AddRepoDialog({
if (tokenID && tokenID !== NO_TOKEN) {
payload.token_id = tokenID;
}
const resp = await api.post<WorkspaceRepoCreated>(
`/workspaces/${workspaceID}/repos`,
payload,
);
const endpoint = workspaceID
? `/workspaces/${workspaceID}/repos`
: `/git-repos`;
const resp = await api.post<WorkspaceRepoCreated>(endpoint, payload);
setCreated(resp);
onAdded();
} catch (e) {
Expand Down
60 changes: 60 additions & 0 deletions server/internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ func Open(path string) (*sql.DB, error) {
return nil, fmt.Errorf("migrate webhook_mode: %w", err)
}

// Standalone /git-repos feature — add workspaces.is_default + the
// partial UNIQUE index. The default workspace row itself is created
// at server startup via workspaces.Service.EnsureDefault, not here.
if err := migrateWorkspacesDefault(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate workspaces is_default: %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
Expand Down Expand Up @@ -389,6 +397,58 @@ func migrateIndexedWithModel(db *sql.DB) error {
return nil
}

// migrateWorkspacesDefault adds workspaces.is_default to pre-feature
// databases plus the partial UNIQUE index that enforces "at most one
// default workspace". Idempotent: re-runs are no-ops. The actual
// bootstrap (inserting the singleton row) happens at server startup
// via workspaces.Service.EnsureDefault — keeping it out of the DB
// migration so tests can opt in/out by skipping the call.
func migrateWorkspacesDefault(db *sql.DB) error {
rows, err := db.Query(`PRAGMA table_info(workspaces)`)
if err != nil {
return fmt.Errorf("table_info workspaces: %w", err)
}
have := false
tableExists := false
for rows.Next() {
var (
cid int
name, typ string
notnull, pk int
dflt sql.NullString
)
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
tableExists = true
if name == "is_default" {
have = true
}
}
rows.Close()
if !tableExists {
return nil
}
if !have {
if _, err := db.Exec(
`ALTER TABLE workspaces ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0`,
); err != nil {
return fmt.Errorf("add is_default column: %w", err)
}
}
// The partial UNIQUE index is in Schema's CREATE INDEX IF NOT
// EXISTS so fresh DBs already have it; create it here for older
// installs that just got the column added.
if _, err := db.Exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_default
ON workspaces(is_default) WHERE is_default = 1`,
); err != nil {
return fmt.Errorf("create idx_workspaces_default: %w", err)
}
return nil
}

// migrateWebhookMode adds workspace_repos.webhook_mode to pre-PR10
// databases and backfills it from the older auto_webhook bool so rows
// inserted before this migration keep their effective behaviour. Same
Expand Down
10 changes: 10 additions & 0 deletions server/internal/db/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
-- is_default flags the singleton "Personal" workspace that holds
-- repos added from /projects (the standalone Add repo flow). At
-- most one row carries is_default=1 — enforced by the partial
-- UNIQUE index created in migrateWorkspacesDefault + the
-- EnsureDefault bootstrap. The index is intentionally NOT here:
-- a pre-feature DB whose workspaces table lacks is_default would
-- fail the multi-statement Schema.Exec before the migration has
-- a chance to ALTER TABLE ADD COLUMN (same pattern as
-- idx_projects_path_hash).
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Expand Down
Loading
Loading