feat: plane webhook intake & protosocket consumer wire readiness

- Add Plane webhook handler for issue events (created, state, assignees)
- Add Plane webhook integration tests with testdata fixtures
- Add Gito Protosocket consumer wire readiness milestone
- Add Plane work item webhook intake milestone
- Add agent-task for plane-work-item-webhook-intake (trigger dispatch, idempotency, live smoke)
- Update service config, router, handlers for Plane webhook endpoints
- Add SOPS env setup script and secrets configuration
- Update agent-ops domain rules and phase roadmap
This commit is contained in:
toki 2026-06-14 20:37:48 +09:00
parent 2afa534338
commit f23ba78eba
31 changed files with 3339 additions and 27 deletions

3
.sops.yaml Normal file
View file

@ -0,0 +1,3 @@
creation_rules:
- path_regex: ^secrets/nomadcode\.dev\.sops\.yaml$
age: age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3

View file

@ -71,7 +71,8 @@ NomadCode의 백엔드 오케스트레이션 도메인이다. workflow, scheduli
- NomadCode project state id: Backlog `62d4c50c-0cea-4a76-a0ed-ec97498b2d5f`, Todo `45ba7449-f684-4381-af6d-5854747c5e8d`, In Progress `c6ac1a6b-74d5-47fb-8b36-646d2bf0284d`, User Review `dc4fb920-f763-4ae3-9569-3c5e2d500391`, Done `ea2e5b48-8bf1-4723-b749-de7723be41e9`, Cancelled `f29c06c2-d70c-4b56-a83c-fccc4db60ae4`.
- Plane API 호출은 repo root의 ignored local secret file `.env.plane.local`을 우선 사용한다. 이 파일은 `PLANE_BASE_URL`, `PLANE_TOKEN`, `PLANE_WORKSPACE_SLUG`, `PLANE_PROJECT_ID`를 담고 있으며 `.gitignore`로 제외된다. shell 작업에서는 `set -a; source .env.plane.local; set +a`로 로드한다.
- `.env.plane.local`의 현재 Plane token 계정은 NomadCode project에서 Admin role로 설정되어 있다.
- Plane API token이나 서버 `.env`의 secret 값은 domain rule에 직접 기록하지 않는다. 토큰 파일이 없거나 API 권한이 부족할 때만 서버 내부 조작이 명시적으로 요청되면 `plane-api`의 Django shell/ORM을 사용한다.
- Plane webhook secret은 SOPS encrypted file `secrets/nomadcode.dev.sops.yaml``PLANE_WEBHOOK_SECRET`으로 관리하고, runtime에는 같은 이름의 env로 주입한다. dev Plane UI에서 발급한 webhook secret 원문은 domain rule/roadmap/docs/로그에 기록하지 않는다.
- Plane API token, webhook secret, 서버 `.env`의 secret 값은 domain rule에 직접 기록하지 않는다. 토큰 파일이 없거나 API 권한이 부족할 때만 서버 내부 조작이 명시적으로 요청되면 `plane-api`의 Django shell/ORM을 사용한다.
- Plane custom property는 work item types 기반 기능이며, 현재 NomadCode dev project의 `is_issue_type_enabled``False`다. 무료 self-hosted/free tier에서는 이 기능을 전제로 설계하지 않는다.
- Plane work item 샘플 `NOMAD-13``In Progress` state에 있으며, board state와 agent 내부 실행 상태를 분리하는 예시다. external source/id는 `nomadcode` / `sample-agent-in-progress-state`이고, 라벨 `agent:waiting-user`, `phase:planning`으로 내부 상태를 보드에서 보이게 한다.
- Plane control flow의 board state는 `backlog`, `todo`, `in_progress`, `user_review`, `done`, `cancelled`로 둔다. canonical agent 실행 상태는 core task metadata에 저장하고, provider에는 labels를 우선 투영하며, 자세한 사유는 comment로 남긴다.

View file

@ -6,7 +6,7 @@
## 목표
로드맵 기반 agent-ops 운영 루프, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent 제어 표면을 Core action과 MCP tool 계층으로 분리한다. 현재는 Milestone Work Item Creation Sync를 검토중 완료 후보로 두고, Gito branch event bridge와 실행 lifecycle은 계획 후보로 둔다.
로드맵 기반 agent-ops 운영 루프, Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인, 외부 agent 제어 표면을 Core action과 MCP tool 계층으로 분리한다. 현재는 Milestone Work Item Creation Sync를 검토중 완료 후보로 두고, Plane work item webhook intake, Gito branch event bridge, Gito proto-socket consumer wire readiness, 실행 lifecycle은 계획 후보로 둔다.
## Milestone 흐름
@ -22,10 +22,18 @@
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
- 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 마일스톤이다. `creation-sync-orchestrator` 완료 로그까지 반영되어 모든 기능 Task가 완료 후보가 되었고, 사용자 최종 확인과 archive 승인 대기 상태다.
- [계획] Plane Work Item Webhook Intake
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- 요약: Plane work item 생성, 상태 변경, assignee 변경 webhook을 NomadCode Core가 받아 `Backlog + AGENT assignee` 조건을 재검증하고 기존 Milestone creation sync pipeline을 시작하도록 연결한다. 사용자에게 필요한 webhook URL, 인증 방식, AGENT assignee id, live smoke 티켓 선택을 명시한다.
- [계획] Gito Branch Event Creation Sync Bridge
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md`
- 요약: Gito가 proto-socket `branch.updated` wakeup을 제공하면 NomadCode core가 이를 구독하고 target branch를 재검증/스캔한 뒤 creation sync job으로 enqueue하는 bridge를 구현한다.
- [계획] Gito Proto-Socket Consumer Wire Readiness
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-protosocket-consumer-wire-readiness.md`
- 요약: NomadCode Gito consumer가 실제 Gito `/proto-socket` binary protocol로 subscribe/event를 주고받고, 운용 env와 cross-project smoke 기준을 갖추게 한다.
- [계획] Roadmap Driven Agent-Ops Automation
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md`
- 요약: roadmap/action core와 Plane/Jira 기반 Milestone item 동기화의 상위 방향과 계약을 정리하는 설계 마일스톤이며, 실제 구현은 slice 마일스톤으로 진행한다.

View file

@ -0,0 +1,74 @@
# Milestone: Gito Proto-Socket Consumer Wire Readiness
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
## 목표
NomadCode core의 Gito branch event consumer를 실제 Gito `/proto-socket` wire protocol과 맞춘다. Gito event schema를 NomadCode 전용으로 좁히지 않고, consumer-neutral `branch.updated` base payload를 받아 NomadCode 내부 wakeup과 creation sync enqueue로 매핑하는 MVP 운영 연결 기준을 닫는다.
## 상태
[계획]
## 승격 조건
- 없음
## 구현 잠금
- 상태: 해제
- 결정 필요: 없음
## 범위
- Gito `/proto-socket` binary protobuf transport를 소비하는 NomadCode production transport
- `event.subscribe` request와 `branch.updated` event envelope의 struct payload 변환
- Gito base schema는 다중 consumer용 범용 규약으로 소비하고, NomadCode 전용 힌트가 필요하면 optional metadata/custom field만 사용
- 기존 `gitoevents` decode/filter, `gitosync` scan/enqueue 의미 로직과 wire transport의 결합
- Gito dev server 또는 fixture와 연결하는 cross-project integration smoke
- Gito consumer runtime env와 local/deploy 설정 문서화
## 기능
### Epic: [wire-consumer] Gito wire-compatible consumer
NomadCode가 Gito branch event를 논리 DTO가 아니라 실제 proto-socket wire frame으로 소비하되, Gito의 base event schema를 NomadCode 내부 모델로 오염시키지 않는다.
- [ ] [transport-adapter] JSON text websocket transport를 실제 proto-socket binary/protobuf transport 또는 동등 adapter로 교체한다. 검증: 실제 proto-socket test server가 `event.subscribe`를 받고 `branch.updated`를 binary frame으로 돌려주는 테스트가 통과한다.
- [ ] [schema-boundary] Gito `branch.updated` base payload를 repo/branch/revision/change hint 중심으로 해석하고 NomadCode 전용 값은 optional metadata/custom field가 있을 때만 소비한다. 검증: NomadCode 전용 필드 없이도 branch scan/enqueue wakeup이 동작한다.
- [ ] [envelope-decode] binary frame에서 받은 `structpb.Struct` envelope을 기존 `gitoevents.DecodeBranchUpdatedEnvelope` 입력으로 안전하게 변환한다. 검증: malformed frame, 다른 channel/action, payload 누락, 알 수 없는 metadata/custom field가 side effect 없이 drop 또는 ignore된다.
- [ ] [bridge-e2e] wire-compatible consumer, branch scan, `EnqueueRoadmapCreationSync`까지 이어지는 integration test 또는 dev smoke를 추가한다. 검증: Gito `branch.updated` 한 건이 target branch 재검증 후 정확히 하나의 creation sync job으로 수렴한다.
- [ ] [config-docs] `GITO_PROTO_SOCKET_URL`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH`, `GITO_BRANCH`, `GITO_REMOTE_NAME`, `ROADMAP_CREATION_TODO_STATE_ID` 또는 `PLANE_TODO_STATE_ID` 설정을 README, compose, env sample 중 적절한 위치에 문서화한다. 검증: secret 없이 local/dev consumer를 켤 수 있는 설정 경로가 남는다.
- [ ] [failure-ops] reconnect, not-ready revision, duplicate event, malformed frame 처리와 로그 기준을 정리한다. 검증: 장애 입력이 core server shutdown이나 중복 Plane mutation으로 이어지지 않는다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- Gito의 Forgejo webhook producer 구현
- Gito base event schema를 NomadCode 전용 필드로 확장하는 작업
- Gito provider event dedupe와 listener persistence 고도화
- Plane `Todo -> In Progress` 실행 lifecycle
- Milestone Task와 Plane 하위 티켓 동기화
- Flutter UI에서 branch event 상태를 표시하는 기능
## 작업 컨텍스트
- 관련 경로: `services/core/internal/gitoevents/`, `services/core/internal/gitosync/`, `services/core/cmd/server/main.go`, `services/core/internal/config/`, `services/core/docker-compose.yml`, `README.md`, `packages/contracts/notes/flutter-core-api-candidates.md`
- 외부 계약: Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md`
- 관련 Gito Milestone: `Branch Event Proto-Socket Interop Readiness`
- 표준선(선택): NomadCode는 Gito event payload를 wakeup signal로만 사용하고, 최종 판단은 target branch fetch/scan과 provider identity 재검증으로 수행한다. Gito schema는 여러 consumer를 위한 범용 규약으로 보고, NomadCode 전용 정보는 optional metadata/custom field가 있을 때만 소비한다.
- 선행 작업: Gito Branch Event Creation Sync Bridge의 semantic consumer/scan/enqueue 경계
- 후속 작업: Milestone Execution Lifecycle Sync
- 확인 필요: 없음

View file

@ -0,0 +1,87 @@
# Milestone: Plane Work Item Webhook Intake
## 위치
- Roadmap: `agent-roadmap/ROADMAP.md`
- Phase: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
## 목표
Plane에서 상위 work item 생성, 상태 변경, assignee 변경 이벤트가 발생하면 NomadCode Core가 이를 webhook으로 받아 `Backlog + AGENT assignee` 조건을 재검증하고 기존 Plane-origin Milestone creation sync pipeline을 시작한다. 이 마일스톤이 끝나면 사용자는 Plane UI에서 티켓을 만들고 AGENT에 assign하는 동작만으로 NomadCode authoring flow가 시작되는지 확인할 수 있다.
## 상태
[계획]
## 구현 잠금
- 상태: 해제
- 결정 필요:
- [x] dev Plane에서 호출할 NomadCode Core webhook URL은 `http://toki-labs.com:18010/api/integrations/plane/webhook`로 둔다. `plane.toki-labs.com`은 Plane의 동일 호스트/하위 도메인 차단 규칙에 걸리므로 사용하지 않는다.
- [x] Plane webhook 인증 방식은 Plane UI에서 발급한 webhook secret/signature 검증으로 둔다. secret 원문은 tracked 파일에 기록하지 않고 SOPS encrypted file `secrets/nomadcode.dev.sops.yaml``PLANE_WEBHOOK_SECRET`으로 보관한다.
- [x] AGENT assignee로 사용할 Plane user id는 `5d116a77-d3df-4f54-80bf-eca61e0118c4`로 확인했다.
- [x] webhook-only live smoke는 기존 Backlog item `NOMAD-10`, `NOMAD-11`의 AGENT assign 이벤트로 확인했다.
## 범위
- Plane native webhook payload 샘플을 확보하고 work item 생성, 상태 변경, assignee 변경 이벤트에서 필요한 필드를 식별한다.
- NomadCode Core에 Plane raw webhook receiver endpoint를 추가한다.
- Plane webhook secret/signature 또는 동등한 shared-secret 검증을 구현하고, raw secret은 tracked 파일과 로그에 남기지 않는다.
- webhook payload는 변경 힌트로만 사용하고, 최종 trigger 판정 전 Plane API로 work item을 다시 조회한다.
- 기존 `workitempipeline` trigger gate를 재사용해 `Backlog + AGENT assignee` 조건, actor/self guard, project sync setting, workspace provision, slot reservation 경계를 유지한다.
- 중복 webhook, 재시도, NomadCode self mutation event가 들어와도 중복 task나 중복 Milestone authoring run을 만들지 않도록 idempotency 기준을 고정한다.
- dev Plane project에 webhook을 등록하고, 사용자가 Plane UI에서 상위 티켓 생성/assign만으로 NomadCode flow가 시작되는 smoke를 확인한다.
## 기능
### Epic: [plane-webhook] Plane Webhook Intake
Plane에서 발생한 work item event를 NomadCode creation sync trigger로 안전하게 정규화한다.
- [ ] [payload-sample] dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다. 검증: raw secret/token 없이 work item create/update, state change, assignee change 후보 payload shape를 확인한다.
- [x] [webhook-receiver] `POST /api/integrations/plane/webhook` receiver를 추가하고 webhook auth 또는 shared-secret 검증을 통과한 요청만 처리한다. 검증: HTTP handler 테스트에서 인증 실패, payload 오류, 정상 accepted 응답을 확인한다.
- [ ] [event-normalize] Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다. 검증: payload 필드 누락 또는 stale event가 있어도 fetch 기반 trigger 판정으로 수렴한다.
- [ ] [trigger-dispatch] `Backlog + AGENT assignee` 이벤트만 기존 `workitempipeline.CreateTaskFromWorkItem` 경로로 넘기고 나머지는 side effect 없이 ignored 처리한다. 검증: ignored event가 workspace provision 또는 slot reservation을 발생시키지 않는다.
- [ ] [loop-idempotency] duplicate webhook, provider retry, NomadCode self actor event를 중복 creation task 없이 처리한다. 검증: 같은 provider/work item/revision 재처리와 self actor 입력이 task 중복 생성으로 이어지지 않는다.
- [x] [plane-registration] dev Plane project에 NomadCode webhook을 등록하고 운영자가 바꿔야 하는 URL, secret, event scope를 tracked 문서나 roadmap에 secret 없이 남긴다. 검증: Plane DB/UI에서 active project webhook이 존재하고 target이 NomadCode receiver를 가리키는지 확인한다.
- [ ] [live-smoke] Plane UI에서 상위 티켓을 만들고 Backlog 상태에서 AGENT에 assign하면 NomadCode task가 생성되고 Milestone authoring flow가 시작된다. 검증: 테스트 Plane 티켓 id, Core task id, authoring run 시작 또는 ignored 사유를 secret redaction 상태로 기록한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
## 범위 제외
- Plane `Todo -> In Progress` 실행 lifecycle 시작
- Milestone Task를 Plane 하위 티켓으로 생성하거나 동기화하는 구현
- Gito `branch.updated` event producer 또는 proto-socket consumer 구현
- Jira webhook intake
- provider polling 기반 reconcile loop 구현
- 사용자 승인 없는 archive 이동
## 작업 컨텍스트
- 관련 경로: `services/core/internal/http/`, `services/core/internal/workitempipeline/`, `services/core/internal/adapters/plane/`, `services/core/internal/scheduler/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/config/`, `packages/contracts/notes/`, `agent-roadmap/`
- 표준선: Plane webhook payload는 신뢰할 source of truth가 아니라 wakeup hint로 취급하고, NomadCode는 Plane API 재조회와 기존 trigger gate로 실제 실행 여부를 결정한다.
- 선행 작업: `Milestone Work Item Creation Sync`
- 후속 작업: `Gito Branch Event Creation Sync Bridge`, `Milestone Execution Lifecycle Sync`
- 사용자 액션:
- [x] NomadCode Core webhook endpoint는 dev Plane에서 접근 가능한 `http://toki-labs.com:18010/api/integrations/plane/webhook`로 사용한다.
- [x] webhook secret은 Plane UI에서 발급받았고 `secrets/nomadcode.dev.sops.yaml`에 SOPS로 저장했다. 구현 시 `PLANE_WEBHOOK_SECRET` env로 주입한다. raw secret 값은 roadmap/docs/log에 남기지 않는다.
- [x] AGENT assignee로 쓸 Plane 계정/id는 `5d116a77-d3df-4f54-80bf-eca61e0118c4`다.
- [x] webhook-only live smoke용 기존 Plane 티켓은 `NOMAD-10`, `NOMAD-11`을 사용했다.
- [ ] live smoke 후 Plane UI에서 `Todo` projection 또는 authoring 시작 상태를 확인한다.
- 확인 필요: Plane self-hosted webhook payload의 실제 event type, signature header, custom header 지원 여부를 dev Plane에서 확인해야 한다.
- 작은 작업 처리 결과: `webhook-receiver`는 signed fixture/invalid signature/basic-auth bypass 테스트와 dev Core synthetic/live webhook smoke로 확인했다. `plane-registration`은 dev Plane active webhook `e25a2d41-2c9f-4f5f-a0ce-cad6b993853f`, target `http://toki-labs.com:18010/api/integrations/plane/webhook`, `issue=True`, response 202 발송 로그 8건으로 확인했다.
- payload fixture: assignee update 샘플은 `services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json`에 PII redaction 상태로 남겼다. create/state-change 후보는 `plane_webhook_issue_created.json`, `plane_webhook_issue_state_updated.json`으로 추가했고 live create/state 로그 미확보로 관찰된 issue envelope 기반 synthetic candidate임을 `agent-test/plane-dev.md`에 명시했다. normalize helper(`normalizePlaneWebhookPayload`)는 payload를 wakeup hint로만 보고 provider-neutral ref/actor/change hint만 추출한다.
- plane-webhook 후속 plan:
- `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/PLAN-local-G06.md`: `[payload-sample]`, `[event-normalize]`
- `agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/PLAN-local-G07.md`: `[trigger-dispatch]`
- `agent-task/m-plane-work-item-webhook-intake/03+02_idempotency/PLAN-cloud-G06.md`: `[loop-idempotency]`
- `agent-task/m-plane-work-item-webhook-intake/04+01,02,03_live_smoke/PLAN-local-G05.md`: `[live-smoke]`

View file

@ -0,0 +1,175 @@
<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=0 tag=WEBHOOK_PAYLOAD -->
# Code Review Reference - WEBHOOK_PAYLOAD
> **[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`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.
## 개요
date=2026-06-14
task=m-plane-work-item-webhook-intake/01_payload_normalization, plan=0, tag=WEBHOOK_PAYLOAD
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
- `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 구현 결과를 실제 소스와 대조하고, PASS이면 code-review 절차에 따라 log/complete/archive를 처리한다. 구현 에이전트는 이 섹션을 실행하지 않는다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [WEBHOOK_PAYLOAD-1] Normalize Helper | [x] |
| [WEBHOOK_PAYLOAD-2] Fixtures And Contract Candidates | [x] |
| [WEBHOOK_PAYLOAD-3] Normalize Test Coverage | [x] |
## 구현 체크리스트
- [x] Plane issue create/update/state/assignee payload에서 provider-neutral ref, actor, change hint를 추출하는 normalize helper를 추가한다. 검증: raw secret/token 없이 work item create/update, state change, assignee change 후보 payload shape를 확인한다.
- [x] redacted fixture 또는 문서화된 contract candidate를 추가하고 PII/secret 원문이 포함되지 않음을 확인한다.
- [x] normalize 단위 테스트를 추가해 missing field, stale/irrelevant field, assignee update fixture accepted 경로를 검증한다. 검증: payload 필드 누락 또는 stale event가 있어도 fetch 기반 trigger 판정으로 수렴할 수 있는 ref/hint만 만든다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] active `CODE_REVIEW-*-G??.md`와 `PLAN-*-G??.md`를 `.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] FAIL 후속으로 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-work-item-webhook-intake/01_payload_normalization/`로 이동한다.
- [ ] PASS이고 task group이 `m-plane-work-item-webhook-intake`이므로 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다.
## 계획 대비 변경 사항
- 계획의 `planeWebhookPayload`는 "existing fields plus normalized data support"로만 표기되어 있었다. 실제로는 기존 필드를 유지하면서 `Activity.Actor`(`planeWebhookActor`)만 추가했고, data 파싱은 별도 `planeWebhookData` 구조체(`id`/`project`/`workspace`)로 분리했다. 이유: payload state(state/assignees/labels/body)를 truth로 흡수하지 않도록 식별 필드만 좁게 디코딩하기 위함이다.
- 기존 `stringFieldFromRawJSON` helper는 normalize helper가 같은 역할을 typed로 대체하므로 제거했다. 핸들러 로그는 `event.Ref.ID`/`Project`/`Tenant`/`ChangeField`/`ActorID`만 남긴다.
- workspace 식별자는 `data.workspace`를 우선하고 top-level `workspace_id`로 fallback한다. 둘 다 UUID이며, `FetchWorkItem`이 요구하는 workspace slug 변환은 이 단계 범위가 아니라 `02+01_trigger_dispatch`의 fetch 경로 책임으로 남겼다. 이 단계는 ref/hint만 만든다.
- 계획에 없던 보조 테스트 `TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID`를 추가해 workspace 우선순위/fallback 경계를 고정했다.
## 주요 설계 결정
- `normalizePlaneWebhookPayload(payload) (event, ok, error)` 3값 시그니처: `ok=false`는 work item이 아닌 이벤트(project/cycle/module/빈 event)로 side effect 없이 ignore, `ok=true && err!=nil`은 work item 이벤트이지만 ref 필수 필드(work item id) 누락이다. 핸들러는 두 경우 모두 202 accepted를 반환하되 로그 `reason`을 `irrelevant_event` / `missing_work_item_ref`로 구분한다.
- payload는 wakeup hint로만 취급한다. `state`, `assignees`, `labels`, `description`은 디코딩하지 않으며 ref(`provider=plane`, `tenant`=workspace, `project`, `id`)와 actor/change hint만 추출한다. 최종 trigger 판정은 후속 단계의 Plane API 재조회가 source of truth다 (milestone 표준선과 일치).
- `workitem.NormalizeRef`를 통과시켜 provider/id 필수성과 metadata 기본값을 기존 work item 계약과 동일하게 보장한다.
- create/state fixture는 live WebhookLog를 별도로 확보하지 않아 관찰된 issue envelope 기반 synthetic candidate로 추가하고 `agent-test/plane-dev.md`에 candidate임을 명시했다. PII 필드(email/avatar/profile)는 새 fixture에서 아예 생략해 secret/PII scan 노이즈를 만들지 않았다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Payload normalize가 source-of-truth 판정을 하지 않고 ref/hint만 만든다.
- Fixture/docs에 raw secret/token/PII가 없다.
- Missing/irrelevant payload가 side effect 없이 처리된다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### WEBHOOK_PAYLOAD-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.005s
```
### WEBHOOK_PAYLOAD-2 중간 검증
```bash
$ rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:20: "email": "redacted@example.invalid",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:21: "avatar": "",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:22: "avatar_url": null,
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:69: "email": "redacted@example.invalid",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:70: "avatar": "redacted",
services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json:71: "avatar_url": "redacted",
agent-test/plane-dev.md:3:이 문서는 dev Plane 연동 테스트에서 반복 사용하는 non-secret 식별자를 기록한다.
agent-test/plane-dev.md:4:API token, webhook secret, password 같은 secret 원문은 이 파일에 쓰지 않는다.
agent-test/plane-dev.md:30:- Webhook secret storage: SOPS encrypted `secrets/nomadcode.dev.sops.yaml` key `PLANE_WEBHOOK_SECRET`
agent-test/plane-dev.md:35:`services/core/internal/http/testdata/` 아래 fixture로 payload shape를 고정한다. 모든 fixture는 PII/secret redaction 상태이며 work item id는 placeholder다.
# 기존 assignee fixture의 redacted marker와 문서의 keyword 언급만 매칭됨. 원문 secret/token/password 없음.
# 새 create/state fixture는 PII 필드를 생략해 매칭 0건이다.
```
### WEBHOOK_PAYLOAD-3 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.006s
```
### 최종 검증
```bash
$ cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
(no output)
$ cd services/core && go test -count=1 ./internal/http ./internal/adapters/plane ./internal/workitempipeline
ok github.com/nomadcode/nomadcode-core/internal/http 0.006s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
$ rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
# WEBHOOK_PAYLOAD-2 중간 검증과 동일. 원문 secret/token/password 없음.
$ tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
(sync ok)
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.465s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 1.691s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.867s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 1.276s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 2.052s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 2.351s
ok github.com/nomadcode/nomadcode-core/internal/http 3.703s
ok github.com/nomadcode/nomadcode-core/internal/workitem 3.748s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 3.718s
# 전체 패키지 통과 (생략 패키지 포함 모두 ok / no test files).
```
## 코드리뷰 결과
### 종합 판정
FAIL
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Fail | normalize가 다음 Plane fetch/project binding 경로에서 필수인 ref 필드를 누락해도 성공으로 표시할 수 있다. |
| Completeness | Fail | 계획의 missing/stale field 수렴 검증이 work item id에만 걸려 있고 tenant/project 누락을 닫지 못했다. |
| Test coverage | Fail | missing project, missing workspace/tenant, dispatch-compatible ref shape를 고정하는 테스트가 없다. |
| API contract | Fail | `workitem.Ref`를 소비하는 Plane adapter/project-sync 경계는 tenant/project/id를 요구하는데 normalize 경계는 provider/id만 보장한다. |
| Code quality | Pass | 범위 내 디버그 출력, dead code, 불필요한 상태 흡수는 보이지 않는다. |
| Plan deviation | Fail | provider-neutral ref/hint만 만든다는 방향은 맞지만, fetch 기반 trigger 판정으로 넘길 수 있는 ref 완성 조건을 충족하지 못했다. |
| Verification trust | Pass | 현 checkout에서 대상 패키지 테스트, 전체 core 테스트, secret/PII grep, `git diff --check`를 재실행해 출력이 대체로 일치함을 확인했다. |
### 발견된 문제
- Required: `services/core/internal/http/plane_webhook.go:86`에서 `workspace`와 `project`를 비워 둔 채 `workitem.NormalizeRef`에 넘기면, `workitem.NormalizeRef`는 `provider/id`만 검사하므로 `ok=true`인 normalized event가 만들어진다. 하지만 다음 단계가 쓸 Plane adapter는 `tenant/project/id` 모두를 필수로 보고(`services/core/internal/adapters/plane/client.go:259`), project-sync target도 `provider/tenant/project`를 필수로 본다(`services/core/internal/projectsync/config.go:102`). 이 상태로 follow-up dispatch가 붙으면 missing project/tenant payload가 ignored가 아니라 "normalized=true" ref로 넘어가거나, 빈 project/tenant key로 binding/fetch 실패를 일으킨다. fix: relevant work item event에서는 dispatch-compatible ref tuple을 명시적으로 검증한다. 최소한 trimmed workspace/tenant와 project가 없으면 `ok=true, err!=nil`로 처리하고, tests에 missing project 및 missing workspace/tenant 케이스를 추가한다. 만약 `Tenant`에 workspace id를 유지할 의도라면 Plane adapter/project-sync가 그 key를 실제로 허용한다는 테스트나 변환 경계를 함께 고정한다.
### 다음 단계
WARN/FAIL 후속: `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성해 ref 필수 필드 검증과 회귀 테스트만 좁게 수정한다.

View file

@ -0,0 +1,225 @@
<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=1 tag=REVIEW_WEBHOOK_PAYLOAD -->
# Code Review Reference - REVIEW_WEBHOOK_PAYLOAD
> **[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-plane-work-item-webhook-intake/01_payload_normalization, plan=1, tag=REVIEW_WEBHOOK_PAYLOAD
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
- `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_WEBHOOK_PAYLOAD-1] Dispatch-Compatible Ref Validation | [x] |
## 구현 체크리스트
- [x] `normalizePlaneWebhookPayload`가 relevant Plane work item event에서 dispatch-compatible ref tuple(`tenant/workspace`, `project`, `id`)을 모두 검증하도록 수정한다. 검증: missing project, missing workspace/tenant, top-level workspace fallback cases가 의도한 `ok/error` semantics를 갖는다.
- [x] normalize 단위 테스트를 보강해 missing project, missing workspace/tenant, fixture 기반 full ref의 non-empty tenant/project/id를 고정한다. 검증: downstream Plane adapter/project-sync가 요구하는 ref tuple과 불일치하지 않음을 테스트 이름 또는 assertion으로 드러낸다.
- [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로 이동한다.
## 계획 대비 변경 사항
- plan의 After 스니펫을 그대로 적용했다. workspace/project/id 중 하나라도 비면 `return planeWebhookWorkItemEvent{}, true, workitem.ErrInvalidRef`로 처리한다. 표현 변환은 도입하지 않았다.
- workspace 표현은 변환하지 않고 webhook의 workspace UUID(`data.workspace` 우선, top-level `workspace_id` fallback)를 `Tenant`로 유지했다. slug 변환은 plan 범위 제외(`02+01_trigger_dispatch`의 fetch 경로 책임)이므로, 이 단계는 "비어 있지 않은 tuple"만 보장하고 변환 책임은 downstream에 명시 위임한다. 이를 fixture 테스트에서 실제 workspace UUID(`dadf050e-...`)가 `Tenant`로 들어오는지 assertion으로 고정했다.
- 기존 `TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID`는 plan 권고대로 유지했다(top-level workspace fallback 정상 경로). missing/blank 케이스는 신규 reject 테스트로 분리했다.
## 주요 설계 결정
- relevant work item event에서 dispatch-compatible ref tuple(`workspace/tenant`, `project`, `id`)을 `workitem.NormalizeRef` 호출 전에 명시 검증한다. `workitem.NormalizeRef`는 provider/id만 강제하므로, downstream 계약(`adapters/plane/client.go:259` `planeWorkItemRef`, `projectsync/config.go:102` `ProviderProjectTarget.Normalize`)이 요구하는 tenant/project 필수성을 normalize 경계에서 선제 차단한다.
- 누락 케이스는 irrelevant event(`ok=false`)와 구분해 `ok=true, err=workitem.ErrInvalidRef`로 처리한다. 핸들러는 두 경우 모두 202 accepted를 반환하되 로그 `reason`을 `irrelevant_event` / `missing_work_item_ref`로 이미 구분하고 있어, 핸들러 분기는 추가 변경 없이 새 검증을 그대로 흡수한다.
- 새 reject 테스트(`TestNormalizePlaneWebhookPayloadRejectsMissingProject`, `TestNormalizePlaneWebhookPayloadRejectsMissingWorkspace`)와 fixture의 non-empty tuple assertion으로 downstream ref 계약 불일치를 회귀 테스트로 고정했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Relevant Plane work item event는 dispatch-compatible ref tuple이 완성되지 않으면 `ok=true, err!=nil`로 처리된다.
- Missing project와 missing workspace/tenant가 테스트로 고정되어 있다.
- Assignee fixture 기반 normalize 결과가 non-empty tenant/project/id를 가진다.
- 이번 follow-up은 dispatch/idempotency/live smoke 범위로 새 부작용을 만들지 않는다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_WEBHOOK_PAYLOAD-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.006s
```
### 최종 검증
```bash
$ cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
(no output)
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.006s
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.006s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.010s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.013s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.021s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.009s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.033s
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.004s
ok github.com/nomadcode/nomadcode-core/internal/config 0.003s
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.008s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 0.010s
ok github.com/nomadcode/nomadcode-core/internal/http 0.009s
ok github.com/nomadcode/nomadcode-core/internal/notification 0.002s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.005s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.012s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.006s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.004s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.017s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
$ git diff --check
(no output)
$ tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
sync ok
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.399s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.782s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 1.188s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 1.579s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 1.963s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 2.291s
ok github.com/nomadcode/nomadcode-core/internal/authoring 2.656s
ok github.com/nomadcode/nomadcode-core/internal/config 2.998s
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 3.400s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 3.694s
ok github.com/nomadcode/nomadcode-core/internal/http 3.750s
ok github.com/nomadcode/nomadcode-core/internal/notification 3.945s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 3.911s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 3.503s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 3.754s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 3.768s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 5.689s
ok github.com/nomadcode/nomadcode-core/internal/storage 3.845s
ok github.com/nomadcode/nomadcode-core/internal/workflow 3.797s
ok github.com/nomadcode/nomadcode-core/internal/workitem 3.775s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 3.738s
```
---
> **[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.
## Ownership
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지; 구현 중 직접 질문은 금지; 필요하면 결정 항목, 근거, 명령 출력, 자동 후속 불가 이유, 재개 조건을 기록 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
### 종합 판정
FAIL
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Fail | missing field reject는 닫혔지만 normalized `Ref.Tenant`가 downstream Plane fetch/project-sync가 쓰는 workspace slug가 아니라 workspace UUID로 고정되어 있다. |
| Completeness | Fail | G07 plan의 "downstream이 실제로 소비할 표현" 보장이 충족되지 않았다. |
| Test coverage | Fail | fixture test가 UUID tenant를 고정하지만, 그 값이 `planeWorkItemRef`/project-sync target에서 실제로 소비 가능한지 검증하지 않는다. |
| API contract | Fail | `planeWorkItemRef`는 `WorkspaceSlug`로 API path를 만들고 project-sync 설정도 tenant 예시/테스트가 slug(`general`) 기준인데, normalize 결과는 webhook의 workspace id(`dadf...`)를 tenant로 반환한다. |
| Code quality | Pass | 동작 범위 안에서 불필요한 payload truth 흡수나 debug 출력은 보이지 않는다. stale comment 하나는 리뷰 중 비동작 수정으로 정리했다. |
| Plan deviation | Fail | 구현은 slug 변환을 `02+01_trigger_dispatch` 책임이라고 적었지만, 해당 active plan은 workspace id -> slug 변환 설정/경로를 포함하지 않는다. |
| Verification trust | Pass | `go test -count=1 ./internal/http`, `go test -count=1 ./...`, `git diff --check`를 재실행해 통과를 확인했다. |
### 발견된 문제
- Required: `services/core/internal/http/plane_webhook_test.go:282`가 assignee fixture의 `event.Ref.Tenant`를 workspace UUID(`dadf050e-cd1e-4590-bc33-672511630841`)로 고정하고, `services/core/internal/http/plane_webhook.go:101`도 그 값을 그대로 `workitem.Ref.Tenant`에 넣는다. 하지만 이 ref를 다음 단계가 그대로 넘기면 `services/core/internal/adapters/plane/client.go:259`의 `planeWorkItemRef`는 `Tenant`를 `WorkspaceSlug`로 사용해 `/api/v1/workspaces/{tenant}/...` path를 만들고, project-sync 설정 역시 `provider/tenant/project` target을 slug 예시(`general`)로 다룬다. 현재 G07 plan은 workspace id를 유지할 경우 downstream 경계가 그 표현을 허용한다는 테스트 또는 변환 근거를 요구했지만, 구현은 변환을 후속 dispatch 책임이라고 설명만 했고 `02+01_trigger_dispatch` plan에도 workspace id -> slug 변환 입력이 없다. fix: normalized event가 반환하는 `workitem.Ref.Tenant`를 실제 Plane adapter/project-sync가 소비할 workspace slug로 만들거나, `workitem.Ref` 생성을 dispatch의 workspace-id-to-slug mapping 뒤로 미루고 normalize event에는 raw `WorkspaceID`를 별도 필드/metadata로 보존한다. 어느 쪽이든 test가 "fixture workspace id -> downstream-consumable tenant" 계약을 검증해야 한다.
### 다음 단계
WARN/FAIL 후속: `PLAN-local-G08.md`와 `CODE_REVIEW-local-G08.md`를 작성해 Plane webhook workspace identity를 downstream-consumable tenant 계약으로 고정한다.

View file

@ -0,0 +1,228 @@
<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=2 tag=REVIEW_REVIEW_WEBHOOK_PAYLOAD -->
# Code Review Reference - REVIEW_REVIEW_WEBHOOK_PAYLOAD
> **[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-plane-work-item-webhook-intake/01_payload_normalization, plan=2, tag=REVIEW_REVIEW_WEBHOOK_PAYLOAD
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
- `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-{review_lane}-GNN.md` -> `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` -> `plan_{build_lane}_GNN_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_WEBHOOK_PAYLOAD-1] Separate Raw Workspace ID From Dispatch Ref | [x] |
## 구현 체크리스트
- [x] `planeWebhookWorkItemEvent`가 raw Plane identity(`WorkspaceID`, `ProjectID`, `WorkItemID`)와 actor/change/action hint를 보존하고, raw workspace UUID를 `workitem.Ref.Tenant`로 직접 저장하지 않도록 수정한다. 검증: assignee fixture normalize 결과가 workspace id와 project/work item id를 별도 필드로 가진다.
- [x] workspace slug가 명시적으로 주어질 때만 provider-neutral `workitem.Ref`를 만드는 helper를 추가한다. 검증: helper는 blank slug/project/id를 reject하고, `"general"` slug 입력 시 `Ref.Tenant == "general"`인 Plane ref를 만든다.
- [x] normalize/handler tests를 갱신해 missing workspace/project/id semantics, top-level workspace fallback, fixture raw identity, slug-based ref helper를 고정한다.
- [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로 이동한다.
## 계획 대비 변경 사항
- plan의 Before/After shape를 그대로 적용했다. `planeWebhookWorkItemEvent`에서 `Ref workitem.Ref`를 제거하고 `WorkspaceID`/`ProjectID`/`WorkItemID` raw identity 필드로 교체했으며, `WorkItemRef(workspaceSlug string) (workitem.Ref, error)` helper를 추가했다.
- plan의 helper는 `workitem.NormalizeRef`만 호출하지만, 빈 slug에서 명확한 의미를 주기 위해 helper 안에서 slug/project/id를 trim 후 하나라도 비면 `workitem.ErrInvalidRef`를 먼저 반환하도록 했다(이유: `NormalizeRef`는 tenant 빈 값을 막지 않으므로, slug 누락을 reject하려면 명시 검증이 필요하다). plan 의도(blank slug reject)와 일치한다.
- G07에서 추가했던 fixture의 "non-empty dispatch ref tuple" assertion은 더 이상 `event.Ref`가 없으므로 제거하고, 대신 raw identity assertion + `WorkItemRef("general")` 결과 검증으로 대체했다.
- 핸들러 로그 키 `tenant`(raw UUID였음)를 `workspace_id_normalized`로 바꿔, 정규화된 raw workspace id를 로그하되 dispatch tenant(slug)와 혼동되지 않게 했다.
- 기존 테스트 `TestNormalizePlaneWebhookPayloadBuildsWorkItemRef`는 ref를 더 만들지 않으므로 `...ExtractsRawIdentity`로 의미를 맞춰 이름을 바꿨다.
## 주요 설계 결정
- 핵심 수정: webhook payload의 workspace 값은 **workspace UUID**인데 downstream Plane adapter(`planeWorkItemRef`, `client.go:259`)는 `Tenant`를 **workspace slug**로 사용해 API path를 만들고 project-sync target도 slug(`general`)를 쓴다. 따라서 normalize 단계에서 raw UUID를 `Ref.Tenant`에 넣는 것은 API contract 위반이었다. 이번 단계는 `workitem.Ref` 생성을 dispatch의 workspace-id→slug 매핑 뒤로 미루고, normalize event에는 raw identity만 보존한다.
- slug 매핑/dispatch wiring/config는 plan 범위 제외(`02+01_trigger_dispatch`)이므로 이 단계에 hardcode나 dev-only mapping을 넣지 않았다. slug는 helper 인자로만 받아 호출자가 책임지게 했다.
- `WorkItemRef` helper는 slug가 제공될 때만 ref를 만들고 blank slug/project/id를 reject한다. 이로써 "fixture workspace id → downstream-consumable tenant(slug)" 계약을 테스트(`TestPlaneWebhookWorkItemEventBuildsRefWithWorkspaceSlug`, fixture test의 `WorkItemRef("general")` assertion)로 드러냈다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Raw Plane workspace id와 provider-neutral ref tenant가 섞이지 않는다.
- `workitem.Ref`는 workspace slug가 명시적으로 제공된 뒤에만 만들어진다.
- Fixture tests는 workspace id 보존과 slug-based ref 생성 둘 다 확인한다.
- 이번 follow-up은 dispatch/config/idempotency/live smoke 범위로 새 부작용을 만들지 않는다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_WEBHOOK_PAYLOAD-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.006s
```
### 최종 검증
```bash
$ cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
(no output)
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.007s
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.003s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.009s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.008s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.007s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.007s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.010s
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.004s
ok github.com/nomadcode/nomadcode-core/internal/config 0.002s
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.004s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 0.006s
ok github.com/nomadcode/nomadcode-core/internal/http 0.007s
ok github.com/nomadcode/nomadcode-core/internal/notification 0.003s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.007s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.014s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.005s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.020s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
$ git diff --check
(no output)
$ tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
sync ok
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.817s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 1.224s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.447s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 1.616s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 1.997s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 2.393s
ok github.com/nomadcode/nomadcode-core/internal/authoring 2.723s
ok github.com/nomadcode/nomadcode-core/internal/config 3.039s
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 3.430s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 3.695s
ok github.com/nomadcode/nomadcode-core/internal/http 3.659s
ok github.com/nomadcode/nomadcode-core/internal/notification 3.898s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 3.762s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 3.474s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 3.768s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 3.759s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 5.663s
ok github.com/nomadcode/nomadcode-core/internal/storage 3.827s
ok github.com/nomadcode/nomadcode-core/internal/workflow 3.773s
ok github.com/nomadcode/nomadcode-core/internal/workitem 3.748s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 3.713s
```
---
> **[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.
## Ownership
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지; 구현 중 직접 질문은 금지; 필요하면 결정 항목, 근거, 명령 출력, 자동 후속 불가 이유, 재개 조건을 기록 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
### 종합 판정
PASS
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Pass | raw Plane workspace id와 dispatch-ready `workitem.Ref.Tenant`를 분리했고, slug가 명시된 뒤에만 ref를 만들도록 수정됐다. |
| Completeness | Pass | G08 plan의 raw identity 보존, slug-based ref helper, handler logging/test 갱신이 모두 완료됐다. |
| Test coverage | Pass | missing workspace/project/id, top-level fallback, fixture raw identity, slug 기반 `WorkItemRef("general")`, blank slug/project/id reject가 테스트로 고정됐다. |
| API contract | Pass | `workitem.Ref.Tenant`가 workspace slug 계약임을 helper 경계로 밀어냈고, webhook normalize result는 raw workspace id를 별도 필드로 보존한다. |
| Code quality | Pass | 디버그 출력, dead code, 불필요한 상태 흡수는 보이지 않는다. stale comment 한 줄은 리뷰 중 비동작 수정으로 정리했다. |
| Plan deviation | Pass | slug mapping/config/dispatch는 계획대로 후속 subtask 범위로 남겼고, 이 단계는 raw identity와 helper만 고정했다. |
| Verification trust | Pass | local `go test -count=1 ./internal/http`, local `go test -count=1 ./...`, `git diff --check`, remote runner `go test -count=1 ./...`를 재실행해 통과를 확인했다. |
### 발견된 문제
없음
### 다음 단계
PASS: `complete.log`를 작성하고 task directory를 `agent-task/archive/2026/06/m-plane-work-item-webhook-intake/01_payload_normalization/`로 이동한다.

View file

@ -0,0 +1,47 @@
# Complete - m-plane-work-item-webhook-intake/01_payload_normalization
## 완료 일시
2026-06-14
## 요약
Plane work item webhook payload normalization을 3회 리뷰 루프로 완료했으며 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | relevant work item event에서 project/workspace 누락 payload가 normalized ref로 통과할 수 있어 follow-up 필요. |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | FAIL | raw Plane workspace UUID를 `workitem.Ref.Tenant`에 저장해 downstream workspace slug 계약과 맞지 않아 follow-up 필요. |
| `plan_local_G08_2.log` | `code_review_local_G08_2.log` | PASS | raw workspace id와 slug 기반 dispatch ref 생성을 분리했고, 관련 tests와 검증이 통과했다. |
## 구현/정리 내용
- Plane webhook normalized event가 raw Plane identity(`WorkspaceID`, `ProjectID`, `WorkItemID`)와 actor/change/action hint를 보존하도록 정리했다.
- `WorkItemRef(workspaceSlug)` helper를 추가해 workspace slug가 명시된 뒤에만 provider-neutral `workitem.Ref`를 만들도록 했다.
- missing workspace/project/id, top-level workspace fallback, fixture raw identity, slug 기반 ref helper, blank slug/project/id reject 테스트를 고정했다.
- 리뷰 중 stale comment 한 줄을 raw identity 중심 설명으로 정리했다.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/http` - PASS; `github.com/nomadcode/nomadcode-core/internal/http` 통과.
- `cd services/core && go test -count=1 ./...` - PASS; services/core 전체 Go package 통과.
- `git diff --check` - PASS; 출력 없음.
- `ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'` - PASS; remote runner에서 services/core 전체 Go package 통과.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Completed task ids:
- `payload-sample`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`, `plan_local_G08_2.log`, `code_review_local_G08_2.log`; verification=`cd services/core && go test -count=1 ./internal/http`
- `event-normalize`: PASS; evidence=`plan_local_G08_2.log`, `code_review_local_G08_2.log`; verification=`cd services/core && go test -count=1 ./...`, remote `go test -count=1 ./...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,234 @@
<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=0 tag=WEBHOOK_PAYLOAD -->
# Plan - WEBHOOK_PAYLOAD
## 이 파일을 읽는 구현 에이전트에게
구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
현재 Plane webhook receiver는 signature 검증 뒤 payload를 로그로만 남기고 `202 Accepted`를 반환한다. assignee update live payload fixture는 확보됐지만 create/state-change 후보와 provider-neutral ref 정규화가 아직 없다. 이 작업은 webhook을 실행 파이프라인에 연결하기 전, 입력 payload shape와 normalize boundary를 먼저 고정한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 USER_REVIEW 작성 여부를 판단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
- `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-test/plane-dev.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`
- `services/core/internal/adapters/plane/client.go`
- `services/core/internal/adapters/plane/client_test.go`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`
### 테스트 환경 규칙
- `test_env=local`로 적용한다.
- `agent-test/local/rules.md`가 존재하고 읽혔다.
- core 변경이므로 `agent-test/local/core-smoke.md`를 적용한다. 최종 core 검증은 원격 기본 checkout `toki@toki-labs.com:$HOME/agent-work/nomadcode`에서 `zsh -lc "cd ~/agent-work/nomadcode/services/core && go test ./..."`를 실행한다.
- contracts 파일은 수정하지 않으므로 `agent-test/local/contracts-smoke.md`의 contracts 명령은 fallback 참고만 한다.
- `<확인 필요>` 값은 없다.
### 테스트 커버리지 공백
- 현재 `services/core/internal/http/plane_webhook_test.go:14`는 synthetic signed payload accepted만 확인하고, `:32`는 redacted assignee fixture accepted만 확인한다.
- create/state-change 후보 payload, 누락 필드, stale event가 normalize 단계에서 side effect 없이 정리되는지는 테스트가 없다.
- `services/core/internal/adapters/plane/client.go:120`의 fetch 기반 source-of-truth 경계는 기존 client tests로 일부 보장되지만 webhook payload normalize와 직접 연결된 테스트는 없다.
### 심볼 참조
- renamed/removed symbol 없음.
### 분할 판단
- split decision policy를 먼저 평가했다.
- 공유 task group: `m-plane-work-item-webhook-intake`
- sibling plan:
- `01_payload_normalization`: payload 샘플과 normalize foundation. 선행 없음.
- `02+01_trigger_dispatch`: normalized ref를 받아 기존 pipeline에 연결. `01` 완료 필요.
- `03+02_idempotency`: dispatch 뒤 duplicate/self-retry 저장소 경계. `02` 완료 필요.
- `04+01,02,03_live_smoke`: 원격 배포와 live smoke 증거. `01`, `02`, `03` 완료 필요.
- 이 subtask는 선행 predecessor가 없다.
### 범위 결정 근거
- 이 작업은 payload shape, fixture, normalize helper까지만 다룬다.
- `ReceivePlaneWebhook`에서 task 생성 호출을 시작하지 않는다. 부작용은 `02+01_trigger_dispatch`에서 다룬다.
- DB uniqueness, duplicate retry, self actor storage 정책은 `03+02_idempotency`로 남긴다.
- live Plane task 생성/authoring run 증거는 `04+01,02,03_live_smoke`로 남긴다.
### 빌드 등급
- `local-G06`: auth 수신기는 이미 있고, 이번 변경은 bounded parser/fixture/test 중심이다. 외부 payload 불확실성은 redacted fixture와 negative tests로 containment 가능하다.
## 구현 체크리스트
- [ ] Plane issue create/update/state/assignee payload에서 provider-neutral ref, actor, change hint를 추출하는 normalize helper를 추가한다. 검증: raw secret/token 없이 work item create/update, state change, assignee change 후보 payload shape를 확인한다.
- [ ] redacted fixture 또는 문서화된 contract candidate를 추가하고 PII/secret 원문이 포함되지 않음을 확인한다.
- [ ] normalize 단위 테스트를 추가해 missing field, stale/irrelevant field, assignee update fixture accepted 경로를 검증한다. 검증: payload 필드 누락 또는 stale event가 있어도 fetch 기반 trigger 판정으로 수렴할 수 있는 ref/hint만 만든다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [WEBHOOK_PAYLOAD-1] Normalize Helper
### 문제
`services/core/internal/http/plane_webhook.go:16`의 `planeWebhookPayload`는 event/action/webhook/workspace/data/activity만 담고, `ReceivePlaneWebhook`은 `:66`에서 로그만 남긴 뒤 `:84`에서 accepted를 반환한다. 다음 dispatch 단계가 사용할 provider-neutral `workitem.Ref`, actor, changed field boundary가 없다.
### 해결 방법
Before:
```go
// services/core/internal/http/plane_webhook.go:16
type planeWebhookPayload struct {
Event string `json:"event"`
Action string `json:"action"`
WebhookID string `json:"webhook_id"`
WorkspaceID string `json:"workspace_id"`
Data json.RawMessage `json:"data"`
Activity struct {
Field string `json:"field"`
} `json:"activity"`
}
```
After:
```go
type planeWebhookPayload struct { /* existing fields plus normalized data support */ }
type planeWebhookWorkItemEvent struct {
Ref workitem.Ref
ActorID string
ChangeField string
Action string
}
func normalizePlaneWebhookPayload(payload planeWebhookPayload) (planeWebhookWorkItemEvent, bool, error) {
// accept only issue/work-item events; return ok=false for irrelevant events
// extract workspace slug/id, project id, work item id from payload.Data
}
```
Use `workitem.NormalizeRef` before returning. Treat webhook payload as a wakeup hint only; do not read current state/assignees from payload as truth.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/plane_webhook.go`: add normalize structs/helpers and safe error handling.
- [ ] `services/core/internal/http/plane_webhook.go`: keep logs secret-free and include only ids/action/change field.
### 테스트 작성
- Write tests in `services/core/internal/http/plane_webhook_test.go`.
- Test names:
- `TestNormalizePlaneWebhookPayloadBuildsWorkItemRef`
- `TestNormalizePlaneWebhookPayloadIgnoresIrrelevantEvent`
- `TestNormalizePlaneWebhookPayloadRejectsMissingWorkItemID`
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
Expected: package passes.
## [WEBHOOK_PAYLOAD-2] Fixtures And Contract Candidates
### 문제
`services/core/internal/http/testdata/plane_webhook_issue_assignees_updated.json` covers one assignee update. Milestone `payload-sample` also asks for create/update/state-change candidates.
### 해결 방법
Collect dev Plane `WebhookLog.request_body` for create and state-change if available on the remote Plane DB. If live logs are unavailable, add synthetic contract candidates under `services/core/internal/http/testdata/` based on the observed issue event envelope and clearly mark them as candidates in a tracked note without secrets.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/testdata/`: add redacted create/state fixture or candidate JSON files.
- [ ] `agent-test/plane-dev.md`: record non-secret fixture provenance and ids only.
- [ ] `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`: update work context with fixture coverage.
### 테스트 작성
- Fixture tests are required because payload shape is the core behavior.
- Extend `TestReceivePlaneWebhookAcceptsRedactedPlaneFixture` or add table tests for each fixture file.
### 중간 검증
```bash
rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
```
Expected: no raw secret/token/password and no unredacted PII appears in tracked fixture/docs.
## [WEBHOOK_PAYLOAD-3] Normalize Test Coverage
### 문제
Existing tests at `services/core/internal/http/plane_webhook_test.go:14` and `:32` assert accepted responses, but no tests prove ref extraction or ignored event semantics.
### 해결 방법
Add focused unit tests for normalize helper and keep handler tests for signature/route behavior. The normalize tests should not require DB, Plane API, SOPS, or remote services.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/plane_webhook_test.go`: add table-driven normalize tests.
- [ ] Keep existing signature tests intact.
### 테스트 작성
- Required. This is a new parsing boundary.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
Expected: package passes without cache-only output.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/http/plane_webhook.go` | WEBHOOK_PAYLOAD-1 |
| `services/core/internal/http/plane_webhook_test.go` | WEBHOOK_PAYLOAD-1, WEBHOOK_PAYLOAD-2, WEBHOOK_PAYLOAD-3 |
| `services/core/internal/http/testdata/*.json` | WEBHOOK_PAYLOAD-2 |
| `agent-test/plane-dev.md` | WEBHOOK_PAYLOAD-2 |
| `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md` | WEBHOOK_PAYLOAD-2 |
## 최종 검증
```bash
cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
cd services/core && go test -count=1 ./internal/http ./internal/adapters/plane ./internal/workitempipeline
rg --sort path -n "secret|token|password|email|avatar|profile" services/core/internal/http/testdata agent-test/plane-dev.md
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```
Expected: go tests pass; secret/PII search has no raw secret/token/password and only intentional redacted words if any; remote core test passes.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,124 @@
<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=1 tag=REVIEW_WEBHOOK_PAYLOAD -->
# Plan - REVIEW_WEBHOOK_PAYLOAD
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 리뷰의 Required issue 하나만 닫는다. 구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
첫 리뷰는 `normalizePlaneWebhookPayload`가 relevant Plane work item event를 성공으로 정규화하면서도 다음 dispatch/fetch 경계가 요구하는 `tenant/project/id` ref tuple을 보장하지 못한다고 판정했다. `workitem.NormalizeRef`는 provider/id만 검사하므로 현재 구현은 project 또는 workspace/tenant가 비어도 `ok=true`를 반환할 수 있다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
- `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass
## 리뷰 입력
- 이전 plan: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/plan_local_G06_0.log`
- 이전 review: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/code_review_local_G06_0.log`
- 판정: FAIL
- Required issue: `services/core/internal/http/plane_webhook.go:86`에서 missing project/workspace가 dispatch-compatible ref로 실패하지 않는다.
## 범위 결정 근거
- 이 follow-up은 normalize helper와 해당 단위 테스트만 수정한다.
- webhook dispatch, DB idempotency, live smoke, Plane API 조회 실행은 sibling subtask 범위로 남긴다.
- workspace tenant 표현은 downstream이 실제로 소비할 표현으로 고정한다. workspace id를 `Tenant`로 유지한다면 Plane adapter/project-sync 경계가 그 표현을 허용한다는 테스트 또는 근거를 함께 남긴다. 불확실한 상태로 `ok=true`를 반환하지 않는다.
## 빌드 등급
- `local-G07`: 이전 local-G06 리뷰에서 명확하고 재현 가능한 correctness gap이 발견됐다. 수정 범위는 bounded parser/test이며 local review로 충분하되, ref 계약 누락을 더 꼼꼼히 확인해야 한다.
## 구현 체크리스트
- [ ] `normalizePlaneWebhookPayload`가 relevant Plane work item event에서 dispatch-compatible ref tuple(`tenant/workspace`, `project`, `id`)을 모두 검증하도록 수정한다. 검증: missing project, missing workspace/tenant, top-level workspace fallback cases가 의도한 `ok/error` semantics를 갖는다.
- [ ] normalize 단위 테스트를 보강해 missing project, missing workspace/tenant, fixture 기반 full ref의 non-empty tenant/project/id를 고정한다. 검증: downstream Plane adapter/project-sync가 요구하는 ref tuple과 불일치하지 않음을 테스트 이름 또는 assertion으로 드러낸다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_WEBHOOK_PAYLOAD-1] Dispatch-Compatible Ref Validation
### 문제
`services/core/internal/http/plane_webhook.go:86`은 `workspace := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID)`와 `strings.TrimSpace(data.Project)`를 그대로 `workitem.NormalizeRef`에 넘긴다. 그러나 `workitem.NormalizeRef`는 provider/id만 필수로 검사한다. 반면 `services/core/internal/adapters/plane/client.go`의 `planeWorkItemRef`와 `projectsync.ProviderProjectTarget.Normalize`는 tenant/project/id가 비면 실패한다.
### 해결 방법
Before:
```go
ref, err := workitem.NormalizeRef(workitem.Ref{
Provider: planeProvider,
Tenant: workspace,
Project: strings.TrimSpace(data.Project),
ID: strings.TrimSpace(data.ID),
})
```
After:
```go
workspace := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID)
project := strings.TrimSpace(data.Project)
id := strings.TrimSpace(data.ID)
if workspace == "" || project == "" || id == "" {
return planeWebhookWorkItemEvent{}, true, workitem.ErrInvalidRef
}
ref, err := workitem.NormalizeRef(workitem.Ref{
Provider: planeProvider,
Tenant: workspace,
Project: project,
ID: id,
})
```
표현 선택이 필요하면 `Tenant`는 다음 dispatch 단계가 `FetchWorkItem`과 project-sync target에 그대로 넘길 수 있는 값으로 고정한다. 이 follow-up에서 변환을 구현하지 않는다면, 최소한 현재 helper가 반환하는 값이 후속 단계의 필수 tuple을 모두 채운다는 테스트를 남긴다.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/plane_webhook.go`: missing workspace/project/id를 relevant event의 normalize error로 처리한다.
- [ ] `services/core/internal/http/plane_webhook_test.go`: missing project, missing workspace/tenant, top-level workspace fallback, fixture full ref assertions를 보강한다.
### 테스트 작성
- Required. 누락 필드가 이번 FAIL의 원인이므로 테스트 없이는 닫을 수 없다.
- 권장 테스트 이름:
- `TestNormalizePlaneWebhookPayloadRejectsMissingProject`
- `TestNormalizePlaneWebhookPayloadRejectsMissingWorkspace`
- 기존 `TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID` 유지 또는 이름 보강
- `TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture`에서 `Tenant`, `Project`, `ID` 모두 assertion
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
Expected: package passes without cache-only output.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/http/plane_webhook.go` | REVIEW_WEBHOOK_PAYLOAD-1 |
| `services/core/internal/http/plane_webhook_test.go` | REVIEW_WEBHOOK_PAYLOAD-1 |
## 최종 검증
```bash
cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
cd services/core && go test -count=1 ./internal/http
cd services/core && go test -count=1 ./...
git diff --check
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```
Expected: local and remote Go tests pass; `git diff --check` has no output. Remote command output must not contain raw secret/token/password values.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,131 @@
<!-- task=m-plane-work-item-webhook-intake/01_payload_normalization plan=2 tag=REVIEW_REVIEW_WEBHOOK_PAYLOAD -->
# Plan - REVIEW_REVIEW_WEBHOOK_PAYLOAD
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 리뷰의 Required issue 하나만 닫는다. 구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
G07은 relevant Plane work item event에서 workspace/project/id 누락을 reject하도록 고쳤지만, assignee fixture의 `workspace` 값은 Plane workspace UUID이고 기존 Plane adapter/project-sync 경계는 workspace slug를 `workitem.Ref.Tenant`로 소비한다. 따라서 normalize helper가 raw workspace UUID를 `workitem.Ref.Tenant`에 넣은 채 "dispatch-compatible ref"라고 부르는 것은 아직 API contract를 만족하지 않는다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `payload-sample`: dev Plane webhook payload 샘플과 이벤트 종류를 확보하고 receiver 입력 fixture 또는 문서화된 contract candidate로 남긴다.
- `event-normalize`: Plane webhook payload를 provider-neutral work item ref와 actor/change hint로 정규화하되, 최종 work item 상태는 Plane API 재조회 결과를 사용한다.
- Completion mode: check-on-pass
## 리뷰 입력
- 이전 plan: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/plan_local_G07_1.log`
- 이전 review: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/code_review_local_G07_1.log`
- 판정: FAIL
- Required issue: webhook workspace id를 `workitem.Ref.Tenant`로 고정해 downstream Plane adapter/project-sync의 workspace slug 계약과 맞지 않는다.
## 범위 결정 근거
- 이 follow-up은 normalize event shape와 tests만 고친다.
- webhook dispatch, config env 추가, workspace id -> slug mapping wiring은 `02+01_trigger_dispatch`가 수행한다.
- raw webhook payload에는 workspace slug가 없으므로 이 단계에서 임의 hardcode 또는 dev-only mapping을 넣지 않는다.
- 대신 normalize result가 raw workspace id와 work item/project id를 명확히 보존하고, slug가 제공된 뒤에만 `workitem.Ref`를 만들 수 있게 작은 helper를 둔다.
## 빌드 등급
- `local-G08`: 같은 bounded parser/test 문제의 두 번째 follow-up이다. 실패 원인이 명확하지만 API contract 의미를 더 엄격히 봐야 하므로 local 등급을 올린다.
## 구현 체크리스트
- [ ] `planeWebhookWorkItemEvent`가 raw Plane identity(`WorkspaceID`, `ProjectID`, `WorkItemID`)와 actor/change/action hint를 보존하고, raw workspace UUID를 `workitem.Ref.Tenant`로 직접 저장하지 않도록 수정한다. 검증: assignee fixture normalize 결과가 workspace id와 project/work item id를 별도 필드로 가진다.
- [ ] workspace slug가 명시적으로 주어질 때만 provider-neutral `workitem.Ref`를 만드는 helper를 추가한다. 검증: helper는 blank slug/project/id를 reject하고, `"general"` slug 입력 시 `Ref.Tenant == "general"`인 Plane ref를 만든다.
- [ ] normalize/handler tests를 갱신해 missing workspace/project/id semantics, top-level workspace fallback, fixture raw identity, slug-based ref helper를 고정한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_REVIEW_WEBHOOK_PAYLOAD-1] Separate Raw Workspace ID From Dispatch Ref
### 문제
`services/core/internal/http/plane_webhook.go:99`는 webhook payload의 `data.workspace` 또는 `workspace_id`를 `workitem.Ref.Tenant`에 넣는다. live/redacted fixture의 값은 workspace id(`dadf050e-cd1e-4590-bc33-672511630841`)다. 하지만 `services/core/internal/adapters/plane/client.go:259`의 `planeWorkItemRef`는 `Tenant`를 `WorkspaceSlug`로 사용해 Plane API path를 만들고, project-sync target 예시/테스트도 `Tenant == "general"` 같은 slug를 쓴다.
### 해결 방법
Before:
```go
type planeWebhookWorkItemEvent struct {
Ref workitem.Ref
ActorID string
ChangeField string
Action string
}
```
After:
```go
type planeWebhookWorkItemEvent struct {
WorkspaceID string
ProjectID string
WorkItemID string
ActorID string
ChangeField string
Action string
}
func (e planeWebhookWorkItemEvent) WorkItemRef(workspaceSlug string) (workitem.Ref, error) {
return workitem.NormalizeRef(workitem.Ref{
Provider: planeProvider,
Tenant: strings.TrimSpace(workspaceSlug),
Project: strings.TrimSpace(e.ProjectID),
ID: strings.TrimSpace(e.WorkItemID),
})
}
```
`normalizePlaneWebhookPayload`는 relevant event에서 `WorkspaceID`, `ProjectID`, `WorkItemID`가 비면 `ok=true, err!=nil`을 반환한다. `ReceivePlaneWebhook` 로그는 `event.WorkItemID`, `event.ProjectID`, `event.WorkspaceID`를 사용한다. 이 단계에서는 dispatch를 호출하지 않으므로 slug 매핑은 하지 않는다.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/plane_webhook.go`: normalized event shape를 raw identity + hint로 바꾸고, slug 기반 ref helper를 추가한다.
- [ ] `services/core/internal/http/plane_webhook.go`: handler logging이 `event.Ref.*` 대신 raw identity fields를 사용하도록 수정한다.
- [ ] `services/core/internal/http/plane_webhook_test.go`: fixture assertion을 `WorkspaceID` raw value와 `WorkItemRef("general")` 결과로 나눠 검증한다.
### 테스트 작성
- Required. 이번 FAIL은 API contract test 공백이 원인이다.
- 권장 테스트 이름:
- `TestPlaneWebhookWorkItemEventBuildsRefWithWorkspaceSlug`
- 기존 `TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture`에서 `WorkspaceID`, `ProjectID`, `WorkItemID` assertion
- missing project/workspace/id reject tests 유지
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
Expected: package passes without cache-only output.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/http/plane_webhook.go` | REVIEW_REVIEW_WEBHOOK_PAYLOAD-1 |
| `services/core/internal/http/plane_webhook_test.go` | REVIEW_REVIEW_WEBHOOK_PAYLOAD-1 |
## 최종 검증
```bash
cd services/core && gofmt -w internal/http/plane_webhook.go internal/http/plane_webhook_test.go
cd services/core && go test -count=1 ./internal/http
cd services/core && go test -count=1 ./...
git diff --check
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```
Expected: local and remote Go tests pass; `git diff --check` has no output. Remote command output must not contain raw secret/token/password values.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,115 @@
<!-- task=m-plane-work-item-webhook-intake/02+01_trigger_dispatch plan=0 tag=WEBHOOK_DISPATCH -->
# Code Review Reference - WEBHOOK_DISPATCH
> **[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`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.
## 개요
date=2026-06-14
task=m-plane-work-item-webhook-intake/02+01_trigger_dispatch, plan=0, tag=WEBHOOK_DISPATCH
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `trigger-dispatch`: `Backlog + AGENT assignee` 이벤트만 기존 `workitempipeline.CreateTaskFromWorkItem` 경로로 넘기고 나머지는 side effect 없이 ignored 처리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 구현 결과를 실제 소스와 대조하고, PASS이면 code-review 절차에 따라 log/complete/archive를 처리한다. 구현 에이전트는 이 섹션을 실행하지 않는다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [WEBHOOK_DISPATCH-1] Dispatch Configuration | [ ] |
| [WEBHOOK_DISPATCH-2] Handler Dispatch Path | [ ] |
| [WEBHOOK_DISPATCH-3] Side Effect Guard Verification | [ ] |
## 구현 체크리스트
- [ ] Plane webhook dispatch 설정을 config/server/handler에 추가하고 `PLANE_CREATION_BACKLOG_STATE_ID`, `PLANE_AGENT_ASSIGNEE_ID`, optional `PLANE_SELF_ACTOR_ID` env를 주입한다.
- [ ] `ReceivePlaneWebhook`에서 normalized Plane issue event만 `workitempipeline.CreateTaskFromWorkItem`으로 넘기고 ignored event는 side effect 없이 202로 반환한다. 검증: ignored event가 workspace provision 또는 slot reservation을 발생시키지 않는다.
- [ ] handler/config tests를 추가해 matching event는 trigger input으로 전달되고 non-matching/ignored event는 task creator를 호출하지 않음을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active `CODE_REVIEW-*-G??.md``PLAN-*-G??.md``.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/`로 이동한다.
- [ ] PASS이고 task group이 `m-plane-work-item-webhook-intake`이므로 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Dispatch 설정이 raw secret을 추가로 노출하지 않고 non-secret ids만 문서화한다.
- Matching event만 creator에 전달되고 ignored event는 creator call 없이 202가 된다.
- `ErrCreationTriggerIgnored`가 provider retry storm을 만들지 않도록 202로 매핑된다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### WEBHOOK_DISPATCH-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/config
(output)
```
### WEBHOOK_DISPATCH-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
(output)
```
### WEBHOOK_DISPATCH-3 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http ./internal/workitempipeline
(output)
```
### 최종 검증
```bash
$ cd services/core && gofmt -w internal/config/config.go internal/config/config_test.go internal/http/handlers.go internal/http/plane_webhook.go internal/http/plane_webhook_test.go cmd/server/main.go
(output)
$ cd services/core && go test -count=1 ./internal/config ./internal/http ./internal/workitempipeline
(output)
$ tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
(output)
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
(output)
```

View file

@ -0,0 +1,273 @@
<!-- task=m-plane-work-item-webhook-intake/02+01_trigger_dispatch plan=0 tag=WEBHOOK_DISPATCH -->
# Plan - WEBHOOK_DISPATCH
## 이 파일을 읽는 구현 에이전트에게
구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
Plane webhook receiver는 현재 인증된 요청을 받지만 creation pipeline을 호출하지 않는다. 기존 HTTP task creation route는 `workitempipeline.CreateTaskFromWorkItem`에 trigger state/assignee/self actor를 전달하는 경로가 이미 있다. 이 작업은 normalize 결과를 그 경로에 연결하되, ignored event는 workspace provision/slot reservation 없이 202로 끝나게 한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 USER_REVIEW 작성 여부를 판단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `trigger-dispatch`: `Backlog + AGENT assignee` 이벤트만 기존 `workitempipeline.CreateTaskFromWorkItem` 경로로 넘기고 나머지는 side effect 없이 ignored 처리한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-test/plane-dev.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/cmd/server/main.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`
### 테스트 환경 규칙
- `test_env=local`로 적용한다.
- `agent-test/local/rules.md``agent-test/local/core-smoke.md`를 읽었다.
- core 변경이므로 최종 검증은 원격 기본 checkout에서 `zsh -lc "cd ~/agent-work/nomadcode/services/core && go test ./..."`를 실행한다.
- contracts 파일은 수정하지 않으므로 contracts smoke는 적용하지 않는다.
- `<확인 필요>` 값은 없다.
### 테스트 커버리지 공백
- `services/core/internal/http/handlers_test.go:738`는 provider-neutral task route가 trigger를 전달하는지만 확인한다.
- `services/core/internal/workitempipeline/service_test.go:456`은 wrong state/missing assignee ignored 시 side effect가 없음을 확인한다.
- `ReceivePlaneWebhook`에서 normalize 결과를 `workItemProviders["plane"]`로 dispatch하는 테스트는 없다.
- config에는 `PLANE_WEBHOOK_SECRET`만 있고 `Backlog` state id, AGENT assignee id, self actor id 설정은 없다.
### 심볼 참조
- renamed/removed symbol 없음.
### 분할 판단
- split decision policy를 먼저 평가했다.
- shared task group: `m-plane-work-item-webhook-intake`
- 이 subtask directory `02+01_trigger_dispatch`는 predecessor `01`을 요구한다.
- predecessor `01` 상태: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/complete.log` 없음. 구현 시작 전 `01_payload_normalization` PASS/archive 또는 active complete.log가 필요하다.
### 범위 결정 근거
- 이 작업은 webhook-to-pipeline dispatch와 설정 주입까지만 다룬다.
- payload normalize helper는 `01_payload_normalization`의 산출물을 사용한다.
- DB unique constraint, duplicate delivery, retry idempotency는 `03+02_idempotency`에서 다룬다.
- remote live smoke와 Plane UI evidence는 `04+01,02,03_live_smoke`에서 다룬다.
### 빌드 등급
- `local-G07`: HTTP handler, config, server wiring, pipeline call boundary를 함께 만지지만 기존 trigger gate와 focused handler tests로 검증 가능하다.
## 의존 관계 및 구현 순서
- `01_payload_normalization``complete.log`를 생성한 뒤 시작한다.
- 이 plan은 directory name의 predecessor `+01`만 의존한다. 다른 숨은 의존성을 추가하지 않는다.
## 구현 체크리스트
- [ ] Plane webhook dispatch 설정을 config/server/handler에 추가하고 `PLANE_CREATION_BACKLOG_STATE_ID`, `PLANE_AGENT_ASSIGNEE_ID`, optional `PLANE_SELF_ACTOR_ID` env를 주입한다.
- [ ] `ReceivePlaneWebhook`에서 normalized Plane issue event만 `workitempipeline.CreateTaskFromWorkItem`으로 넘기고 ignored event는 side effect 없이 202로 반환한다. 검증: ignored event가 workspace provision 또는 slot reservation을 발생시키지 않는다.
- [ ] handler/config tests를 추가해 matching event는 trigger input으로 전달되고 non-matching/ignored event는 task creator를 호출하지 않음을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [WEBHOOK_DISPATCH-1] Dispatch Configuration
### 문제
`services/core/internal/config/config.go:28`에는 Plane base/token/webhook secret만 있고, trigger gate에 필요한 Backlog state id와 AGENT assignee id 설정이 없다. `services/core/cmd/server/main.go:146`은 secret만 handler에 주입한다.
### 해결 방법
Before:
```go
// services/core/internal/config/config.go:28
PlaneBaseURL string
PlaneToken string
PlaneWebhookSecret string
```
After:
```go
PlaneBaseURL string
PlaneToken string
PlaneWebhookSecret string
PlaneCreationBacklogStateID string
PlaneAgentAssigneeID string
PlaneSelfActorID string
```
Add env loading:
```go
PlaneCreationBacklogStateID: firstEnv("PLANE_CREATION_BACKLOG_STATE_ID", "PLANE_BACKLOG_STATE_ID"),
PlaneAgentAssigneeID: os.Getenv("PLANE_AGENT_ASSIGNEE_ID"),
PlaneSelfActorID: os.Getenv("PLANE_SELF_ACTOR_ID"),
```
Wire a handler method such as `SetPlaneWebhookDispatchConfig`.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/config/config.go`: add fields/env loading.
- [ ] `services/core/internal/config/config_test.go`: add env test.
- [ ] `services/core/cmd/server/main.go`: pass config to handler.
- [ ] `services/core/docker-compose.yml`: expose non-secret ids with defaults.
- [ ] `agent-test/plane-dev.md`: document non-secret env values using existing Backlog and AGENT ids.
### 테스트 작성
- Required config test:
- `TestConfigLoadsPlaneWebhookDispatchEnv`
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/config
```
Expected: package passes.
## [WEBHOOK_DISPATCH-2] Handler Dispatch Path
### 문제
`services/core/internal/http/plane_webhook.go:60` unmarshals payload and `:84` returns accepted. It does not use `Handler.workItemProviders` from `services/core/internal/http/handlers.go:32`, so a valid Plane event cannot start the existing creation sync path.
### 해결 방법
Before:
```go
// services/core/internal/http/plane_webhook.go:81
h.logger.Info("plane webhook received", attrs...)
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted"})
```
After:
```go
event, ok, err := normalizePlaneWebhookPayload(payload)
if err != nil { writeError(w, stdhttp.StatusBadRequest, "invalid plane webhook payload"); return }
if !ok { writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status":"ignored"}); return }
task, err := h.dispatchPlaneWebhookWorkItem(r.Context(), event)
if errors.Is(err, workitempipeline.ErrCreationTriggerIgnored) {
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status":"ignored"})
return
}
if err != nil { h.writeServiceError(w, err); return }
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status":"accepted","task_id":task.ID})
```
Dispatch input must set:
```go
Trigger: workitempipeline.CreationTrigger{
RequiredStateID: h.planeWebhookDispatch.BacklogStateID,
RequiredAssigneeID: h.planeWebhookDispatch.AgentAssigneeID,
Actor: event.ActorID,
SelfActor: h.planeWebhookDispatch.SelfActorID,
}
```
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/handlers.go`: add dispatch config field.
- [ ] `services/core/internal/http/plane_webhook.go`: add dispatch helper and error mapping.
- [ ] Ensure missing dispatch config yields 202 ignored or 503 only by explicit choice recorded in review. Prefer 202 ignored when receiver is reachable but trigger config absent to avoid provider retry storms.
### 테스트 작성
- Required handler tests:
- `TestReceivePlaneWebhookDispatchesMatchingIssueEvent`
- `TestReceivePlaneWebhookIgnoresIrrelevantEventWithoutCreatorCall`
- `TestReceivePlaneWebhookReturnsAcceptedWhenCreationTriggerIgnored`
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
Expected: package passes.
## [WEBHOOK_DISPATCH-3] Side Effect Guard Verification
### 문제
`services/core/internal/workitempipeline/service.go:103` fetches item and `:109` evaluates trigger before provision/slot reservation at `:113`, but webhook handler tests do not prove ignored webhook events avoid dispatch or map ignored trigger to 202.
### 해결 방법
Keep side-effect guard inside `workitempipeline`; handler should only pass strict trigger config and map `ErrCreationTriggerIgnored` to accepted/ignored. Do not duplicate Plane current-state checks in webhook payload logic.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/plane_webhook_test.go`: assert fake creator call counts.
- [ ] `services/core/internal/workitempipeline/service_test.go`: only add tests if dispatch requires new trigger semantics; otherwise rely on existing `TestCreateTaskFromWorkItemIgnoresNonMatchingTrigger`.
### 테스트 작성
- Handler tests required.
- Workitempipeline tests can be skipped if no pipeline behavior changes; record skip reason in review.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http ./internal/workitempipeline
```
Expected: packages pass.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/config/config.go` | WEBHOOK_DISPATCH-1 |
| `services/core/internal/config/config_test.go` | WEBHOOK_DISPATCH-1 |
| `services/core/cmd/server/main.go` | WEBHOOK_DISPATCH-1 |
| `services/core/docker-compose.yml` | WEBHOOK_DISPATCH-1 |
| `agent-test/plane-dev.md` | WEBHOOK_DISPATCH-1 |
| `services/core/internal/http/handlers.go` | WEBHOOK_DISPATCH-2 |
| `services/core/internal/http/plane_webhook.go` | WEBHOOK_DISPATCH-2 |
| `services/core/internal/http/plane_webhook_test.go` | WEBHOOK_DISPATCH-2, WEBHOOK_DISPATCH-3 |
| `services/core/internal/workitempipeline/service_test.go` | WEBHOOK_DISPATCH-3, optional |
## 최종 검증
```bash
cd services/core && gofmt -w internal/config/config.go internal/config/config_test.go internal/http/handlers.go internal/http/plane_webhook.go internal/http/plane_webhook_test.go cmd/server/main.go
cd services/core && go test -count=1 ./internal/config ./internal/http ./internal/workitempipeline
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```
Expected: local focused tests pass and remote full core tests pass.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,117 @@
<!-- task=m-plane-work-item-webhook-intake/03+02_idempotency plan=0 tag=WEBHOOK_IDEMPOTENCY -->
# Code Review Reference - WEBHOOK_IDEMPOTENCY
> **[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`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.
## 개요
date=2026-06-14
task=m-plane-work-item-webhook-intake/03+02_idempotency, plan=0, tag=WEBHOOK_IDEMPOTENCY
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `loop-idempotency`: duplicate webhook, provider retry, NomadCode self actor event를 중복 creation task 없이 처리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 구현 결과를 실제 소스와 대조하고, PASS이면 code-review 절차에 따라 log/complete/archive를 처리한다. 구현 에이전트는 이 섹션을 실행하지 않는다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [WEBHOOK_IDEMPOTENCY-1] Storage Idempotency Boundary | [ ] |
| [WEBHOOK_IDEMPOTENCY-2] Workflow And Pipeline Semantics | [ ] |
| [WEBHOOK_IDEMPOTENCY-3] Webhook Duplicate/Self Tests | [ ] |
## 구현 체크리스트
- [ ] Plane-origin task idempotency key를 external provider/id로 고정하고 DB unique/index 또는 upsert semantics를 추가한다.
- [ ] duplicate webhook/provider retry가 기존 task를 반환하거나 ignored 처리되어 새 task, workspace provision, slot reservation을 만들지 않도록 workflow/workitempipeline 경계를 조정한다. 검증: 같은 provider/work item/revision 재처리와 self actor 입력이 task 중복 생성으로 이어지지 않는다.
- [ ] storage/workflow/workitempipeline/http tests를 추가해 duplicate external ref, duplicate webhook dispatch, self actor ignored를 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active `CODE_REVIEW-*-G??.md``PLAN-*-G??.md``.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-work-item-webhook-intake/03+02_idempotency/`로 이동한다.
- [ ] PASS이고 task group이 `m-plane-work-item-webhook-intake`이므로 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Duplicate prevention happens before workspace provision/slot reservation, or tests prove no duplicate side effect.
- DB unique/upsert semantics do not break tasks without external refs.
- Generated sqlc code matches `queries/tasks.sql`.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### WEBHOOK_IDEMPOTENCY-1 중간 검증
```bash
$ cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
(output)
```
### WEBHOOK_IDEMPOTENCY-2 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/workflow ./internal/workitempipeline
(output)
```
### WEBHOOK_IDEMPOTENCY-3 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http ./internal/workitempipeline
(output)
```
### 최종 검증
```bash
$ cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
(output)
$ cd services/core && gofmt -w internal/db/tasks.sql.go internal/storage/store.go internal/workflow/service.go internal/workflow/service_test.go internal/workitempipeline/service.go internal/workitempipeline/service_test.go internal/http/plane_webhook_test.go
(output)
$ cd services/core && go test -count=1 ./internal/storage ./internal/workflow ./internal/workitempipeline ./internal/http
(output)
$ tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
(output)
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
(output)
```

View file

@ -0,0 +1,244 @@
<!-- task=m-plane-work-item-webhook-intake/03+02_idempotency plan=0 tag=WEBHOOK_IDEMPOTENCY -->
# Plan - WEBHOOK_IDEMPOTENCY
## 이 파일을 읽는 구현 에이전트에게
구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
Plane은 webhook retry나 duplicate delivery를 보낼 수 있고, NomadCode self mutation event도 다시 들어올 수 있다. 현재 task insert는 external provider/id를 저장하지만 unique/idempotent guard가 없다. 이 작업은 같은 Plane work item이 여러 번 들어와도 creation task와 authoring run이 중복되지 않도록 저장소 경계를 고정한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 USER_REVIEW 작성 여부를 판단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `loop-idempotency`: duplicate webhook, provider retry, NomadCode self actor event를 중복 creation task 없이 처리한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-test/plane-dev.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `services/core/internal/storage/store.go`
- `services/core/internal/workflow/service.go`
- `services/core/internal/workflow/service_test.go`
- `services/core/internal/db/tasks.sql.go`
- `services/core/queries/tasks.sql`
- `services/core/migrations/00001_create_tasks.sql`
- `services/core/migrations/00002_add_task_external_refs.sql`
- `services/core/sqlc.yaml`
- `services/core/internal/workitempipeline/service.go`
- `services/core/internal/workitempipeline/service_test.go`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`
### 테스트 환경 규칙
- `test_env=local`로 적용한다.
- `agent-test/local/rules.md``agent-test/local/core-smoke.md`를 읽었다.
- core storage/query/generated code 변경이므로 원격 기본 checkout에서 `zsh -lc "cd ~/agent-work/nomadcode/services/core && go test ./..."`를 실행한다.
- 로컬에 `sqlc` binary는 없었다. generated db code가 필요하면 `cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate`를 사용한다.
- `<확인 필요>` 값은 없다.
### 테스트 커버리지 공백
- `services/core/queries/tasks.sql:1``CreateTask`는 plain insert라 duplicate external ref를 막지 않는다.
- `services/core/migrations/00002_add_task_external_refs.sql:2`는 external columns만 추가하고 unique index가 없다.
- `services/core/internal/workflow/service.go:39`는 normalized external ref를 저장소로 넘기지만 idempotent result semantics를 정의하지 않는다.
- `services/core/internal/workitempipeline/service_test.go:522`는 self actor ignored가 side effect를 만들지 않음을 확인하지만 duplicate webhook retry는 확인하지 않는다.
### 심볼 참조
- renamed/removed symbol 없음. `CreateTask` semantics 변경 또는 새 query 추가 시 call site는 `workflow.Service.CreateTask``workitempipeline.TaskCreator` 경계다.
### 분할 판단
- split decision policy를 먼저 평가했다.
- shared task group: `m-plane-work-item-webhook-intake`
- 이 subtask directory `03+02_idempotency`는 predecessor `02`를 요구한다.
- predecessor `02` 상태: `agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log` 없음. 구현 시작 전 `02+01_trigger_dispatch` PASS/archive 또는 active complete.log가 필요하다.
### 범위 결정 근거
- 이 작업은 duplicate task prevention까지 다룬다.
- Payload parsing과 handler dispatch는 선행 task 산출물로 간주한다.
- UI/live smoke evidence는 `04+01,02,03_live_smoke`에서 다룬다.
- Historical data backfill은 필요할 때 migration의 duplicate-precheck로만 다루고, 사용자 데이터 수동 정리는 이 plan 범위 밖이다. migration이 기존 duplicate로 실패한다면 review stub의 사용자 리뷰 요청에 정확한 SQL evidence를 남긴다.
### 빌드 등급
- `cloud-G06`: DB unique constraint, generated SQL, workflow idempotent semantics가 얽혀 있어 저장소/protocol 판단 리스크가 있다.
## 의존 관계 및 구현 순서
- `02+01_trigger_dispatch``complete.log`를 생성한 뒤 시작한다.
- 이 plan은 directory name의 predecessor `+02`만 의존한다.
## 구현 체크리스트
- [ ] Plane-origin task idempotency key를 external provider/id로 고정하고 DB unique/index 또는 upsert semantics를 추가한다.
- [ ] duplicate webhook/provider retry가 기존 task를 반환하거나 ignored 처리되어 새 task, workspace provision, slot reservation을 만들지 않도록 workflow/workitempipeline 경계를 조정한다. 검증: 같은 provider/work item/revision 재처리와 self actor 입력이 task 중복 생성으로 이어지지 않는다.
- [ ] storage/workflow/workitempipeline/http tests를 추가해 duplicate external ref, duplicate webhook dispatch, self actor ignored를 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [WEBHOOK_IDEMPOTENCY-1] Storage Idempotency Boundary
### 문제
`services/core/queries/tasks.sql:1`은 plain insert이고 `services/core/migrations/00002_add_task_external_refs.sql:2`에는 unique index가 없다. 같은 `external_provider=plane`, `external_id=<work item id>`가 여러 번 들어오면 task row가 중복될 수 있다.
### 해결 방법
Before:
```sql
-- services/core/queries/tasks.sql:1
INSERT INTO tasks (title, source, payload, metadata, external_provider, external_id, external_url, external_metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, source, status, payload, result, error, created_at, updated_at, external_provider, external_id, external_url, external_metadata, metadata;
```
After:
```sql
CREATE UNIQUE INDEX IF NOT EXISTS ux_tasks_external_ref
ON tasks (external_provider, external_id)
WHERE external_provider IS NOT NULL AND external_id IS NOT NULL;
```
Use an upsert/no-op-returning query for external refs:
```sql
INSERT INTO tasks (...)
VALUES (...)
ON CONFLICT (external_provider, external_id)
WHERE external_provider IS NOT NULL AND external_id IS NOT NULL
DO UPDATE SET updated_at = tasks.updated_at
RETURNING ...;
```
If changing global `CreateTask` is too broad after reading current call sites, add a separate generated query and keep plain internal task creation unchanged. Record the choice in review.
### 수정 파일 및 체크리스트
- [ ] `services/core/migrations/00008_add_task_external_ref_unique.sql`: add partial unique index.
- [ ] `services/core/queries/tasks.sql`: add idempotent create query or adjust `CreateTask`.
- [ ] `services/core/internal/db/tasks.sql.go`: regenerate with sqlc.
- [ ] `services/core/internal/storage/store.go`: expose idempotent create semantics.
### 테스트 작성
- Required storage/workflow tests.
- If no DB integration test harness exists, use generated SQL string assertions plus workflow fake store semantics, and record the limitation.
### 중간 검증
```bash
cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
```
Expected: generated db files update cleanly.
## [WEBHOOK_IDEMPOTENCY-2] Workflow And Pipeline Semantics
### 문제
`services/core/internal/workflow/service.go:64` always calls `store.CreateTask`; `services/core/internal/workitempipeline/service.go:113` provisions workspace and reserves slot before task insert. If duplicate detection happens only at insert time, duplicate webhook can still reserve a slot before returning existing task.
### 해결 방법
Prefer checking idempotency before workspace provision/slot reservation when external provider/id is present. Options:
1. Add a narrow store lookup by external ref and let `workitempipeline` return existing task before provision.
2. Add an idempotent reservation-safe `TaskCreator` method only for work item creation.
Choose the smallest change that prevents duplicate slot reservation. Do not rely solely on post-provision insert conflict unless tests prove no duplicate side effects.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/workflow/service.go`: add or route idempotent external-ref lookup/create semantics.
- [ ] `services/core/internal/workitempipeline/service.go`: avoid workspace/slot side effects for already-created work item task.
- [ ] `services/core/internal/workitempipeline/service_test.go`: assert duplicate path does not call `EnsureProjectWorkspace` or `ReserveWorkspaceSlot`.
### 테스트 작성
- Required:
- `TestCreateTaskFromWorkItemReturnsExistingExternalTaskWithoutProvision`
- `TestCreateTaskFromWorkItemDuplicateExternalRefDoesNotReserveSlot`
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/workflow ./internal/workitempipeline
```
Expected: packages pass.
## [WEBHOOK_IDEMPOTENCY-3] Webhook Duplicate/Self Tests
### 문제
Self actor guard exists in `services/core/internal/workitempipeline/service.go:162`, but webhook handler must pass actor/self actor consistently. Duplicate webhook handler tests do not exist yet.
### 해결 방법
Add handler-level tests that call `ReceivePlaneWebhook` twice with same work item event. The fake creator should prove the second call does not create duplicate side effects once idempotency boundary exists. Keep self actor ignored returning 202.
### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/plane_webhook_test.go`: duplicate signed event test.
- [ ] `services/core/internal/http/plane_webhook_test.go`: self actor ignored test if not covered by dispatch task.
### 테스트 작성
- Required because webhook retry is the milestone task's behavioral surface.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http ./internal/workitempipeline
```
Expected: packages pass.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/migrations/00008_add_task_external_ref_unique.sql` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/queries/tasks.sql` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/db/tasks.sql.go` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/storage/store.go` | WEBHOOK_IDEMPOTENCY-1 |
| `services/core/internal/workflow/service.go` | WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/workflow/service_test.go` | WEBHOOK_IDEMPOTENCY-1, WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/workitempipeline/service.go` | WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/workitempipeline/service_test.go` | WEBHOOK_IDEMPOTENCY-2 |
| `services/core/internal/http/plane_webhook_test.go` | WEBHOOK_IDEMPOTENCY-3 |
## 최종 검증
```bash
cd services/core && go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
cd services/core && gofmt -w internal/db/tasks.sql.go internal/storage/store.go internal/workflow/service.go internal/workflow/service_test.go internal/workitempipeline/service.go internal/workitempipeline/service_test.go internal/http/plane_webhook_test.go
cd services/core && go test -count=1 ./internal/storage ./internal/workflow ./internal/workitempipeline ./internal/http
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
```
Expected: sqlc generation succeeds, focused tests pass, remote full core tests pass.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,119 @@
<!-- task=m-plane-work-item-webhook-intake/04+01,02,03_live_smoke plan=0 tag=WEBHOOK_SMOKE -->
# Code Review Reference - WEBHOOK_SMOKE
> **[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`.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation.
> Finalization is review-agent-only.
## 개요
date=2026-06-14
task=m-plane-work-item-webhook-intake/04+01,02,03_live_smoke, plan=0, tag=WEBHOOK_SMOKE
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `live-smoke`: Plane UI에서 상위 티켓을 만들고 Backlog 상태에서 AGENT에 assign하면 NomadCode task가 생성되고 Milestone authoring flow가 시작된다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 구현 결과를 실제 소스와 대조하고, PASS이면 code-review 절차에 따라 log/complete/archive를 처리한다. 구현 에이전트는 이 섹션을 실행하지 않는다.
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [WEBHOOK_SMOKE-1] Remote Deploy | [ ] |
| [WEBHOOK_SMOKE-2] Plane Event Delivery | [ ] |
| [WEBHOOK_SMOKE-3] Core Task And Authoring Evidence | [ ] |
## 구현 체크리스트
- [ ] 원격 Core checkout에 services/core, root `bin/nomadcode-sops-env`, `secrets/nomadcode.dev.sops.yaml`을 동기화하고 SOPS env로 compose를 재기동한다.
- [ ] Plane UI 또는 API로 새 top-level work item을 Backlog 상태에서 만들고 AGENT assignee를 설정해 webhook delivery를 발생시킨다. 검증: 테스트 Plane 티켓 id, Core task id, authoring run 시작 또는 ignored 사유를 secret redaction 상태로 기록한다.
- [ ] Plane webhook log와 Core log/API evidence를 수집해 `agent-test/plane-dev.md`와 milestone 작업 컨텍스트에 non-secret 결과만 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] active `CODE_REVIEW-*-G??.md``PLAN-*-G??.md``.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-work-item-webhook-intake/04+01,02,03_live_smoke/`로 이동한다.
- [ ] PASS이고 task group이 `m-plane-work-item-webhook-intake`이므로 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Evidence includes Plane ticket id, Core task id, and authoring start or explicit ignored reason.
- No raw secret/token/auth password appears in tracked docs or pasted output.
- Remote health, Plane webhook log, and Core log evidence refer to the same smoke work item.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### WEBHOOK_SMOKE-1 중간 검증
```bash
$ curl -fsS http://toki-labs.com:18010/healthz
(output)
$ ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=80 nomadcode-core'
(output)
```
### WEBHOOK_SMOKE-2 중간 검증
```bash
$ ssh toki@toki-labs.com "cd ~/docker/agent-service/plane/compose && /usr/local/bin/docker compose exec -T postgres sh -lc 'psql -U plane -d plane -Atc \"select created_at, event_type, response_status, request_body::jsonb->>''action'', request_body::jsonb#>>''{data,id}'', request_body::jsonb#>>''{activity,field}'' from webhook_logs order by created_at desc limit 10;\"'"
(output)
```
### WEBHOOK_SMOKE-3 중간 검증
```bash
$ ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=300 nomadcode-core | grep -E "plane webhook|work item|task|authoring|ignored"'
(output)
```
### 최종 검증
```bash
$ ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
(output)
$ curl -fsS http://toki-labs.com:18010/healthz
(output)
$ ssh toki@toki-labs.com "cd ~/docker/agent-service/plane/compose && /usr/local/bin/docker compose exec -T postgres sh -lc 'psql -U plane -d plane -Atc \"select created_at, event_type, response_status, request_body::jsonb->>''action'', request_body::jsonb#>>''{data,id}'', request_body::jsonb#>>''{activity,field}'' from webhook_logs order by created_at desc limit 10;\"'"
(output)
$ ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=300 nomadcode-core | grep -E "plane webhook|work item|task|authoring|ignored"'
(output)
$ git diff --check
(output)
```

View file

@ -0,0 +1,211 @@
<!-- task=m-plane-work-item-webhook-intake/04+01,02,03_live_smoke plan=0 tag=WEBHOOK_SMOKE -->
# Plan - WEBHOOK_SMOKE
## 이 파일을 읽는 구현 에이전트에게
구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 설계 결정, 검증 출력으로 반드시 채운다. 구현 중 사용자만 결정할 수 있는 외부 환경, secret, scope 충돌이 생기면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 증거와 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
선행 작업들이 끝나면 사용자는 Plane UI에서 Backlog item을 AGENT에 assign하는 것만으로 NomadCode authoring flow가 시작되는지 확인해야 한다. 이 작업은 dev Core 배포, Plane webhook delivery, Core task id, authoring run 시작 또는 ignored reason을 secret redaction 상태로 남기는 검증 전용 작업이다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록한다. 직접 사용자 프롬프트는 금지하며, code-review가 USER_REVIEW 작성 여부를 판단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- Task ids:
- `live-smoke`: Plane UI에서 상위 티켓을 만들고 Backlog 상태에서 AGENT에 assign하면 NomadCode task가 생성되고 Milestone authoring flow가 시작된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/contracts-smoke.md`
- `agent-test/plane-dev.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `services/core/docker-compose.yml`
- `services/core/internal/http/router.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/plane_webhook.go`
### 테스트 환경 규칙
- `test_env=local`로 적용한다.
- `agent-test/local/rules.md``agent-test/local/core-smoke.md`를 읽었다.
- live smoke의 표준 실행지는 원격 `toki@toki-labs.com`이다.
- Plane compose 위치는 `~/docker/agent-service/plane/compose`, 서비스는 `plane-api`, `plane-worker`, `postgres`로 확인했다.
- Core compose 위치는 `~/agent-work/nomadcode/services/core`, 서비스는 `nomadcode-core`로 확인했다.
- `<확인 필요>` 값은 없다.
### 테스트 커버리지 공백
- Unit tests는 webhook parsing/dispatch/idempotency를 검증하지만 실제 Plane delivery와 Core scheduler/authoring run 시작은 보장하지 않는다.
- 기존 webhook-only smoke는 `NOMAD-10`, `NOMAD-11` assign event가 Core receiver까지 202로 도달한 것만 확인했다. task 생성과 authoring run 시작 evidence는 아직 없다.
### 심볼 참조
- renamed/removed symbol 없음.
### 분할 판단
- split decision policy를 먼저 평가했다.
- shared task group: `m-plane-work-item-webhook-intake`
- 이 subtask directory `04+01,02,03_live_smoke`는 predecessors `01`, `02`, `03`을 요구한다.
- predecessor `01` 상태: `agent-task/m-plane-work-item-webhook-intake/01_payload_normalization/complete.log` 없음.
- predecessor `02` 상태: `agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log` 없음.
- predecessor `03` 상태: `agent-task/m-plane-work-item-webhook-intake/03+02_idempotency/complete.log` 없음.
- 구현 시작 전 세 predecessor가 PASS/archive 또는 active complete.log를 가져야 한다.
### 범위 결정 근거
- 이 작업은 remote deploy/smoke evidence와 tracked non-secret 기록만 다룬다.
- 코드 수정은 선행 task의 누락을 발견한 경우에만 follow-up plan으로 분리한다.
- raw webhook secret, Plane API token, auth password를 출력하거나 tracked 파일에 기록하지 않는다.
### 빌드 등급
- `local-G05`: 구현보다는 deterministic remote smoke 절차와 evidence 기록이다. 외부 환경이 걸리지만 선행 구현 완료 후 명령 중심으로 검증 가능하다.
## 의존 관계 및 구현 순서
- `01_payload_normalization`, `02+01_trigger_dispatch`, `03+02_idempotency`가 각각 `complete.log`를 생성한 뒤 시작한다.
- 이 plan은 directory name의 predecessors `+01,02,03`만 의존한다.
## 구현 체크리스트
- [ ] 원격 Core checkout에 services/core, root `bin/nomadcode-sops-env`, `secrets/nomadcode.dev.sops.yaml`을 동기화하고 SOPS env로 compose를 재기동한다.
- [ ] Plane UI 또는 API로 새 top-level work item을 Backlog 상태에서 만들고 AGENT assignee를 설정해 webhook delivery를 발생시킨다. 검증: 테스트 Plane 티켓 id, Core task id, authoring run 시작 또는 ignored 사유를 secret redaction 상태로 기록한다.
- [ ] Plane webhook log와 Core log/API evidence를 수집해 `agent-test/plane-dev.md`와 milestone 작업 컨텍스트에 non-secret 결과만 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [WEBHOOK_SMOKE-1] Remote Deploy
### 문제
`services/core/docker-compose.yml`은 Core service만 정의하고 secret/env는 remote runtime에서 주입해야 한다. 현재 원격 root checkout에는 `bin/nomadcode-sops-env`가 없을 수 있으므로 live smoke 전에 helper와 encrypted secret file을 동기화해야 한다.
### 해결 방법
Sync needed files and restart Core with SOPS env. Do not print decrypted env.
```bash
tar -C services/core -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -'
tar -czf - bin/nomadcode-sops-env secrets/nomadcode.dev.sops.yaml | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode && tar -C ~/agent-work/nomadcode -xzf -'
ssh toki@toki-labs.com 'chmod +x ~/agent-work/nomadcode/bin/nomadcode-sops-env'
ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt ../../bin/nomadcode-sops-env /usr/local/bin/docker compose up -d --build'
```
### 수정 파일 및 체크리스트
- [ ] No source edit required unless deploy evidence reveals a bug.
- [ ] `agent-test/plane-dev.md`: record deploy date and Core URL only.
### 테스트 작성
- No unit test. This is deployment verification.
### 중간 검증
```bash
curl -fsS http://toki-labs.com:18010/healthz
ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=80 nomadcode-core'
```
Expected: health endpoint returns success; logs show server listening and no secret values.
## [WEBHOOK_SMOKE-2] Plane Event Delivery
### 문제
Prior evidence proved webhook-only delivery to receiver. After dispatch/idempotency, delivery must create or intentionally ignore a task with a clear reason.
### 해결 방법
Use a new test Plane top-level work item when possible. If Plane UI action is unavailable to the implementing agent, use Plane API with `.env.plane.local` on the repo root if present; otherwise record a user-review blocker with exact missing prerequisite. Never print `PLANE_TOKEN`.
Plane webhook log query:
```bash
ssh toki@toki-labs.com "cd ~/docker/agent-service/plane/compose && /usr/local/bin/docker compose exec -T postgres sh -lc 'psql -U plane -d plane -Atc \"select created_at, event_type, response_status, request_body::jsonb->>''action'', request_body::jsonb#>>''{data,id}'', request_body::jsonb#>>''{activity,field}'' from webhook_logs order by created_at desc limit 10;\"'"
```
### 수정 파일 및 체크리스트
- [ ] `agent-test/plane-dev.md`: record smoke item key/id and non-secret delivery result.
- [ ] milestone file: record smoke evidence in 작업 컨텍스트.
### 테스트 작성
- No unit test. This verifies remote integration.
### 중간 검증
```bash
ssh toki@toki-labs.com "cd ~/docker/agent-service/plane/compose && /usr/local/bin/docker compose exec -T postgres sh -lc 'psql -U plane -d plane -Atc \"select created_at, event_type, response_status, request_body::jsonb->>''action'', request_body::jsonb#>>''{data,id}'', request_body::jsonb#>>''{activity,field}'' from webhook_logs order by created_at desc limit 10;\"'"
```
Expected: latest relevant issue event has response status 202.
## [WEBHOOK_SMOKE-3] Core Task And Authoring Evidence
### 문제
Milestone pass requires task id and authoring run started or ignored reason. Receiver accepted alone is insufficient.
### 해결 방법
Collect Core logs around the Plane work item id. Prefer structured log lines emitted by dispatch/idempotency implementation. If API auth is available through remote env without printing password, use `/api/tasks` to confirm `external_provider=plane` and `external_id=<work item id>`.
```bash
ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=300 nomadcode-core | grep -E "plane webhook|work item|task|authoring|ignored"'
```
If Core exposes task evidence only through protected API, run the command via a secret-safe script that does not echo credentials, and paste redacted output only.
### 수정 파일 및 체크리스트
- [ ] `agent-test/plane-dev.md`: add task id / authoring run evidence or ignored reason.
- [ ] milestone file: update `live-smoke` context with test item id and result.
### 테스트 작성
- No unit test.
### 중간 검증
```bash
ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=300 nomadcode-core | grep -E "plane webhook|work item|task|authoring|ignored"'
```
Expected: logs identify the smoke work item and either task creation/authoring start or an intentional ignored reason.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-test/plane-dev.md` | WEBHOOK_SMOKE-1, WEBHOOK_SMOKE-2, WEBHOOK_SMOKE-3 |
| `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md` | WEBHOOK_SMOKE-2, WEBHOOK_SMOKE-3 |
## 최종 검증
```bash
ssh toki@toki-labs.com 'zsh -lc "cd ~/agent-work/nomadcode/services/core && go test -count=1 ./..."'
curl -fsS http://toki-labs.com:18010/healthz
ssh toki@toki-labs.com "cd ~/docker/agent-service/plane/compose && /usr/local/bin/docker compose exec -T postgres sh -lc 'psql -U plane -d plane -Atc \"select created_at, event_type, response_status, request_body::jsonb->>''action'', request_body::jsonb#>>''{data,id}'', request_body::jsonb#>>''{activity,field}'' from webhook_logs order by created_at desc limit 10;\"'"
ssh toki@toki-labs.com 'cd ~/agent-work/nomadcode/services/core && /usr/local/bin/docker compose logs --tail=300 nomadcode-core | grep -E "plane webhook|work item|task|authoring|ignored"'
git diff --check
```
Expected: remote tests pass, Core health succeeds, Plane log shows 202 for smoke event, Core evidence shows task id plus authoring start or explicit ignored reason, diff check is clean.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

40
agent-test/plane-dev.md Normal file
View file

@ -0,0 +1,40 @@
# Plane Dev Test Fixtures
이 문서는 dev Plane 연동 테스트에서 반복 사용하는 non-secret 식별자를 기록한다.
API token, webhook secret, password 같은 secret 원문은 이 파일에 쓰지 않는다.
## NomadCode Project
- Plane base URL: `https://plane.toki-labs.com`
- Workspace slug: `general`
- Workspace id: `dadf050e-cd1e-4590-bc33-672511630841`
- Project id: `a6beb42f-7a8a-410c-b50f-ea3ca94828f3`
- Project identifier: `NOMAD`
## State IDs
- Backlog: `62d4c50c-0cea-4a76-a0ed-ec97498b2d5f`
- Todo: `45ba7449-f684-4381-af6d-5854747c5e8d`
- In Progress: `c6ac1a6b-74d5-47fb-8b36-646d2bf0284d`
- User Review: `dc4fb920-f763-4ae3-9569-3c5e2d500391`
- Done: `ea2e5b48-8bf1-4723-b749-de7723be41e9`
- Cancelled: `f29c06c2-d70c-4b56-a83c-fccc4db60ae4`
## Users
- AGENT assignee user id: `5d116a77-d3df-4f54-80bf-eca61e0118c4`
## Webhook Smoke Baseline
- Webhook URL: `http://toki-labs.com:18010/api/integrations/plane/webhook`
- Webhook secret storage: SOPS encrypted `secrets/nomadcode.dev.sops.yaml` key `PLANE_WEBHOOK_SECRET`
- 2026-06-14 webhook-only smoke used existing Backlog items `NOMAD-10` and `NOMAD-11` by assigning AGENT.
## Webhook Payload Fixtures
`services/core/internal/http/testdata/` 아래 fixture로 payload shape를 고정한다. 모든 fixture는 PII/secret redaction 상태이며 work item id는 placeholder다.
- `plane_webhook_issue_assignees_updated.json`: live dev Plane assignee-update 로그 기반 redacted 샘플 (`event=issue`, `action=updated`, `activity.field=assignees`).
- `plane_webhook_issue_created.json`: create 후보. live create 로그를 따로 확보하지 않아 관찰된 issue envelope 기반 synthetic candidate다 (`event=issue`, `action=created`, Backlog state).
- `plane_webhook_issue_state_updated.json`: state-change 후보. synthetic candidate다 (`event=issue`, `action=updated`, `activity.field=state`, Backlog -> Todo).

47
bin/nomadcode-sops-env Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "usage: bin/nomadcode-sops-env <command> [args...]" >&2
exit 64
fi
sops_bin="${SOPS_BIN:-sops}"
if ! command -v "$sops_bin" >/dev/null 2>&1; then
if [[ -x /opt/homebrew/bin/sops ]]; then
sops_bin="/opt/homebrew/bin/sops"
fi
fi
if ! command -v "$sops_bin" >/dev/null 2>&1; then
echo "sops is required to load NomadCode runtime secrets" >&2
exit 127
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
secret_file="${NOMADCODE_SOPS_FILE:-$repo_root/secrets/nomadcode.dev.sops.yaml}"
if [[ ! -f "$secret_file" ]]; then
echo "NomadCode SOPS file not found: $secret_file" >&2
exit 66
fi
"$sops_bin" -d --output-type json "$secret_file" | python3 -c '
import json
import os
import sys
cmd = sys.argv[1:]
if not cmd:
raise SystemExit("missing command")
data = json.load(sys.stdin)
env = os.environ.copy()
for key, value in data.items():
if value is None:
continue
if isinstance(value, (str, int, float, bool)):
env[str(key)] = str(value)
os.execvpe(cmd[0], cmd, env)
' "$@"

View file

@ -0,0 +1,16 @@
PLANE_WEBHOOK_SECRET: ENC[AES256_GCM,data:rsGPiM/FOWCEJUP1/R+jpHQh9zk4Dtd3ttmPcdRNvJAYw6dmo1L+5II=,iv:DdH4nfEu6ZDYt7SdRXyKZ+8ttx5kYidDWlFIBTLizcE=,tag:8XVZ1JZfY8xOWFWy6Dq94w==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGK3JTcS9GMjNJei9QWGZy
bVFpanRIaCtPS0M2aW1BMElYVEtYQXZmMmowCnhjUVp0Z21wWTllY2dIdjhOV1Rp
bGhDcllqNnpycFY5MXorMFJaQmt0ZUUKLS0tIDRTWjRGTHFlQXpwVkJSNEVjRjBo
d08yNU5WMTNaaUpUcktYcjhpUjJvUVkKDwh577jPBlf7kvhRIIqhX88digpNQ3JA
D2X+PLUpogetsLEBcRx4sbIuXdn0CeiU8isoB29PaGIZy1KB+1bqrg==
-----END AGE ENCRYPTED FILE-----
recipient: age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3
lastmodified: "2026-06-14T09:09:30Z"
mac: ENC[AES256_GCM,data:87DQmGQgXb3EeEEKmlyHU8oVXFWtcFJgIffzDa59Ur3wJQZqmQLyk0u36EaaGH2/jtYarADz9MH5ohrVWRK8H1q6kHWCaXpgEI8xrPoTLHz4eFcifhwa2cP87ih/JjpSQQu/xQHjzysa6PWwSoKFjjzFImxw0eSrQEIL2EGIAmA=,iv:BNjv6xaVFK/+8saZj1gFoQB/9JnjGDASORhZUoKa5S0=,tag:0Y/ndncobjQlF1Q9OtB8dw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.1

View file

@ -143,6 +143,7 @@ func run(logger *slog.Logger) error {
apphttp.WorkItemProvider{ID: "plane", Reader: planeClient},
apphttp.WorkItemProvider{ID: "jira", Reader: jiraClient},
)
handler.SetPlaneWebhookSecret(cfg.PlaneWebhookSecret)
protosocket.NewTaskChannels(workflowService).Register(protoSocketServer.Dispatcher())

View file

@ -24,6 +24,7 @@ services:
MATTERMOST_TOKEN: ""
PLANE_BASE_URL: ${PLANE_BASE_URL:-}
PLANE_TOKEN: ${PLANE_TOKEN:-}
PLANE_WEBHOOK_SECRET: ${PLANE_WEBHOOK_SECRET:-}
ports:
- "${NOMADCODE_CORE_HOST_PORT:-18010}:8080"
networks:

View file

@ -27,6 +27,7 @@ type Config struct {
MattermostChannelID string
PlaneBaseURL string
PlaneToken string
PlaneWebhookSecret string
JiraBaseURL string
JiraEmail string
JiraAPIToken string
@ -76,6 +77,7 @@ func Load() Config {
MattermostChannelID: os.Getenv("MATTERMOST_CHANNEL_ID"),
PlaneBaseURL: os.Getenv("PLANE_BASE_URL"),
PlaneToken: os.Getenv("PLANE_TOKEN"),
PlaneWebhookSecret: os.Getenv("PLANE_WEBHOOK_SECRET"),
JiraBaseURL: os.Getenv("JIRA_BASE_URL"),
JiraEmail: os.Getenv("JIRA_EMAIL"),
JiraAPIToken: os.Getenv("JIRA_API_TOKEN"),

View file

@ -27,10 +27,11 @@ type WorkItemTaskCreator interface {
}
type Handler struct {
db *pgxpool.Pool
workflow *workflow.Service
workItemProviders map[workitem.ProviderID]WorkItemTaskCreator
logger *slog.Logger
db *pgxpool.Pool
workflow *workflow.Service
workItemProviders map[workitem.ProviderID]WorkItemTaskCreator
logger *slog.Logger
planeWebhookSecret string
}
type WorkItemProvider struct {
@ -184,16 +185,16 @@ func (h *Handler) CreateTask(w stdhttp.ResponseWriter, r *stdhttp.Request) {
}
type legacyWorkItemTaskRequest struct {
WorkspaceSlug string `json:"workspace_slug"`
ProjectID string `json:"project_id"`
WorkItemID string `json:"work_item_id"`
StateID string `json:"state_id"`
ExternalURL string `json:"external_url"`
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"`
WorkspaceSlug string `json:"workspace_slug"`
ProjectID string `json:"project_id"`
WorkItemID string `json:"work_item_id"`
StateID string `json:"state_id"`
ExternalURL string `json:"external_url"`
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) {
@ -290,17 +291,17 @@ func (input legacyWorkItemTaskRequest) workItemRef(provider workitem.ProviderID)
}
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"`
TriggerStateID string `json:"trigger_state_id"`
AgentAssigneeID string `json:"agent_assignee_id"`
Actor string `json:"actor"`
SelfActor string `json:"self_actor"`
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"`
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) {

View file

@ -0,0 +1,234 @@
package http
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
stdhttp "net/http"
"strings"
"github.com/nomadcode/nomadcode-core/internal/workitem"
)
const maxPlaneWebhookBodyBytes = 1 << 20
// planeProvider is the provider-neutral id used for Plane-originated work items.
const planeProvider = workitem.ProviderID("plane")
type planeWebhookPayload struct {
Event string `json:"event"`
Action string `json:"action"`
WebhookID string `json:"webhook_id"`
WorkspaceID string `json:"workspace_id"`
Data json.RawMessage `json:"data"`
Activity struct {
Field string `json:"field"`
Actor planeWebhookActor `json:"actor"`
} `json:"activity"`
}
type planeWebhookActor struct {
ID string `json:"id"`
}
// planeWebhookData holds only the raw identity fields needed to locate the work
// item later. Current state, assignees, labels, and body from the webhook payload
// are intentionally ignored: the payload is a wakeup hint, and the source of
// truth is a Plane API re-fetch in a later stage.
type planeWebhookData struct {
ID string `json:"id"`
Project string `json:"project"`
Workspace string `json:"workspace"`
}
// planeWebhookWorkItemEvent is the provider-neutral wakeup hint extracted from a
// Plane webhook. It preserves the raw Plane identity (workspace id, project id,
// work item id) plus actor/change/action hints for later self-guard and trigger
// decisions. It never carries Plane payload state (state/assignees/labels/body)
// as truth.
//
// WorkspaceID is the raw Plane workspace UUID from the payload. It is NOT a
// dispatch-ready tenant: the downstream Plane adapter (planeWorkItemRef) and
// project-sync target consume a workspace slug as workitem.Ref.Tenant, and the
// webhook payload has no slug. Building the workitem.Ref is therefore deferred
// to dispatch (02+01_trigger_dispatch), which resolves the workspace id to a
// slug and calls WorkItemRef.
type planeWebhookWorkItemEvent struct {
WorkspaceID string
ProjectID string
WorkItemID string
ActorID string
ChangeField string
Action string
}
// WorkItemRef builds a provider-neutral workitem.Ref once the caller supplies a
// dispatch-ready workspace slug. The webhook payload only carries a workspace
// UUID, so the slug must be resolved by the dispatch stage before a ref that the
// Plane adapter / project-sync can consume exists. Blank slug/project/id are
// rejected via workitem.NormalizeRef and the explicit check below.
func (e planeWebhookWorkItemEvent) WorkItemRef(workspaceSlug string) (workitem.Ref, error) {
slug := strings.TrimSpace(workspaceSlug)
project := strings.TrimSpace(e.ProjectID)
id := strings.TrimSpace(e.WorkItemID)
if slug == "" || project == "" || id == "" {
return workitem.Ref{}, workitem.ErrInvalidRef
}
return workitem.NormalizeRef(workitem.Ref{
Provider: planeProvider,
Tenant: slug,
Project: project,
ID: id,
})
}
// planeWorkItemEvents are the Plane webhook event types that map to a work item.
// Other events (e.g. project, cycle, module) are ignored without side effects.
func isPlaneWorkItemEvent(event string) bool {
switch strings.TrimSpace(strings.ToLower(event)) {
case "issue", "work_item", "work-item", "workitem":
return true
default:
return false
}
}
// normalizePlaneWebhookPayload extracts a provider-neutral wakeup hint from a
// Plane webhook payload. The second return value reports whether the event is a
// relevant work item event; irrelevant events return ok=false with no error so
// the caller can ignore them without side effects. An error is returned only
// when a relevant event is missing the ref tuple required by dispatch.
func normalizePlaneWebhookPayload(payload planeWebhookPayload) (planeWebhookWorkItemEvent, bool, error) {
if !isPlaneWorkItemEvent(payload.Event) {
return planeWebhookWorkItemEvent{}, false, nil
}
var data planeWebhookData
if len(payload.Data) > 0 && string(payload.Data) != "null" {
if err := json.Unmarshal(payload.Data, &data); err != nil {
return planeWebhookWorkItemEvent{}, true, err
}
}
// A relevant work item event must carry the raw identity tuple that dispatch
// needs to build a ref after resolving the workspace slug. Reject missing
// workspace id / project / work item id here as a relevant-but-invalid event
// (ok=true, err!=nil) so dispatch never sees an event with empty identity.
workspaceID := firstNonEmptyTrimmed(data.Workspace, payload.WorkspaceID)
projectID := strings.TrimSpace(data.Project)
workItemID := strings.TrimSpace(data.ID)
if workspaceID == "" || projectID == "" || workItemID == "" {
return planeWebhookWorkItemEvent{}, true, workitem.ErrInvalidRef
}
return planeWebhookWorkItemEvent{
WorkspaceID: workspaceID,
ProjectID: projectID,
WorkItemID: workItemID,
ActorID: strings.TrimSpace(payload.Activity.Actor.ID),
ChangeField: strings.TrimSpace(payload.Activity.Field),
Action: strings.TrimSpace(payload.Action),
}, true, nil
}
func firstNonEmptyTrimmed(vals ...string) string {
for _, v := range vals {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return ""
}
func (h *Handler) SetPlaneWebhookSecret(secret string) {
h.planeWebhookSecret = strings.TrimSpace(secret)
}
func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) {
secret := strings.TrimSpace(h.planeWebhookSecret)
if secret == "" {
writeError(w, stdhttp.StatusServiceUnavailable, "plane webhook secret is not configured")
return
}
body, err := io.ReadAll(stdhttp.MaxBytesReader(w, r.Body, maxPlaneWebhookBodyBytes))
if err != nil {
if errors.As(err, new(*stdhttp.MaxBytesError)) {
writeError(w, stdhttp.StatusRequestEntityTooLarge, "webhook body is too large")
return
}
writeError(w, stdhttp.StatusBadRequest, "invalid webhook body")
return
}
signature := strings.TrimSpace(r.Header.Get("X-Plane-Signature"))
if signature == "" || !validPlaneWebhookSignature(secret, body, signature) {
if h.logger != nil {
h.logger.Warn("plane webhook signature rejected",
"delivery", r.Header.Get("X-Plane-Delivery"),
"event", r.Header.Get("X-Plane-Event"),
)
}
writeError(w, stdhttp.StatusUnauthorized, "invalid plane webhook signature")
return
}
var payload planeWebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
writeError(w, stdhttp.StatusBadRequest, "invalid JSON body")
return
}
event, relevant, normErr := normalizePlaneWebhookPayload(payload)
if h.logger != nil {
attrs := []any{
"delivery", r.Header.Get("X-Plane-Delivery"),
"header_event", r.Header.Get("X-Plane-Event"),
"event", payload.Event,
"action", payload.Action,
"webhook_id", payload.WebhookID,
"workspace_id", payload.WorkspaceID,
}
switch {
case !relevant:
attrs = append(attrs, "normalized", false, "reason", "irrelevant_event")
case normErr != nil:
attrs = append(attrs, "normalized", false, "reason", "missing_work_item_ref")
default:
attrs = append(attrs,
"normalized", true,
"work_item_id", event.WorkItemID,
"project_id", event.ProjectID,
"workspace_id_normalized", event.WorkspaceID,
)
if event.ChangeField != "" {
attrs = append(attrs, "change_field", event.ChangeField)
}
if event.ActorID != "" {
attrs = append(attrs, "actor_id", event.ActorID)
}
}
h.logger.Info("plane webhook received", attrs...)
}
// The webhook is a wakeup hint only. Trigger dispatch and idempotency are
// handled in later milestones; here we accept all valid signed payloads so
// Plane does not retry while normalization boundaries settle.
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted"})
}
func validPlaneWebhookSignature(secret string, body []byte, signature string) bool {
expectedMAC := hmac.New(sha256.New, []byte(secret))
_, _ = expectedMAC.Write(body)
expected := expectedMAC.Sum(nil)
actual, err := hex.DecodeString(signature)
if err != nil {
return false
}
return hmac.Equal(actual, expected)
}

View file

@ -0,0 +1,355 @@
package http
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestReceivePlaneWebhookAcceptsSignedPayload(t *testing.T) {
h := newHandlerForTest()
h.SetPlaneWebhookSecret("secret")
body := `{"event": "issue", "action": "update", "webhook_id": "wh-1", "workspace_id": "ws-1", "data": {"id": "item-1"}, "activity": {"field": "assignees"}}`
req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(body))
req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", body))
req.Header.Set("X-Plane-Delivery", "delivery-1")
req.Header.Set("X-Plane-Event", "issue")
rec := httptest.NewRecorder()
h.ReceivePlaneWebhook(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
}
}
func TestReceivePlaneWebhookAcceptsRedactedPlaneFixture(t *testing.T) {
fixtures := []string{
"testdata/plane_webhook_issue_assignees_updated.json",
"testdata/plane_webhook_issue_created.json",
"testdata/plane_webhook_issue_state_updated.json",
}
for _, fixture := range fixtures {
t.Run(fixture, func(t *testing.T) {
h := newHandlerForTest()
h.SetPlaneWebhookSecret("secret")
body, err := os.ReadFile(fixture)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(string(body)))
req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", string(body)))
req.Header.Set("X-Plane-Delivery", "fixture-delivery")
req.Header.Set("X-Plane-Event", "issue")
rec := httptest.NewRecorder()
h.ReceivePlaneWebhook(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String())
}
})
}
}
func TestReceivePlaneWebhookRejectsBadSignature(t *testing.T) {
h := newHandlerForTest()
h.SetPlaneWebhookSecret("secret")
req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(`{"event":"issue"}`))
req.Header.Set("X-Plane-Signature", testPlaneSignature("wrong-secret", `{"event":"issue"}`))
rec := httptest.NewRecorder()
h.ReceivePlaneWebhook(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected %d, got %d: %s", http.StatusUnauthorized, rec.Code, rec.Body.String())
}
}
func TestPlaneWebhookRouteBypassesBasicAuth(t *testing.T) {
h := newHandlerForTest()
h.SetPlaneWebhookSecret("secret")
body := `{"event": "issue", "action": "update"}`
req := httptest.NewRequest(http.MethodPost, "/api/integrations/plane/webhook", strings.NewReader(body))
req.Header.Set("X-Plane-Signature", testPlaneSignature("secret", body))
rec := httptest.NewRecorder()
router := NewRouter(
h,
nil,
AuthConfig{Username: "nomadcode", Password: "required"},
nil,
"",
)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected webhook route to bypass basic auth, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestNormalizePlaneWebhookPayloadExtractsRawIdentity(t *testing.T) {
payload := planeWebhookPayload{
Event: "issue",
Action: "updated",
WorkspaceID: "ws-1",
Data: json.RawMessage(`{"id":"item-1","project":"proj-1","workspace":"ws-data"}`),
}
payload.Activity.Field = "assignees"
payload.Activity.Actor.ID = "actor-1"
event, ok, err := normalizePlaneWebhookPayload(payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("expected relevant work item event")
}
if event.WorkItemID != "item-1" {
t.Fatalf("expected work item id item-1, got %q", event.WorkItemID)
}
if event.ProjectID != "proj-1" {
t.Fatalf("expected project proj-1, got %q", event.ProjectID)
}
// data.workspace (raw UUID/id) takes precedence over the top-level
// workspace_id and is preserved raw, not as a dispatch-ready tenant.
if event.WorkspaceID != "ws-data" {
t.Fatalf("expected workspace id ws-data, got %q", event.WorkspaceID)
}
if event.ActorID != "actor-1" {
t.Fatalf("expected actor actor-1, got %q", event.ActorID)
}
if event.ChangeField != "assignees" {
t.Fatalf("expected change field assignees, got %q", event.ChangeField)
}
if event.Action != "updated" {
t.Fatalf("expected action updated, got %q", event.Action)
}
}
func TestNormalizePlaneWebhookPayloadFallsBackToWorkspaceID(t *testing.T) {
payload := planeWebhookPayload{
Event: "issue",
WorkspaceID: "ws-top",
Data: json.RawMessage(`{"id":"item-1","project":"proj-1"}`),
}
event, ok, err := normalizePlaneWebhookPayload(payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("expected relevant work item event")
}
if event.WorkspaceID != "ws-top" {
t.Fatalf("expected workspace id ws-top from workspace_id fallback, got %q", event.WorkspaceID)
}
}
func TestPlaneWebhookWorkItemEventBuildsRefWithWorkspaceSlug(t *testing.T) {
event := planeWebhookWorkItemEvent{
WorkspaceID: "dadf050e-cd1e-4590-bc33-672511630841",
ProjectID: "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
WorkItemID: "954c1887-48d6-4626-9ac6-34e323392ed1",
}
ref, err := event.WorkItemRef("general")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref.Provider != planeProvider {
t.Fatalf("expected provider %q, got %q", planeProvider, ref.Provider)
}
// The dispatch-ready tenant is the resolved workspace slug, not the raw
// workspace UUID from the webhook payload.
if ref.Tenant != "general" {
t.Fatalf("expected tenant general (slug), got %q", ref.Tenant)
}
if ref.Project != event.ProjectID {
t.Fatalf("expected project %q, got %q", event.ProjectID, ref.Project)
}
if ref.ID != event.WorkItemID {
t.Fatalf("expected id %q, got %q", event.WorkItemID, ref.ID)
}
}
func TestPlaneWebhookWorkItemEventRefRejectsBlankSlugOrIdentity(t *testing.T) {
full := planeWebhookWorkItemEvent{
WorkspaceID: "ws-id",
ProjectID: "proj-1",
WorkItemID: "item-1",
}
cases := map[string]struct {
event planeWebhookWorkItemEvent
slug string
}{
"blank slug": {full, " "},
"missing project": {planeWebhookWorkItemEvent{WorkspaceID: "ws-id", WorkItemID: "item-1"}, "general"},
"missing id": {planeWebhookWorkItemEvent{WorkspaceID: "ws-id", ProjectID: "proj-1"}, "general"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
if _, err := tc.event.WorkItemRef(tc.slug); err == nil {
t.Fatalf("expected error for %s", name)
}
})
}
}
func TestNormalizePlaneWebhookPayloadIgnoresIrrelevantEvent(t *testing.T) {
for _, event := range []string{"project", "cycle", "module", ""} {
t.Run(event, func(t *testing.T) {
payload := planeWebhookPayload{
Event: event,
Data: json.RawMessage(`{"id":"item-1","project":"proj-1","workspace":"ws-1"}`),
}
got, ok, err := normalizePlaneWebhookPayload(payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Fatalf("expected irrelevant event to be ignored")
}
if got.WorkItemID != "" || got.WorkspaceID != "" || got.ActorID != "" || got.ChangeField != "" {
t.Fatalf("expected zero event for irrelevant event, got %+v", got)
}
})
}
}
func TestNormalizePlaneWebhookPayloadRejectsMissingWorkItemID(t *testing.T) {
cases := map[string]json.RawMessage{
"missing id": json.RawMessage(`{"project":"proj-1","workspace":"ws-1"}`),
"empty data": json.RawMessage(`{}`),
"null data": json.RawMessage(`null`),
"blank id": json.RawMessage(`{"id":" ","project":"proj-1"}`),
"missing entire": nil,
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
payload := planeWebhookPayload{Event: "issue", Data: data}
_, ok, err := normalizePlaneWebhookPayload(payload)
if !ok {
t.Fatalf("expected event to be relevant even when ref is missing")
}
if err == nil {
t.Fatalf("expected error for missing work item id")
}
})
}
}
func TestNormalizePlaneWebhookPayloadRejectsMissingProject(t *testing.T) {
cases := map[string]planeWebhookPayload{
"no project key": {
Event: "issue",
Data: json.RawMessage(`{"id":"item-1","workspace":"ws-1"}`),
},
"blank project": {
Event: "issue",
Data: json.RawMessage(`{"id":"item-1","project":" ","workspace":"ws-1"}`),
},
}
for name, payload := range cases {
t.Run(name, func(t *testing.T) {
_, ok, err := normalizePlaneWebhookPayload(payload)
if !ok {
t.Fatalf("expected event to be relevant even when project is missing")
}
if err == nil {
t.Fatalf("expected error for missing project so dispatch never gets an empty project key")
}
})
}
}
func TestNormalizePlaneWebhookPayloadRejectsMissingWorkspace(t *testing.T) {
cases := map[string]planeWebhookPayload{
"no workspace anywhere": {
Event: "issue",
Data: json.RawMessage(`{"id":"item-1","project":"proj-1"}`),
},
"blank workspace data and id": {
Event: "issue",
WorkspaceID: " ",
Data: json.RawMessage(`{"id":"item-1","project":"proj-1","workspace":" "}`),
},
}
for name, payload := range cases {
t.Run(name, func(t *testing.T) {
_, ok, err := normalizePlaneWebhookPayload(payload)
if !ok {
t.Fatalf("expected event to be relevant even when workspace/tenant is missing")
}
if err == nil {
t.Fatalf("expected error for missing workspace/tenant so dispatch never gets an empty tenant key")
}
})
}
}
func TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture(t *testing.T) {
body, err := os.ReadFile("testdata/plane_webhook_issue_assignees_updated.json")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var payload planeWebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
event, ok, err := normalizePlaneWebhookPayload(payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("expected relevant work item event")
}
if event.WorkItemID != "954c1887-48d6-4626-9ac6-34e323392ed1" {
t.Fatalf("unexpected work item id: %q", event.WorkItemID)
}
if event.ProjectID != "a6beb42f-7a8a-410c-b50f-ea3ca94828f3" {
t.Fatalf("unexpected project: %q", event.ProjectID)
}
// The fixture workspace is a raw Plane workspace UUID, preserved raw and not
// treated as a dispatch-ready tenant.
if event.WorkspaceID != "dadf050e-cd1e-4590-bc33-672511630841" {
t.Fatalf("unexpected workspace id: %q", event.WorkspaceID)
}
if event.ChangeField != "assignees" {
t.Fatalf("expected change field assignees, got %q", event.ChangeField)
}
// A dispatch-ready ref exists only once a workspace slug is resolved; the
// fixture workspace UUID alone is not the tenant the Plane adapter consumes.
ref, err := event.WorkItemRef("general")
if err != nil {
t.Fatalf("build ref with slug: %v", err)
}
if ref.Tenant != "general" || ref.Project != event.ProjectID || ref.ID != event.WorkItemID {
t.Fatalf("unexpected dispatch ref tuple: %+v", ref)
}
}
func testPlaneSignature(secret, body string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}

View file

@ -21,6 +21,7 @@ func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig, protoSock
r.Use(chimiddleware.Recoverer)
r.Get("/healthz", handler.Healthz)
r.Post("/api/integrations/plane/webhook", handler.ReceivePlaneWebhook)
protected := chi.NewRouter()
protected.Use(basicAuthMiddleware(auth))

View file

@ -0,0 +1,77 @@
{
"event": "issue",
"action": "updated",
"webhook_id": "e25a2d41-2c9f-4f5f-a0ce-cad6b993853f",
"workspace_id": "dadf050e-cd1e-4590-bc33-672511630841",
"data": {
"id": "954c1887-48d6-4626-9ac6-34e323392ed1",
"labels": [
{
"id": "995d5acd-9e33-44e4-8124-18eed68d151b",
"name": "android-remote-debug",
"color": "#eb144c"
}
],
"assignees": [
{
"id": "5d116a77-d3df-4f54-80bf-eca61e0118c4",
"first_name": "Agent",
"last_name": "AI",
"email": "redacted@example.invalid",
"avatar": "",
"avatar_url": null,
"display_name": "AGENT"
}
],
"state": {
"id": "62d4c50c-0cea-4a76-a0ed-ec97498b2d5f",
"name": "Backlog",
"color": "#A3A3A3",
"group": "backlog"
},
"description": {},
"created_at": "2026-03-21T05:50:20.597190Z",
"updated_at": "2026-03-21T05:58:38.879092Z",
"deleted_at": null,
"point": null,
"name": "Code-server Android remote debug plugin",
"description_json": {},
"description_html": "<p>redacted description html</p>",
"description_stripped": "redacted description",
"description_binary": null,
"priority": "none",
"start_date": null,
"target_date": null,
"sequence_id": 10,
"sort_order": 95535.0,
"completed_at": null,
"archived_at": null,
"last_activity_at": "2026-06-14T09:36:42.788857Z",
"is_draft": false,
"external_source": null,
"external_id": null,
"created_by": "82b2039e-8b4c-4a97-948c-1562572745a5",
"updated_by": "82b2039e-8b4c-4a97-948c-1562572745a5",
"project": "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
"workspace": "dadf050e-cd1e-4590-bc33-672511630841",
"parent": "5febcd2d-d62a-4577-9f8f-306691cff003",
"estimate_point": null,
"type": null
},
"activity": {
"field": "assignees",
"new_value": "AGENT",
"old_value": null,
"actor": {
"id": "82b2039e-8b4c-4a97-948c-1562572745a5",
"first_name": "Toki",
"last_name": "User",
"email": "redacted@example.invalid",
"avatar": "redacted",
"avatar_url": "redacted",
"display_name": "Toki"
},
"old_identifier": null,
"new_identifier": "5d116a77-d3df-4f54-80bf-eca61e0118c4"
}
}

View file

@ -0,0 +1,58 @@
{
"event": "issue",
"action": "created",
"webhook_id": "e25a2d41-2c9f-4f5f-a0ce-cad6b993853f",
"workspace_id": "dadf050e-cd1e-4590-bc33-672511630841",
"data": {
"id": "11111111-1111-4111-8111-111111111111",
"labels": [],
"assignees": [],
"state": {
"id": "62d4c50c-0cea-4a76-a0ed-ec97498b2d5f",
"name": "Backlog",
"color": "#A3A3A3",
"group": "backlog"
},
"description": {},
"created_at": "2026-06-14T09:30:00.000000Z",
"updated_at": "2026-06-14T09:30:00.000000Z",
"deleted_at": null,
"point": null,
"name": "redacted work item title",
"description_json": {},
"description_html": "<p>redacted description html</p>",
"description_stripped": "redacted description",
"description_binary": null,
"priority": "none",
"start_date": null,
"target_date": null,
"sequence_id": 20,
"sort_order": 100000.0,
"completed_at": null,
"archived_at": null,
"last_activity_at": "2026-06-14T09:30:00.000000Z",
"is_draft": false,
"external_source": null,
"external_id": null,
"created_by": "82b2039e-8b4c-4a97-948c-1562572745a5",
"updated_by": "82b2039e-8b4c-4a97-948c-1562572745a5",
"project": "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
"workspace": "dadf050e-cd1e-4590-bc33-672511630841",
"parent": null,
"estimate_point": null,
"type": null
},
"activity": {
"field": null,
"new_value": null,
"old_value": null,
"actor": {
"id": "82b2039e-8b4c-4a97-948c-1562572745a5",
"first_name": "redacted",
"last_name": "redacted",
"display_name": "redacted"
},
"old_identifier": null,
"new_identifier": null
}
}

View file

@ -0,0 +1,63 @@
{
"event": "issue",
"action": "updated",
"webhook_id": "e25a2d41-2c9f-4f5f-a0ce-cad6b993853f",
"workspace_id": "dadf050e-cd1e-4590-bc33-672511630841",
"data": {
"id": "22222222-2222-4222-8222-222222222222",
"labels": [],
"assignees": [
{
"id": "5d116a77-d3df-4f54-80bf-eca61e0118c4",
"display_name": "AGENT"
}
],
"state": {
"id": "45ba7449-f684-4381-af6d-5854747c5e8d",
"name": "Todo",
"color": "#3F76FF",
"group": "unstarted"
},
"description": {},
"created_at": "2026-06-14T09:30:00.000000Z",
"updated_at": "2026-06-14T09:40:00.000000Z",
"deleted_at": null,
"point": null,
"name": "redacted work item title",
"description_json": {},
"description_html": "<p>redacted description html</p>",
"description_stripped": "redacted description",
"description_binary": null,
"priority": "none",
"start_date": null,
"target_date": null,
"sequence_id": 20,
"sort_order": 100000.0,
"completed_at": null,
"archived_at": null,
"last_activity_at": "2026-06-14T09:40:00.000000Z",
"is_draft": false,
"external_source": null,
"external_id": null,
"created_by": "82b2039e-8b4c-4a97-948c-1562572745a5",
"updated_by": "82b2039e-8b4c-4a97-948c-1562572745a5",
"project": "a6beb42f-7a8a-410c-b50f-ea3ca94828f3",
"workspace": "dadf050e-cd1e-4590-bc33-672511630841",
"parent": null,
"estimate_point": null,
"type": null
},
"activity": {
"field": "state",
"new_value": "Todo",
"old_value": "Backlog",
"actor": {
"id": "82b2039e-8b4c-4a97-948c-1562572745a5",
"first_name": "redacted",
"last_name": "redacted",
"display_name": "redacted"
},
"old_identifier": "62d4c50c-0cea-4a76-a0ed-ec97498b2d5f",
"new_identifier": "45ba7449-f684-4381-af6d-5854747c5e8d"
}
}