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:
parent
f23ba78eba
commit
210f67bd9a
12 changed files with 563 additions and 119 deletions
|
|
@ -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/`로 이동한다.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -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)
|
|
||||||
```
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue