feat: work item creation sync milestone implementation

- Add Plane API client integration for work item creation
- Update HTTP handlers for work item sync endpoints
- Implement workitem provider and pipeline service updates
- Add test cases for Plane client, handlers, and pipeline
- Update milestone documentation
- Archive task artifacts from 09_creation_trigger_gate and 10+09_plane_draft_authoring
This commit is contained in:
toki 2026-06-14 06:38:50 +09:00
parent dd0c4d953b
commit 8803d29554
16 changed files with 1574 additions and 41 deletions

View file

@ -86,22 +86,22 @@ Plane-origin sync가 workspace agent를 실행할 수 있도록 IOP Edge OpenAI-
- [x] [iop-http-channel] Core/runtime이 IOP Edge OpenAI-compatible HTTP를 통해 workspace-bound agent authoring run을 시작하는 최소 통로를 정의한다. 검증: `authoring.BuildAuthoringGenerateInput`이 Plane 티켓 본문, provider work item id, slot workspace path를 `model.GenerateInput`으로 만들고, scheduler가 Plane-origin authoring task를 A2A가 아니라 OpenAI-compatible model bridge로 라우팅한다. - [x] [iop-http-channel] Core/runtime이 IOP Edge OpenAI-compatible HTTP를 통해 workspace-bound agent authoring run을 시작하는 최소 통로를 정의한다. 검증: `authoring.BuildAuthoringGenerateInput`이 Plane 티켓 본문, provider work item id, slot workspace path를 `model.GenerateInput`으로 만들고, scheduler가 Plane-origin authoring task를 A2A가 아니라 OpenAI-compatible model bridge로 라우팅한다.
- [x] [authoring-state] Workspace agent authoring run의 최소 상태 모델을 정의한다. 검증: 첫 구현은 `in_progress`, `succeeded`, `failed`만 상태로 저장하고, stale/timeout은 별도 상태가 아니라 `updated_at` 기준으로 판정한다. - [x] [authoring-state] Workspace agent authoring run의 최소 상태 모델을 정의한다. 검증: 첫 구현은 `in_progress`, `succeeded`, `failed`만 상태로 저장하고, stale/timeout은 별도 상태가 아니라 `updated_at` 기준으로 판정한다.
- [x] [provider-identity] Plane 티켓과 workspace agent 결과를 연결하는 최소 identity를 정의한다. 검증: `ExternalProvider`/`ExternalID` 또는 payload `work_item`의 provider/work item id가 workspace metadata에 남고, pushed Milestone matching은 provider + work item id + Milestone path를 쓰며 별도 `authoring_run_id`를 필수 매칭 키로 쓰지 않는다. - [x] [provider-identity] Plane 티켓과 workspace agent 결과를 연결하는 최소 identity를 정의한다. 검증: `ExternalProvider`/`ExternalID` 또는 payload `work_item`의 provider/work item id가 workspace metadata에 남고, pushed Milestone matching은 provider + work item id + Milestone path를 쓰며 별도 `authoring_run_id`를 필수 매칭 키로 쓰지 않는다.
- [ ] [agent-run-result] Workspace agent authoring run의 성공/실패 판정 기준을 정의한다. 검증: authoring 성공은 별도 구조화 응답이 아니라 `develop` branch에 Milestone 파일 변경이 commit/push된 상태로 판정하며, sync layer의 Plane work item id 검증은 `pushed-milestone-match` 단계에서 처리한다. 실패는 exit code, push 실패, dirty workspace, 충돌 상태로 구분된다. 계획: `agent-task/m-milestone-work-item-creation-sync/08_agent_run_result_gate/` - [x] [agent-run-result] Workspace agent authoring run의 성공/실패 판정 기준을 정의한다. 검증: authoring 성공은 별도 구조화 응답이 아니라 `develop` branch에 Milestone 파일 변경이 commit/push된 상태로 판정하며, sync layer의 Plane work item id 검증은 `pushed-milestone-match` 단계에서 처리한다. 실패는 exit code, push 실패, dirty workspace, 충돌 상태로 구분된다. 계획: `agent-task/m-milestone-work-item-creation-sync/08_agent_run_result_gate/`
- [x] [agent-bridge-lock] Plane authoring 실행 단계의 선행 잠금을 정의한다. 검증: IOP Edge OpenAI-compatible HTTP bridge가 Plane-origin authoring task의 실행 통로가 되었고, authoring 조건이 없거나 workspace checkout metadata가 없으면 일반 실행/준비 흐름으로 남는다. - [x] [agent-bridge-lock] Plane authoring 실행 단계의 선행 잠금을 정의한다. 검증: IOP Edge OpenAI-compatible HTTP bridge가 Plane-origin authoring task의 실행 통로가 되었고, authoring 조건이 없거나 workspace checkout metadata가 없으면 일반 실행/준비 흐름으로 남는다.
### Epic: [creation-sync] Milestone creation sync ### Epic: [creation-sync] Milestone creation sync
Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 상태까지 수렴시킨다. Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 상태까지 수렴시킨다.
- [ ] [plane-trigger] Plane `Backlog + AGENT assignee` 상위 티켓을 Milestone 생성 trigger로 처리한다. 검증: NomadCode가 자신이 만든 후속 변경을 다시 사용자 trigger로 오인하지 않는 actor guard가 설명되어 있다. - [ ] [plane-trigger] Plane `Backlog + AGENT assignee` 상위 티켓을 Milestone 생성 trigger로 처리한다. 검증: NomadCode가 자신이 만든 후속 변경을 다시 사용자 trigger로 오인하지 않는 actor guard가 설명되어 있다. 계획: `agent-task/m-milestone-work-item-creation-sync/09_creation_trigger_gate/`
- [ ] [plane-agent-authoring] Plane 티켓 제목/본문을 workspace agent authoring run의 입력으로 전달하는 경계를 정의한다. 검증: 정상 happy path에서 필수 agent/model 개입은 1회이며, 결과물은 별도 변환 응답이 아니라 `slots/<index>` workspace 안의 `agent-roadmap` Milestone 파일 작성/갱신과 `develop` commit/push다. 이 단계는 IOP Edge OpenAI-compatible HTTP 통로가 준비된 뒤에만 실행된다. - [x] [plane-agent-authoring] Plane 티켓 제목/본문을 workspace agent authoring run의 입력으로 전달하는 경계를 정의한다. 검증: 정상 happy path에서 필수 agent/model 개입은 1회이며, 결과물은 별도 변환 응답이 아니라 `slots/<index>` workspace 안의 `agent-roadmap` Milestone 파일 작성/갱신과 `develop` commit/push다. 이 단계는 IOP Edge OpenAI-compatible HTTP 통로가 준비된 뒤에만 실행된다.
- [ ] [plane-draft] Plane 티켓 본문을 읽어 workspace agent가 roadmap skill로 Milestone 초안을 생성하거나 갱신하고 `develop`에 반영한다. 검증: 1상위 티켓은 1마일스톤으로 매핑되고 같은 Plane 티켓 재처리 시 중복 Milestone을 만들지 않는다. 이번 범위는 Plane-origin에 한정하되, Milestone 파일 작성과 push는 IDE-origin과 같은 workspace agent 경로를 쓴다. - [ ] [plane-draft] Plane 티켓 본문을 읽어 workspace agent가 roadmap skill로 Milestone 초안을 생성하거나 갱신하고 `develop`에 반영한다. 검증: 1상위 티켓은 1마일스톤으로 매핑되고 같은 Plane 티켓 재처리 시 중복 Milestone을 만들지 않는다. 이번 범위는 Plane-origin에 한정하되, Milestone 파일 작성과 push는 IDE-origin과 같은 workspace agent 경로를 쓴다. 계획: `agent-task/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/`
- [ ] [develop-truth] `develop` branch의 agent-roadmap을 sync source of truth로 정의한다. 검증: feature branch나 로컬 초안은 Plane `Todo` projection 대상이 아니며, Plane `Todo` 티켓은 `develop`에 Milestone 변경이 commit/push된 뒤에만 생성/갱신된다. - [x] [develop-truth] `develop` branch의 agent-roadmap을 sync source of truth로 정의한다. 검증: feature branch나 로컬 초안은 Plane `Todo` projection 대상이 아니며, Plane `Todo` 티켓은 `develop`에 Milestone 변경이 commit/push된 뒤에만 생성/갱신된다.
- [ ] [pushed-milestone-match] Sync layer가 pushed commit과 Plane 티켓을 매칭하는 규칙을 정의한다. 검증: pushed commit 또는 roadmap scan에서 실제 `agent-roadmap/phase/*/milestones/*.md` 변경이 있고, 해당 Milestone에 provider와 Plane work item id가 확인될 때만 Plane 갱신 단계로 넘어간다. - [x] [pushed-milestone-match] Sync layer가 pushed commit과 Plane 티켓을 매칭하는 규칙을 정의한다. 검증: pushed commit 또는 roadmap scan에서 실제 `agent-roadmap/phase/*/milestones/*.md` 변경이 있고, 해당 Milestone에 provider와 Plane work item id가 확인될 때만 Plane 갱신 단계로 넘어간다.
- [ ] [plane-preserve] 원본 Plane 본문을 `사용자 요청:` 댓글로 보존한 뒤, `develop`에 반영된 Milestone 내용을 기준으로 Plane 본문을 치환한다. 검증: 댓글 보존 실패 시 본문 치환으로 넘어가지 않고, develop 반영 전에는 Todo 이동을 하지 않는다. - [x] [plane-preserve] 원본 Plane 본문을 `사용자 요청:` 댓글로 보존한 뒤, `develop`에 반영된 Milestone 내용을 기준으로 Plane 본문을 치환한다. 검증: 댓글 보존 실패 시 본문 치환으로 넘어가지 않고, develop 반영 전에는 Todo 이동을 하지 않는다.
- [ ] [plane-todo] sync layer가 `develop` push 완료 또는 roadmap scan으로 감지한 Milestone을 기준으로 Plane 제목을 `[milestone-id] 제목` 형식으로 바꾸고 티켓을 `Todo`로 이동한다. 검증: milestone id는 `develop`의 Milestone 파일 slug를 기본값으로 쓰고, 실제 identity는 제목이 아니라 identity map의 Milestone path와 provider work item id로 추적된다. - [x] [plane-todo] sync layer가 `develop` push 완료 또는 roadmap scan으로 감지한 Milestone을 기준으로 Plane 제목을 `[milestone-id] 제목` 형식으로 바꾸고 티켓을 `Todo`로 이동한다. 검증: milestone id는 `develop`의 Milestone 파일 slug를 기본값으로 쓰고, 실제 identity는 제목이 아니라 identity map의 Milestone path와 provider work item id로 추적된다.
- [ ] [creation-idmap] Milestone path, Milestone id, provider, work item id, provider revision, roadmap revision을 identity map으로 연결한다. 검증: 같은 Plane 티켓 또는 같은 Milestone path가 재처리되어도 같은 Milestone/work item pair를 찾을 수 있다. - [x] [creation-idmap] Milestone path, Milestone id, provider, work item id, provider revision, roadmap revision을 identity map으로 연결한다. 검증: 같은 Plane 티켓 또는 같은 Milestone path가 재처리되어도 같은 Milestone/work item pair를 찾을 수 있다.
- [ ] [creation-retry] 생성 동기화의 idempotency와 부분 실패 재시도 정책을 정한다. 검증: develop 반영, 댓글 보존, 본문 치환, 제목 변경, Todo 이동 중 일부만 성공해도 재시도 시 중복 생성 없이 남은 단계만 적용된다. - [x] [creation-retry] 생성 동기화의 idempotency와 부분 실패 재시도 정책을 정한다. 검증: develop 반영, 댓글 보존, 본문 치환, 제목 변경, Todo 이동 중 일부만 성공해도 재시도 시 중복 생성 없이 남은 단계만 적용된다.
## 완료 리뷰 ## 완료 리뷰
@ -150,7 +150,9 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토
- Workspace topology 세부 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/01_workspace_topology/complete.log`, `02+01_workspace_slot_store/complete.log`, `03+01,02_project_binding_checkout/complete.log`, `03+02_develop_match/complete.log`, `04+03_plane_todo_projection/complete.log`와 현재 `projectsync`/`workitempipeline` 테스트 근거로 `[branch-workspaces]`, `[slot-layout]`, `[slot-source-branch]`, `[branch-sync-state]`를 완료 처리했다. `[workspace-provision]`은 실제 first-use checkout 준비 검증과 기본 slot 등록, missing workspace 명시 오류 연결이 남아 `agent-task/m-milestone-work-item-creation-sync/06_workspace_provision/``agent-task/m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline/` 계획으로 분리했다. - Workspace topology 세부 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/01_workspace_topology/complete.log`, `02+01_workspace_slot_store/complete.log`, `03+01,02_project_binding_checkout/complete.log`, `03+02_develop_match/complete.log`, `04+03_plane_todo_projection/complete.log`와 현재 `projectsync`/`workitempipeline` 테스트 근거로 `[branch-workspaces]`, `[slot-layout]`, `[slot-source-branch]`, `[branch-sync-state]`를 완료 처리했다. `[workspace-provision]`은 실제 first-use checkout 준비 검증과 기본 slot 등록, missing workspace 명시 오류 연결이 남아 `agent-task/m-milestone-work-item-creation-sync/06_workspace_provision/``agent-task/m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline/` 계획으로 분리했다.
- Workspace provision pipeline 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline/complete.log``Roadmap Completion` 근거로 `[workspace-provision]`을 완료 처리했다. 검증 근거는 workspace provision hook의 slot 예약/provider fetch/task creation 이전 실행, HTTP binder의 default `slots/000` 등록, `ErrWorkspaceProvisionNotReady` 409 매핑, `cd services/core && go test -count=1 ./internal/projectsync ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다. - Workspace provision pipeline 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline/complete.log``Roadmap Completion` 근거로 `[workspace-provision]`을 완료 처리했다. 검증 근거는 workspace provision hook의 slot 예약/provider fetch/task creation 이전 실행, HTTP binder의 default `slots/000` 등록, `ErrWorkspaceProvisionNotReady` 409 매핑, `cd services/core && go test -count=1 ./internal/projectsync ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다.
- Agent bridge 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `authoring.BuildAuthoringGenerateInput`, OpenAI-compatible `/v1/responses` adapter, scheduler authoring route/state metadata, `roadmapsync` identity/develop match 기준을 근거로 `[iop-http-channel]`, `[authoring-state]`, `[provider-identity]`, `[agent-bridge-lock]`을 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/authoring ./internal/scheduler ./internal/roadmapsync` PASS. `[agent-run-result]`는 model bridge 응답 성공과 `develop` push 검증 사이의 성공 판정 경계를 보강해야 하므로 `agent-task/m-milestone-work-item-creation-sync/08_agent_run_result_gate/` 계획으로 분리했다. - Agent bridge 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `authoring.BuildAuthoringGenerateInput`, OpenAI-compatible `/v1/responses` adapter, scheduler authoring route/state metadata, `roadmapsync` identity/develop match 기준을 근거로 `[iop-http-channel]`, `[authoring-state]`, `[provider-identity]`, `[agent-bridge-lock]`을 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/authoring ./internal/scheduler ./internal/roadmapsync` PASS. `[agent-run-result]`는 model bridge 응답 성공과 `develop` push 검증 사이의 성공 판정 경계를 보강해야 하므로 `agent-task/m-milestone-work-item-creation-sync/08_agent_run_result_gate/` 계획으로 분리했다.
- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 완료 근거가 있다. - Agent run result gate 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/08_agent_run_result_gate/complete.log``Roadmap Completion` 근거로 `[agent-run-result]`를 완료 처리했다. 검증 근거는 `DecideAuthoringResult` helper, authoring failure category metadata, scheduler authoring state 보정, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다.
- Creation sync 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `workitem.BuildCreateTaskInput`, `authoring.BuildAuthoringGenerateInput`, scheduler authoring route, `roadmapsync.MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, roadmap sync identity/step store, `ReconcileCreationCycle` 근거로 `[plane-agent-authoring]`, `[develop-truth]`, `[pushed-milestone-match]`, `[plane-preserve]`, `[plane-todo]`, `[creation-idmap]`, `[creation-retry]`를 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/authoring ./internal/roadmapsync ./internal/adapters/plane ./internal/storage ./internal/http ./internal/scheduler` PASS, `git diff --check` PASS. `[plane-trigger]`는 Backlog + AGENT assignee eligibility gate가 아직 없고, `[plane-draft]`는 Milestone 초안 작성/갱신의 provider identity와 중복 방지 authoring contract 보강이 필요해 각각 plan으로 분리했다.
- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 완료 근거가 있다.
- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/<index>` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위는 완료 근거가 있으며, `In Progress` 이후 실행은 후속 잠금 범위다. - 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/<index>` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위는 완료 근거가 있으며, `In Progress` 이후 실행은 후속 잠금 범위다.
- 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다. - 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다.
- Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다. - Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다.

View file

@ -0,0 +1,174 @@
<!-- task=m-milestone-work-item-creation-sync/09_creation_trigger_gate plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-14
task=m-milestone-work-item-creation-sync/09_creation_trigger_gate, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `plane-trigger`: Plane Backlog + AGENT assignee trigger gate
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/09_creation_trigger_gate/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Pipeline Trigger Decision | [x] |
| [API-2] Plane And HTTP Trigger Inputs | [x] |
## 구현 체크리스트
- [x] provider-neutral trigger criteria와 self actor no-op 결정을 추가하고 `workitempipeline.CreateTaskFromWorkItem`에서 task 생성 전 적용한다. 검증: NomadCode self mutation과 state/assignee 불일치가 task 생성 없이 typed no-op으로 끝난다.
- [x] Plane adapter가 fetched work item의 state와 assignee id 후보를 `workitem.WorkItem`에 매핑하도록 보강한다.
- [x] HTTP Plane/generic work item task request가 trigger state, AGENT assignee, actor/self actor 입력을 pipeline으로 전달하고 ignored trigger를 202 no-op 응답으로 반환한다.
- [x] focused Go tests와 `git diff --check`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [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하는지 확인한다.
- [ ] 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] 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로 이동한다.
## 계획 대비 변경 사항
- Plane `assignees` json unmarshal 시 `json.Unmarshal`이 에러를 뱉지 않고 아이디가 빈 값으로 파싱되어, nested object structure unmarshal 로직이 무시되는 현상을 발견했습니다.
- 해결을 위해 unmarshal 결과를 검증하여 수집된 ID가 없는 경우 순차적으로 nested object structure 파싱을 추가 시도하도록 변경했습니다.
## 주요 설계 결정
- `EvaluateCreationTrigger`를 `workitempipeline`에 추가하고, `workitem.WorkItem.HasAssignee` 헬퍼 메서드를 추가하여 DTO 레이어에서 assignee ID 목록을 trim 검사하도록 책임을 나눴습니다.
- `ErrCreationTriggerIgnored` 매핑을 `Handler.writeServiceError`에 추가하여, pipeline에서 trigger가 무시되었을 때 client에 202 Accepted 및 ignored 사유를 명시하도록 HTTP 인터페이스를 개선했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Non-matching Backlog/AGENT criteria and NomadCode self actor path must not create tasks.
- Existing compatibility callers with empty trigger criteria must still create tasks.
- Plane state/assignee parsing must be defensive and must not require secrets or dev-only ids.
- HTTP ignored trigger semantics must not mask real validation, provider, project sync, or slot errors.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.006s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
```
### API-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/adapters/plane ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s
ok github.com/nomadcode/nomadcode-core/internal/http 0.003s
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/adapters/plane ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.006s
ok github.com/nomadcode/nomadcode-core/internal/http 0.003s
$ 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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: [services/core/internal/workitempipeline/service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:104)에서 `EnsureProjectWorkspace`와 [services/core/internal/workitempipeline/service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:107)에서 `ReserveWorkspaceSlot`을 먼저 실행한 뒤 [services/core/internal/workitempipeline/service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:124)에서 trigger를 평가합니다. `ReserveWorkspaceSlot`은 DB slot을 `in_use`로 전환하므로 non-matching Backlog/AGENT 또는 self actor ignored trigger가 HTTP 202 no-op으로 보이더라도 slot을 소비할 수 있습니다. `FetchWorkItem`과 `EvaluateCreationTrigger`를 project binding/config validation 뒤, workspace provision 및 slot reservation 앞에 두고, ignored trigger에서 `EnsureProjectWorkspace`, `ReserveWorkspaceSlot`, `CreateTask`가 호출되지 않음을 테스트하세요. 이때 `Actor`/`SelfActor` 비교도 helper 내부에서 trim-normalize해야 direct pipeline caller가 공백 값으로 self guard를 우회하지 않습니다.
- Required: [services/core/internal/http/handlers_test.go](/config/workspace/nomadcode/services/core/internal/http/handlers_test.go:190)의 legacy Plane route 테스트는 기존 `state_id`/`comment`만 확인하고, 새 `trigger_state_id`, `agent_assignee_id`, `actor`, `self_actor`가 `CreateTaskInput.Trigger`로 전달되는지 검증하지 않습니다. 계획은 Plane compatibility route와 generic route 양쪽의 trigger 전달 테스트를 요구했으므로 legacy `/api/integrations/plane/tasks` 경로에 trigger field assertion을 추가하세요.
- 다음 단계: WARN/FAIL 후속 plan/review 파일을 작성한다.

View file

@ -0,0 +1,174 @@
<!-- task=m-milestone-work-item-creation-sync/09_creation_trigger_gate plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-14
task=m-milestone-work-item-creation-sync/09_creation_trigger_gate, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `plane-trigger`: Plane Backlog + AGENT assignee trigger gate
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/09_creation_trigger_gate/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Trigger Gate Before Side Effects | [x] |
| [REVIEW_API-2] Legacy Plane Route Trigger Coverage | [x] |
## 구현 체크리스트
- [x] ignored trigger를 workspace provision/slot reservation 전에서 판정하도록 `workitempipeline.CreateTaskFromWorkItem` ordering을 조정하고, `EvaluateCreationTrigger`가 `Actor`/`SelfActor`를 trim-normalize해 비교하도록 보강한다.
- [x] ignored trigger 테스트가 `EnsureProjectWorkspace`, `ReserveWorkspaceSlot`, `CreateTask` 미호출을 검증하도록 보강하고 self actor whitespace case를 추가한다.
- [x] legacy Plane compatibility route가 `trigger_state_id`, `agent_assignee_id`, `actor`, `self_actor`를 `CreateTaskInput.Trigger`로 전달하는 테스트를 추가한다.
- [x] focused/full Go tests와 `git diff --check`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [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로 이동한다.
## 계획 대비 변경 사항
- `FetchWorkItem`과 `EvaluateCreationTrigger`가 workspace provision 및 slot reservation 이전에 수행됨에 따라, 기존의 `TestCreateTaskFromWorkItemBlocksWhenNoSlotAvailable` 및 `TestCreateTaskFromWorkItemBlocksWhenWorkspaceProvisionFails` 테스트 코드의 assertion(fetchCalled가 false여야 한다는 항목)이 유효하지 않게 되었습니다. 순서 보장에 부합하도록 해당 실패 테스트 케이스들의 `fetchCalled` 단언문을 `true`로 수정했습니다.
## 주요 설계 결정
- Trigger gate의 판정이 workspace provision 및 slot reservation보다 먼저 실행되도록 순서를 재배열하여 무시된 트리거(ignored trigger) 조건 시 리소스가 불필요하게 선점되는 부작용(side effect)을 근본적으로 방어했습니다.
- pipeline helper 영역에서 공백(whitespace)이 있는 actor명도 정확히 self mutation 필터링에 반응할 수 있게 TrimSpace를 통한 normalization을 수행했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- ignored trigger must not provision workspace, reserve a slot, or create a task.
- actor/self actor comparison must be robust to whitespace at the pipeline helper boundary.
- Existing compatibility callers with empty trigger criteria must still create tasks and reserve slots normally.
- Legacy Plane and generic provider routes must both pass trigger fields into `CreateTaskInput.Trigger`.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/workitempipeline
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s
```
### REVIEW_API-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.005s
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.002s
ok github.com/nomadcode/nomadcode-core/internal/http 0.004s
$ cd services/core && go test ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
ok github.com/nomadcode/nomadcode-core/internal/http 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
(모든 core 패키지 테스트 ok)
$ 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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 완료 처리로 `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,44 @@
# Complete - m-milestone-work-item-creation-sync/09_creation_trigger_gate
## 완료 일시
2026-06-14
## 요약
Plane creation trigger gate follow-up까지 총 2회 리뷰 루프로 완료했으며 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | ignored trigger가 workspace slot reservation 뒤에 판정되는 side effect와 legacy Plane route trigger 전달 테스트 누락으로 follow-up 필요 |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | trigger evaluation을 workspace provision/slot reservation 앞으로 이동하고 legacy Plane route trigger 전달 테스트를 보강 |
## 구현/정리 내용
- Plane/generic work item task intake가 trigger state, AGENT assignee, actor/self actor 값을 pipeline으로 전달하도록 보강했다.
- Plane adapter가 fetched work item state와 assignee id 후보를 provider-neutral `workitem.WorkItem`에 매핑하도록 보강했다.
- `workitempipeline.CreateTaskFromWorkItem`이 ignored trigger를 workspace provision 및 slot reservation 전에 판정하도록 정리했고, self actor 비교를 trim-normalize했다.
- ignored trigger side effect 미발생, empty trigger compatibility, legacy Plane route trigger 전달을 테스트로 고정했다.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http` - PASS; `workitempipeline` 및 `http` focused package가 ok로 종료
- `cd services/core && go test ./...` - PASS; core 전체 package가 ok 또는 no test files로 종료
- `git diff --check` - PASS; 출력 없음
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Completed task ids:
- `plane-trigger`: PASS; evidence=`agent-task/archive/2026/06/m-milestone-work-item-creation-sync/09_creation_trigger_gate/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/09_creation_trigger_gate/code_review_local_G07_1.log`; verification=`cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,194 @@
<!-- task=m-milestone-work-item-creation-sync/09_creation_trigger_gate plan=0 tag=API -->
# Plane Creation Trigger Gate
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증 명령의 실제 stdout/stderr를 붙이고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 종결, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 범위 충돌이 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 실행 명령/출력, 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행/증거 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다.
## 배경
`creation-sync` Epic의 앞단은 Plane `Backlog + AGENT assignee` 상위 티켓만 Milestone 생성 trigger로 받아야 한다. 현재 HTTP/pipeline 경로는 provider work item id만 받으면 task를 만들고, fetched work item의 state/assignee 또는 NomadCode self mutation 여부를 gate하지 않는다. 이 계획은 기존 project sync/slot 예약 흐름은 유지하면서 trigger eligibility와 no-op 결과를 provider-neutral하게 추가한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자에게 묻지 않으며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `plane-trigger`: Plane Backlog + AGENT assignee trigger gate
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `services/core/internal/workitem/provider.go`
- `services/core/internal/workitem/provider_test.go`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`
- `services/core/internal/adapters/plane/client.go`
- `services/core/internal/adapters/plane/client_test.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/authoring/request.go`
- `services/core/internal/authoring/request_test.go`
- `services/core/internal/roadmapsync/retry.go`
- `services/core/internal/roadmapsync/retry_test.go`
- `services/core/internal/storage/roadmap_sync_identities.go`
- `services/core/internal/storage/roadmap_sync_identities_test.go`
- `services/core/internal/storage/roadmap_sync_steps.go`
- `services/core/internal/storage/roadmap_sync_steps_test.go`
### 테스트 환경 규칙
`test_env=local`. `agent-test/local/rules.md`를 읽었고 core 변경은 `agent-test/local/core-smoke.md`, roadmap/task artifact 변경은 `agent-test/local/workspace-ops-smoke.md`를 적용한다. contracts 파일은 읽기만 했고 수정 대상이 아니므로 `contracts-smoke`는 영향 검토용이다. 필수 검증은 core focused `go test -count=1`과 `git diff --check`다. local 규칙의 remote runner 표준은 남아 있으나 이 계획은 repo-local 명령으로 검증 계약을 고정한다.
### 테스트 커버리지 공백
- Trigger state/assignee eligibility: 기존 테스트 없음. `workitempipeline`와 `http` 테스트 추가 필요.
- Plane fetched state/assignee mapping: 기존 테스트 없음. `adapters/plane` 테스트 추가 필요.
- NomadCode self mutation no-op: `roadmapsync.ReconcileCreationCycle` 단위 테스트는 있으나 intake gate 연결 테스트 없음. pipeline 또는 HTTP 테스트 추가 필요.
### 심볼 참조
새 symbol 추가 중심이며 rename/remove 없음. 기존 연결점: `workitem.WorkItem.State`, `workitem.WorkItem.AssigneeIDs`, `workitempipeline.CreateTaskInput`, `Handler.createWorkItemTask`, `plane.Client.FetchWorkItem`, `roadmapsync.ReconcileCreationCycle`.
### 분할 판단
split decision policy를 먼저 평가했다. shared task group은 `m-milestone-work-item-creation-sync`다.
- `09_creation_trigger_gate`: trigger eligibility, actor guard 연결, HTTP no-op semantics. 선행 의존성 없음.
- `10+09_plane_draft_authoring`: authoring draft/idempotency contract. predecessor `09`는 현재 active/archive `complete.log`가 없으므로 implementation은 `09_creation_trigger_gate` PASS 후 시작해야 한다.
이 plan은 `09`만 구현한다. `10`은 별도 active plan이므로 이 파일에서 구현하지 않는다.
### 범위 결정 근거
Plane native webhook signature 검증, polling, Plane custom property, Todo 이후 In Progress 실행 lifecycle은 마일스톤 범위 밖이다. 이번 변경은 existing HTTP intake와 provider-neutral pipeline에 eligibility gate를 추가하는 데 한정한다. AGENT assignee id는 secret이 아니지만 project별 runtime 설정일 수 있으므로 hard-code하지 않고 request/config에서 주입 가능한 입력으로 둔다.
### 빌드 등급
`local-G06`: core HTTP/pipeline/adapter 경계를 건드리지만 파일 수와 테스트 범위가 제한적이고 결정적 단위 테스트로 검증 가능하다.
## 구현 체크리스트
- [ ] provider-neutral trigger criteria와 self actor no-op 결정을 추가하고 `workitempipeline.CreateTaskFromWorkItem`에서 task 생성 전 적용한다. 검증: NomadCode self mutation과 state/assignee 불일치가 task 생성 없이 typed no-op으로 끝난다.
- [ ] Plane adapter가 fetched work item의 state와 assignee id 후보를 `workitem.WorkItem`에 매핑하도록 보강한다.
- [ ] HTTP Plane/generic work item task request가 trigger state, AGENT assignee, actor/self actor 입력을 pipeline으로 전달하고 ignored trigger를 202 no-op 응답으로 반환한다.
- [ ] focused Go tests와 `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Pipeline Trigger Decision
문제: [service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:109)는 fetched work item을 받은 뒤 바로 task input을 만들고 [service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:125)에서 task를 생성한다. `workitem.WorkItem`에는 state/assignee 필드가 이미 있지만 [provider.go](/config/workspace/nomadcode/services/core/internal/workitem/provider.go:74), gate가 없다.
Before:
```go
// services/core/internal/workitempipeline/service.go:109
item, err := s.reader.FetchWorkItem(ctx, input.Ref)
if err != nil {
return storage.Task{}, err
}
createInput, err := workitem.BuildCreateTaskInput(workitem.TaskCreateInput{
Ref: input.Ref,
StateID: input.StateID,
Comment: input.Comment,
}, item)
```
해결 방법: `workitempipeline.CreateTaskInput`에 `Trigger CreationTrigger`를 추가한다. `CreationTrigger`는 `RequiredStateID`, `RequiredAssigneeID`, `Actor`, `SelfActor`를 가진다. `EvaluateCreationTrigger(item, trigger)` helper를 추가해 criteria가 비어 있으면 기존 호환 경로처럼 accept하고, `Actor == SelfActor` 또는 state/assignee 불일치면 `ErrCreationTriggerIgnored`를 반환한다. `CreateTaskFromWorkItem`은 `FetchWorkItem` 후, `BuildCreateTaskInput` 전 gate를 호출한다. self actor guard는 `roadmapsync.ReconcileCreationCycle`과 같은 의미의 reason string을 쓰되, pipeline helper가 `roadmapsync`를 import해 cycle domain을 거꾸로 당기지 않게 한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/workitempipeline/service.go`: trigger input type, typed error, gate helper, `CreateTaskFromWorkItem` 적용.
- [ ] `services/core/internal/workitempipeline/service_test.go`: accepted, wrong state ignored, missing AGENT assignee ignored, self actor ignored, empty trigger compatibility 테스트.
- [ ] `services/core/internal/workitem/provider.go`: 필요하면 assignee matching helper만 추가하고 provider-neutral DTO 의미를 보강한다.
- [ ] `services/core/internal/workitem/provider_test.go`: helper 추가 시 trim/contains 테스트.
테스트 작성: `TestCreateTaskFromWorkItemIgnoresNonMatchingTrigger`, `TestCreateTaskFromWorkItemIgnoresSelfActor`, `TestCreateTaskFromWorkItemEmptyTriggerKeepsCompatibility`를 추가한다.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline
```
### [API-2] Plane And HTTP Trigger Inputs
문제: Plane compatibility route와 generic route는 body에서 provider ref만 만들며 [handlers.go](/config/workspace/nomadcode/services/core/internal/http/handlers.go:284) request shape에 trigger fields가 없다. Plane adapter `FetchWorkItem`도 현재 title/body만 neutral item으로 옮겨 trigger gate가 볼 state/assignee evidence를 제공하지 않는다.
Before:
```go
// services/core/internal/http/handlers.go:284
type createWorkItemTaskRequest struct {
Tenant string `json:"tenant"`
Project string `json:"project"`
ID string `json:"id"`
Provider string `json:"provider,omitempty"`
ExternalURL string `json:"external_url"`
StateID string `json:"state_id"`
Comment string `json:"comment"`
}
```
해결 방법: request에 `trigger_state_id`, `agent_assignee_id`, `actor`, `self_actor`를 추가한다. legacy Plane route에도 같은 optional fields를 추가한다. `createWorkItemTask`가 `workitempipeline.CreationTrigger`를 채워 전달한다. `ErrCreationTriggerIgnored`는 HTTP에서 `202 Accepted`와 `{"status":"ignored","reason":...}`로 반환해 webhook/retry caller가 실패로 보지 않게 한다. Plane adapter는 response의 `state`/`state_id`와 `assignees`/`assignee_ids` 후보를 문자열 id 목록으로 수집해 `workitem.WorkItem.State`와 `AssigneeIDs`에 채운다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/http/handlers.go`: request fields, trigger mapping, 202 ignored response.
- [ ] `services/core/internal/http/handlers_test.go`: Plane/generic route trigger 전달, ignored trigger 202 응답.
- [ ] `services/core/internal/adapters/plane/client.go`: fetched state/assignee mapping.
- [ ] `services/core/internal/adapters/plane/client_test.go`: state and assignee id mapping 테스트.
테스트 작성: `TestCreateWorkItemTaskPassesCreationTrigger`, `TestCreateWorkItemTaskReturns202WhenTriggerIgnored`, `TestFetchWorkItemMapsStateAndAssignees`.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/adapters/plane ./internal/http
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/workitempipeline/service.go` | API-1 |
| `services/core/internal/workitempipeline/service_test.go` | API-1 |
| `services/core/internal/workitem/provider.go` | API-1 |
| `services/core/internal/workitem/provider_test.go` | API-1 |
| `services/core/internal/http/handlers.go` | API-2 |
| `services/core/internal/http/handlers_test.go` | API-2 |
| `services/core/internal/adapters/plane/client.go` | API-2 |
| `services/core/internal/adapters/plane/client_test.go` | API-2 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/adapters/plane ./internal/http
git diff --check
```
기대 결과: 모든 package가 `ok`로 종료하고 `git diff --check` 출력이 없다. 캐시 출력은 허용하지 않으며 `-count=1`로 fresh 실행한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,86 @@
<!-- task=m-milestone-work-item-creation-sync/09_creation_trigger_gate plan=1 tag=REVIEW_API -->
# Review Follow-up Plan - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증 명령의 실제 stdout/stderr를 붙이고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 종결, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 범위 충돌이 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 실행 명령/출력, 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행/증거 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다.
## 배경
첫 리뷰(`code_review_local_G06_0.log`)에서 Plane creation trigger gate 구현은 대체로 들어왔지만, ignored trigger가 `ErrCreationTriggerIgnored`로 끝나기 전에 workspace provision/slot reservation side effect를 만들 수 있음이 확인됐다. 또한 계획상 요구된 legacy Plane compatibility route trigger 전달 테스트가 빠져 있다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `plane-trigger`: Plane Backlog + AGENT assignee trigger gate
- Completion mode: check-on-pass
## 범위 결정 근거
이번 follow-up은 리뷰 Required 이슈만 닫는다. Plane webhook signature, draft authoring, Plane state id 설정 방식, roadmap 문서 상태 갱신은 범위 밖이다. 사용자 결정은 필요하지 않으며 repository-local 코드와 테스트로 닫을 수 있다.
## 빌드 등급
`local-G07`: 실패 원인은 명확하고 deterministic하지만, pipeline side effect ordering을 바꾸므로 기존 workspace provision/slot 예약 테스트와 HTTP route 테스트를 함께 검증한다.
## 구현 체크리스트
- [ ] ignored trigger를 workspace provision/slot reservation 전에서 판정하도록 `workitempipeline.CreateTaskFromWorkItem` ordering을 조정하고, `EvaluateCreationTrigger`가 `Actor`/`SelfActor`를 trim-normalize해 비교하도록 보강한다.
- [ ] ignored trigger 테스트가 `EnsureProjectWorkspace`, `ReserveWorkspaceSlot`, `CreateTask` 미호출을 검증하도록 보강하고 self actor whitespace case를 추가한다.
- [ ] legacy Plane compatibility route가 `trigger_state_id`, `agent_assignee_id`, `actor`, `self_actor`를 `CreateTaskInput.Trigger`로 전달하는 테스트를 추가한다.
- [ ] focused/full Go tests와 `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] Trigger Gate Before Side Effects
문제: [service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:104)는 workspace를 보장하고 [service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:107)는 slot을 `in_use`로 예약한 뒤 [service.go](/config/workspace/nomadcode/services/core/internal/workitempipeline/service.go:124)에서 trigger를 평가한다. ignored trigger가 task를 만들지 않더라도 slot을 소비할 수 있다.
해결 방법: project binding resolve와 config validation은 유지하되, provider `FetchWorkItem`과 `EvaluateCreationTrigger`를 `EnsureProjectWorkspace` 및 `ReserveWorkspaceSlot`보다 앞에 둔다. ignored trigger가 반환되면 workspace provision, slot reservation, task creation 모두 발생하지 않아야 한다. `EvaluateCreationTrigger` 내부에서는 `strings.TrimSpace(trigger.Actor)`와 `strings.TrimSpace(trigger.SelfActor)`를 비교해 direct pipeline caller의 공백 값도 self mutation으로 처리한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/workitempipeline/service.go`: trigger evaluation ordering과 actor/self normalization 보강.
- [ ] `services/core/internal/workitempipeline/service_test.go`: ignored state/assignee/self actor 경로에서 `ensureCalled == false`, `reserveCalled == false`, `creator.calledWith == nil` 검증.
- [ ] `services/core/internal/workitempipeline/service_test.go`: actor/self actor whitespace normalization 테스트 추가.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/workitempipeline
```
### [REVIEW_API-2] Legacy Plane Route Trigger Coverage
문제: [handlers_test.go](/config/workspace/nomadcode/services/core/internal/http/handlers_test.go:190)의 legacy Plane compatibility route 테스트는 `trigger_state_id`, `agent_assignee_id`, `actor`, `self_actor` 전달을 검증하지 않는다. 첫 계획은 generic route뿐 아니라 legacy Plane route도 같은 optional trigger fields를 전달하도록 요구했다.
해결 방법: `TestCreatePlaneTaskCompatibilityRouteUsesRegistry`를 확장하거나 별도 테스트를 추가해 `/api/integrations/plane/tasks` body의 trigger fields가 `workitempipeline.CreateTaskInput.Trigger`에 trim된 값으로 들어가는지 검증한다. 기존 ref/state/comment 호환성 assertion은 유지한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/http/handlers_test.go`: legacy Plane route trigger 전달 assertion 추가.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/http
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/workitempipeline/service.go` | REVIEW_API-1 |
| `services/core/internal/workitempipeline/service_test.go` | REVIEW_API-1 |
| `services/core/internal/http/handlers_test.go` | REVIEW_API-2 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http
cd services/core && go test ./...
git diff --check
```
기대 결과: focused package와 전체 core package가 실패 없이 종료하고 `git diff --check` 출력이 없다.

View file

@ -0,0 +1,158 @@
<!-- task=m-milestone-work-item-creation-sync/10+09_plane_draft_authoring plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-14
task=m-milestone-work-item-creation-sync/10+09_plane_draft_authoring, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `plane-draft`: Plane ticket body to idempotent Milestone draft authoring
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Preserve Full Work Item Identity For Authoring | [ ] |
| [API-2] Idempotent Milestone Draft Instructions | [ ] |
| [API-3] Scheduler Authoring Contract Regression | [ ] |
## 구현 체크리스트
- [ ] `09_creation_trigger_gate` predecessor complete.log가 active 또는 archive 경로에 있는지 확인하고, 없으면 구현하지 않고 review stub의 사용자 리뷰 요청이 아니라 plan dependency 미충족으로 보고한다.
- [ ] Plane work item identity context를 authoring input에서 구조적으로 보존한다. 검증: provider, tenant, project, work item id가 instructions 또는 client-side metadata에 포함된다.
- [ ] fallback authoring instructions에 1상위 티켓=1마일스톤, 기존 Provider identity 검색 후 갱신, `## Provider identity` block 작성, `develop` commit/push, duplicate 금지 조건을 명시한다.
- [ ] focused Go tests와 `git diff --check`를 실행한다.
- [ ] 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이 있어 유지했다고 확인한다.
- [ ] 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Implementation must not start until `09_creation_trigger_gate` has `complete.log`.
- Authoring instructions must force Provider identity block creation/update and duplicate prevention.
- Existing OpenAI-compatible `metadata.workspace` shape must remain compatible.
- Scheduler must still treat model response as an authoring run, not as a structured Milestone response.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/authoring ./internal/adapters/openai
(output)
```
### API-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync
(output)
```
### API-3 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/scheduler
(output)
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync ./internal/adapters/openai ./internal/scheduler
(output)
$ git diff --check
(output)
```
---
> **[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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation; when filled, include exact decision, evidence, commands/output, why automatic follow-up cannot resolve it, and resume condition |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |

View file

@ -0,0 +1,217 @@
<!-- task=m-milestone-work-item-creation-sync/10+09_plane_draft_authoring plan=0 tag=API -->
# Plane Draft Authoring Contract
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 반드시 채운다. 구현 후 검증 명령의 실제 stdout/stderr를 붙이고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 종결, log rename, `complete.log`, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 범위 충돌이 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 실행 명령/출력, 재개 조건을 적고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 재실행/증거 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다.
## 배경
Plane-origin authoring route는 IOP Edge HTTP bridge까지 연결되어 있지만, workspace agent에게 1상위 티켓=1마일스톤, provider identity block, 기존 Milestone 갱신 우선, duplicate 금지 같은 초안 작성 계약을 충분히 전달하지 않는다. `roadmapsync.ParseMilestoneIdentity`와 identity map은 후단 매칭/재시도 기준을 갖고 있으므로, authoring 입력이 그 기준을 안정적으로 생산하도록 보강해야 한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-local-G06.md``사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자에게 묻지 않으며, code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- Task ids:
- `plane-draft`: Plane ticket body to idempotent Milestone draft authoring
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `services/core/internal/model/model.go`
- `services/core/internal/adapters/openai/client.go`
- `services/core/internal/adapters/openai/client_test.go`
- `services/core/internal/workitem/provider.go`
- `services/core/internal/workitem/provider_test.go`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`
- `services/core/internal/authoring/request.go`
- `services/core/internal/authoring/request_test.go`
- `services/core/internal/scheduler/jobs.go`
- `services/core/internal/scheduler/jobs_test.go`
- `services/core/internal/roadmapsync/identity.go`
- `services/core/internal/roadmapsync/identity_test.go`
- `services/core/internal/roadmapsync/milestone_parser.go`
- `services/core/internal/roadmapsync/milestone_parser_test.go`
- `services/core/internal/roadmapsync/develop_match.go`
- `services/core/internal/roadmapsync/develop_match_test.go`
- `services/core/internal/roadmapsync/retry.go`
- `services/core/internal/roadmapsync/retry_test.go`
### 테스트 환경 규칙
`test_env=local`. `agent-test/local/rules.md`를 읽었고 core 변경은 `agent-test/local/core-smoke.md`, roadmap/task artifact 변경은 `agent-test/local/workspace-ops-smoke.md`를 적용한다. 필수 검증은 focused `go test -count=1``git diff --check`다. local 규칙의 remote runner 표준은 남아 있으나 이 계획은 repo-local 명령으로 검증 계약을 고정한다.
### 테스트 커버리지 공백
- Authoring instructions include title/body, provider identity, existing Milestone update first, duplicate prevention: 기존 테스트는 workspace path/source branch와 generic fallback만 확인한다. 새 assertion 필요.
- Tenant/project/external URL identity context in authoring input: 기존 `providerIdentity`는 provider/work item id만 반환한다. 새 테스트 필요.
- Provider identity parser and develop match: 기존 `roadmapsync` 테스트가 있음. 이 plan에서는 parser 자체 변경이 필요 없으면 기존 테스트로 회귀 확인한다.
### 심볼 참조
rename/remove 없음. 수정 후보 symbols: `authoring.pipelinePayload`, `authoring.providerIdentity`, `authoring.BuildAuthoringGenerateInput`, `authoring.buildAuthoringInstructions`, `model.WorkspaceMetadata`는 필드 추가가 필요한 경우에만 수정한다.
### 분할 판단
split decision policy를 먼저 평가했다. shared task group은 `m-milestone-work-item-creation-sync`다.
- `09_creation_trigger_gate`: predecessor. 현재 active/archive `complete.log`가 없으므로 이 plan implementation은 시작하지 않는다.
- `10+09_plane_draft_authoring`: 이 plan. 디렉터리명의 `+09`가 runtime 의존성 source of truth다.
의존 관계 및 구현 순서: `agent-task/m-milestone-work-item-creation-sync/09_creation_trigger_gate/complete.log` 또는 matching archive path가 생긴 뒤 이 plan을 구현한다.
### 범위 결정 근거
실제 git push executor, remote develop scanner, Plane Todo projection, Todo 이후 In Progress lifecycle은 기존/다른 Task 소유다. 이번 변경은 workspace agent에게 Milestone 초안 산출 계약을 명시하고, authoring input이 후단 `roadmapsync` identity/match 기준을 생산하도록 만드는 데 한정한다. IOP Edge wire payload는 기존 `metadata.workspace` 호환을 깨지 않는다. provider/work item 세부는 instructions 또는 client-side metadata로 추가한다.
### 빌드 등급
`local-G06`: authoring/model boundary와 scheduler 테스트를 건드릴 수 있지만 핵심은 deterministic prompt contract와 unit tests이며, 후단 identity/match 테스트로 회귀를 닫을 수 있다.
## 구현 체크리스트
- [ ] `09_creation_trigger_gate` predecessor complete.log가 active 또는 archive 경로에 있는지 확인하고, 없으면 구현하지 않고 review stub의 사용자 리뷰 요청이 아니라 plan dependency 미충족으로 보고한다.
- [ ] Plane work item identity context를 authoring input에서 구조적으로 보존한다. 검증: provider, tenant, project, work item id가 instructions 또는 client-side metadata에 포함된다.
- [ ] fallback authoring instructions에 1상위 티켓=1마일스톤, 기존 Provider identity 검색 후 갱신, `## Provider identity` block 작성, `develop` commit/push, duplicate 금지 조건을 명시한다.
- [ ] focused Go tests와 `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
1. `09_creation_trigger_gate`가 PASS되어 `complete.log`를 남긴 뒤 시작한다.
2. Authoring input/context 보강을 먼저 적용한다.
3. Instruction contract와 tests를 갱신한다.
4. focused tests와 `git diff --check`를 실행한다.
### [API-1] Preserve Full Work Item Identity For Authoring
문제: `authoring.providerIdentity`는 [request.go](/config/workspace/nomadcode/services/core/internal/authoring/request.go:49)에서 provider/work item id만 반환한다. `model.WorkspaceMetadata`도 [model.go](/config/workspace/nomadcode/services/core/internal/model/model.go:16)에서 provider/work item id만 담고 tenant/project가 없다. 그런데 `roadmapsync.Identity`의 strict retry key는 [identity.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/identity.go:105)에서 tenant/project까지 비교한다.
Before:
```go
// services/core/internal/authoring/request.go:49
func providerIdentity(task storage.Task, payload pipelinePayload) (provider, workItemID string) {
provider = task.Source
workItemID = task.ID
```
해결 방법: `providerIdentity``workItemIdentity` helper로 바꾸고 provider, tenant, project, work item id, external URL, state id를 payload `work_item`과 task external fields에서 추출한다. `model.WorkspaceMetadata`는 wire 호환을 깨지 않는 범위에서 tenant/project fields를 추가하거나, fields를 추가하지 않는다면 instructions에 identity를 반드시 넣는다. `openai.buildRequestMetadata`는 기존 `metadata.workspace` string behavior를 유지한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/authoring/request.go`: identity struct/helper 추가, `BuildAuthoringGenerateInput`에서 identity context 보존.
- [ ] `services/core/internal/authoring/request_test.go`: tenant/project/work item id/external URL identity assertion.
- [ ] `services/core/internal/model/model.go`: 필요한 경우 `WorkspaceMetadata` additive fields 추가.
- [ ] `services/core/internal/adapters/openai/client.go`: 수정이 필요하다면 `metadata.workspace` 기존 shape 회귀 테스트 유지.
테스트 작성: `TestBuildAuthoringGenerateInputCarriesProviderIdentityContext`를 추가한다. OpenAI adapter를 수정하면 `TestGenerateWorkspaceMetadataUsesFlatWorkspacePath`를 갱신해 기존 flat workspace path가 유지되는지 확인한다.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/authoring ./internal/adapters/openai
```
### [API-2] Idempotent Milestone Draft Instructions
문제: fallback instructions는 [request.go](/config/workspace/nomadcode/services/core/internal/authoring/request.go:122)에서 active Phase 아래 Milestone을 작성/갱신하고 develop에 push하라고만 말한다. `roadmapsync.ParseMilestoneIdentity`는 [milestone_parser.go](/config/workspace/nomadcode/services/core/internal/roadmapsync/milestone_parser.go:39)에서 strict Provider identity block을 요구하지만, authoring prompt는 그 block 작성, 기존 block 검색, duplicate 금지를 명시하지 않는다.
Before:
```go
// services/core/internal/authoring/request.go:124
b.WriteString("Your task is to author or update the agent-roadmap Milestone file for the following Plane work item.\n\n")
b.WriteString("Use the roadmap skill to create or update the Milestone under the active Phase, then commit and push to the develop branch.\n\n")
```
해결 방법: `buildAuthoringInstructions`가 identity context를 받아 다음 계약을 명시한다.
```text
- One Plane top-level work item maps to exactly one agent-roadmap Milestone.
- First search active Milestones for a Provider identity block matching provider/tenant/project/work item id; update that file if found.
- If none exists, create one Milestone under the active Phase and include a `## Provider identity` block.
- The block must include provider, tenant, project, work item id, and milestone id/roadmap item id when known.
- Commit and push the Milestone file change to develop from the slot checkout.
- Do not create duplicate Milestones or Plane child work items in this slice.
```
수정 파일 및 체크리스트:
- [ ] `services/core/internal/authoring/request.go`: fallback instructions 보강.
- [ ] `services/core/internal/authoring/request_test.go`: instructions에 provider identity block, duplicate prevention, active Milestone search, develop push가 포함되는지 assertion.
- [ ] `services/core/internal/roadmapsync/milestone_parser_test.go`: parser compatibility 회귀를 focused command로 실행.
테스트 작성: `TestBuildAuthoringGenerateInputFallbackInstructionsRequireIdentityBlockAndNoDuplicate`를 추가한다.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync
```
### [API-3] Scheduler Authoring Contract Regression
문제: scheduler는 Plane-origin task를 IOP model bridge로 라우팅하지만 [jobs.go](/config/workspace/nomadcode/services/core/internal/scheduler/jobs.go:126) 이후에는 model response를 별도 draft artifact로 해석하지 않는다. 이 plan은 별도 변환 응답을 만들지 않는 기존 경계를 유지하면서 authoring request contract만 강화해야 한다.
해결 방법: scheduler behavior는 유지한다. `TestRunTaskRoutesPlaneOriginToAuthoringBridge`, `TestRunTaskAuthoringPreemptsA2AClient`, `TestRunTaskAuthoringSuccessRecordsState`가 계속 통과해야 한다. instruction/input 변경으로 captured `model.GenerateInput` assertions가 필요하면 scheduler test에 최소 assertion만 추가한다.
수정 파일 및 체크리스트:
- [ ] `services/core/internal/scheduler/jobs_test.go`: 필요 시 captured input에 identity contract가 전달되는지 regression assertion 추가.
- [ ] `services/core/internal/scheduler/jobs.go`: behavior 변경은 피한다. 불가피하면 계획 대비 변경 사항에 이유를 기록한다.
테스트 작성: scheduler behavior가 바뀌지 않으면 기존 tests만 사용한다. `GenerateInput` assertions를 보강하면 `jobs_test.go`에 추가한다.
중간 검증:
```bash
cd services/core && go test -count=1 ./internal/scheduler
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/authoring/request.go` | API-1, API-2 |
| `services/core/internal/authoring/request_test.go` | API-1, API-2 |
| `services/core/internal/model/model.go` | API-1 |
| `services/core/internal/adapters/openai/client.go` | API-1 |
| `services/core/internal/adapters/openai/client_test.go` | API-1 |
| `services/core/internal/roadmapsync/milestone_parser_test.go` | API-2 |
| `services/core/internal/scheduler/jobs.go` | API-3 |
| `services/core/internal/scheduler/jobs_test.go` | API-3 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync ./internal/adapters/openai ./internal/scheduler
git diff --check
```
기대 결과: 모든 package가 `ok`로 종료하고 `git diff --check` 출력이 없다. 캐시 출력은 허용하지 않으며 `-count=1`로 fresh 실행한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -48,11 +48,15 @@ type WorkItemRef struct {
} }
type WorkItem struct { type WorkItem struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
DescriptionHTML string `json:"description_html"` DescriptionHTML string `json:"description_html"`
DescriptionStripped string `json:"description_stripped"` DescriptionStripped string `json:"description_stripped"`
State string `json:"state_id"`
StateObj json.RawMessage `json:"state"`
AssigneeIDs []string `json:"assignee_ids"`
Assignees json.RawMessage `json:"assignees"`
} }
type CreateIssueInput struct { type CreateIssueInput struct {
@ -122,11 +126,67 @@ func (c *Client) FetchWorkItem(ctx context.Context, ref workitem.Ref) (workitem.
if err != nil { if err != nil {
return workitem.WorkItem{}, err return workitem.WorkItem{}, err
} }
state := item.State
if state == "" && len(item.StateObj) > 0 {
var stateStr string
if err := json.Unmarshal(item.StateObj, &stateStr); err == nil {
state = stateStr
} else {
var stateStruct struct {
ID string `json:"id"`
}
if err := json.Unmarshal(item.StateObj, &stateStruct); err == nil {
state = stateStruct.ID
}
}
}
var assigneeIDs []string
if len(item.AssigneeIDs) > 0 {
assigneeIDs = item.AssigneeIDs
} else if len(item.Assignees) > 0 {
var listStr []string
if err := json.Unmarshal(item.Assignees, &listStr); err == nil && len(listStr) > 0 && listStr[0] != "" {
assigneeIDs = listStr
} else {
var listObj []struct {
ID string `json:"id"`
}
var parsedIDs []string
if err := json.Unmarshal(item.Assignees, &listObj); err == nil {
for _, obj := range listObj {
if obj.ID != "" {
parsedIDs = append(parsedIDs, obj.ID)
}
}
}
if len(parsedIDs) > 0 {
assigneeIDs = parsedIDs
} else {
var listNested []struct {
Assignee struct {
ID string `json:"id"`
} `json:"assignee"`
}
if err := json.Unmarshal(item.Assignees, &listNested); err == nil {
for _, obj := range listNested {
if obj.Assignee.ID != "" {
assigneeIDs = append(assigneeIDs, obj.Assignee.ID)
}
}
}
}
}
}
return workitem.WorkItem{ return workitem.WorkItem{
Ref: ref, Ref: ref,
Title: item.Name, Title: item.Name,
DescriptionText: firstNonEmpty(item.DescriptionStripped, item.Description), DescriptionText: firstNonEmpty(item.DescriptionStripped, item.Description),
DescriptionHTML: item.DescriptionHTML, DescriptionHTML: item.DescriptionHTML,
State: state,
AssigneeIDs: assigneeIDs,
}, nil }, nil
} }

View file

@ -491,3 +491,82 @@ func TestClientRequiresBaseURLAndToken(t *testing.T) {
t.Fatalf("expected missing config error, got %v", err) t.Fatalf("expected missing config error, got %v", err)
} }
} }
func TestFetchWorkItemMapsStateAndAssignees(t *testing.T) {
// Test case 1: state as string, assignees as list of string
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "work-1",
"name": "Test 1",
"description_stripped": "Desc",
"state": "state-active",
"assignees": []string{"user-a", "user-b"},
})
}))
defer server1.Close()
client1 := NewClient(Config{BaseURL: server1.URL, Token: "t"}, nil)
item1, err := client1.FetchWorkItem(context.Background(), workitem.Ref{Provider: "plane", Tenant: "t", Project: "p", ID: "1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item1.State != "state-active" {
t.Errorf("state: got %q, want 'state-active'", item1.State)
}
if len(item1.AssigneeIDs) != 2 || item1.AssigneeIDs[0] != "user-a" || item1.AssigneeIDs[1] != "user-b" {
t.Errorf("assigneeIDs: got %v", item1.AssigneeIDs)
}
// Test case 2: state as object, assignees as list of objects
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "work-2",
"name": "Test 2",
"state": map[string]string{
"id": "state-uuid",
},
"assignees": []map[string]string{
{"id": "user-uuid-1"},
{"id": "user-uuid-2"},
},
})
}))
defer server2.Close()
client2 := NewClient(Config{BaseURL: server2.URL, Token: "t"}, nil)
item2, err := client2.FetchWorkItem(context.Background(), workitem.Ref{Provider: "plane", Tenant: "t", Project: "p", ID: "2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item2.State != "state-uuid" {
t.Errorf("state: got %q, want 'state-uuid'", item2.State)
}
if len(item2.AssigneeIDs) != 2 || item2.AssigneeIDs[0] != "user-uuid-1" || item2.AssigneeIDs[1] != "user-uuid-2" {
t.Errorf("assigneeIDs: got %v", item2.AssigneeIDs)
}
// Test case 3: assignees as nested objects (e.g. assignee detail)
server3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "work-3",
"name": "Test 3",
"state_id": "state-direct",
"assignees": []map[string]any{
{"assignee": map[string]string{"id": "user-nested-1"}},
},
})
}))
defer server3.Close()
client3 := NewClient(Config{BaseURL: server3.URL, Token: "t"}, nil)
item3, err := client3.FetchWorkItem(context.Background(), workitem.Ref{Provider: "plane", Tenant: "t", Project: "p", ID: "3"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item3.State != "state-direct" {
t.Errorf("state: got %q, want 'state-direct'", item3.State)
}
if len(item3.AssigneeIDs) != 1 || item3.AssigneeIDs[0] != "user-nested-1" {
t.Errorf("assigneeIDs: got %v", item3.AssigneeIDs)
}
}

View file

@ -184,12 +184,16 @@ func (h *Handler) CreateTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
} }
type legacyWorkItemTaskRequest struct { type legacyWorkItemTaskRequest struct {
WorkspaceSlug string `json:"workspace_slug"` WorkspaceSlug string `json:"workspace_slug"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
WorkItemID string `json:"work_item_id"` WorkItemID string `json:"work_item_id"`
StateID string `json:"state_id"` StateID string `json:"state_id"`
ExternalURL string `json:"external_url"` ExternalURL string `json:"external_url"`
Comment string `json:"comment"` Comment string `json:"comment"`
TriggerStateID string `json:"trigger_state_id"`
AgentAssigneeID string `json:"agent_assignee_id"`
Actor string `json:"actor"`
SelfActor string `json:"self_actor"`
} }
func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request) { func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
@ -217,6 +221,10 @@ func (h *Handler) CreatePlaneTask(w stdhttp.ResponseWriter, r *stdhttp.Request)
body.ExternalURL = strings.TrimSpace(input.ExternalURL) body.ExternalURL = strings.TrimSpace(input.ExternalURL)
body.StateID = strings.TrimSpace(input.StateID) body.StateID = strings.TrimSpace(input.StateID)
body.Comment = strings.TrimSpace(input.Comment) body.Comment = strings.TrimSpace(input.Comment)
body.TriggerStateID = strings.TrimSpace(input.TriggerStateID)
body.AgentAssigneeID = strings.TrimSpace(input.AgentAssigneeID)
body.Actor = strings.TrimSpace(input.Actor)
body.SelfActor = strings.TrimSpace(input.SelfActor)
// Delegate to generic registry path with provider="plane" // Delegate to generic registry path with provider="plane"
h.createWorkItemTask(w, r, "plane", &body) h.createWorkItemTask(w, r, "plane", &body)
@ -282,13 +290,17 @@ func (input legacyWorkItemTaskRequest) workItemRef(provider workitem.ProviderID)
} }
type createWorkItemTaskRequest struct { type createWorkItemTaskRequest struct {
Tenant string `json:"tenant"` Tenant string `json:"tenant"`
Project string `json:"project"` Project string `json:"project"`
ID string `json:"id"` ID string `json:"id"`
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
ExternalURL string `json:"external_url"` ExternalURL string `json:"external_url"`
StateID string `json:"state_id"` StateID string `json:"state_id"`
Comment string `json:"comment"` Comment string `json:"comment"`
TriggerStateID string `json:"trigger_state_id"`
AgentAssigneeID string `json:"agent_assignee_id"`
Actor string `json:"actor"`
SelfActor string `json:"self_actor"`
} }
func (h *Handler) createWorkItemTask(w stdhttp.ResponseWriter, r *stdhttp.Request, provider string, body *createWorkItemTaskRequest) { func (h *Handler) createWorkItemTask(w stdhttp.ResponseWriter, r *stdhttp.Request, provider string, body *createWorkItemTaskRequest) {
@ -332,6 +344,12 @@ func (h *Handler) createWorkItemTask(w stdhttp.ResponseWriter, r *stdhttp.Reques
Ref: ref, Ref: ref,
StateID: strings.TrimSpace(body.StateID), StateID: strings.TrimSpace(body.StateID),
Comment: strings.TrimSpace(body.Comment), Comment: strings.TrimSpace(body.Comment),
Trigger: workitempipeline.CreationTrigger{
RequiredStateID: strings.TrimSpace(body.TriggerStateID),
RequiredAssigneeID: strings.TrimSpace(body.AgentAssigneeID),
Actor: strings.TrimSpace(body.Actor),
SelfActor: strings.TrimSpace(body.SelfActor),
},
}) })
if err != nil { if err != nil {
h.writeServiceError(w, err) h.writeServiceError(w, err)
@ -378,6 +396,11 @@ func (h *Handler) writeServiceError(w stdhttp.ResponseWriter, err error) {
writeError(w, stdhttp.StatusConflict, "workspace provision is not ready") writeError(w, stdhttp.StatusConflict, "workspace provision is not ready")
case errors.Is(err, storage.ErrNoAvailableWorkspaceSlot): case errors.Is(err, storage.ErrNoAvailableWorkspaceSlot):
writeError(w, stdhttp.StatusConflict, "no available workspace slot") writeError(w, stdhttp.StatusConflict, "no available workspace slot")
case errors.Is(err, workitempipeline.ErrCreationTriggerIgnored):
writeJSON(w, stdhttp.StatusAccepted, map[string]string{
"status": "ignored",
"reason": err.Error(),
})
case errors.Is(err, pgx.ErrNoRows): case errors.Is(err, pgx.ErrNoRows):
writeError(w, stdhttp.StatusNotFound, "task not found") writeError(w, stdhttp.StatusNotFound, "task not found")
default: default:

View file

@ -199,7 +199,7 @@ func TestCreatePlaneTaskCompatibilityRouteUsesRegistry(t *testing.T) {
withPlaneCreator(planeCreator), withPlaneCreator(planeCreator),
) )
body := `{"workspace_slug":"acme","project_id":"proj-1","work_item_id":"work-1","state_id":"state-1","comment":"my note"}` body := `{"workspace_slug":"acme","project_id":"proj-1","work_item_id":"work-1","state_id":"state-1","comment":"my note","trigger_state_id":" state-backlog ","agent_assignee_id":" agent-1 ","actor":" actor-user ","self_actor":" self-bot "}`
req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/tasks", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -224,6 +224,18 @@ func TestCreatePlaneTaskCompatibilityRouteUsesRegistry(t *testing.T) {
if planeCreator.calledWith.Ref.ID != "work-1" { if planeCreator.calledWith.Ref.ID != "work-1" {
t.Errorf("ref.id: got %q", planeCreator.calledWith.Ref.ID) t.Errorf("ref.id: got %q", planeCreator.calledWith.Ref.ID)
} }
if planeCreator.calledWith.Trigger.RequiredStateID != "state-backlog" {
t.Errorf("trigger.RequiredStateID: got %q, want 'state-backlog'", planeCreator.calledWith.Trigger.RequiredStateID)
}
if planeCreator.calledWith.Trigger.RequiredAssigneeID != "agent-1" {
t.Errorf("trigger.RequiredAssigneeID: got %q, want 'agent-1'", planeCreator.calledWith.Trigger.RequiredAssigneeID)
}
if planeCreator.calledWith.Trigger.Actor != "actor-user" {
t.Errorf("trigger.Actor: got %q, want 'actor-user'", planeCreator.calledWith.Trigger.Actor)
}
if planeCreator.calledWith.Trigger.SelfActor != "self-bot" {
t.Errorf("trigger.SelfActor: got %q, want 'self-bot'", planeCreator.calledWith.Trigger.SelfActor)
}
var resp map[string]string var resp map[string]string
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
@ -722,3 +734,74 @@ func TestCreateWorkItemTaskReturns409WhenWorkspaceProvisionNotReady(t *testing.T
t.Errorf("unexpected error: %q", resp["error"]) t.Errorf("unexpected error: %q", resp["error"])
} }
} }
func TestCreateWorkItemTaskPassesCreationTrigger(t *testing.T) {
creator := &fakeWorkItemTaskCreator{
task: storage.Task{ID: "task-123", Status: "pending"},
}
h := newHandlerForTest(
withProviders(map[workitem.ProviderID]WorkItemTaskCreator{
"plane": creator,
}),
)
body := `{
"tenant": "acme",
"project": "proj-1",
"id": "work-1",
"trigger_state_id": "state-backlog",
"agent_assignee_id": "agent-1",
"actor": "user-a",
"self_actor": "nomadcode-bot"
}`
req := httptest.NewRequest(http.MethodPost, "/integrations/plane/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.StatusCreated {
t.Fatalf("expected 201 Created, got %d: %s", rec.Code, rec.Body.String())
}
if creator.calledWith == nil {
t.Fatal("expected CreateTaskFromWorkItem call")
}
trig := creator.calledWith.Trigger
if trig.RequiredStateID != "state-backlog" || trig.RequiredAssigneeID != "agent-1" || trig.Actor != "user-a" || trig.SelfActor != "nomadcode-bot" {
t.Errorf("unexpected trigger passed: %+v", trig)
}
}
func TestCreateWorkItemTaskReturns202WhenTriggerIgnored(t *testing.T) {
creator := &fakeWorkItemTaskCreator{
err: workitempipeline.ErrCreationTriggerIgnored,
}
h := newHandlerForTest(
withProviders(map[workitem.ProviderID]WorkItemTaskCreator{
"plane": creator,
}),
)
body := `{"tenant":"acme","project":"proj-1","id":"work-1"}`
req := httptest.NewRequest(http.MethodPost, "/integrations/plane/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.StatusAccepted {
t.Fatalf("expected 202 Accepted, 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 resp["status"] != "ignored" || resp["reason"] != workitempipeline.ErrCreationTriggerIgnored.Error() {
t.Errorf("unexpected ignored response: %v", resp)
}
}

View file

@ -82,6 +82,19 @@ type WorkItem struct {
Metadata json.RawMessage `json:"metadata,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"`
} }
func (w WorkItem) HasAssignee(id string) bool {
target := strings.TrimSpace(id)
if target == "" {
return false
}
for _, a := range w.AssigneeIDs {
if strings.TrimSpace(a) == target {
return true
}
}
return false
}
type CommentFormat string type CommentFormat string
const ( const (

View file

@ -308,3 +308,22 @@ func TestBuildCreateTaskInputFallsBackToRefIDAndRequiresValidRef(t *testing.T) {
t.Errorf("missing id: expected ErrInvalidRef, got %v", err) t.Errorf("missing id: expected ErrInvalidRef, got %v", err)
} }
} }
func TestWorkItemHasAssignee(t *testing.T) {
item := WorkItem{
AssigneeIDs: []string{" user-1 ", "user-2"},
}
if !item.HasAssignee("user-1") {
t.Error("expected to find user-1")
}
if !item.HasAssignee(" user-2 ") {
t.Error("expected to find user-2 with trimmed spaces")
}
if item.HasAssignee("user-3") {
t.Error("did not expect to find user-3")
}
if item.HasAssignee("") {
t.Error("did not expect to find empty assignee")
}
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"github.com/nomadcode/nomadcode-core/internal/projectsync" "github.com/nomadcode/nomadcode-core/internal/projectsync"
"github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/storage"
@ -17,10 +18,20 @@ import (
// fails explicitly instead of creating a task with an unbound workspace. // fails explicitly instead of creating a task with an unbound workspace.
var ErrProjectSyncNotConfigured = errors.New("project sync resolver is not configured") var ErrProjectSyncNotConfigured = errors.New("project sync resolver is not configured")
var ErrCreationTriggerIgnored = errors.New("creation trigger ignored")
type CreationTrigger struct {
RequiredStateID string
RequiredAssigneeID string
Actor string
SelfActor string
}
type CreateTaskInput struct { type CreateTaskInput struct {
Ref workitem.Ref Ref workitem.Ref
StateID string StateID string
Comment string Comment string
Trigger CreationTrigger
} }
type TaskCreator interface { type TaskCreator interface {
@ -76,9 +87,7 @@ func New(reader workitem.Reader, binder ProjectBinder, tasks TaskCreator) *Servi
} }
func (s *Service) CreateTaskFromWorkItem(ctx context.Context, input CreateTaskInput) (storage.Task, error) { func (s *Service) CreateTaskFromWorkItem(ctx context.Context, input CreateTaskInput) (storage.Task, error) {
// Resolve the project sync binding and reserve a workspace slot before // Resolve the project sync binding first.
// touching the provider so a ticket whose project has no active config,
// an invalid config, or no available slot never reaches task creation.
if s.binder == nil { if s.binder == nil {
return storage.Task{}, ErrProjectSyncNotConfigured return storage.Task{}, ErrProjectSyncNotConfigured
} }
@ -90,6 +99,18 @@ func (s *Service) CreateTaskFromWorkItem(ctx context.Context, input CreateTaskIn
if err != nil { if err != nil {
return storage.Task{}, err return storage.Task{}, err
} }
// Fetch work item and evaluate creation trigger before creating workspace
// side effects (EnsureProjectWorkspace / ReserveWorkspaceSlot).
item, err := s.reader.FetchWorkItem(ctx, input.Ref)
if err != nil {
return storage.Task{}, err
}
if err := EvaluateCreationTrigger(item, input.Trigger); err != nil {
return storage.Task{}, err
}
// Run workspace provision and slot reservation after passing trigger validation.
if err := s.binder.EnsureProjectWorkspace(ctx, binding); err != nil { if err := s.binder.EnsureProjectWorkspace(ctx, binding); err != nil {
return storage.Task{}, err return storage.Task{}, err
} }
@ -106,10 +127,6 @@ func (s *Service) CreateTaskFromWorkItem(ctx context.Context, input CreateTaskIn
return storage.Task{}, err return storage.Task{}, err
} }
item, err := s.reader.FetchWorkItem(ctx, input.Ref)
if err != nil {
return storage.Task{}, err
}
createInput, err := workitem.BuildCreateTaskInput(workitem.TaskCreateInput{ createInput, err := workitem.BuildCreateTaskInput(workitem.TaskCreateInput{
Ref: input.Ref, Ref: input.Ref,
StateID: input.StateID, StateID: input.StateID,
@ -141,3 +158,29 @@ func checkoutTaskMetadata(settingID int64, config projectsync.Config, slot proje
}, },
}) })
} }
func EvaluateCreationTrigger(item workitem.WorkItem, trigger CreationTrigger) error {
actor := strings.TrimSpace(trigger.Actor)
selfActor := strings.TrimSpace(trigger.SelfActor)
if actor != "" && selfActor != "" && actor == selfActor {
return ErrCreationTriggerIgnored
}
if trigger.RequiredStateID == "" && trigger.RequiredAssigneeID == "" {
return nil
}
if trigger.RequiredStateID != "" {
if strings.TrimSpace(item.State) != strings.TrimSpace(trigger.RequiredStateID) {
return ErrCreationTriggerIgnored
}
}
if trigger.RequiredAssigneeID != "" {
if !item.HasAssignee(trigger.RequiredAssigneeID) {
return ErrCreationTriggerIgnored
}
}
return nil
}

View file

@ -319,8 +319,8 @@ func TestCreateTaskFromWorkItemBlocksWhenNoSlotAvailable(t *testing.T) {
if !errors.Is(err, storage.ErrNoAvailableWorkspaceSlot) { if !errors.Is(err, storage.ErrNoAvailableWorkspaceSlot) {
t.Errorf("expected ErrNoAvailableWorkspaceSlot, got %v", err) t.Errorf("expected ErrNoAvailableWorkspaceSlot, got %v", err)
} }
if reader.fetchCalled { if !reader.fetchCalled {
t.Error("provider fetch should not run when no slot is available") t.Error("provider fetch should run before slot reservation")
} }
if creator.calledWith != nil { if creator.calledWith != nil {
t.Error("CreateTask should not run when no slot is available") t.Error("CreateTask should not run when no slot is available")
@ -445,10 +445,174 @@ func TestCreateTaskFromWorkItemBlocksWhenWorkspaceProvisionFails(t *testing.T) {
if binder.reserveCalled { if binder.reserveCalled {
t.Error("slot reservation should not run when workspace provision fails") t.Error("slot reservation should not run when workspace provision fails")
} }
if reader.fetchCalled { if !reader.fetchCalled {
t.Error("provider fetch should not run when workspace provision fails") t.Error("provider fetch should run before workspace provision")
} }
if creator.calledWith != nil { if creator.calledWith != nil {
t.Error("CreateTask should not run when workspace provision fails") t.Error("CreateTask should not run when workspace provision fails")
} }
} }
func TestCreateTaskFromWorkItemIgnoresNonMatchingTrigger(t *testing.T) {
ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
// Test case 1: Wrong state ID
reader1 := &fakeReader{item: workitem.WorkItem{
Ref: ref,
Title: "Wrong State",
State: "state-other",
AssigneeIDs: []string{"agent-1"},
}}
creator1 := &fakeTaskCreator{task: storage.Task{ID: "task-1"}}
binder1 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
svc1 := New(reader1, binder1, creator1)
_, err1 := svc1.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
Ref: ref,
Trigger: CreationTrigger{
RequiredStateID: "state-backlog",
RequiredAssigneeID: "agent-1",
},
})
if !errors.Is(err1, ErrCreationTriggerIgnored) {
t.Errorf("expected ErrCreationTriggerIgnored due to wrong state, got %v", err1)
}
if binder1.ensureCalled {
t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
}
if binder1.reserveCalled {
t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
}
if creator1.calledWith != nil {
t.Error("CreateTask should not be called on ignored trigger")
}
// Test case 2: Missing AGENT assignee
reader2 := &fakeReader{item: workitem.WorkItem{
Ref: ref,
Title: "Missing Assignee",
State: "state-backlog",
AssigneeIDs: []string{"user-1"},
}}
creator2 := &fakeTaskCreator{task: storage.Task{ID: "task-2"}}
binder2 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
svc2 := New(reader2, binder2, creator2)
_, err2 := svc2.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
Ref: ref,
Trigger: CreationTrigger{
RequiredStateID: "state-backlog",
RequiredAssigneeID: "agent-1",
},
})
if !errors.Is(err2, ErrCreationTriggerIgnored) {
t.Errorf("expected ErrCreationTriggerIgnored due to missing assignee, got %v", err2)
}
if binder2.ensureCalled {
t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
}
if binder2.reserveCalled {
t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
}
if creator2.calledWith != nil {
t.Error("CreateTask should not be called on ignored trigger")
}
}
func TestCreateTaskFromWorkItemIgnoresSelfActor(t *testing.T) {
ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
// Test case 1: normal self actor matching
reader1 := &fakeReader{item: workitem.WorkItem{
Ref: ref,
Title: "Self Mutation",
State: "state-backlog",
AssigneeIDs: []string{"agent-1"},
}}
creator1 := &fakeTaskCreator{task: storage.Task{ID: "task-1"}}
binder1 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
svc1 := New(reader1, binder1, creator1)
_, err1 := svc1.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
Ref: ref,
Trigger: CreationTrigger{
RequiredStateID: "state-backlog",
RequiredAssigneeID: "agent-1",
Actor: "nomadcode-bot",
SelfActor: "nomadcode-bot",
},
})
if !errors.Is(err1, ErrCreationTriggerIgnored) {
t.Errorf("expected ErrCreationTriggerIgnored due to self actor, got %v", err1)
}
if binder1.ensureCalled {
t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
}
if binder1.reserveCalled {
t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
}
if creator1.calledWith != nil {
t.Error("CreateTask should not be called on ignored trigger")
}
// Test case 2: whitespace self actor matching (normalization check)
reader2 := &fakeReader{item: workitem.WorkItem{
Ref: ref,
Title: "Self Mutation Whitespace",
State: "state-backlog",
AssigneeIDs: []string{"agent-1"},
}}
creator2 := &fakeTaskCreator{task: storage.Task{ID: "task-2"}}
binder2 := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
svc2 := New(reader2, binder2, creator2)
_, err2 := svc2.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
Ref: ref,
Trigger: CreationTrigger{
RequiredStateID: "state-backlog",
RequiredAssigneeID: "agent-1",
Actor: " nomadcode-bot ",
SelfActor: "nomadcode-bot\n",
},
})
if !errors.Is(err2, ErrCreationTriggerIgnored) {
t.Errorf("expected ErrCreationTriggerIgnored due to whitespace self actor, got %v", err2)
}
if binder2.ensureCalled {
t.Error("EnsureProjectWorkspace should not be called on ignored trigger")
}
if binder2.reserveCalled {
t.Error("ReserveWorkspaceSlot should not be called on ignored trigger")
}
if creator2.calledWith != nil {
t.Error("CreateTask should not be called on ignored trigger")
}
}
func TestCreateTaskFromWorkItemEmptyTriggerKeepsCompatibility(t *testing.T) {
ref := workitem.Ref{Provider: "plane", Tenant: "acme", Project: "proj-1", ID: "work-1"}
reader := &fakeReader{item: workitem.WorkItem{
Ref: ref,
Title: "Empty Trigger",
}}
creator := &fakeTaskCreator{task: storage.Task{ID: "task-1"}}
binder := &fakeBinder{binding: validBinding(), slot: reservedSlot()}
svc := New(reader, binder, creator)
_, err := svc.CreateTaskFromWorkItem(context.Background(), CreateTaskInput{
Ref: ref,
Trigger: CreationTrigger{}, // empty trigger
})
if err != nil {
t.Fatalf("expected compatibility call to succeed, got error: %v", err)
}
if !binder.ensureCalled {
t.Error("EnsureProjectWorkspace should be called on compatible request")
}
if !binder.reserveCalled {
t.Error("ReserveWorkspaceSlot should be called on compatible request")
}
if creator.calledWith == nil {
t.Fatal("expected task creation call")
}
}