You've already forked agentic-coding-workflow
feat(app): wire workspace module + integration test for ws closed loop
This commit is contained in:
@@ -24,18 +24,23 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/config"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/user"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||
)
|
||||
|
||||
// App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。
|
||||
@@ -103,6 +108,71 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
||||
notify.NewHandler(notifyRepo, userSvc).Mount(r)
|
||||
project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
||||
|
||||
// ===== workspace 模块装配 =====
|
||||
// 依赖图:
|
||||
// git.Runner (cfg) ─┐
|
||||
// crypto.Encryptor ─┼─ workspaceService (clones=nil) ─┐
|
||||
// workspace.Repo ───┘ ├─ CloneRunner ─┐
|
||||
// │ │
|
||||
// SetScheduler ───┴───────────────┘
|
||||
// │
|
||||
// WorktreeService / GitOpsService
|
||||
// wsDataDir 是数据根;pathing.MainPath/WorktreePath 内部会再 join "workspaces" 子目录。
|
||||
// 这里显式 mkdir 出 ${dataDir}/workspaces 让首个 clone 的父目录已经存在。
|
||||
wsDataDir := cfg.Workspace.DataDir
|
||||
if err := os.MkdirAll(filepath.Join(wsDataDir, "workspaces"), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir workspace data dir: %w", err)
|
||||
}
|
||||
tmpDir := filepath.Join(wsDataDir, "tmp")
|
||||
// 启动期清掉残留 tmp(可能含上次崩溃留下的 askpass 脚本与 SSH 私钥)
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("mkdir workspace tmp: %w", err)
|
||||
}
|
||||
|
||||
enc, err := crypto.NewEncryptor(cfg.MasterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crypto encryptor: %w", err)
|
||||
}
|
||||
|
||||
gitr := git.NewDefaultRunner(git.Config{
|
||||
Binary: cfg.Git.Binary,
|
||||
TmpDir: tmpDir,
|
||||
CloneTimeout: cfg.Workspace.CloneTimeout,
|
||||
FetchTimeout: cfg.Workspace.FetchTimeout,
|
||||
PushTimeout: cfg.Workspace.PushTimeout,
|
||||
OpsTimeout: cfg.Workspace.OpsTimeout,
|
||||
})
|
||||
|
||||
wsRepo := workspace.NewPostgresRepository(pool)
|
||||
// 进程启动时把卡死的 cloning/syncing 复位为 error,避免行永远卡住
|
||||
if err := wsRepo.ResetStuckSyncStatuses(ctx); err != nil {
|
||||
log.Warn("reset stuck workspace sync statuses", "err", err.Error())
|
||||
}
|
||||
|
||||
pa := projectAccessAdapter{p: projectSvc}
|
||||
// 先以 nil scheduler 构造 service,cloneRunner 再回填(环依赖:runner 需要凭据访问器,访问器在 service 上)
|
||||
wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, wsDataDir)
|
||||
|
||||
// 共享凭据加载器:CloneRunner 与 GitOpsService 都需要。
|
||||
// 通过 CredentialAccessor 接口避免直接持有 *workspaceService 私有类型。
|
||||
credAccessor, ok := wsSvc.(workspace.CredentialAccessor)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("workspace service does not implement CredentialAccessor")
|
||||
}
|
||||
credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
|
||||
return credAccessor.LoadDecryptedCredential(ctx, wsID)
|
||||
}
|
||||
|
||||
cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, cfg.Workspace.CloneTimeout)
|
||||
if setter, ok := wsSvc.(workspace.SchedulerSetter); ok {
|
||||
setter.SetScheduler(cloneRunner)
|
||||
}
|
||||
|
||||
wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, wsDataDir)
|
||||
gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader)
|
||||
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r)
|
||||
|
||||
if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" {
|
||||
if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil {
|
||||
log.Warn("bootstrap admin failed", "err", err.Error())
|
||||
@@ -160,6 +230,8 @@ func (a *App) Run(ctx context.Context) error {
|
||||
|
||||
// userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。
|
||||
// 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。
|
||||
// workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名),
|
||||
// 因此本类型同时满足两者,可在两个 handler 复用。
|
||||
type userAdminAdapter struct{ svc user.Service }
|
||||
|
||||
func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
@@ -169,3 +241,51 @@ func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, erro
|
||||
}
|
||||
return u.IsAdmin, nil
|
||||
}
|
||||
|
||||
// projectAccessAdapter 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。
|
||||
// - Resolve = by slug:用于 POST /projects/{slug}/workspaces 等路径
|
||||
// - ResolveByID = by uuid:用于 wsID 拿到 ws 后再校验 project 权限
|
||||
//
|
||||
// ACL 语义复刻 project 包内部 assertReadable / assertWritable:
|
||||
//
|
||||
// read private: owner + admin
|
||||
// read internal: 任意已登录
|
||||
// write *: owner + admin
|
||||
//
|
||||
// 注意:project.assertReadable 在 visibility=private 且非 owner+admin 时返回 NotFound
|
||||
// 防探测;这里 ProjectService.Get/GetByID 已替我们兜住,到本适配层时 pj 一定可读。
|
||||
type projectAccessAdapter struct {
|
||||
p project.ProjectService
|
||||
}
|
||||
|
||||
func (a projectAccessAdapter) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) {
|
||||
pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, false, err
|
||||
}
|
||||
canRead, canWrite := projectACL(pj, callerID, isAdmin)
|
||||
return pj.ID, canRead, canWrite, nil
|
||||
}
|
||||
|
||||
func (a projectAccessAdapter) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) {
|
||||
pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
canRead, canWrite := projectACL(pj, callerID, isAdmin)
|
||||
return canRead, canWrite, nil
|
||||
}
|
||||
|
||||
// projectACL 复制 project 包内部 assertReadable/assertWritable 的语义:
|
||||
// - admin 永远可读可写
|
||||
// - owner 可读可写
|
||||
// - internal 可见性:所有登录用户可读
|
||||
//
|
||||
// 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess
|
||||
// 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。
|
||||
func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) {
|
||||
owned := pj.OwnerID == callerID
|
||||
canWrite = isAdmin || owned
|
||||
canRead = canWrite || pj.Visibility == project.VisibilityInternal
|
||||
return canRead, canWrite
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,12 +27,15 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/db"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/logger"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/user"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||
)
|
||||
|
||||
func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) {
|
||||
@@ -208,3 +215,287 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, count, 8, "expected at least 8 audit log entries for project/requirement/issue actions")
|
||||
}
|
||||
|
||||
// projectAccessAdapterTest 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。
|
||||
// 与 internal/app/app.go 里的 projectAccessAdapter 同形(拷贝粘贴),保持 test-internal 不导出。
|
||||
type projectAccessAdapterTest struct {
|
||||
p project.ProjectService
|
||||
}
|
||||
|
||||
func (a projectAccessAdapterTest) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) {
|
||||
pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, false, err
|
||||
}
|
||||
canRead, canWrite := projectACLTest(pj, callerID, isAdmin)
|
||||
return pj.ID, canRead, canWrite, nil
|
||||
}
|
||||
|
||||
func (a projectAccessAdapterTest) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) {
|
||||
pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
canRead, canWrite := projectACLTest(pj, callerID, isAdmin)
|
||||
return canRead, canWrite, nil
|
||||
}
|
||||
|
||||
func projectACLTest(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) {
|
||||
owned := pj.OwnerID == callerID
|
||||
canWrite = isAdmin || owned
|
||||
canRead = canWrite || pj.Visibility == project.VisibilityInternal
|
||||
return canRead, canWrite
|
||||
}
|
||||
|
||||
// makeLocalBareRepo 在临时目录里建一个带 main 分支与一次 commit 的 bare 仓库,
|
||||
// 返回路径(用 file:// 协议作为 git_remote_url,让 Plan #3 的 clone 走 file 协议)。
|
||||
func makeLocalBareRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
bare := filepath.Join(dir, "remote.git")
|
||||
require.NoError(t, exec.Command("git", "init", "--bare", "-b", "main", bare).Run())
|
||||
work := filepath.Join(dir, "seed")
|
||||
require.NoError(t, os.MkdirAll(work, 0o755))
|
||||
for _, args := range [][]string{
|
||||
{"init", "-b", "main"},
|
||||
{"config", "user.email", "t@e.c"},
|
||||
{"config", "user.name", "T"},
|
||||
} {
|
||||
c := exec.Command("git", args...)
|
||||
c.Dir = work
|
||||
require.NoError(t, c.Run())
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(work, "README.md"), []byte("hi\n"), 0o644))
|
||||
for _, args := range [][]string{
|
||||
{"add", "."},
|
||||
{"commit", "-m", "init"},
|
||||
{"remote", "add", "origin", bare},
|
||||
{"push", "origin", "main"},
|
||||
} {
|
||||
c := exec.Command("git", args...)
|
||||
c.Dir = work
|
||||
require.NoError(t, c.Run())
|
||||
}
|
||||
return bare
|
||||
}
|
||||
|
||||
// TestPlan3_WorkspaceClosedLoop 端到端覆盖 workspace 模块:
|
||||
// 1. bootstrap admin → login 拿 token
|
||||
// 2. 创建 project + bare 远端
|
||||
// 3. POST /workspaces 创建 workspace(异步 clone)
|
||||
// 4. 轮询 GET /workspaces/{id} 直到 sync_status == "idle"
|
||||
// 5. POST /worktrees 创建支线 → acquire/release 状态机切换
|
||||
// 6. POST /sync(等同 git fetch)→ sync_status 仍为 idle
|
||||
// 7. DELETE worktree → DELETE workspace 都返回 204
|
||||
//
|
||||
// 跳过条件:本机没有 git 二进制时直接 t.Skip。Docker 不可用会让 testcontainers
|
||||
// Run 失败,由上层的 require 触发测试失败(与已有 PM 闭环测试一致)。
|
||||
func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
container, err := tcpg.Run(ctx, "postgres:16-alpine",
|
||||
tcpg.WithDatabase("acw"),
|
||||
tcpg.WithUsername("acw"),
|
||||
tcpg.WithPassword("acw"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = container.Terminate(ctx) })
|
||||
|
||||
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations"))
|
||||
|
||||
pool, err := db.NewPool(ctx, dsn)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo})
|
||||
auditRec := audit.NewPostgresRecorder(pool)
|
||||
|
||||
userRepo := user.NewPostgresRepository(pool)
|
||||
userSvc := user.NewService(userRepo, auditRec)
|
||||
|
||||
projectRepo := project.NewPostgresRepository(pool)
|
||||
projectSvc := project.NewProjectService(projectRepo, auditRec)
|
||||
requirementSvc := project.NewRequirementService(projectRepo, auditRec)
|
||||
issueSvc := project.NewIssueService(projectRepo, auditRec)
|
||||
|
||||
adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, adminUser)
|
||||
|
||||
// ---- workspace 模块装配(与 app.go 同形,但不依赖 cfg) ----
|
||||
dataDir := t.TempDir()
|
||||
tmpDir := filepath.Join(dataDir, "tmp")
|
||||
require.NoError(t, os.MkdirAll(tmpDir, 0o700))
|
||||
|
||||
enc, err := crypto.NewEncryptor(make([]byte, 32))
|
||||
require.NoError(t, err)
|
||||
|
||||
gitr := git.NewDefaultRunner(git.Config{
|
||||
Binary: "git", TmpDir: tmpDir,
|
||||
CloneTimeout: 30 * time.Second, FetchTimeout: 30 * time.Second,
|
||||
PushTimeout: 30 * time.Second, OpsTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
wsRepo := workspace.NewPostgresRepository(pool)
|
||||
require.NoError(t, wsRepo.ResetStuckSyncStatuses(ctx))
|
||||
|
||||
pa := projectAccessAdapterTest{p: projectSvc}
|
||||
wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, dataDir)
|
||||
|
||||
credAcc, ok := wsSvc.(workspace.CredentialAccessor)
|
||||
require.True(t, ok)
|
||||
credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) {
|
||||
return credAcc.LoadDecryptedCredential(ctx, wsID)
|
||||
}
|
||||
cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, 30*time.Second)
|
||||
if setter, ok := wsSvc.(workspace.SchedulerSetter); ok {
|
||||
setter.SetScheduler(cloneRunner)
|
||||
}
|
||||
wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, dataDir)
|
||||
gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
user.NewHandler(userSvc).Mount(r)
|
||||
project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
||||
workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r)
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// ---- 登录拿 token ----
|
||||
loginBody, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"})
|
||||
loginResp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
var lr struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&lr))
|
||||
_ = loginResp.Body.Close()
|
||||
require.NotEmpty(t, lr.Token)
|
||||
token := lr.Token
|
||||
|
||||
// 三个内联 helper:内联是有意为之,避免引入 test 外部 helper 包。
|
||||
postJSON := func(path string, body any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
buf, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp, buf
|
||||
}
|
||||
getJSON := func(path string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
buf, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp, buf
|
||||
}
|
||||
deleteJSON := func(path string) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, srv.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
// ---- 1. 创建 project ----
|
||||
resp, _ := postJSON("/api/v1/projects/", map[string]string{"slug": "p1", "name": "P1"})
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// ---- 2. 准备本地 bare 仓库(file://协议) ----
|
||||
bare := makeLocalBareRepo(t)
|
||||
remoteURL := "file://" + filepath.ToSlash(bare)
|
||||
|
||||
// ---- 3. 创建 workspace(credential.kind=none,本地 file 协议无需凭据) ----
|
||||
resp, body := postJSON("/api/v1/projects/p1/workspaces", map[string]any{
|
||||
"slug": "ws1", "name": "WS1",
|
||||
"git_remote_url": remoteURL,
|
||||
"default_branch": "main",
|
||||
"credential": map[string]any{"kind": "none"},
|
||||
})
|
||||
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create workspace failed: %s", string(body))
|
||||
var wsResp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &wsResp))
|
||||
require.NotEqual(t, uuid.Nil, wsResp.ID)
|
||||
|
||||
// ---- 4. 等 clone 完成 → sync_status = idle ----
|
||||
require.Eventuallyf(t, func() bool {
|
||||
r, b := getJSON("/api/v1/workspaces/" + wsResp.ID.String())
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
var ws struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
LastSyncError string `json:"last_sync_error"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &ws)
|
||||
if ws.SyncStatus == "error" {
|
||||
t.Logf("clone errored: %s", ws.LastSyncError)
|
||||
return false
|
||||
}
|
||||
return ws.SyncStatus == "idle"
|
||||
}, 30*time.Second, 200*time.Millisecond, "workspace clone did not become idle")
|
||||
|
||||
// ---- 5. 创建 worktree feat-x ----
|
||||
resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/worktrees", map[string]string{
|
||||
"branch": "feat-x", "base_branch": "main",
|
||||
})
|
||||
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create worktree: %s", string(body))
|
||||
var wtResp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Branch string `json:"branch"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &wtResp))
|
||||
require.Equal(t, "feat-x", wtResp.Branch)
|
||||
require.Equal(t, "idle", wtResp.Status)
|
||||
|
||||
// ---- 6. acquire / release ----
|
||||
resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/acquire", nil)
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode, "acquire: %s", string(body))
|
||||
var afterAcq struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &afterAcq))
|
||||
require.Equal(t, "active", afterAcq.Status)
|
||||
|
||||
resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/release", nil)
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode, "release: %s", string(body))
|
||||
var afterRel struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &afterRel))
|
||||
require.Equal(t, "idle", afterRel.Status)
|
||||
|
||||
// ---- 7. 同步 workspace(git fetch)→ 仍为 idle ----
|
||||
resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/sync", nil)
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode, "sync: %s", string(body))
|
||||
var afterSync struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &afterSync))
|
||||
require.Equal(t, "idle", afterSync.SyncStatus)
|
||||
|
||||
// ---- 8. 删除 worktree / workspace ----
|
||||
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/worktrees/"+wtResp.ID.String()).StatusCode)
|
||||
require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/workspaces/"+wsResp.ID.String()).StatusCode)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user