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
This commit is contained in:
toki 2026-06-15 14:46:20 +09:00
parent f23ba78eba
commit 210f67bd9a
12 changed files with 563 additions and 119 deletions

View file

@ -0,0 +1,167 @@
<!-- 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 | [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/`로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -1,115 +0,0 @@
<!-- 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

@ -24,6 +24,16 @@ API token, webhook secret, password 같은 secret 원문은 이 파일에 쓰지
- AGENT assignee user id: `5d116a77-d3df-4f54-80bf-eca61e0118c4` - 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 Smoke Baseline
- Webhook URL: `http://toki-labs.com:18010/api/integrations/plane/webhook` - Webhook URL: `http://toki-labs.com:18010/api/integrations/plane/webhook`

View file

@ -144,6 +144,13 @@ func run(logger *slog.Logger) error {
apphttp.WorkItemProvider{ID: "jira", Reader: jiraClient}, apphttp.WorkItemProvider{ID: "jira", Reader: jiraClient},
) )
handler.SetPlaneWebhookSecret(cfg.PlaneWebhookSecret) 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()) protosocket.NewTaskChannels(workflowService).Register(protoSocketServer.Dispatcher())

View file

@ -25,6 +25,11 @@ services:
PLANE_BASE_URL: ${PLANE_BASE_URL:-} PLANE_BASE_URL: ${PLANE_BASE_URL:-}
PLANE_TOKEN: ${PLANE_TOKEN:-} PLANE_TOKEN: ${PLANE_TOKEN:-}
PLANE_WEBHOOK_SECRET: ${PLANE_WEBHOOK_SECRET:-} 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: ports:
- "${NOMADCODE_CORE_HOST_PORT:-18010}:8080" - "${NOMADCODE_CORE_HOST_PORT:-18010}:8080"
networks: networks:

View file

@ -28,6 +28,11 @@ type Config struct {
PlaneBaseURL string PlaneBaseURL string
PlaneToken string PlaneToken string
PlaneWebhookSecret string PlaneWebhookSecret string
PlaneWorkspaceSlug string
PlaneWorkspaceID string
PlaneCreationBacklogStateID string
PlaneAgentAssigneeID string
PlaneSelfActorID string
JiraBaseURL string JiraBaseURL string
JiraEmail string JiraEmail string
JiraAPIToken string JiraAPIToken string
@ -78,6 +83,11 @@ func Load() Config {
PlaneBaseURL: os.Getenv("PLANE_BASE_URL"), PlaneBaseURL: os.Getenv("PLANE_BASE_URL"),
PlaneToken: os.Getenv("PLANE_TOKEN"), PlaneToken: os.Getenv("PLANE_TOKEN"),
PlaneWebhookSecret: os.Getenv("PLANE_WEBHOOK_SECRET"), 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"), JiraBaseURL: os.Getenv("JIRA_BASE_URL"),
JiraEmail: os.Getenv("JIRA_EMAIL"), JiraEmail: os.Getenv("JIRA_EMAIL"),
JiraAPIToken: os.Getenv("JIRA_API_TOKEN"), JiraAPIToken: os.Getenv("JIRA_API_TOKEN"),

View file

@ -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) { func TestConfigJiraFieldsAreEmptyByDefault(t *testing.T) {
cfg := Load() cfg := Load()

View file

@ -32,6 +32,36 @@ type Handler struct {
workItemProviders map[workitem.ProviderID]WorkItemTaskCreator workItemProviders map[workitem.ProviderID]WorkItemTaskCreator
logger *slog.Logger logger *slog.Logger
planeWebhookSecret string 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 { type WorkItemProvider struct {

View file

@ -1,6 +1,7 @@
package http package http
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
@ -10,7 +11,9 @@ import (
stdhttp "net/http" stdhttp "net/http"
"strings" "strings"
"github.com/nomadcode/nomadcode-core/internal/storage"
"github.com/nomadcode/nomadcode-core/internal/workitem" "github.com/nomadcode/nomadcode-core/internal/workitem"
"github.com/nomadcode/nomadcode-core/internal/workitempipeline"
) )
const maxPlaneWebhookBodyBytes = 1 << 20 const maxPlaneWebhookBodyBytes = 1 << 20
@ -147,6 +150,58 @@ func (h *Handler) SetPlaneWebhookSecret(secret string) {
h.planeWebhookSecret = strings.TrimSpace(secret) 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) { func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Request) {
secret := strings.TrimSpace(h.planeWebhookSecret) secret := strings.TrimSpace(h.planeWebhookSecret)
if secret == "" { if secret == "" {
@ -215,10 +270,27 @@ func (h *Handler) ReceivePlaneWebhook(w stdhttp.ResponseWriter, r *stdhttp.Reque
h.logger.Info("plane webhook received", attrs...) h.logger.Info("plane webhook received", attrs...)
} }
// The webhook is a wakeup hint only. Trigger dispatch and idempotency are // Irrelevant events (non work item) and relevant-but-malformed events (missing
// handled in later milestones; here we accept all valid signed payloads so // ref) are wakeup hints with nothing to dispatch: ack 202 ignored without side
// Plane does not retry while normalization boundaries settle. // effects so Plane does not retry.
writeJSON(w, stdhttp.StatusAccepted, map[string]string{"status": "accepted"}) 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 { func validPlaneWebhookSignature(secret string, body []byte, signature string) bool {

View file

@ -10,6 +10,9 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"github.com/nomadcode/nomadcode-core/internal/storage"
"github.com/nomadcode/nomadcode-core/internal/workitempipeline"
) )
func TestReceivePlaneWebhookAcceptsSignedPayload(t *testing.T) { 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 { func testPlaneSignature(secret, body string) string {
mac := hmac.New(sha256.New, []byte(secret)) mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(body)) _, _ = mac.Write([]byte(body))