feat(workitempipeline): add provision service and archive G06 artifacts

- Add projectsync provision service with tests
- Update HTTP handlers with provision endpoint support
- Update workitempipeline service and tests
- Archive G06 workspace provision artifacts (plan, code review)
- Update G07 cloud code review
This commit is contained in:
toki 2026-06-14 05:13:16 +09:00
parent ca70f4b85e
commit 096efb0a83
10 changed files with 515 additions and 51 deletions

View file

@ -35,40 +35,41 @@ task=m-milestone-work-item-creation-sync/06_workspace_provision, plan=0, tag=API
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Filesystem workspace readiness guard | [ ] |
| [API-1] Filesystem workspace readiness guard | [x] |
## 구현 체크리스트
- [ ] `projectsync`에 prepared checkout 검증용 provisioner interface, filesystem 구현, sentinel error를 추가한다.
- [ ] `projectsync` temp dir 회귀 테스트로 main/develop/default slot checkout 준비 성공과 missing checkout/.git 실패를 검증한다.
- [ ] focused/final 검증을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `projectsync`에 prepared checkout 검증용 provisioner interface, filesystem 구현, sentinel error를 추가한다.
- [x] `projectsync` temp dir 회귀 테스트로 main/develop/default slot checkout 준비 성공과 missing checkout/.git 실패를 검증한다.
- [x] focused/final 검증을 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 없음. 계획서(`PLAN-local-G06.md`)에 정의된 내용에 맞추어 `FilesystemWorkspaceProvisioner`와 관련 테스트 스펙을 정확히 구현하였습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- Git 메타데이터(`.git`) 검증: `git worktree`나 `git submodule`로 준비된 개발 영역에서는 `.git`이 디렉토리가 아닌 파일(gitdir 포인터가 적힌 텍스트 파일)일 수 있습니다. 따라서 `.git`의 파일 속성이 디렉토리이든 일반 파일이든 무관하게 `os.Stat`으로 단순히 해당 경로가 정상적으로 존재하는지만 검증하도록 처리했습니다.
- Context 제어: `EnsureProvisioned` 내의 개별 검증 루프 실행 시점에 `ctx.Err()`를 매번 체크하여, 컨텍스트가 임의의 시점에 취소(`context.Canceled`)되거나 만료되면 더 이상 디렉토리 검사 작업을 수행하지 않고 즉시 early return 하도록 작성했습니다.
## 사용자 리뷰 요청
@ -90,35 +91,47 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```
$ cd services/core && go test -count=1 ./internal/projectsync
(output)
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.005s
```
### 최종 검증
```
$ cd services/core && go test -count=1 ./internal/projectsync
(output)
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.005s
```
```
$ cd services/core && go test ./...
(output)
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached)
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring (cached)
ok github.com/nomadcode/nomadcode-core/internal/config (cached)
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/http 0.004s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification (cached)
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.011s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync (cached)
ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached)
ok github.com/nomadcode/nomadcode-core/internal/storage (cached)
ok github.com/nomadcode/nomadcode-core/internal/workflow (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
```
```
$ git diff --check
(output)
(아무런 출력 없음)
```
---
@ -126,3 +139,17 @@ $ git diff --check
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.

View file

@ -0,0 +1,35 @@
# Complete - m-milestone-work-item-creation-sync/06_workspace_provision
## 완료 일시
2026-06-14
## 요약
Filesystem workspace readiness guard 구현을 1회 리뷰 루프로 검토했고 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | `projectsync` provisioner API, filesystem readiness guard, temp dir 회귀 테스트가 계획 범위와 검증 기준을 충족했다. |
## 구현/정리 내용
- `projectsync`에 `ErrWorkspaceProvisionNotReady`, `WorkspaceProvisioner`, `FilesystemWorkspaceProvisioner`를 추가해 prepared checkout 경로와 `.git` metadata 존재를 명시적으로 검증한다.
- `provision_test.go`에 성공, missing checkout, missing `.git`, canceled context 회귀 테스트를 추가했다.
## 최종 검증
- `gofmt -l services/core/internal/projectsync/provision.go services/core/internal/projectsync/provision_test.go` - PASS; 출력 없음.
- `cd services/core && go test -count=1 ./internal/projectsync` - PASS; `ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.003s`.
- `cd services/core && go test ./...` - PASS; core 전체 package 테스트 성공, 일부 package는 cache 또는 no test files.
- `git diff --check` - PASS; 출력 없음.
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -42,17 +42,17 @@ task=m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline, pla
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Pipeline provision step before slot reservation | [ ] |
| [API-2] HTTP binder provision and default slot registration | [ ] |
| [API-3] Provision error HTTP mapping | [ ] |
| [API-1] Pipeline provision step before slot reservation | [x] |
| [API-2] HTTP binder provision and default slot registration | [x] |
| [API-3] Provision error HTTP mapping | [x] |
## 구현 체크리스트
- [ ] `workitempipeline.ProjectBinder`에 project workspace provision step을 추가하고 `CreateTaskFromWorkItem`이 slot 예약/provider fetch 전에 호출하도록 한다.
- [ ] HTTP project sync binder가 provisioner를 실행하고 기본 `slots/000` row를 기존 `UpsertWorkspaceSlot`로 등록하도록 연결한다.
- [ ] provision failure를 HTTP service error로 명시 매핑하고 pipeline/HTTP 회귀 테스트를 추가한다.
- [ ] focused/final 검증을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `workitempipeline.ProjectBinder`에 project workspace provision step을 추가하고 `CreateTaskFromWorkItem`이 slot 예약/provider fetch 전에 호출하도록 한다.
- [x] HTTP project sync binder가 provisioner를 실행하고 기본 `slots/000` row를 기존 `UpsertWorkspaceSlot`로 등록하도록 연결한다.
- [x] provision failure를 HTTP service error로 명시 매핑하고 pipeline/HTTP 회귀 테스트를 추가한다.
- [x] focused/final 검증을 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
@ -74,16 +74,15 @@ task=m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline, pla
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
계획과 동일하게 구현 완료되었으며 변경 사항은 없습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `projectSyncBinder` 구조체에 `WorkspaceProvisioner` 필드를 주입하여 런타임에서는 파일 시스템 검증에 실재하는 `FilesystemWorkspaceProvisioner`를 기본으로 주입하고, 테스트 중에는 `fakeWorkspaceProvisioner` 목 객체를 주입할 수 있도록 `NewProjectBinderWithProvisioner` 생성자 함수를 추가하였습니다.
- `ProjectBinder` 인터페이스에 `EnsureProjectWorkspace` 함수를 새로 선언함으로써 Slot 예약 및 Provider Fetch 실행 이전 시점에 Workspace 가용성 및 checkout 준비 상태가 만족하는지를 안전하게 가드할 수 있게 설계하였습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음

View file

@ -61,11 +61,13 @@ func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, binder wo
// available workspace slot for it.
type ProjectSyncStore interface {
GetActiveProjectSyncSettingByTarget(ctx context.Context, provider, tenant, project string) (db.ProjectSyncSetting, error)
UpsertWorkspaceSlot(ctx context.Context, args db.UpsertWorkspaceSlotParams) (db.WorkspaceSlot, error)
ReserveWorkspaceSlot(ctx context.Context, projectSyncSettingID int64) (db.WorkspaceSlot, error)
}
type projectSyncBinder struct {
store ProjectSyncStore
store ProjectSyncStore
provisioner projectsync.WorkspaceProvisioner
}
// NewProjectBinder adapts a project sync store (the storage Store) into the
@ -78,7 +80,21 @@ func NewProjectBinder(store ProjectSyncStore) workitempipeline.ProjectBinder {
if store == nil {
return nil
}
return projectSyncBinder{store: store}
return projectSyncBinder{
store: store,
provisioner: projectsync.FilesystemWorkspaceProvisioner{},
}
}
// NewProjectBinderWithProvisioner allows injecting a custom provisioner (e.g. for testing).
func NewProjectBinderWithProvisioner(store ProjectSyncStore, provisioner projectsync.WorkspaceProvisioner) workitempipeline.ProjectBinder {
if store == nil {
return nil
}
return projectSyncBinder{
store: store,
provisioner: provisioner,
}
}
func (b projectSyncBinder) ResolveProjectBinding(ctx context.Context, ref workitem.Ref) (workitempipeline.ProjectBinding, error) {
@ -92,6 +108,28 @@ func (b projectSyncBinder) ResolveProjectBinding(ctx context.Context, ref workit
}, nil
}
func (b projectSyncBinder) EnsureProjectWorkspace(ctx context.Context, binding workitempipeline.ProjectBinding) error {
config, err := binding.Config.Normalize()
if err != nil {
return err
}
plan, err := projectsync.BuildProvisionPlan(config)
if err != nil {
return err
}
if b.provisioner != nil {
if err := b.provisioner.EnsureProvisioned(ctx, plan); err != nil {
return err
}
}
params, err := projectsync.ToUpsertWorkspaceSlotParams(binding.SettingID, projectsync.DefaultSlotIndex, plan.DefaultSlotPath)
if err != nil {
return err
}
_, err = b.store.UpsertWorkspaceSlot(ctx, params)
return err
}
func (b projectSyncBinder) ReserveWorkspaceSlot(ctx context.Context, projectSyncSettingID int64) (projectsync.WorkspaceSlot, error) {
slot, err := b.store.ReserveWorkspaceSlot(ctx, projectSyncSettingID)
if err != nil {
@ -336,6 +374,8 @@ func (h *Handler) writeServiceError(w stdhttp.ResponseWriter, err error) {
writeError(w, stdhttp.StatusServiceUnavailable, "project sync resolver is not configured")
case errors.Is(err, storage.ErrProjectSyncNotFound):
writeError(w, stdhttp.StatusUnprocessableEntity, "no active project sync setting for the work item's project")
case errors.Is(err, projectsync.ErrWorkspaceProvisionNotReady):
writeError(w, stdhttp.StatusConflict, "workspace provision is not ready")
case errors.Is(err, storage.ErrNoAvailableWorkspaceSlot):
writeError(w, stdhttp.StatusConflict, "no available workspace slot")
case errors.Is(err, pgx.ErrNoRows):

View file

@ -488,14 +488,17 @@ func TestCreateWorkItemTaskReturns409WhenNoSlotAvailable(t *testing.T) {
}
type fakeProjectSyncStore struct {
setting db.ProjectSyncSetting
settingErr error
slot db.WorkspaceSlot
slotErr error
gotProvider string
gotTenant string
gotProject string
reservedFor int64
setting db.ProjectSyncSetting
settingErr error
slot db.WorkspaceSlot
slotErr error
gotProvider string
gotTenant string
gotProject string
reservedFor int64
upsertedSlot *db.UpsertWorkspaceSlotParams
upsertSlot db.WorkspaceSlot
upsertErr error
}
func (f *fakeProjectSyncStore) GetActiveProjectSyncSettingByTarget(_ context.Context, provider, tenant, project string) (db.ProjectSyncSetting, error) {
@ -503,6 +506,11 @@ func (f *fakeProjectSyncStore) GetActiveProjectSyncSettingByTarget(_ context.Con
return f.setting, f.settingErr
}
func (f *fakeProjectSyncStore) UpsertWorkspaceSlot(_ context.Context, args db.UpsertWorkspaceSlotParams) (db.WorkspaceSlot, error) {
f.upsertedSlot = &args
return f.upsertSlot, f.upsertErr
}
func (f *fakeProjectSyncStore) ReserveWorkspaceSlot(_ context.Context, projectSyncSettingID int64) (db.WorkspaceSlot, error) {
f.reservedFor = projectSyncSettingID
return f.slot, f.slotErr
@ -583,3 +591,134 @@ func TestNewProjectBinderNilStoreYieldsNil(t *testing.T) {
t.Fatalf("expected nil binder for nil store, got %T", binder)
}
}
type fakeWorkspaceProvisioner struct {
err error
calledWith *projectsync.ProvisionPlan
}
func (f *fakeWorkspaceProvisioner) EnsureProvisioned(_ context.Context, plan projectsync.ProvisionPlan) error {
f.calledWith = &plan
return f.err
}
func TestNewProjectBinderEnsuresWorkspaceAndRegistersDefaultSlot(t *testing.T) {
store := &fakeProjectSyncStore{
setting: db.ProjectSyncSetting{
ID: 5,
Provider: "plane",
Tenant: "acme",
Project: "proj-1",
GitRemoteUrl: "git@example.com:acme/nomadcode.git",
SourceBranch: "develop",
WorkspaceID: "ws-main",
WorkspaceBasePath: "/home/user/workspace",
RepoDirName: "nomadcode",
Active: true,
},
upsertSlot: db.WorkspaceSlot{
ID: 11,
},
}
provisioner := &fakeWorkspaceProvisioner{}
binder := NewProjectBinderWithProvisioner(store, provisioner)
binding := workitempipeline.ProjectBinding{
SettingID: 5,
Config: projectsync.Config{
Target: projectsync.ProviderProjectTarget{
Provider: "plane", Tenant: "acme", Project: "proj-1",
},
GitRemoteURL: "git@example.com:acme/nomadcode.git",
SourceBranch: "develop",
WorkspaceID: "ws-main",
WorkspaceBasePath: "/home/user/workspace",
RepoDirName: "nomadcode",
},
}
err := binder.EnsureProjectWorkspace(context.Background(), binding)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if provisioner.calledWith == nil {
t.Fatal("provisioner.EnsureProvisioned was not called")
}
wantRoot := "/home/user/workspace/nomadcode"
if provisioner.calledWith.ProjectWorkspaceRoot != wantRoot {
t.Errorf("ProjectWorkspaceRoot: got %q, want %q", provisioner.calledWith.ProjectWorkspaceRoot, wantRoot)
}
if store.upsertedSlot == nil {
t.Fatal("store.UpsertWorkspaceSlot was not called")
}
if store.upsertedSlot.ProjectSyncSettingID != 5 {
t.Errorf("upserted ProjectSyncSettingID: got %d, want 5", store.upsertedSlot.ProjectSyncSettingID)
}
if store.upsertedSlot.SlotIndex != int32(projectsync.DefaultSlotIndex) {
t.Errorf("upserted SlotIndex: got %d, want %d", store.upsertedSlot.SlotIndex, projectsync.DefaultSlotIndex)
}
wantSlotPath := "/home/user/workspace/nomadcode/slots/000"
if store.upsertedSlot.Path != wantSlotPath {
t.Errorf("upserted Path: got %q, want %q", store.upsertedSlot.Path, wantSlotPath)
}
}
func TestNewProjectBinderPropagatesProvisionErrorWithoutUpsert(t *testing.T) {
store := &fakeProjectSyncStore{}
provisionErr := errors.New("provision error")
provisioner := &fakeWorkspaceProvisioner{err: provisionErr}
binder := NewProjectBinderWithProvisioner(store, provisioner)
binding := workitempipeline.ProjectBinding{
SettingID: 5,
Config: projectsync.Config{
Target: projectsync.ProviderProjectTarget{
Provider: "plane", Tenant: "acme", Project: "proj-1",
},
GitRemoteURL: "git@example.com:acme/nomadcode.git",
SourceBranch: "develop",
WorkspaceID: "ws-main",
WorkspaceBasePath: "/home/user/workspace",
RepoDirName: "nomadcode",
},
}
err := binder.EnsureProjectWorkspace(context.Background(), binding)
if !errors.Is(err, provisionErr) {
t.Fatalf("expected provision error, got %v", err)
}
if store.upsertedSlot != nil {
t.Error("store.UpsertWorkspaceSlot should not be called on provision failure")
}
}
func TestCreateWorkItemTaskReturns409WhenWorkspaceProvisionNotReady(t *testing.T) {
h := newHandlerForTest(
withProviders(map[workitem.ProviderID]WorkItemTaskCreator{
"jira": &fakeWorkItemTaskCreator{err: projectsync.ErrWorkspaceProvisionNotReady},
}),
)
body := `{"tenant":"acme","project":"proj-1","id":"PROJ-1"}`
req := httptest.NewRequest(http.MethodPost, "/integrations/jira/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router := chi.NewRouter()
router.Post("/integrations/{provider}/tasks", h.CreateWorkItemTask)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !strings.Contains(resp["error"], "workspace provision is not ready") {
t.Errorf("unexpected error: %q", resp["error"])
}
}

View file

@ -0,0 +1,70 @@
package projectsync
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// ErrWorkspaceProvisionNotReady is returned when a workspace is not prepared.
var ErrWorkspaceProvisionNotReady = errors.New("workspace provision is not ready")
// WorkspaceProvisioner defines the interface for ensuring that the required
// workspace paths and Git metadata are prepared.
type WorkspaceProvisioner interface {
EnsureProvisioned(ctx context.Context, plan ProvisionPlan) error
}
// FilesystemWorkspaceProvisioner checks the local filesystem to verify
// that the workspace topology matches the plan.
type FilesystemWorkspaceProvisioner struct{}
// EnsureProvisioned checks that the workspace root is a directory,
// and that main branch, develop branch, and default slot paths are directories
// containing a .git file or directory.
func (FilesystemWorkspaceProvisioner) EnsureProvisioned(ctx context.Context, plan ProvisionPlan) error {
if err := ctx.Err(); err != nil {
return err
}
if err := ensureDirectory(plan.ProjectWorkspaceRoot); err != nil {
return err
}
for _, path := range []string{plan.MainBranchPath, plan.DevelopBranchPath, plan.DefaultSlotPath} {
if err := ctx.Err(); err != nil {
return err
}
if err := ensureGitCheckout(path); err != nil {
return err
}
}
return nil
}
func ensureDirectory(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%w: path %q is not accessible: %v", ErrWorkspaceProvisionNotReady, path, err)
}
if !info.IsDir() {
return fmt.Errorf("%w: path %q is not a directory", ErrWorkspaceProvisionNotReady, path)
}
return nil
}
func ensureGitCheckout(path string) error {
if err := ensureDirectory(path); err != nil {
return err
}
gitPath := filepath.Join(path, ".git")
_, err := os.Stat(gitPath)
if err != nil {
return fmt.Errorf("%w: git metadata is missing at %q: %v", ErrWorkspaceProvisionNotReady, gitPath, err)
}
return nil
}

View file

@ -0,0 +1,114 @@
package projectsync
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestFilesystemWorkspaceProvisionerAcceptsPreparedCheckouts(t *testing.T) {
tmpDir := t.TempDir()
plan := ProvisionPlan{
ProjectWorkspaceRoot: tmpDir,
MainBranchPath: filepath.Join(tmpDir, "branches", "main"),
DevelopBranchPath: filepath.Join(tmpDir, "branches", "develop"),
DefaultSlotPath: filepath.Join(tmpDir, "slots", "000"),
}
// Create directories
for _, path := range []string{plan.MainBranchPath, plan.DevelopBranchPath, plan.DefaultSlotPath} {
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("failed to create dir %s: %v", path, err)
}
// Create .git directory
if err := os.MkdirAll(filepath.Join(path, ".git"), 0755); err != nil {
t.Fatalf("failed to create .git in %s: %v", path, err)
}
}
provisioner := FilesystemWorkspaceProvisioner{}
err := provisioner.EnsureProvisioned(context.Background(), plan)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestFilesystemWorkspaceProvisionerRejectsMissingCheckout(t *testing.T) {
tmpDir := t.TempDir()
plan := ProvisionPlan{
ProjectWorkspaceRoot: tmpDir,
MainBranchPath: filepath.Join(tmpDir, "branches", "main"),
DevelopBranchPath: filepath.Join(tmpDir, "branches", "develop"),
DefaultSlotPath: filepath.Join(tmpDir, "slots", "000"),
}
// Create main and develop only, skip default slot
for _, path := range []string{plan.MainBranchPath, plan.DevelopBranchPath} {
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("failed to create dir %s: %v", path, err)
}
if err := os.MkdirAll(filepath.Join(path, ".git"), 0755); err != nil {
t.Fatalf("failed to create .git in %s: %v", path, err)
}
}
provisioner := FilesystemWorkspaceProvisioner{}
err := provisioner.EnsureProvisioned(context.Background(), plan)
if !errors.Is(err, ErrWorkspaceProvisionNotReady) {
t.Errorf("expected ErrWorkspaceProvisionNotReady error, got %v", err)
}
}
func TestFilesystemWorkspaceProvisionerRejectsPathWithoutGitMetadata(t *testing.T) {
tmpDir := t.TempDir()
plan := ProvisionPlan{
ProjectWorkspaceRoot: tmpDir,
MainBranchPath: filepath.Join(tmpDir, "branches", "main"),
DevelopBranchPath: filepath.Join(tmpDir, "branches", "develop"),
DefaultSlotPath: filepath.Join(tmpDir, "slots", "000"),
}
// Create directories but do not create .git for default slot
for _, path := range []string{plan.MainBranchPath, plan.DevelopBranchPath, plan.DefaultSlotPath} {
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("failed to create dir %s: %v", path, err)
}
}
// Create .git only for main and develop
for _, path := range []string{plan.MainBranchPath, plan.DevelopBranchPath} {
if err := os.MkdirAll(filepath.Join(path, ".git"), 0755); err != nil {
t.Fatalf("failed to create .git in %s: %v", path, err)
}
}
provisioner := FilesystemWorkspaceProvisioner{}
err := provisioner.EnsureProvisioned(context.Background(), plan)
if !errors.Is(err, ErrWorkspaceProvisionNotReady) {
t.Errorf("expected ErrWorkspaceProvisionNotReady error, got %v", err)
}
}
func TestFilesystemWorkspaceProvisionerHonorsCanceledContext(t *testing.T) {
tmpDir := t.TempDir()
plan := ProvisionPlan{
ProjectWorkspaceRoot: tmpDir,
MainBranchPath: filepath.Join(tmpDir, "branches", "main"),
DevelopBranchPath: filepath.Join(tmpDir, "branches", "develop"),
DefaultSlotPath: filepath.Join(tmpDir, "slots", "000"),
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
provisioner := FilesystemWorkspaceProvisioner{}
err := provisioner.EnsureProvisioned(ctx, plan)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}

View file

@ -43,6 +43,7 @@ type ProjectBinding struct {
// atomically so concurrent requests never receive the same slot.
type ProjectBinder interface {
ResolveProjectBinding(ctx context.Context, ref workitem.Ref) (ProjectBinding, error)
EnsureProjectWorkspace(ctx context.Context, binding ProjectBinding) error
ReserveWorkspaceSlot(ctx context.Context, projectSyncSettingID int64) (projectsync.WorkspaceSlot, error)
}
@ -89,6 +90,9 @@ func (s *Service) CreateTaskFromWorkItem(ctx context.Context, input CreateTaskIn
if err != nil {
return storage.Task{}, err
}
if err := s.binder.EnsureProjectWorkspace(ctx, binding); err != nil {
return storage.Task{}, err
}
slot, err := s.binder.ReserveWorkspaceSlot(ctx, binding.SettingID)
if err != nil {
return storage.Task{}, err

View file

@ -44,6 +44,9 @@ type fakeBinder struct {
resolvedRef *workitem.Ref
reservedFor *int64
reserveCalled bool
ensureErr error
ensureCalled bool
ensuredWith *ProjectBinding
}
func (f *fakeBinder) ResolveProjectBinding(_ context.Context, ref workitem.Ref) (ProjectBinding, error) {
@ -51,6 +54,12 @@ func (f *fakeBinder) ResolveProjectBinding(_ context.Context, ref workitem.Ref)
return f.binding, f.resolveErr
}
func (f *fakeBinder) EnsureProjectWorkspace(_ context.Context, binding ProjectBinding) error {
f.ensureCalled = true
f.ensuredWith = &binding
return f.ensureErr
}
func (f *fakeBinder) ReserveWorkspaceSlot(_ context.Context, id int64) (projectsync.WorkspaceSlot, error) {
f.reserveCalled = true
f.reservedFor = &id
@ -74,6 +83,10 @@ func (m *mapBinder) ResolveProjectBinding(_ context.Context, ref workitem.Ref) (
return binding, nil
}
func (m *mapBinder) EnsureProjectWorkspace(_ context.Context, _ ProjectBinding) error {
return nil
}
func (m *mapBinder) ReserveWorkspaceSlot(_ context.Context, id int64) (projectsync.WorkspaceSlot, error) {
slot, ok := m.slots[id]
if !ok {
@ -416,3 +429,26 @@ func TestCreateTaskFromWorkItemBindsConfigAndSlotPerProject(t *testing.T) {
t.Error("unconfigured project should not create a task")
}
}
func TestCreateTaskFromWorkItemBlocksWhenWorkspaceProvisionFails(t *testing.T) {
ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
reader := &fakeReader{item: workitem.WorkItem{Ref: ref, Title: "T"}}
creator := &fakeTaskCreator{task: storage.Task{ID: "x"}}
provisionErr := errors.New("provision error")
binder := &fakeBinder{binding: validBinding(), slot: reservedSlot(), ensureErr: provisionErr}
svc := New(reader, binder, creator)
_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{Ref: ref})
if !errors.Is(err, provisionErr) {
t.Errorf("expected provision error, got %v", err)
}
if binder.reserveCalled {
t.Error("slot reservation should not run when workspace provision fails")
}
if reader.fetchCalled {
t.Error("provider fetch should not run when workspace provision fails")
}
if creator.calledWith != nil {
t.Error("CreateTask should not run when workspace provision fails")
}
}