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:
parent
dd0c4d953b
commit
8803d29554
16 changed files with 1574 additions and 41 deletions
|
|
@ -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이다.
|
||||||
|
|
|
||||||
|
|
@ -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 파일을 작성한다.
|
||||||
|
|
@ -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로 이동한다.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -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` 출력이 없다.
|
||||||
|
|
@ -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 |
|
||||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue