diff --git a/internal/infra/errs/errs.go b/internal/infra/errs/errs.go index 9b6f30d..4570466 100644 --- a/internal/infra/errs/errs.go +++ b/internal/infra/errs/errs.go @@ -32,6 +32,18 @@ const ( CodeProjectSlugTaken Code = "project_slug_taken" CodeProjectArchived Code = "project_archived" CodeRequirementClosed Code = "requirement_closed" + + // Workspace 模块细粒度 code + CodeWorkspaceSlugTaken Code = "workspace_slug_taken" + CodeWorkspaceCloneFailed Code = "workspace_clone_failed" + CodeWorkspaceSyncInProgress Code = "workspace_sync_in_progress" + CodeWorkspaceRemoteInvalid Code = "workspace_remote_invalid" + CodeWorktreeBranchConflict Code = "worktree_branch_conflict" + CodeWorktreeAlreadyActive Code = "worktree_already_active" + CodeWorktreeNotHeldByCaller Code = "worktree_not_held_by_caller" + CodeWorktreePathInvalid Code = "worktree_path_invalid" + CodeGitCredentialInvalid Code = "git_credential_invalid" + CodeGitCmdFailed Code = "git_cmd_failed" ) // AppError is the application's structured error type. It carries a stable @@ -103,7 +115,9 @@ func HTTPStatus(code Code) int { case CodeNotFound: return http.StatusNotFound case CodeConflict, - CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed: + CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed, + CodeWorkspaceSlugTaken, CodeWorkspaceSyncInProgress, + CodeWorktreeBranchConflict, CodeWorktreeAlreadyActive: return http.StatusConflict case CodeRateLimited: return http.StatusTooManyRequests @@ -111,6 +125,12 @@ func HTTPStatus(code Code) int { return http.StatusBadGateway case CodeUnavailable: return http.StatusServiceUnavailable + case CodeWorkspaceCloneFailed, CodeGitCmdFailed: + return http.StatusBadGateway + case CodeWorkspaceRemoteInvalid, CodeWorktreePathInvalid, CodeGitCredentialInvalid: + return http.StatusBadRequest + case CodeWorktreeNotHeldByCaller: + return http.StatusForbidden default: return http.StatusInternalServerError } diff --git a/internal/infra/errs/errs_test.go b/internal/infra/errs/errs_test.go index a3cb1ed..e6ca8ec 100644 --- a/internal/infra/errs/errs_test.go +++ b/internal/infra/errs/errs_test.go @@ -29,18 +29,28 @@ func TestWrap_PreservesCause(t *testing.T) { func TestHTTPStatus(t *testing.T) { cases := map[Code]int{ - CodeInvalidInput: http.StatusBadRequest, - CodeUnauthorized: http.StatusUnauthorized, - CodeForbidden: http.StatusForbidden, - CodeNotFound: http.StatusNotFound, - CodeConflict: http.StatusConflict, - CodeProjectSlugTaken: http.StatusConflict, - CodeProjectArchived: http.StatusConflict, - CodeRequirementClosed: http.StatusConflict, - CodeRateLimited: http.StatusTooManyRequests, - CodeUpstream: http.StatusBadGateway, - CodeUnavailable: http.StatusServiceUnavailable, - CodeInternal: http.StatusInternalServerError, + CodeInvalidInput: http.StatusBadRequest, + CodeUnauthorized: http.StatusUnauthorized, + CodeForbidden: http.StatusForbidden, + CodeNotFound: http.StatusNotFound, + CodeConflict: http.StatusConflict, + CodeProjectSlugTaken: http.StatusConflict, + CodeProjectArchived: http.StatusConflict, + CodeRequirementClosed: http.StatusConflict, + CodeRateLimited: http.StatusTooManyRequests, + CodeUpstream: http.StatusBadGateway, + CodeUnavailable: http.StatusServiceUnavailable, + CodeInternal: http.StatusInternalServerError, + CodeWorkspaceSlugTaken: http.StatusConflict, + CodeWorkspaceSyncInProgress: http.StatusConflict, + CodeWorktreeBranchConflict: http.StatusConflict, + CodeWorktreeAlreadyActive: http.StatusConflict, + CodeWorkspaceCloneFailed: http.StatusBadGateway, + CodeGitCmdFailed: http.StatusBadGateway, + CodeWorkspaceRemoteInvalid: http.StatusBadRequest, + CodeWorktreePathInvalid: http.StatusBadRequest, + CodeGitCredentialInvalid: http.StatusBadRequest, + CodeWorktreeNotHeldByCaller: http.StatusForbidden, } for code, want := range cases { require.Equal(t, want, HTTPStatus(code), "code=%s", code) diff --git a/migrations/0003_workspace.down.sql b/migrations/0003_workspace.down.sql new file mode 100644 index 0000000..af28fe5 --- /dev/null +++ b/migrations/0003_workspace.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS idx_requirements_workspace; +ALTER TABLE requirements DROP COLUMN IF EXISTS workspace_id; + +DROP INDEX IF EXISTS idx_issues_workspace_status; +ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_project_workspace_fk; +ALTER TABLE issues DROP COLUMN IF EXISTS workspace_id; + +DROP TABLE IF EXISTS git_credentials; +DROP TABLE IF EXISTS workspace_worktrees; +DROP TABLE IF EXISTS workspaces; diff --git a/migrations/0003_workspace.up.sql b/migrations/0003_workspace.up.sql new file mode 100644 index 0000000..b46021f --- /dev/null +++ b/migrations/0003_workspace.up.sql @@ -0,0 +1,62 @@ +-- ============ Workspaces ============ +CREATE TABLE workspaces ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + git_remote_url TEXT NOT NULL, + default_branch TEXT NOT NULL DEFAULT 'main', + main_path TEXT NOT NULL, + sync_status TEXT NOT NULL DEFAULT 'cloning', + last_synced_at TIMESTAMPTZ, + last_sync_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT workspaces_sync_status_check CHECK (sync_status IN ('cloning','idle','syncing','error')), + UNIQUE (project_id, slug), + UNIQUE (project_id, id) +); +CREATE INDEX idx_workspaces_project ON workspaces(project_id); + +-- ============ Worktrees ============ +CREATE TABLE workspace_worktrees ( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + branch TEXT NOT NULL, + path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle', + active_holder TEXT, + acquired_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT worktrees_status_check CHECK (status IN ('idle','active','pruning')), + UNIQUE (workspace_id, branch), + UNIQUE (workspace_id, path) +); +CREATE INDEX idx_worktrees_ws_status ON workspace_worktrees(workspace_id, status, last_used_at); + +-- ============ Git credentials ============ +CREATE TABLE git_credentials ( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL UNIQUE REFERENCES workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + username TEXT, + encrypted_secret BYTEA, + fingerprint TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT git_credentials_kind_check CHECK (kind IN ('pat','ssh_key','none')) +); + +-- ============ ALTER existing PM tables ============ +ALTER TABLE issues ADD COLUMN workspace_id UUID NULL; +ALTER TABLE issues ADD CONSTRAINT issues_project_workspace_fk + FOREIGN KEY (project_id, workspace_id) + REFERENCES workspaces (project_id, id) MATCH SIMPLE + ON DELETE SET NULL; +CREATE INDEX idx_issues_workspace_status ON issues(workspace_id, status); + +ALTER TABLE requirements ADD COLUMN workspace_id UUID NULL + REFERENCES workspaces(id) ON DELETE SET NULL; +CREATE INDEX idx_requirements_workspace ON requirements(workspace_id);