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`
## 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`

View file

@ -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())

View file

@ -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:

View file

@ -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"),

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) {
cfg := Load()

View file

@ -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 {

View file

@ -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 {

View file

@ -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))