From 210f67bd9a34827ef71d726f6e210708e5074024 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 15 Jun 2026 14:46:20 +0900 Subject: [PATCH] feat: complete G07 trigger dispatch and refactor webhook handling - Move G07 task artifacts to archive (plan, code review, complete.log) - Refactor main.go to use new webhook router - Update plane webhook handlers with improved error handling - Add config for Plane webhook integration - Update docker-compose for webhook testing - Update plane-dev test documentation --- .../code_review_local_G07_0.log | 167 ++++++++++++++++++ .../02+01_trigger_dispatch/complete.log | 44 +++++ .../plan_local_G07_0.log} | 0 .../CODE_REVIEW-local-G07.md | 115 ------------ agent-test/plane-dev.md | 10 ++ services/core/cmd/server/main.go | 7 + services/core/docker-compose.yml | 5 + services/core/internal/config/config.go | 10 ++ services/core/internal/config/config_test.go | 52 ++++++ services/core/internal/http/handlers.go | 30 ++++ services/core/internal/http/plane_webhook.go | 80 ++++++++- .../core/internal/http/plane_webhook_test.go | 162 +++++++++++++++++ 12 files changed, 563 insertions(+), 119 deletions(-) create mode 100644 agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/code_review_local_G07_0.log create mode 100644 agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log rename agent-task/{m-plane-work-item-webhook-intake/02+01_trigger_dispatch/PLAN-local-G07.md => archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/plan_local_G07_0.log} (100%) delete mode 100644 agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/CODE_REVIEW-local-G07.md diff --git a/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/code_review_local_G07_0.log b/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/code_review_local_G07_0.log new file mode 100644 index 0000000..856658a --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/code_review_local_G07_0.log @@ -0,0 +1,167 @@ + + +# 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 | [x] | +| [WEBHOOK_DISPATCH-2] Handler Dispatch Path | [x] | +| [WEBHOOK_DISPATCH-3] Side Effect Guard Verification | [x] | + +## 구현 체크리스트 + +- [x] Plane webhook dispatch 설정을 config/server/handler에 추가하고 `PLANE_CREATION_BACKLOG_STATE_ID`, `PLANE_AGENT_ASSIGNEE_ID`, optional `PLANE_SELF_ACTOR_ID` env를 주입한다. (ref 구성을 위해 `PLANE_WORKSPACE_SLUG`와 optional guard `PLANE_WORKSPACE_ID`를 함께 추가; 아래 `계획 대비 변경 사항` 참고.) +- [x] `ReceivePlaneWebhook`에서 normalized Plane issue event만 `workitempipeline.CreateTaskFromWorkItem`으로 넘기고 ignored event는 side effect 없이 202로 반환한다. 검증: ignored event가 workspace provision 또는 slot reservation을 발생시키지 않는다. (trigger gate는 `workitempipeline`에서 provision/slot 이전에 평가되므로 ignored event는 creator 호출 없이 202; foreign workspace/unbuildable ref/dispatch 미설정도 creator 호출 없이 202 ignored.) +- [x] handler/config tests를 추가해 matching event는 trigger input으로 전달되고 non-matching/ignored event는 task creator를 호출하지 않음을 확인한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] active `CODE_REVIEW-*-G??.md`와 `PLAN-*-G??.md`를 `.log`로 아카이브한다. +- [x] PASS이면 `complete.log`를 작성하고 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/`로 이동한다. +- [x] PASS이고 task group이 `m-plane-work-item-webhook-intake`이므로 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다. + +## 계획 대비 변경 사항 + +- config 필드 2개 추가(계획은 3개 명시): `PlaneWorkspaceSlug`(env `PLANE_WORKSPACE_SLUG`), `PlaneWorkspaceID`(env `PLANE_WORKSPACE_ID`)를 추가했다. 이유: webhook payload는 workspace UUID만 담고 slug가 없는데, `planeWebhookWorkItemEvent.WorkItemRef(slug)`는 dispatch-ready tenant slug가 비어 있으면 ref를 만들 수 없다. slug 주입이 dispatch의 필수 입력이다. `PlaneWorkspaceID`는 optional guard로, payload workspace id가 설정값과 다르면 dispatch 없이 202 ignored로 끝내 잘못된 slug로 Plane API를 치는 것을 막는다. slug/id는 모두 non-secret 값이라 `agent-test/plane-dev.md`와 docker-compose 기본값에만 기록했다. +- 계획 sketch는 handler에서 `errors.Is(err, ErrCreationTriggerIgnored)`를 명시적으로 검사했지만, 기존 `writeServiceError`가 이미 `ErrCreationTriggerIgnored -> 202 {"status":"ignored"}` 매핑을 갖고 있어 중복을 피하려 그 경로를 재사용했다(legacy `CreatePlaneTask`와 동일 동작). +- dispatch helper 시그니처는 `(storage.Task, bool, error)`로, `dispatched=false`는 creator 호출 없이 ignored 처리(미설정/foreign workspace/unbuildable ref)를 의미하도록 했다. +- 필수 handler test 3개 외에 `foreign workspace`, `dispatch config absent` 케이스 2개를 추가해 WEBHOOK_DISPATCH-3의 creator 호출 횟수(미호출) 검증을 보강했다. +- `workitempipeline` 신규 테스트는 추가하지 않았다(계획상 optional). pipeline behavior 변경이 없고, 기존 `TestCreateTaskFromWorkItemIgnoresNonMatchingTrigger`/`TestCreateTaskFromWorkItemIgnoresSelfActor`가 trigger gate와 side-effect 순서를 이미 검증한다. + +## 주요 설계 결정 + +- side-effect guard는 `workitempipeline.CreateTaskFromWorkItem` 내부에 유지한다. trigger 평가(`EvaluateCreationTrigger`)가 `EnsureProjectWorkspace`/`ReserveWorkspaceSlot`보다 먼저 일어나므로, handler는 strict trigger config(Backlog state + AGENT assignee + self-actor guard)만 전달하면 ignored event가 workspace provision/slot reservation을 발생시키지 않는다. webhook payload logic에 Plane current-state 검사를 중복 구현하지 않았다. +- dispatch 미설정/foreign workspace/unbuildable ref는 503이 아니라 202 ignored로 응답한다(계획의 명시 선택). receiver는 도달 가능하지만 trigger config가 없을 때 provider retry storm을 피하기 위함이다. +- workspace slug는 config로 주입한다. webhook payload가 workspace UUID만 제공하고 slug를 담지 않으므로, `.env.plane.local`의 기존 `PLANE_WORKSPACE_SLUG` 관례와 동일한 env로 dispatch-ready tenant를 주입한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/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 +ok github.com/nomadcode/nomadcode-core/internal/config 0.002s +``` + +### WEBHOOK_DISPATCH-2 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/http +ok github.com/nomadcode/nomadcode-core/internal/http 0.007s +``` + +### WEBHOOK_DISPATCH-3 중간 검증 + +```bash +$ cd services/core && go test -count=1 ./internal/http ./internal/workitempipeline +ok github.com/nomadcode/nomadcode-core/internal/http 0.007s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s +``` + +### 최종 검증 + +```bash +$ cd services/core && gofmt -l 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 +(빈 출력 = gofmt diff 없음) + +$ cd services/core && go test -count=1 ./internal/config ./internal/http ./internal/workitempipeline +ok github.com/nomadcode/nomadcode-core/internal/config 0.002s +ok github.com/nomadcode/nomadcode-core/internal/http 0.007s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s + +# 로컬 전체 core 빌드/테스트도 통과 +$ cd services/core && go test -count=1 ./... +ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.004s +... (전 패키지 ok) ... +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s + +$ tar --exclude='./.git' -czf - . | ssh toki@toki-labs.com 'mkdir -p ~/agent-work/nomadcode/services/core && tar -C ~/agent-work/nomadcode/services/core -xzf -' +(exit 0) + +$ 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.409s +ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.782s +ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 1.201s +ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 1.598s +ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 1.867s +ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 2.255s +ok github.com/nomadcode/nomadcode-core/internal/authoring 2.608s +ok github.com/nomadcode/nomadcode-core/internal/config 2.942s +ok github.com/nomadcode/nomadcode-core/internal/gitoevents 3.367s +ok github.com/nomadcode/nomadcode-core/internal/gitosync 3.654s +ok github.com/nomadcode/nomadcode-core/internal/http 3.707s +ok github.com/nomadcode/nomadcode-core/internal/notification 3.930s +ok github.com/nomadcode/nomadcode-core/internal/projectsync 3.895s +ok github.com/nomadcode/nomadcode-core/internal/protosocket 3.605s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 3.809s +ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 3.752s +ok github.com/nomadcode/nomadcode-core/internal/scheduler 5.663s +ok github.com/nomadcode/nomadcode-core/internal/storage 3.790s +ok github.com/nomadcode/nomadcode-core/internal/workflow 3.735s +ok github.com/nomadcode/nomadcode-core/internal/workitem 3.722s +ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 3.711s +``` + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass - normalized Plane issue event만 dispatch 대상이 되고, dispatch 미설정/foreign workspace/trigger ignored 경로는 202 ignored로 닫힌다. + - completeness: Pass - config/server/handler wiring, handler tests, side-effect guard 검증이 계획 범위와 일치한다. + - test coverage: Pass - required config/handler tests가 추가되었고 `workitempipeline` side-effect guard는 기존 tests로 유지됨이 확인된다. + - API contract: Pass - public webhook endpoint는 signed payload 인증과 202 ack contract를 유지하고, 신규 env는 non-secret dispatch gate로만 추가되었다. + - code quality: Pass - debug 출력, stale TODO, formatting 문제 없음. + - plan deviation: Pass - workspace slug/id config 추가와 malformed relevant event 202 ignored 선택이 구현 스텁에 근거와 함께 기록되어 있고 범위 내로 판단된다. + - verification trust: Pass - 리뷰 중 `gofmt -l`, focused Go tests, 전체 `go test -count=1 ./...`, `git diff --check`를 재실행해 통과를 확인했다. +- 발견된 문제: 없음 +- 다음 단계: PASS - `CODE_REVIEW-local-G07.md`와 `PLAN-local-G07.md`를 log로 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/`로 이동한다. diff --git a/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log b/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log new file mode 100644 index 0000000..b3d1031 --- /dev/null +++ b/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/complete.log @@ -0,0 +1,44 @@ +# Complete - m-plane-work-item-webhook-intake/02+01_trigger_dispatch + +## 완료 일시 + +2026-06-15 + +## 요약 + +Plane webhook dispatch 설정과 handler dispatch path를 1회 리뷰 루프로 완료했으며 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | PASS | webhook dispatch config/server wiring, matching issue event dispatch, ignored event 202 처리, side-effect guard 검증이 계획 범위와 일치했다. | + +## 구현/정리 내용 + +- `PLANE_WORKSPACE_SLUG`, optional `PLANE_WORKSPACE_ID`, `PLANE_CREATION_BACKLOG_STATE_ID`, `PLANE_AGENT_ASSIGNEE_ID`, optional `PLANE_SELF_ACTOR_ID`를 config/server/handler dispatch 설정으로 연결했다. +- signed Plane work item webhook을 normalized event로 만든 뒤 `workitempipeline.CreateTaskFromWorkItem`의 trigger gate로 전달하고, irrelevant/config-missing/foreign workspace/trigger-ignored 경로는 202 ignored로 닫았다. +- matching dispatch, irrelevant event, foreign workspace, absent dispatch config, trigger ignored, config env loading tests를 추가했고, Plane dev non-secret env 문서를 갱신했다. + +## 최종 검증 + +- `cd services/core && gofmt -l 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` - PASS; 출력 없음. +- `cd services/core && go test -count=1 ./internal/config ./internal/http ./internal/workitempipeline` - PASS; focused packages 통과. +- `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; archived review evidence 기준 remote runner 전체 core tests 통과. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/plane-work-item-webhook-intake.md` +- Completed task ids: + - `trigger-dispatch`: PASS; evidence=`plan_local_G07_0.log`, `code_review_local_G07_0.log`; verification=`cd services/core && go test -count=1 ./internal/config ./internal/http ./internal/workitempipeline`, `cd services/core && go test -count=1 ./...`, remote `go test -count=1 ./...` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/PLAN-local-G07.md b/agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/plan_local_G07_0.log similarity index 100% rename from agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/PLAN-local-G07.md rename to agent-task/archive/2026/06/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/plan_local_G07_0.log diff --git a/agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/CODE_REVIEW-local-G07.md b/agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/CODE_REVIEW-local-G07.md deleted file mode 100644 index fde92d8..0000000 --- a/agent-task/m-plane-work-item-webhook-intake/02+01_trigger_dispatch/CODE_REVIEW-local-G07.md +++ /dev/null @@ -1,115 +0,0 @@ - - -# 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) -``` diff --git a/agent-test/plane-dev.md b/agent-test/plane-dev.md index 170cb57..b882e9e 100644 --- a/agent-test/plane-dev.md +++ b/agent-test/plane-dev.md @@ -24,6 +24,16 @@ API token, webhook secret, password 같은 secret 원문은 이 파일에 쓰지 - AGENT assignee user id: `5d116a77-d3df-4f54-80bf-eca61e0118c4` +## Webhook Dispatch Env (non-secret) + +webhook dispatch trigger gate에 주입하는 non-secret env. secret(`PLANE_WEBHOOK_SECRET`, `PLANE_TOKEN`)은 여기에 쓰지 않는다. + +- `PLANE_WORKSPACE_SLUG`: `general` (webhook payload는 workspace UUID만 주므로 ref tenant slug는 이 값으로 주입한다) +- `PLANE_WORKSPACE_ID`: `dadf050e-cd1e-4590-bc33-672511630841` (optional guard; payload workspace id와 다르면 dispatch 없이 ignored) +- `PLANE_CREATION_BACKLOG_STATE_ID` (alias `PLANE_BACKLOG_STATE_ID`): `62d4c50c-0cea-4a76-a0ed-ec97498b2d5f` +- `PLANE_AGENT_ASSIGNEE_ID`: `5d116a77-d3df-4f54-80bf-eca61e0118c4` +- `PLANE_SELF_ACTOR_ID`: NomadCode self actor id가 정해지면 채운다. 비어 있으면 self-mutation guard는 no-op이다. + ## Webhook Smoke Baseline - Webhook URL: `http://toki-labs.com:18010/api/integrations/plane/webhook` diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index a07a899..aa02b97 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -144,6 +144,13 @@ func run(logger *slog.Logger) error { apphttp.WorkItemProvider{ID: "jira", Reader: jiraClient}, ) handler.SetPlaneWebhookSecret(cfg.PlaneWebhookSecret) + handler.SetPlaneWebhookDispatchConfig(apphttp.PlaneWebhookDispatchConfig{ + WorkspaceID: cfg.PlaneWorkspaceID, + WorkspaceSlug: cfg.PlaneWorkspaceSlug, + BacklogStateID: cfg.PlaneCreationBacklogStateID, + AgentAssigneeID: cfg.PlaneAgentAssigneeID, + SelfActorID: cfg.PlaneSelfActorID, + }) protosocket.NewTaskChannels(workflowService).Register(protoSocketServer.Dispatcher()) diff --git a/services/core/docker-compose.yml b/services/core/docker-compose.yml index d4b921a..673eb09 100644 --- a/services/core/docker-compose.yml +++ b/services/core/docker-compose.yml @@ -25,6 +25,11 @@ services: PLANE_BASE_URL: ${PLANE_BASE_URL:-} PLANE_TOKEN: ${PLANE_TOKEN:-} PLANE_WEBHOOK_SECRET: ${PLANE_WEBHOOK_SECRET:-} + PLANE_WORKSPACE_SLUG: ${PLANE_WORKSPACE_SLUG:-general} + PLANE_WORKSPACE_ID: ${PLANE_WORKSPACE_ID:-dadf050e-cd1e-4590-bc33-672511630841} + PLANE_CREATION_BACKLOG_STATE_ID: ${PLANE_CREATION_BACKLOG_STATE_ID:-62d4c50c-0cea-4a76-a0ed-ec97498b2d5f} + PLANE_AGENT_ASSIGNEE_ID: ${PLANE_AGENT_ASSIGNEE_ID:-5d116a77-d3df-4f54-80bf-eca61e0118c4} + PLANE_SELF_ACTOR_ID: ${PLANE_SELF_ACTOR_ID:-} ports: - "${NOMADCODE_CORE_HOST_PORT:-18010}:8080" networks: diff --git a/services/core/internal/config/config.go b/services/core/internal/config/config.go index a9b2511..da02c9c 100644 --- a/services/core/internal/config/config.go +++ b/services/core/internal/config/config.go @@ -28,6 +28,11 @@ type Config struct { PlaneBaseURL string PlaneToken string PlaneWebhookSecret string + PlaneWorkspaceSlug string + PlaneWorkspaceID string + PlaneCreationBacklogStateID string + PlaneAgentAssigneeID string + PlaneSelfActorID string JiraBaseURL string JiraEmail string JiraAPIToken string @@ -78,6 +83,11 @@ func Load() Config { PlaneBaseURL: os.Getenv("PLANE_BASE_URL"), PlaneToken: os.Getenv("PLANE_TOKEN"), PlaneWebhookSecret: os.Getenv("PLANE_WEBHOOK_SECRET"), + PlaneWorkspaceSlug: os.Getenv("PLANE_WORKSPACE_SLUG"), + PlaneWorkspaceID: os.Getenv("PLANE_WORKSPACE_ID"), + 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"), JiraBaseURL: os.Getenv("JIRA_BASE_URL"), JiraEmail: os.Getenv("JIRA_EMAIL"), JiraAPIToken: os.Getenv("JIRA_API_TOKEN"), diff --git a/services/core/internal/config/config_test.go b/services/core/internal/config/config_test.go index 449cf0a..3797dea 100644 --- a/services/core/internal/config/config_test.go +++ b/services/core/internal/config/config_test.go @@ -222,6 +222,58 @@ func TestConfigRoadmapCreationTodoStateIDFallsBackToPlaneEnv(t *testing.T) { } } +func TestConfigPlaneWebhookDispatchFieldsAreEmptyByDefault(t *testing.T) { + cfg := Load() + + if cfg.PlaneWorkspaceSlug != "" { + t.Fatalf("PlaneWorkspaceSlug default: got %q, want empty", cfg.PlaneWorkspaceSlug) + } + if cfg.PlaneWorkspaceID != "" { + t.Fatalf("PlaneWorkspaceID default: got %q, want empty", cfg.PlaneWorkspaceID) + } + if cfg.PlaneCreationBacklogStateID != "" { + t.Fatalf("PlaneCreationBacklogStateID default: got %q, want empty", cfg.PlaneCreationBacklogStateID) + } + if cfg.PlaneAgentAssigneeID != "" { + t.Fatalf("PlaneAgentAssigneeID default: got %q, want empty", cfg.PlaneAgentAssigneeID) + } + if cfg.PlaneSelfActorID != "" { + t.Fatalf("PlaneSelfActorID default: got %q, want empty", cfg.PlaneSelfActorID) + } +} + +func TestConfigLoadsPlaneWebhookDispatchEnv(t *testing.T) { + t.Setenv("PLANE_WORKSPACE_SLUG", "general") + t.Setenv("PLANE_WORKSPACE_ID", "dadf050e-cd1e-4590-bc33-672511630841") + t.Setenv("PLANE_BACKLOG_STATE_ID", "backlog-state") + t.Setenv("PLANE_AGENT_ASSIGNEE_ID", "agent-assignee") + t.Setenv("PLANE_SELF_ACTOR_ID", "self-actor") + + cfg := Load() + + if cfg.PlaneWorkspaceSlug != "general" { + t.Fatalf("PlaneWorkspaceSlug: got %q", cfg.PlaneWorkspaceSlug) + } + if cfg.PlaneWorkspaceID != "dadf050e-cd1e-4590-bc33-672511630841" { + t.Fatalf("PlaneWorkspaceID: got %q", cfg.PlaneWorkspaceID) + } + if cfg.PlaneCreationBacklogStateID != "backlog-state" { + t.Fatalf("PlaneCreationBacklogStateID from PLANE_BACKLOG_STATE_ID: got %q", cfg.PlaneCreationBacklogStateID) + } + if cfg.PlaneAgentAssigneeID != "agent-assignee" { + t.Fatalf("PlaneAgentAssigneeID: got %q", cfg.PlaneAgentAssigneeID) + } + if cfg.PlaneSelfActorID != "self-actor" { + t.Fatalf("PlaneSelfActorID: got %q", cfg.PlaneSelfActorID) + } + + // PLANE_CREATION_BACKLOG_STATE_ID takes precedence over the PLANE_BACKLOG_STATE_ID alias. + t.Setenv("PLANE_CREATION_BACKLOG_STATE_ID", "creation-backlog-state") + if got := Load().PlaneCreationBacklogStateID; got != "creation-backlog-state" { + t.Fatalf("PlaneCreationBacklogStateID: PLANE_CREATION_BACKLOG_STATE_ID should win, got %q", got) + } +} + func TestConfigJiraFieldsAreEmptyByDefault(t *testing.T) { cfg := Load() diff --git a/services/core/internal/http/handlers.go b/services/core/internal/http/handlers.go index 99d8f24..de62b1e 100644 --- a/services/core/internal/http/handlers.go +++ b/services/core/internal/http/handlers.go @@ -32,6 +32,36 @@ type Handler struct { workItemProviders map[workitem.ProviderID]WorkItemTaskCreator logger *slog.Logger planeWebhookSecret string + planeDispatch planeWebhookDispatch +} + +// PlaneWebhookDispatchConfig carries the non-secret trigger gate identifiers used +// to turn a normalized Plane webhook event into a work item task creation. The +// webhook secret stays separate (SetPlaneWebhookSecret); all values here are +// non-secret Plane identifiers (workspace slug/id, Backlog state id, AGENT +// assignee id, optional self actor id). +type PlaneWebhookDispatchConfig struct { + WorkspaceID string + WorkspaceSlug string + BacklogStateID string + AgentAssigneeID string + SelfActorID string +} + +// planeWebhookDispatch is the trimmed, internal form of the dispatch config. +type planeWebhookDispatch struct { + workspaceID string + workspaceSlug string + backlogStateID string + agentAssigneeID string + selfActorID string +} + +// ready reports whether the minimum dispatch config is present to build a +// dispatch-ready ref and a meaningful trigger gate. The optional self actor and +// workspace id guard are not required. +func (c planeWebhookDispatch) ready() bool { + return c.workspaceSlug != "" && c.backlogStateID != "" && c.agentAssigneeID != "" } type WorkItemProvider struct { diff --git a/services/core/internal/http/plane_webhook.go b/services/core/internal/http/plane_webhook.go index 890cd1b..f5aa1f3 100644 --- a/services/core/internal/http/plane_webhook.go +++ b/services/core/internal/http/plane_webhook.go @@ -1,6 +1,7 @@ package http import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" @@ -10,7 +11,9 @@ import ( stdhttp "net/http" "strings" + "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workitem" + "github.com/nomadcode/nomadcode-core/internal/workitempipeline" ) const maxPlaneWebhookBodyBytes = 1 << 20 @@ -147,6 +150,58 @@ func (h *Handler) SetPlaneWebhookSecret(secret string) { h.planeWebhookSecret = strings.TrimSpace(secret) } +// SetPlaneWebhookDispatchConfig wires the non-secret trigger gate configuration +// used to dispatch normalized Plane webhook events into work item task creation. +// Values are trimmed; an incomplete config leaves dispatch disabled (see +// planeWebhookDispatch.ready) so the receiver still answers 202 without retries. +func (h *Handler) SetPlaneWebhookDispatchConfig(cfg PlaneWebhookDispatchConfig) { + h.planeDispatch = planeWebhookDispatch{ + workspaceID: strings.TrimSpace(cfg.WorkspaceID), + workspaceSlug: strings.TrimSpace(cfg.WorkspaceSlug), + backlogStateID: strings.TrimSpace(cfg.BacklogStateID), + agentAssigneeID: strings.TrimSpace(cfg.AgentAssigneeID), + selfActorID: strings.TrimSpace(cfg.SelfActorID), + } +} + +// dispatchPlaneWebhookWorkItem turns a normalized Plane webhook event into a work +// item task creation through the registered "plane" provider. It returns +// dispatched=true with the created task only when the event was actually handed +// to the creator. When the receiver is reachable but dispatch is not configured, +// the event targets a different workspace, or the ref cannot be built, it returns +// dispatched=false with no error so the caller answers 202 ignored instead of +// triggering a provider retry storm. The strict trigger gate (Backlog state + +// AGENT assignee + self-actor guard) lives in workitempipeline, so a non-matching +// event surfaces as ErrCreationTriggerIgnored, which the caller maps to 202. +func (h *Handler) dispatchPlaneWebhookWorkItem(ctx context.Context, event planeWebhookWorkItemEvent) (storage.Task, bool, error) { + creator, ok := h.workItemProviders[planeProvider] + if !ok || !h.planeDispatch.ready() { + return storage.Task{}, false, nil + } + if h.planeDispatch.workspaceID != "" && event.WorkspaceID != h.planeDispatch.workspaceID { + return storage.Task{}, false, nil + } + + ref, err := event.WorkItemRef(h.planeDispatch.workspaceSlug) + if err != nil { + return storage.Task{}, false, nil + } + + task, err := creator.CreateTaskFromWorkItem(ctx, workitempipeline.CreateTaskInput{ + Ref: ref, + Trigger: workitempipeline.CreationTrigger{ + RequiredStateID: h.planeDispatch.backlogStateID, + RequiredAssigneeID: h.planeDispatch.agentAssigneeID, + Actor: event.ActorID, + SelfActor: h.planeDispatch.selfActorID, + }, + }) + if err != nil { + return storage.Task{}, false, err + } + return task, true, nil +} + func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) { secret := strings.TrimSpace(h.planeWebhookSecret) if secret == "" { @@ -215,10 +270,27 @@ func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Reque 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"}) + // Irrelevant events (non work item) and relevant-but-malformed events (missing + // ref) are wakeup hints with nothing to dispatch: ack 202 ignored without side + // effects so Plane does not retry. + if !relevant || normErr != nil { + writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"}) + return + } + + task, dispatched, err := h.dispatchPlaneWebhookWorkItem(r.Context(), event) + if err != nil { + // writeServiceError maps ErrCreationTriggerIgnored to 202 ignored, keeping + // trigger-gate rejections from becoming provider retries; other errors map + // to their service status. + h.writeServiceError(w, err) + return + } + if !dispatched { + writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "ignored"}) + return + } + writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted", "task_id": task.ID}) } func validPlaneWebhookSignature(secret string, body []byte, signature string) bool { diff --git a/services/core/internal/http/plane_webhook_test.go b/services/core/internal/http/plane_webhook_test.go index 2a18e10..2fc759d 100644 --- a/services/core/internal/http/plane_webhook_test.go +++ b/services/core/internal/http/plane_webhook_test.go @@ -10,6 +10,9 @@ import ( "os" "strings" "testing" + + "github.com/nomadcode/nomadcode-core/internal/storage" + "github.com/nomadcode/nomadcode-core/internal/workitempipeline" ) func TestReceivePlaneWebhookAcceptsSignedPayload(t *testing.T) { @@ -348,6 +351,165 @@ func TestNormalizePlaneWebhookPayloadAcceptsAssigneeFixture(t *testing.T) { } } +func planeDispatchTestConfig() PlaneWebhookDispatchConfig { + return PlaneWebhookDispatchConfig{ + WorkspaceID: "ws-uuid", + WorkspaceSlug: "general", + BacklogStateID: "backlog-state", + AgentAssigneeID: "agent-assignee", + SelfActorID: "self-actor", + } +} + +func signedPlaneWebhookRequest(body string) (*http.Request, *httptest.ResponseRecorder) { + 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") + return req, httptest.NewRecorder() +} + +func decodeWebhookResponse(t *testing.T, rec *httptest.ResponseRecorder) map[string]string { + t.Helper() + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v: %s", err, rec.Body.String()) + } + return resp +} + +func TestReceivePlaneWebhookDispatchesMatchingIssueEvent(t *testing.T) { + creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-1", Status: "created"}} + h := newHandlerForTest(withPlaneCreator(creator)) + h.SetPlaneWebhookSecret("secret") + h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig()) + + body := `{"event":"issue","action":"updated","webhook_id":"wh-1","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"field":"assignees","actor":{"id":"actor-1"}}}` + req, rec := signedPlaneWebhookRequest(body) + + h.ReceivePlaneWebhook(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String()) + } + if creator.calledWith == nil { + t.Fatalf("expected creator to be called for a matching event") + } + got := creator.calledWith + if got.Ref.Provider != planeProvider { + t.Fatalf("ref provider: got %q, want %q", got.Ref.Provider, planeProvider) + } + // The webhook carries a workspace UUID; the dispatch-ready tenant is the + // configured workspace slug, not the raw UUID. + if got.Ref.Tenant != "general" { + t.Fatalf("ref tenant: got %q, want general (slug)", got.Ref.Tenant) + } + if got.Ref.Project != "proj-1" || got.Ref.ID != "item-1" { + t.Fatalf("unexpected dispatch ref: %+v", got.Ref) + } + if got.Trigger.RequiredStateID != "backlog-state" || got.Trigger.RequiredAssigneeID != "agent-assignee" { + t.Fatalf("unexpected trigger gate: %+v", got.Trigger) + } + if got.Trigger.Actor != "actor-1" || got.Trigger.SelfActor != "self-actor" { + t.Fatalf("unexpected actor/self actor: %+v", got.Trigger) + } + + resp := decodeWebhookResponse(t, rec) + if resp["status"] != "accepted" || resp["task_id"] != "task-1" { + t.Fatalf("unexpected response body: %v", resp) + } +} + +func TestReceivePlaneWebhookIgnoresIrrelevantEventWithoutCreatorCall(t *testing.T) { + creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-x"}} + h := newHandlerForTest(withPlaneCreator(creator)) + h.SetPlaneWebhookSecret("secret") + h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig()) + + body := `{"event":"project","action":"updated","data":{"id":"proj-1"}}` + req, rec := signedPlaneWebhookRequest(body) + + h.ReceivePlaneWebhook(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String()) + } + if creator.calledWith != nil { + t.Fatalf("expected creator NOT to be called for an irrelevant event, got %+v", creator.calledWith) + } + if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" { + t.Fatalf("expected ignored status, got %v", resp) + } +} + +func TestReceivePlaneWebhookIgnoresForeignWorkspaceWithoutCreatorCall(t *testing.T) { + creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-x"}} + h := newHandlerForTest(withPlaneCreator(creator)) + h.SetPlaneWebhookSecret("secret") + h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig()) + + // workspace id does not match the configured guard: dispatch is skipped so a + // foreign workspace never builds a ref against the wrong slug. + body := `{"event":"issue","action":"updated","workspace_id":"other-ws","data":{"id":"item-1","project":"proj-1","workspace":"other-ws"},"activity":{"actor":{"id":"actor-1"}}}` + req, rec := signedPlaneWebhookRequest(body) + + h.ReceivePlaneWebhook(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String()) + } + if creator.calledWith != nil { + t.Fatalf("expected creator NOT to be called for a foreign workspace, got %+v", creator.calledWith) + } + if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" { + t.Fatalf("expected ignored status, got %v", resp) + } +} + +func TestReceivePlaneWebhookIgnoresWhenDispatchConfigAbsentWithoutCreatorCall(t *testing.T) { + creator := &fakeWorkItemTaskCreator{task: storage.Task{ID: "task-x"}} + h := newHandlerForTest(withPlaneCreator(creator)) + h.SetPlaneWebhookSecret("secret") + // No SetPlaneWebhookDispatchConfig: receiver reachable but dispatch unconfigured. + + body := `{"event":"issue","action":"updated","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"actor":{"id":"actor-1"}}}` + req, rec := signedPlaneWebhookRequest(body) + + h.ReceivePlaneWebhook(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String()) + } + if creator.calledWith != nil { + t.Fatalf("expected creator NOT to be called when dispatch config is absent, got %+v", creator.calledWith) + } + if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" { + t.Fatalf("expected ignored status, got %v", resp) + } +} + +func TestReceivePlaneWebhookReturnsAcceptedWhenCreationTriggerIgnored(t *testing.T) { + creator := &fakeWorkItemTaskCreator{err: workitempipeline.ErrCreationTriggerIgnored} + h := newHandlerForTest(withPlaneCreator(creator)) + h.SetPlaneWebhookSecret("secret") + h.SetPlaneWebhookDispatchConfig(planeDispatchTestConfig()) + + body := `{"event":"issue","action":"updated","workspace_id":"ws-uuid","data":{"id":"item-1","project":"proj-1","workspace":"ws-uuid"},"activity":{"actor":{"id":"actor-1"}}}` + req, rec := signedPlaneWebhookRequest(body) + + h.ReceivePlaneWebhook(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected %d, got %d: %s", http.StatusAccepted, rec.Code, rec.Body.String()) + } + if creator.calledWith == nil { + t.Fatalf("expected creator to be called so the pipeline can evaluate the trigger gate") + } + if resp := decodeWebhookResponse(t, rec); resp["status"] != "ignored" { + t.Fatalf("expected ignored status when trigger gate rejects, got %v", resp) + } +} + func testPlaneSignature(secret, body string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(body))