feat: gito http webhook consumer readiness milestone completion

- Update milestone and SDD documents
- Add webhook HTTP receiver with idempotency checks
- Add config for webhook endpoints
- Implement gito events processing
- Add HTTP handlers and router updates
- Archive completed task files
This commit is contained in:
toki 2026-06-19 14:06:24 +09:00
parent 5d2acc1afb
commit 9258f9de54
22 changed files with 2072 additions and 62 deletions

View file

@ -19,7 +19,7 @@ NomadCode Core의 Gito branch event consumer를 Gito 제공 계약 `gito.forgejo
## 구현 잠금 ## 구현 잠금
- 상태: 잠금 - 상태: 해제
- SDD: 필요 - SDD: 필요
- SDD 문서: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md` - SDD 문서: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- 잠금 해제 조건: - 잠금 해제 조건:
@ -27,7 +27,7 @@ NomadCode Core의 Gito branch event consumer를 Gito 제공 계약 `gito.forgejo
- [x] SDD 사용자 리뷰가 없거나 승인/해결되었다 - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다
- [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다 - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다
- [x] Evidence Map이 plan의 `Spec Targets`와 완료 시 `Spec Completion`으로 검증 가능하게 연결되어 있다 - [x] Evidence Map이 plan의 `Spec Targets`와 완료 시 `Spec Completion`으로 검증 가능하게 연결되어 있다
- [ ] Workspace lock `nomadcode:gito-http-webhook-consumer-readiness`의 모든 `rely-on.status``enable`이다 - [x] Workspace lock `nomadcode:gito-http-webhook-consumer-readiness`의 모든 `rely-on.status``enable`이다
- 결정 필요: 없음 - 결정 필요: 없음
## 범위 ## 범위
@ -88,4 +88,4 @@ HTTP webhook wakeup을 기존 branch revision 재검증과 creation sync enqueue
- 외부 의존 잠금: Gito `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`의 consumer webhook subscription과 signed HTTP delivery 구현이 완료되어야 실제 연동 구현/검증을 닫을 수 있다. - 외부 의존 잠금: Gito `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`의 consumer webhook subscription과 signed HTTP delivery 구현이 완료되어야 실제 연동 구현/검증을 닫을 수 있다.
- 후속 작업: Milestone Execution Lifecycle Sync - 후속 작업: Milestone Execution Lifecycle Sync
- 확인 필요: - 확인 필요:
- [ ] SDD 작성과 gate 확인 후 구현 잠금을 해제한다. - [x] SDD 작성과 gate 확인 후 구현 잠금을 해제한다.

View file

@ -93,7 +93,7 @@
- `gito:agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` - `gito:agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
- `.agent-roadmap-sync/locks.yaml` entry: `nomadcode:gito-http-webhook-consumer-readiness` - `.agent-roadmap-sync/locks.yaml` entry: `nomadcode:gito-http-webhook-consumer-readiness`
- 현재 lock 상태: `disable`. Gito의 consumer webhook subscription과 signed HTTP delivery 구현이 완료되어야 NomadCode Milestone의 실제 연동 구현/검증을 닫을 수 있다. - 현재 lock 상태: `enable`. Gito의 consumer webhook subscription과 signed HTTP delivery 구현이 완료되어 NomadCode Milestone의 구현 잠금 해제 조건을 충족했다.
## Drift Check ## Drift Check

View file

@ -0,0 +1,297 @@
<!-- task=m-gito-http-webhook-consumer-readiness/01_payload_boundary plan=0 tag=API -->
# Code Review Reference - API
> **[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 selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only SDD/Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-19
task=m-gito-http-webhook-consumer-readiness/01_payload_boundary, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `payload-boundary`: Gito base payload를 repo/branch/revision/change hint 중심으로 해석하고 NomadCode 전용 필드를 요구하지 않는다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S02`: task=`payload-boundary`; evidence=`payload decode/filter tests without NomadCode-specific fields`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G04.md` → `code_review_local_G04_N.log`, `PLAN-local-G04.md` → `plan_local_G04_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/01_payload_boundary/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] 공통 branch.updated payload decoder | [x] |
## 구현 체크리스트
- [x] `gitoevents`에 HTTP JSON body와 proto-socket envelope가 공유하는 `branch.updated` base payload decoder를 추가하고, `payload-boundary` 검증을 포함한다.
- [x] `cd services/core && go test -count=1 ./internal/gitoevents`를 실행한다.
- [x] `cd services/core && go test -count=1 ./...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G04_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G04_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-gito-http-webhook-consumer-readiness/01_payload_boundary/`를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/01_payload_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-gito-http-webhook-consumer-readiness/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G04.md`와 `CODE_REVIEW-local-G04.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획에 명시된 모든 항목을 그대로 구현했다. 추가 변경사항 없음.
## 주요 설계 결정
1. **`DecodeBranchUpdatedPayload`의 public export**: HTTP receiver가 envelope 없이 직접 호출해야 하므로 package-level exported function으로 정의했다.
2. **type 검증 유연성**: `type` 필드가 없으면 Accept, `branch.updated`이면 Accept, 다른 값이면 Reject하는 3자 경로로 설계했다. HTTP webhook이 type 필드를 생략할 수 있기 때문이다.
3. **`DecodeBranchUpdatedEnvelope`의 위임 구조**: 기존 envelope 검증(channel/action/null)을 유지하고 payload decode만 새 함수로 위임했다. 기존 코드 호환성을 최대화하면서 공통 재사용을 달성했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `DecodeBranchUpdatedEnvelope`가 channel/action 검증을 유지하면서 common payload decoder에 위임하는지 확인한다.
- 새 payload decoder가 NomadCode 전용 필드를 요구하지 않는지 확인한다.
- malformed `changed_files`와 wrong `type`이 partial event 없이 reject되는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```
$ cd services/core && go test -count=1 ./internal/gitoevents
=== RUN TestClientSendsSubscribeEnvelope
--- PASS: TestClientSendsSubscribeEnvelope (0.00s)
=== RUN TestClientForwardsTargetEventToHandler
--- PASS: TestClientForwardsTargetEventToHandler (0.00s)
=== RUN TestClientDropsOffTargetAndMalformedEvents
--- PASS: TestClientDropsOffTargetAndMalformedEvents (0.00s)
=== RUN TestClientStopsOnContextCancel
--- PASS: TestClientStopsOnContextCancel (0.00s)
=== RUN TestNewClientRequiresRepoID
--- PASS: TestNewClientRequiresRepoID (0.00s)
=== RUN TestNewClientRequiresTransport
--- PASS: TestNewClientRequiresTransport (0.00s)
=== RUN TestBuildSubscribeEnvelopeMatchesContract
--- PASS: TestBuildSubscribeEnvelopeMatchesContract (0.00s)
=== RUN TestBuildSubscribeEnvelopeDefaultsBranch
--- PASS: TestBuildSubscribeEnvelopeDefaultsBranch (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelope
--- PASS: TestDecodeBranchUpdatedEnvelope (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeAcceptsConsumerNeutralBasePayload
--- PASS: TestDecodeBranchUpdatedEnvelopeAcceptsConsumerNeutralBasePayload (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeIgnoresOptionalConsumerExtensions
--- PASS: TestDecodeBranchUpdatedEnvelopeIgnoresOptionalConsumerExtensions (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsWrongAction
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsWrongAction (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsWrongChannel
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsWrongChannel (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsMalformedChangedFiles
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsMalformedChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields
--- PASS: TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsWrongType
--- PASS: TestDecodeBranchUpdatedPayloadRejectsWrongType (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles
--- PASS: TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsMissingRepoID
--- PASS: TestDecodeBranchUpdatedPayloadRejectsMissingRepoID (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsMissingBranch
--- PASS: TestDecodeBranchUpdatedPayloadRejectsMissingBranch (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadEmptyChangedFiles
--- PASS: TestDecodeBranchUpdatedPayloadEmptyChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadNilPayload
--- PASS: TestDecodeBranchUpdatedPayloadNilPayload (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadAllowsNilChangedFiles
--- PASS: TestDecodeBranchUpdatedPayloadAllowsNilChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadHandlesMalformedChangedFileItem
--- PASS: TestDecodeBranchUpdatedPayloadHandlesMalformedChangedFileItem (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadWithoutTypeField
--- PASS: TestDecodeBranchUpdatedPayloadWithoutTypeField (0.00s)
=== RUN TestIsTarget
--- PASS: TestIsTarget (0.00s)
=== RUN TestMilestoneChangedFiles
--- PASS: TestMilestoneChangedFiles (0.00s)
PASS
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.005s
```
### 최종 검증
```
$ cd services/core && go test -count=1 ./internal/gitoevents
=== RUN TestClientSendsSubscribeEnvelope
--- PASS: TestClientSendsSubscribeEnvelope (0.00s)
=== RUN TestClientForwardsTargetEventToHandler
--- PASS: TestClientForwardsTargetEventToHandler (0.00s)
=== RUN TestClientDropsOffTargetAndMalformedEvents
--- PASS: TestClientDropsOffTargetAndMalformedEvents (0.00s)
=== RUN TestClientStopsOnContextCancel
--- PASS: TestClientStopsOnContextCancel (0.00s)
=== RUN TestNewClientRequiresRepoID
--- PASS: TestNewClientRequiresRepoID (0.00s)
=== RUN TestNewClientRequiresTransport
--- PASS: TestNewClientRequiresTransport (0.00s)
=== RUN TestBuildSubscribeEnvelopeMatchesContract
--- PASS: TestBuildSubscribeEnvelopeMatchesContract (0.00s)
=== RUN TestBuildSubscribeEnvelopeDefaultsBranch
--- PASS: TestBuildSubscribeEnvelopeDefaultsBranch (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelope
--- PASS: TestDecodeBranchUpdatedEnvelope (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeAcceptsConsumerNeutralBasePayload
--- PASS: TestDecodeBranchUpdatedEnvelopeAcceptsConsumerNeutralBasePayload (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeIgnoresOptionalConsumerExtensions
--- PASS: TestDecodeBranchUpdatedEnvelopeIgnoresOptionalConsumerExtensions (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsWrongAction
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsWrongAction (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsWrongChannel
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsWrongChannel (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsMalformedChangedFiles
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsMalformedChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID
--- PASS: TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields
--- PASS: TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsWrongType
--- PASS: TestDecodeBranchUpdatedPayloadRejectsWrongType (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles
--- PASS: TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsMissingRepoID
--- PASS: TestDecodeBranchUpdatedPayloadRejectsMissingRepoID (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadRejectsMissingBranch
--- PASS: TestDecodeBranchUpdatedPayloadRejectsMissingBranch (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadEmptyChangedFiles
--- PASS: TestDecodeBranchUpdatedPayloadEmptyChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadNilPayload
--- PASS: TestDecodeBranchUpdatedPayloadNilPayload (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadAllowsNilChangedFiles
--- PASS: TestDecodeBranchUpdatedPayloadAllowsNilChangedFiles (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadHandlesMalformedChangedFileItem
--- PASS: TestDecodeBranchUpdatedPayloadHandlesMalformedChangedFileItem (0.00s)
=== RUN TestDecodeBranchUpdatedPayloadWithoutTypeField
--- PASS: TestDecodeBranchUpdatedPayloadWithoutTypeField (0.00s)
=== RUN TestIsTarget
--- PASS: TestIsTarget (0.00s)
=== RUN TestMilestoneChangedFiles
--- PASS: TestMilestoneChangedFiles (0.00s)
PASS
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.005s
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.007s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.011s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.026s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.012s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.016s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.016s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.012s
ok github.com/nomadcode/nomadcode-core/internal/config 0.006s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.007s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.381s
ok github.com/nomadcode/nomadcode-core/internal/http 0.010s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.006s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.009s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.014s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.007s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.009s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.014s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.007s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.007s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 `complete.log`를 작성하고 active task directory를 archive로 이동한다.
### 리뷰 근거
- `DecodeBranchUpdatedEnvelope`는 기존 channel/action/null payload 검증을 유지하고 payload decode만 `DecodeBranchUpdatedPayload`에 위임한다.
- `DecodeBranchUpdatedPayload`는 Gito base payload의 `repo_id`, `branch`, revision/change hint를 decode하고 NomadCode 전용 필드를 요구하지 않는다.
- wrong `type`, malformed `changed_files`, missing `repo_id`/`branch`, nil payload 회귀 테스트가 포함되어 있다.
- 리뷰 중 `gofmt`만 직접 적용했다.
- 검증:
- `cd services/core && go test -count=1 ./internal/gitoevents`: PASS
- `cd services/core && go test -count=1 ./...`: PASS

View file

@ -0,0 +1,49 @@
# Complete - m-gito-http-webhook-consumer-readiness/01_payload_boundary
## 완료 일시
2026-06-19
## 요약
Gito `branch.updated` base payload decoder를 공통 경계로 분리하고 payload-boundary 검증을 완료했다. loop count=1, final verdict=PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G04_0.log` | `code_review_local_G04_0.log` | PASS | 계획된 decoder API, tests, 검증이 충족됨 |
## 구현/정리 내용
- `DecodeBranchUpdatedPayload`를 추가해 HTTP JSON body와 proto-socket envelope가 같은 base payload decode 경계를 공유하게 했다.
- `DecodeBranchUpdatedEnvelope`는 기존 channel/action/null payload 검증을 유지하고 payload decode를 새 함수에 위임한다.
- NomadCode 전용 필드 없이 Gito base payload를 decode하는 테스트와 wrong type, malformed `changed_files`, missing required field 회귀 테스트를 추가했다.
- 리뷰 중 `services/core/internal/gitoevents/events.go`와 `services/core/internal/gitoevents/events_test.go`에 `gofmt`를 적용했다.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/gitoevents` - PASS; `ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.004s`
- `cd services/core && go test -count=1 ./...` - PASS; core 전체 Go packages 통과
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Completed task ids:
- `payload-boundary`: PASS; evidence=`plan_local_G04_0.log`, `code_review_local_G04_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitoevents`, `cd services/core && go test -count=1 ./...`
- Not completed task ids: 없음
## Spec Completion
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Completed scenario ids:
- `S02`: PASS; task=`payload-boundary`; evidence=`plan_local_G04_0.log`, `code_review_local_G04_0.log`; verification=`cd services/core && go test -count=1 ./internal/gitoevents`, `cd services/core && go test -count=1 ./...`
- Not completed scenario ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,224 @@
<!-- task=m-gito-http-webhook-consumer-readiness/01_payload_boundary plan=0 tag=API -->
# PLAN local-G04 - API
## 이 파일을 읽는 구현 에이전트에게
이 작업은 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 완료된다. 구현 에이전트는 검증을 실행하고 active 파일을 유지한 채 리뷰 준비를 보고한다. 종결, 로그 rename, `complete.log`, task directory archive는 code-review 스킬 전용이다. 선택된 SDD 결정이나 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 환경, secret, 서비스 준비, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan으로 처리한다.
## 배경
현 Milestone은 Gito `branch.updated`를 HTTP webhook JSON body로 받아야 한다. 현재 `gitoevents` decoder는 proto-socket envelope에 묶여 있어 HTTP receiver가 같은 base payload 계약을 재사용하기 어렵다. 이 선행 작업은 NomadCode 전용 필드 없이 Gito base payload를 decode하는 공통 경계를 만든다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 구현 중 직접 사용자에게 질문하지 않으며, code-review가 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `payload-boundary`: Gito base payload를 repo/branch/revision/change hint 중심으로 해석하고 NomadCode 전용 필드를 요구하지 않는다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S02`: task=`payload-boundary`; evidence=`payload decode/filter tests without NomadCode-specific fields`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/roadmap-sdd/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/check-roadmap-dependency/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- `../gito/agent-contract/provided/gito-forgejo-branch-events-v1.md`
- `services/core/go.mod`
- `services/core/internal/gitoevents/events.go`
- `services/core/internal/gitoevents/events_test.go`
- `services/core/internal/gitoevents/client.go`
- `services/core/internal/gitoevents/client_test.go`
- `services/core/internal/gitosync/bridge.go`
- `services/core/internal/gitosync/bridge_test.go`
- `services/core/internal/gitosync/scanner.go`
- `services/core/internal/http/router.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/cmd/server/main.go`
### 테스트 환경 규칙
- 선택한 test_env: `local`.
- `agent-test/local/rules.md`를 읽었다.
- matching profile: `agent-test/local/core-smoke.md`를 읽었다.
- 적용 명령: core 변경은 최소 `cd services/core && go test ./...`; focused unit은 `cd services/core && go test -count=1 ./internal/gitoevents`.
- `core-smoke.md`의 기준 출력 예시는 `<확인 필요>`지만 필수 검증 명령은 구체적이다.
- dependency manifest는 `services/core/go.mod`를 확인했다. 새 package 추가는 필요 없다.
### 테스트 커버리지 공백
- 기존 `TestDecodeBranchUpdatedEnvelopeAcceptsConsumerNeutralBasePayload`는 proto-socket envelope 내부 payload만 검증한다.
- HTTP JSON body가 같은 base payload decoder를 직접 재사용하는지는 아직 테스트가 없다. 이 작업에서 `DecodeBranchUpdatedPayload` 테스트를 추가한다.
- changed_files malformed, missing repo_id/branch 테스트는 기존 envelope path에 있다. 새 payload decoder에도 최소 base payload, wrong type/action, malformed changed_files 회귀를 추가한다.
### 심볼 참조
- renamed/removed symbol: 없음.
- 새 symbol 후보: `DecodeBranchUpdatedPayload`.
### 분할 판단
- split decision policy를 먼저 평가했다.
- shared task group: `m-gito-http-webhook-consumer-readiness`.
- sibling subtasks:
- `01_payload_boundary`: 독립 선행 작업. payload decoder foundation과 tests.
- `02+01_http_receiver_idempotency`: `01_payload_boundary`의 `complete.log` 필요. HTTP receiver가 공통 decoder를 소비한다.
- API/foundation과 HTTP route/wiring/idempotency의 위험과 검증 전략이 달라 split한다.
- 이 subtask는 predecessor가 없는 독립 작업이다.
### 범위 결정 근거
- 포함: `services/core/internal/gitoevents/events.go`, `services/core/internal/gitoevents/events_test.go`.
- 제외: `services/core/internal/http/**`, `services/core/cmd/server/main.go`, `services/core/internal/config/**`, docs. HTTP route, signing, idempotency, config wiring은 `02+01_http_receiver_idempotency`가 담당한다.
- 제외: Gito 계약 원문 변경. SDD가 외부 계약 source of truth로 링크한다.
### 빌드 등급
- `local-G04`: 내부 decoder API와 focused tests에 한정된 bounded Go 변경이며, 검증은 deterministic unit + core test로 가능하다.
## 구현 체크리스트
- [x] `gitoevents`에 HTTP JSON body와 proto-socket envelope가 공유하는 `branch.updated` base payload decoder를 추가하고, `payload-boundary` 검증을 포함한다.
- [x] `cd services/core && go test -count=1 ./internal/gitoevents`를 실행한다.
- [x] `cd services/core && go test -count=1 ./...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] 공통 branch.updated payload decoder
#### 문제
`services/core/internal/gitoevents/events.go:76`의 `DecodeBranchUpdatedEnvelope`가 envelope channel/action 검증과 payload decode를 한 함수에 묶고 있다. HTTP receiver는 Gito contract의 JSON body를 envelope 없이 받으므로 같은 base payload를 재사용할 공통 decoder가 없다.
Before:
```go
// services/core/internal/gitoevents/events.go:76
// DecodeBranchUpdatedEnvelope validates the envelope channel/action and decodes
// its payload into a BranchUpdatedEvent. It rejects wrong channel/action and
// malformed payloads with a descriptive error instead of returning a partial
// event.
func DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent, error) {
var ev BranchUpdatedEvent
if env.Channel != EventChannel {
return ev, fmt.Errorf("gitoevents: unexpected channel %q, want %q", env.Channel, EventChannel)
}
if env.Action != BranchUpdatedAction {
return ev, fmt.Errorf("gitoevents: unexpected action %q, want %q", env.Action, BranchUpdatedAction)
}
if env.Payload == nil {
return ev, fmt.Errorf("gitoevents: missing payload")
}
ev.ID = stringField(env.Payload, "id")
ev.Type = stringField(env.Payload, "type")
ev.Provider = stringField(env.Payload, "provider")
ev.DeliveryID = stringField(env.Payload, "delivery_id")
ev.RepoID = stringField(env.Payload, "repo_id")
ev.Branch = stringField(env.Payload, "branch")
ev.Before = stringField(env.Payload, "before")
ev.After = stringField(env.Payload, "after")
ev.ObservedAt = stringField(env.Payload, "observed_at")
ev.CreatedAt = stringField(env.Payload, "created_at")
```
#### 해결 방법
- `DecodeBranchUpdatedPayload(payload map[string]any) (BranchUpdatedEvent, error)`를 추가한다.
- `DecodeBranchUpdatedEnvelope`는 channel/action/null 검증 후 `DecodeBranchUpdatedPayload(env.Payload)`만 호출한다.
- payload decoder는 `repo_id`와 `branch`를 필수로 유지하고, `type`이 비어 있지 않으면 `branch.updated`여야 한다.
- `provider`, `delivery_id`, timestamp, optional extension fields는 필수로 만들지 않는다.
After outline:
```go
func DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent, error) {
if env.Channel != EventChannel {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: unexpected channel %q, want %q", env.Channel, EventChannel)
}
if env.Action != BranchUpdatedAction {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: unexpected action %q, want %q", env.Action, BranchUpdatedAction)
}
if env.Payload == nil {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: missing payload")
}
return DecodeBranchUpdatedPayload(env.Payload)
}
func DecodeBranchUpdatedPayload(payload map[string]any) (BranchUpdatedEvent, error) {
// Fill BranchUpdatedEvent from base payload fields.
// Reject missing repo_id/branch and non-branch.updated type.
}
```
#### 수정 파일 및 체크리스트
- [x] `services/core/internal/gitoevents/events.go`: common payload decoder 추가, envelope decoder 위임.
- [x] `services/core/internal/gitoevents/events_test.go`: base payload without NomadCode fields, wrong type, malformed changed_files 테스트 추가.
#### 테스트 작성
- 작성한다.
- `services/core/internal/gitoevents/events_test.go`
- `TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields`: Gito base JSON shape map에서 repo/branch/after/changed_files decode.
- `TestDecodeBranchUpdatedPayloadRejectsWrongType`: `type != branch.updated` reject.
- `TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles`: changed_files malformed reject.
#### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/gitoevents
```
예상: PASS. Go test cache output은 허용하지 않는다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/gitoevents/events.go` | API-1 |
| `services/core/internal/gitoevents/events_test.go` | API-1 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/gitoevents
cd services/core && go test -count=1 ./...
```
예상: 두 명령 모두 PASS. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,266 @@
<!-- task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency plan=0 tag=API -->
# Code Review Reference - API
> **[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 selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only SDD/Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-19
task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `receiver`: Gito `branch.updated` HTTP callback endpoint를 추가한다.
- `idempotency`: `X-Gito-Delivery`와 revision key 기준으로 duplicate delivery를 중복 enqueue 없이 처리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S01`: task=`receiver`; evidence=`HTTP handler unit/integration test, invalid signature test`
- `S03`: task=`idempotency`; evidence=`duplicate delivery/revision idempotency test`
- Completion mode: spec-check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Gito HTTP webhook receiver | [x] |
| [API-2] Delivery/revision duplicate guard | [x] |
| [API-3] Config/server wiring | [x] |
## 구현 체크리스트
- [x] `01_payload_boundary` predecessor `complete.log`가 있는지 확인하고, 없으면 구현을 시작하지 않는다.
- [x] Gito HTTP webhook receiver와 route를 추가하고, signed `branch.updated` delivery accepted 및 wrong event/invalid signature side-effect-free reject를 검증한다.
- [x] `X-Gito-Delivery`와 revision key 기준 process-lifetime duplicate guard를 추가하고, duplicate delivery/revision이 handler를 한 번만 호출함을 검증한다.
- [x] config/server wiring을 추가해 Gito webhook secret/repo/branch/develop checkout/todo state가 준비된 경우 HTTP receiver가 `gitosync.Bridge`를 호출하게 한다.
- [x] `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`를 실행한다.
- [x] `cd services/core && go test -count=1 ./...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-gito-http-webhook-consumer-readiness/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
**[API-1] `gitoevents.DecodeBranchUpdatedPayload` 대신 로컬 JSON 디코딩 사용**
- 계획: `body는 gitoevents.DecodeBranchUpdatedPayload로 decode한다`
- 변경: `gito_webhook.go`에서 `gitoevents` 패키지를 import하지 않고 로컬 `gitoWebhookPayload` struct로 JSON 직접 decode.
- 사유: `internal/http` → `internal/gitoevents` → `internal/protosocket` ← `internal/protosocket/tasks_test.go` → `internal/http` import cycle 발생. `tasks_test.go`가 `package protosocket`(in-package test)로 선언되어 있어 `http` 패키지 import 시 cycle이 생긴다. `gitoevents.DecodeBranchUpdatedPayload`가 검증하는 필드(repo_id, branch 필수)는 로컬 디코더에서 동일하게 검증한다.
**[API-1] `GitoBranchEventHandler` 인터페이스가 `gitoevents.BranchUpdatedEvent` 대신 로컬 `GitoBranchUpdatedEvent` 사용**
- 계획: 인터페이스 파라미터 타입을 명시하지 않았으나 `gitoevents.BranchUpdatedEvent` 기반으로 설계됨.
- 변경: `http` 패키지 내 `GitoBranchUpdatedEvent` struct 정의(RepoID, Branch, Before, After). `gitosync.Bridge`는 `main.go`의 `gitoBridgeHTTPAdapter`를 통해 `http.GitoBranchUpdatedEvent` → `gitoevents.BranchUpdatedEvent` 변환 후 위임.
- 사유: 위 import cycle 방지.
## 주요 설계 결정
**1. import cycle 해결을 위한 adapter 패턴**
`internal/http` 패키지가 `internal/gitoevents`를 import하면 `internal/protosocket/tasks_test.go`와의 순환이 발생한다. `http` 패키지에 로컬 이벤트 타입(`GitoBranchUpdatedEvent`)과 인터페이스(`GitoBranchEventHandler`)를 정의하고, `cmd/server/main.go`에 `gitoBridgeHTTPAdapter`(wrapper)를 두어 `gitosync.Bridge`를 `GitoBranchEventHandler`로 연결했다.
**2. 공유 Bridge 패턴**
proto-socket runner와 HTTP webhook consumer가 하나의 `gitosync.Bridge` 인스턴스를 공유한다. Bridge 내부의 in-memory revision guard는 공유되므로 두 consumer가 동시 운영될 때 bridge 레벨 중복 처리를 자동으로 방지한다.
**3. HTTP 레이어 idempotency store의 위치**
`gitoDeliveryStore`를 `Handler` 필드로 두어 handler 라이프사이클과 동일하게 관리한다. `NewHandler`에서 초기화하고, 테스트용 `newHandlerForTest`에도 초기화를 추가했다.
**4. duplicate 응답 shape**
`{"status":"duplicate"}` 202 Accepted. 계획이 `duplicate` 또는 `accepted+duplicate:true` 두 형태를 허용했으며, `duplicate` 단일 status 형태를 선택했다. 내부 contract로만 고정한다는 계획에 따라 별도 문서화 없음.
**5. `GitoWebhookConsumerEnabled()` required fields**
secret, repo id, develop repo path, roadmap todo state id. branch는 default "develop"이므로 required에서 제외. 기존 `GitoBranchEventsEnabled()`와 동일한 필드 집합(secret만 proto socket URL 대신 webhook secret으로 교체).
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Gito webhook route가 protected basic auth router 밖에 있고 Plane route와 충돌하지 않는지 확인한다.
- invalid signature, wrong event, malformed payload가 handler/bridge를 호출하지 않는지 확인한다.
- duplicate delivery id와 duplicate revision key가 sequential duplicate에서 handler/bridge를 한 번만 호출하는지 확인한다.
- config/server wiring이 기존 proto-socket runner를 무리하게 제거하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.010s
```
### API-2 중간 검증
```
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.010s
```
### API-3 중간 검증
```
$ cd services/core && go test -count=1 ./internal/config ./cmd/server
ok github.com/nomadcode/nomadcode-core/internal/config 0.002s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
```
### 최종 검증
```
$ cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server
ok github.com/nomadcode/nomadcode-core/internal/http 0.010s
ok github.com/nomadcode/nomadcode-core/internal/config 0.002s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.004s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.011s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.010s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.007s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.008s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.003s
ok github.com/nomadcode/nomadcode-core/internal/config 0.002s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.004s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.380s
ok github.com/nomadcode/nomadcode-core/internal/http 0.010s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.005s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.005s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.010s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.004s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.011s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Plan deviation: Fail
- Verification trust: Pass
- Spec conformance: Fail
- 발견된 문제:
- Required: `services/core/internal/http/gito_webhook.go:97`에서 HTTP body를 로컬 `gitoWebhookPayload`로만 decode하면서 `gitoevents.DecodeBranchUpdatedPayload`가 보장하던 `type == "branch.updated"` 검증과 `changed_files` shape 검증이 사라졌습니다. 이 상태에서는 `X-Gito-Event: branch.updated` 헤더만 맞으면 body `type`이 다른 이벤트이거나 `changed_files`가 문자열처럼 잘못된 payload도 accepted/handler 호출 경로로 들어갈 수 있어, 계획의 payload decode 계약과 SDD S01의 "payload shape가 유효하다" 조건을 충족하지 못합니다. import cycle을 피하더라도 공통 decoder를 dependency-light package로 분리하거나 로컬 decoder에 동일 검증을 추가하고, wrong body type 및 malformed `changed_files` 테스트를 추가해야 합니다.
- 다음 단계:
- FAIL: active 파일을 archive한 뒤 payload 계약 검증 복구를 위한 follow-up `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성한다.
### 리뷰 검증
```bash
$ git diff --check
# PASS (no output)
$ cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server
ok github.com/nomadcode/nomadcode-core/internal/http 0.007s
ok github.com/nomadcode/nomadcode-core/internal/config 0.004s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.003s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.008s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.011s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.007s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.009s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.004s
ok github.com/nomadcode/nomadcode-core/internal/config 0.002s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.007s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.382s
ok github.com/nomadcode/nomadcode-core/internal/http 0.009s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.002s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.005s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.011s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.004s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.005s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.014s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.003s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.003s
```

View file

@ -90,16 +90,16 @@ task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency, pla
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. - [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. - [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. - [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. - [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. - [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. - [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. - [ ] PASS이면 active task 디렉터리 `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. - [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-gito-http-webhook-consumer-readiness/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-gito-http-webhook-consumer-readiness/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. - [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. - [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
@ -184,3 +184,19 @@ ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.010s
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** > **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file. > If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged. > Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required: `services/core/internal/http/gito_webhook.go:96`에서 `changed_files`가 `[]interface{}`인지까지만 확인하고 각 원소가 object인지 검증하지 않습니다. 계획과 SDD는 `changed_files`가 present이면 list of objects여야 한다고 요구하고, `gitoevents.decodeChangedFiles`는 `changed_files[0]` 같은 non-object 항목을 reject합니다. 현재 HTTP path는 `{"changed_files":["not-an-object"]}`를 유효 payload로 받아 handler path까지 보낼 수 있습니다. `validateBranchUpdatedPayload`에서 배열 원소마다 `map[string]interface{}`인지 확인하고, `services/core/internal/http/gito_webhook_test.go`에 해당 payload가 400이며 handler를 호출하지 않는 테스트를 추가하세요.
- 다음 단계: FAIL follow-up plan/review를 작성해 위 Required issue만 좁게 수정한다.

View file

@ -0,0 +1,205 @@
<!-- task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency plan=2 tag=REVIEW_REVIEW_API -->
# Code Review Reference - REVIEW_REVIEW_API
> **[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 selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked SDD/Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-19
task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency, plan=2, tag=REVIEW_REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `receiver`: Gito `branch.updated` HTTP callback endpoint를 추가한다.
- `idempotency`: `X-Gito-Delivery`와 revision key 기준으로 duplicate delivery를 중복 enqueue 없이 처리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S01`: task=`receiver`; evidence=`HTTP handler unit/integration test, invalid signature test`
- `S03`: task=`idempotency`; evidence=`duplicate delivery/revision idempotency test`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/plan_local_G06_1.log`
- Archived review: `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/code_review_local_G06_1.log`
- Verdict: FAIL
- Issue summary:
- Required: `services/core/internal/http/gito_webhook.go:96` validates `changed_files` only as an array, not as a list of objects, so `{"changed_files":["not-an-object"]}` can still enter the accepted/handler path even though the SDD and `gitoevents.DecodeBranchUpdatedPayload` contract reject non-object items.
- Suggested: 없음
- Nit: 없음
- Affected files:
- `services/core/internal/http/gito_webhook.go`
- `services/core/internal/http/gito_webhook_test.go`
- Verification evidence:
- `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server` - PASS.
- `cd services/core && go test -count=1 ./...` - PASS.
- Existing HTTP receiver tests are insufficient because they cover `changed_files` as a non-list string but not a list containing non-object elements.
- Roadmap/spec carryover:
- Roadmap Targets remain `receiver` and `idempotency`.
- Spec Targets remain S01 and S03. This follow-up directly repairs S01; S03 should remain green.
- Narrow reread allowed if needed:
- `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/plan_local_G06_1.log`
- `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/code_review_local_G06_1.log`
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_API-1] `changed_files` element contract validation | [x] |
## 구현 체크리스트
- [x] `validateBranchUpdatedPayload`가 `changed_files` present 시 배열 여부뿐 아니라 각 element가 JSON object인지 검증하고, non-object element를 400 reject하도록 보강한다.
- [x] `changed_files` 배열 안의 non-object item이 400이고 handler를 호출하지 않음을 검증하는 HTTP receiver test를 추가하며 기존 wrong body type, malformed string, accepted/duplicate tests가 계속 통과하게 한다.
- [x] `cd services/core && go test -count=1 ./internal/http`를 실행한다.
- [x] `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`를 실행한다.
- [x] `cd services/core && go test -count=1 ./...`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`를 `agent-task/archive/YYYY/MM/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-gito-http-webhook-consumer-readiness/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획에 명시된 범위 내에서만 구현했다. 계획에 없던 package 생성, config 변경, 다른 기능 수정은 하지 않았다.
## 주요 설계 결정
- `gitoevents` package를 import하지 않고, 기존 `interface{}` 타입에 대한 type assertion으로 `changed_files` element의 object 검증을 구현했다. import cycle을 피하기 위한 결정이다.
- 에러 메시지는 `payload changed_files[<index>] is not an object` 형식으로 인덱스를 포함하여 어떤 element가 문제인지 식별할 수 있게 설계했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- HTTP body `changed_files`가 array여도 element가 object가 아니면 400이고 handler call이 없는지 확인한다.
- 기존 wrong body type, `changed_files` non-list string, delivery/revision duplicate tests가 계속 통과하는지 확인한다.
- import cycle이 재발하지 않았고 `go test -count=1 ./...`가 통과했는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_REVIEW_API-1 중간 검증
```bash
$ cd services/core && go test -count=1 ./internal/http
ok github.com/nomadcode/nomadcode-core/internal/http 0.008s
```
### 최종 검증
```bash
$ cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server
ok github.com/nomadcode/nomadcode-core/internal/http 0.009s
ok github.com/nomadcode/nomadcode-core/internal/config 0.006s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
$ cd services/core && go test -count=1 ./...
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.016s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.016s
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.016s
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.020s
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.016s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.037s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.034s
ok github.com/nomadcode/nomadcode-core/internal/config 0.030s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/gitoevents 0.014s
ok github.com/nomadcode/nomadcode-core/internal/gitosync 1.389s
ok github.com/nomadcode/nomadcode-core/internal/http 0.025s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 0.020s
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.020s
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.026s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.017s
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.018s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.019s
ok github.com/nomadcode/nomadcode-core/internal/storage 0.016s
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.017s
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.016s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.025s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결 처리한다.

View file

@ -0,0 +1,54 @@
# Complete - m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency
## 완료 일시
2026-06-19
## 요약
Gito HTTP webhook receiver payload validation/idempotency subtask completed after 3 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | HTTP receiver local decode missed body `type` and malformed `changed_files` validation. |
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | FAIL | `changed_files` array elements were not validated as objects. |
| `plan_local_G06_2.log` | `code_review_local_G06_2.log` | PASS | `changed_files` item object validation and HTTP receiver regression test passed. |
## 구현/정리 내용
- Gito HTTP webhook body validation now rejects non-`branch.updated` body `type`, missing `repo_id`/`branch`, non-list `changed_files`, and non-object `changed_files` items before handler invocation.
- Added HTTP receiver tests covering wrong body `type`, malformed `changed_files`, malformed `changed_files` item, signature rejection, and delivery/revision duplicate behavior.
- Removed an unused `missingGitoFields` helper during review cleanup.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/http` - PASS; `ok github.com/nomadcode/nomadcode-core/internal/http`.
- `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server` - PASS; `internal/http`, `internal/config`, and `cmd/server` passed/no-test-files.
- `cd services/core && go test -count=1 ./...` - PASS; all core packages passed/no-test-files.
- `git diff --check` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Completed task ids:
- `receiver`: PASS; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`, `cd services/core && go test -count=1 ./...`
- `idempotency`: PASS; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`, `cd services/core && go test -count=1 ./...`
- Not completed task ids: 없음
## Spec Completion
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Completed scenario ids:
- `S01`: PASS; task=`receiver`; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`, `cd services/core && go test -count=1 ./...`
- `S03`: PASS; task=`idempotency`; evidence=`plan_local_G06_2.log`, `code_review_local_G06_2.log`; verification=`cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`, `cd services/core && go test -count=1 ./...`
- Not completed scenario ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,325 @@
<!-- task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency plan=0 tag=API -->
# PLAN local-G06 - API
## 이 파일을 읽는 구현 에이전트에게
이 작업은 `agent-task/m-gito-http-webhook-consumer-readiness/01_payload_boundary/complete.log` 또는 matching archive `complete.log`가 있어야 시작할 수 있다. 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 완료된다. 구현 에이전트는 검증을 실행하고 active 파일을 유지한 채 리뷰 준비를 보고한다. 종결, 로그 rename, `complete.log`, task directory archive는 code-review 스킬 전용이다. 선택된 SDD 결정이나 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.
## 배경
NomadCode Core는 아직 Gito signed HTTP webhook delivery를 받는 endpoint가 없다. 기존 Gito 소비자는 proto-socket runner로 시작되고, HTTP router에는 Plane webhook만 공개 callback으로 등록되어 있다. 이 작업은 Gito HTTP callback, signature 검증, duplicate delivery/revision guard를 `gitosync` bridge로 연결한다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 구현 중 직접 사용자에게 질문하지 않으며, code-review가 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `receiver`: Gito `branch.updated` HTTP callback endpoint를 추가한다.
- `idempotency`: `X-Gito-Delivery`와 revision key 기준으로 duplicate delivery를 중복 enqueue 없이 처리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S01`: task=`receiver`; evidence=`HTTP handler unit/integration test, invalid signature test`
- `S03`: task=`idempotency`; evidence=`duplicate delivery/revision idempotency test`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/private/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/core/rules.md`
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/roadmap-sdd/SKILL.md`
- `agent-ops/skills/common/update-roadmap/SKILL.md`
- `agent-ops/skills/common/check-roadmap-dependency/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.md`
- `agent-test/local/workspace-ops-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- `../gito/agent-contract/provided/gito-forgejo-branch-events-v1.md`
- `services/core/go.mod`
- `services/core/internal/gitoevents/events.go`
- `services/core/internal/gitoevents/events_test.go`
- `services/core/internal/gitoevents/client.go`
- `services/core/internal/gitoevents/client_test.go`
- `services/core/internal/gitosync/bridge.go`
- `services/core/internal/gitosync/bridge_test.go`
- `services/core/internal/gitosync/scanner.go`
- `services/core/internal/http/router.go`
- `services/core/internal/http/handlers.go`
- `services/core/internal/http/handlers_test.go`
- `services/core/internal/http/plane_webhook.go`
- `services/core/internal/http/plane_webhook_test.go`
- `services/core/internal/config/config.go`
- `services/core/internal/config/config_test.go`
- `services/core/cmd/server/main.go`
### 테스트 환경 규칙
- 선택한 test_env: `local`.
- `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
- 적용 명령: focused HTTP/config tests plus `cd services/core && go test -count=1 ./...`.
- `core-smoke.md`는 core 코드 변경에 최소 `cd services/core && go test ./...`를 요구한다.
- 새 dependency는 필요 없으며 `services/core/go.mod`에 기존 `chi`, Go stdlib crypto/http/json/sync로 충분하다.
### 테스트 커버리지 공백
- 현재 `router.go`에는 Plane webhook bypass-auth 테스트만 있고 Gito webhook route는 없다.
- 현재 `plane_webhook.go`에는 Plane hex signature verifier만 있고 `sha256=` prefix를 허용하는 Gito verifier가 없다.
- 현재 `gitosync.Bridge`는 revision key process-lifetime dedupe만 제공한다. HTTP `X-Gito-Delivery` duplicate guard는 없다.
- 새 테스트가 필요한 동작:
- signed `branch.updated` HTTP delivery accepted and handler called once.
- wrong `X-Gito-Event` rejected without handler call.
- invalid signature rejected without handler call.
- same `X-Gito-Delivery` duplicate does not call handler twice.
- same revision key duplicate without delivery id does not call handler twice.
- route bypasses basic auth.
### 심볼 참조
- renamed/removed symbol: 없음.
- 새 symbol 후보: `GitoWebhookConfig`, `SetGitoWebhookConfig`, `SetGitoBranchEventHandler`, `ReceiveGitoWebhook`, `GitoWebhookConsumerEnabled`.
### 분할 판단
- split decision policy를 먼저 평가했다.
- shared task group: `m-gito-http-webhook-consumer-readiness`.
- sibling subtasks:
- `01_payload_boundary`: common base payload decoder.
- `02+01_http_receiver_idempotency`: HTTP receiver, signature, idempotency, route/config/server wiring. Depends on `01`.
- predecessor `01`: 현재 active/archive `complete.log` 없음. 구현 시작 전 `agent-task/m-gito-http-webhook-consumer-readiness/01_payload_boundary/complete.log` 또는 matching archive complete.log가 필요하다.
- split 이유: decoder foundation과 HTTP route/wiring/idempotency는 dependency boundary와 risk profile이 다르다.
### 범위 결정 근거
- 포함: HTTP callback endpoint, Gito webhook config fields, process-lifetime duplicate guard, server wiring into `gitosync.Bridge`.
- 제외: `scan-enqueue` S04의 full branch scanner -> `EnqueueRoadmapCreationSync` integration proof. 이 plan은 handler seam 호출 1회까지만 검증하고, full enqueue bridge proof는 `sync-bridge` Epic의 `scan-enqueue` Task에서 한다.
- 제외: README/compose/env sample update. `config-docs` Task가 담당한다.
- 제외: Gito provider webhook producer/outbound worker. Milestone 범위 제외와 SDD 비목표에 있다.
### 빌드 등급
- `local-G06`: 새 HTTP callback surface, config/server wiring, idempotency guard가 있지만 범위는 core 내부 Go와 deterministic unit tests로 제한된다.
## 구현 체크리스트
- [ ] `01_payload_boundary` predecessor `complete.log`가 있는지 확인하고, 없으면 구현을 시작하지 않는다.
- [ ] Gito HTTP webhook receiver와 route를 추가하고, signed `branch.updated` delivery accepted 및 wrong event/invalid signature side-effect-free reject를 검증한다.
- [ ] `X-Gito-Delivery`와 revision key 기준 process-lifetime duplicate guard를 추가하고, duplicate delivery/revision이 handler를 한 번만 호출함을 검증한다.
- [ ] config/server wiring을 추가해 Gito webhook secret/repo/branch/develop checkout/todo state가 준비된 경우 HTTP receiver가 `gitosync.Bridge`를 호출하게 한다.
- [ ] `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`를 실행한다.
- [ ] `cd services/core && go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
- `02+01_http_receiver_idempotency`는 sibling predecessor `01_payload_boundary`에 의존한다.
- runtime source of truth: directory name `02+01_http_receiver_idempotency`.
- 구현 시작 전 확인 후보:
- active: `agent-task/m-gito-http-webhook-consumer-readiness/01_payload_boundary/complete.log`
- archive: `agent-task/archive/*/*/m-gito-http-webhook-consumer-readiness/01_payload_boundary/complete.log`
- predecessor complete.log가 없으면 이 plan의 구현은 blocked이며 사용자 리뷰 요청이 아니라 dependency 대기 상태다.
### [API-1] Gito HTTP webhook receiver
#### 문제
`services/core/internal/http/router.go:23`에는 healthz와 Plane webhook만 public route로 등록되어 있다. Gito HTTP webhook callback route가 없어서 signed `branch.updated` delivery를 받을 수 없다.
Before:
```go
// services/core/internal/http/router.go:23
r.Get("/healthz", handler.Healthz)
r.Post("/api/integrations/plane/webhook", handler.ReceivePlaneWebhook)
```
#### 해결 방법
- `services/core/internal/http/gito_webhook.go`를 추가한다.
- public route `POST /api/integrations/gito/webhook`를 등록한다.
- `ReceiveGitoWebhook`는:
- `GitoWebhookConfig.Secret` 없으면 `503`.
- body max size는 Plane과 같은 1MiB 기준을 쓰거나 `maxGitoWebhookBodyBytes`를 둔다.
- `X-Gito-Signature`를 HMAC-SHA256으로 검증하고 `sha256=` prefix와 raw hex를 모두 허용한다.
- `X-Gito-Event`가 `branch.updated`가 아니면 `400`으로 reject하고 handler를 호출하지 않는다.
- body는 `gitoevents.DecodeBranchUpdatedPayload`로 decode한다.
- target repo/branch가 아니면 `202 {"status":"ignored"}`로 side effect 없이 ack한다.
- valid on-target event는 configured `GitoBranchEventHandler.Handle`에 전달한다.
After outline:
```go
r.Post("/api/integrations/plane/webhook", handler.ReceivePlaneWebhook)
r.Post("/api/integrations/gito/webhook", handler.ReceiveGitoWebhook)
```
#### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/router.go`: Gito webhook public route 추가.
- [ ] `services/core/internal/http/handlers.go`: Handler fields와 setter seam 추가.
- [ ] `services/core/internal/http/gito_webhook.go`: receiver, signature validation, response mapping.
- [ ] `services/core/internal/http/gito_webhook_test.go`: accepted/wrong event/invalid signature/route bypass tests.
#### 테스트 작성
- 작성한다.
- `TestReceiveGitoWebhookAcceptsSignedBranchUpdatedDelivery`: valid signature and header event calls fake handler once.
- `TestReceiveGitoWebhookRejectsWrongEventWithoutHandlerCall`: wrong header event returns 400 and no handler call.
- `TestReceiveGitoWebhookRejectsInvalidSignatureWithoutHandlerCall`: invalid signature returns 401 and no handler call.
- `TestGitoWebhookRouteBypassesBasicAuth`: route mounted outside protected router.
#### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
예상: PASS. Go test cache output은 허용하지 않는다.
### [API-2] Delivery/revision duplicate guard
#### 문제
`services/core/internal/gitosync/bridge.go:29`의 duplicate guard는 `(repo, branch, after)` revision만 처리한다. Milestone Task는 `X-Gito-Delivery`와 revision key 양쪽 duplicate를 중복 enqueue 없이 처리하라고 요구한다.
Before:
```go
// services/core/internal/gitosync/bridge.go:29
// ProcessedRevisionStore is the duplicate guard seam: it reports whether a
// (repo, branch, after) revision has already been processed and records it.
// MarkProcessed must only be called after the revision's enqueue work
// succeeded, so a failed run is retried rather than silently swallowed.
type ProcessedRevisionStore interface {
Seen(repoID, branch, after string) bool
MarkProcessed(repoID, branch, after string)
}
```
#### 해결 방법
- HTTP layer에 process-lifetime dedupe store를 둔다. Bridge의 revision guard는 유지한다.
- duplicate keys:
- `delivery:<X-Gito-Delivery>` when header exists.
- `revision:<repo_id>:<branch>:<after>` always when `after` exists.
- valid on-target delivery가 handler `Handle`에서 nil을 반환한 뒤 keys를 mark한다.
- sequential duplicate request는 handler를 다시 호출하지 않고 `202 {"status":"duplicate"}` 또는 `{"status":"accepted","duplicate":"true"}` 중 하나로 응답한다. 응답 shape는 tests와 docs 없이 내부 contract로만 고정한다.
- handler error에서는 mark하지 않아 retry 가능성을 보존한다.
#### 수정 파일 및 체크리스트
- [ ] `services/core/internal/http/gito_webhook.go`: dedupe store, key construction, mark policy.
- [ ] `services/core/internal/http/gito_webhook_test.go`: duplicate delivery id and duplicate revision tests.
#### 테스트 작성
- 작성한다.
- `TestReceiveGitoWebhookDeduplicatesSameDeliveryID`: same header twice -> fake handler call count 1.
- `TestReceiveGitoWebhookDeduplicatesSameRevisionWithoutDeliveryID`: no delivery header, same repo/branch/after twice -> fake handler call count 1.
#### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
예상: PASS. Go test cache output은 허용하지 않는다.
### [API-3] Config/server wiring
#### 문제
`services/core/internal/config/config.go:43`는 Gito proto-socket URL 중심 config만 갖고 있고, `services/core/cmd/server/main.go:120`은 proto-socket runner만 시작한다. HTTP receiver가 `gitosync.Bridge`로 들어갈 server wiring이 없다.
Before:
```go
// services/core/internal/config/config.go:43
GitoProtoSocketURL string
GitoRepoID string
GitoBranch string
GitoDevelopRepoPath string
GitoRemoteName string
RoadmapCreationTodoStateID string
```
```go
// services/core/cmd/server/main.go:120
// Gito branch event consumer: drives Plane-origin Milestone creation sync
// from a develop push. Started only when the endpoint, repo, local develop
// checkout, and Todo state id are all configured; otherwise the Core server
// behaves exactly as before. Cancelled with the root context on shutdown.
if cfg.GitoBranchEventsEnabled() {
gitoRunner, err := newGitoRunner(cfg, planeClient, taskScheduler, logger)
```
#### 해결 방법
- Config에 `GitoWebhookSecret string`를 추가하고 `GITO_WEBHOOK_SECRET`에서 로드한다.
- `GitoWebhookConsumerEnabled()`를 추가한다. Required fields: secret, repo id, develop repo path, roadmap todo state id. Branch defaults to develop.
- `newGitoBridge(cfg, reader, enqueuer, logger) (*gitosync.Bridge, error)` helper를 만들어 scanner/bridge assembly를 proto runner와 HTTP receiver가 공유할 수 있게 한다.
- `newGitoRunner`는 bridge를 받아 `gitosync.NewRunner(cfg.GitoProtoSocketURL, ...)`만 담당하게 축소한다.
- server run에서 handler 생성 후 `handler.SetGitoWebhookConfig(...)`와 `handler.SetGitoBranchEventHandler(gitoBridge)`를 설정한다.
- 기존 proto-socket runner는 제거하지 않는다. `config-docs` Task에서 docs/env 교체를 다룬다.
#### 수정 파일 및 체크리스트
- [ ] `services/core/internal/config/config.go`: env field/load/enabled method.
- [ ] `services/core/internal/config/config_test.go`: defaults and enabled requirements.
- [ ] `services/core/cmd/server/main.go`: bridge factoring and handler wiring.
#### 테스트 작성
- 작성한다.
- `TestConfigGitoWebhookDefaultsAndDisabled`: default secret empty and disabled.
- `TestConfigGitoWebhookConsumerEnabledRequiresInputs`: secret/repo/develop/todo required, branch default accepted.
- `cmd/server`는 helper unit seam이 작으면 test 추가, 아니면 `go test ./cmd/server` compile check로 검증한다.
#### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/config ./cmd/server
```
예상: PASS. Go test cache output은 허용하지 않는다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/http/router.go` | API-1 |
| `services/core/internal/http/handlers.go` | API-1, API-2 |
| `services/core/internal/http/gito_webhook.go` | API-1, API-2 |
| `services/core/internal/http/gito_webhook_test.go` | API-1, API-2 |
| `services/core/internal/config/config.go` | API-3 |
| `services/core/internal/config/config_test.go` | API-3 |
| `services/core/cmd/server/main.go` | API-3 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server
cd services/core && go test -count=1 ./...
```
예상: 두 명령 모두 PASS. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,108 @@
<!-- task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency plan=1 tag=REVIEW_API -->
# PLAN local-G06 - REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
이 작업은 직전 code-review FAIL의 Required issue만 좁게 해결한다. 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 완료된다. 구현 에이전트는 검증을 실행하고 active 파일을 유지한 채 리뷰 준비를 보고한다. 종결, 로그 rename, `complete.log`, task directory archive는 code-review 스킬 전용이다. 선택된 SDD 결정이나 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `receiver`: Gito `branch.updated` HTTP callback endpoint를 추가한다.
- `idempotency`: `X-Gito-Delivery`와 revision key 기준으로 duplicate delivery를 중복 enqueue 없이 처리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S01`: task=`receiver`; evidence=`HTTP handler unit/integration test, invalid signature test`
- `S03`: task=`idempotency`; evidence=`duplicate delivery/revision idempotency test`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/plan_local_G06_0.log`
- Archived review: `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/code_review_local_G06_0.log`
- Verdict: FAIL
- Issue summary:
- Required: `services/core/internal/http/gito_webhook.go:97` local JSON decode lost `gitoevents.DecodeBranchUpdatedPayload` validation for body `type` and malformed `changed_files`, allowing contract-invalid `branch.updated` payloads into accepted/handler path.
- Suggested: 없음
- Nit: 없음
- Affected files:
- `services/core/internal/http/gito_webhook.go`
- `services/core/internal/http/gito_webhook_test.go`
- Verification evidence:
- `git diff --check` - PASS, no output.
- `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server` - PASS.
- `cd services/core && go test -count=1 ./...` - PASS.
- Existing tests are insufficient because they do not cover wrong body `type` or malformed `changed_files` through the HTTP receiver.
- Roadmap/spec carryover:
- Roadmap Targets remain `receiver` and `idempotency`.
- Spec Targets remain S01 and S03. This follow-up directly repairs S01; S03 should remain green.
- Narrow reread allowed if needed:
- `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/plan_local_G06_0.log`
- `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/code_review_local_G06_0.log`
## 범위 결정 근거
- 포함: HTTP receiver body payload validation and receiver tests for contract-invalid payloads.
- 제외: Gito outbound producer/subscription, full scan-enqueue proof, docs/env samples, and unrelated idempotency behavior changes.
- 새 package는 가능하면 만들지 말고 `services/core/internal/http/gito_webhook.go` 안에서 dependency-light local validation을 보강한다. import cycle 없이 공통화가 더 단순하다고 판단되는 경우에만 최소 파일 추가를 선택하고 이유를 review stub에 남긴다.
## 구현 체크리스트
- [ ] Gito HTTP webhook body validation이 `repo_id`/`branch` 필수 검증을 유지하면서 body `type`이 있으면 `branch.updated`만 허용하고, malformed `changed_files`를 400으로 reject하도록 보강한다.
- [ ] wrong body `type`과 malformed `changed_files`가 handler를 호출하지 않음을 검증하는 HTTP receiver tests를 추가하고 기존 accepted/duplicate tests가 계속 통과하게 한다.
- [ ] `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`를 실행한다.
- [ ] `cd services/core && go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_API-1] HTTP payload contract validation 복구
### 문제
직전 구현은 import cycle을 피하려고 HTTP receiver에서 로컬 `gitoWebhookPayload` struct를 사용했다. 하지만 현재 struct는 `repo_id`, `branch`, `before`, `after`만 decode하므로, 이전 subtask가 만든 `gitoevents.DecodeBranchUpdatedPayload`의 다음 검증이 HTTP path에서 빠졌다.
- body `type`이 present이면 `branch.updated`여야 한다.
- `changed_files`가 present이면 list of objects여야 한다.
이 때문에 header `X-Gito-Event: branch.updated`가 맞는 malformed body가 accepted path로 들어갈 수 있다.
### 해결 방법
- `services/core/internal/http/gito_webhook.go`의 local decoder를 보강한다.
- `gitoWebhookPayload`에 `Type`과 `ChangedFiles` validation path를 추가하거나, 별도 local `decodeGitoWebhookPayload` helper를 만든다.
- 검증 의미:
- invalid JSON: 400, handler call 없음.
- missing `repo_id` or `branch`: 400, handler call 없음.
- body `type` present and not `branch.updated`: 400, handler call 없음.
- `changed_files` present but not a list of objects: 400, handler call 없음.
- valid minimal base payload without NomadCode-specific fields: accepted behavior 유지.
- import cycle을 다시 만들지 않는다.
### 테스트 결정
- `services/core/internal/http/gito_webhook_test.go`에 다음 테스트를 추가한다.
- `TestReceiveGitoWebhookRejectsWrongBodyTypeWithoutHandlerCall`
- `TestReceiveGitoWebhookRejectsMalformedChangedFilesWithoutHandlerCall`
- 기존 accepted, wrong event, invalid signature, delivery duplicate, revision duplicate tests가 계속 통과해야 한다.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
예상: PASS. Go test cache output은 허용하지 않는다.
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server
cd services/core && go test -count=1 ./...
```
예상: 두 명령 모두 PASS. Go test cache output은 허용하지 않는다.

View file

@ -0,0 +1,101 @@
<!-- task=m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency plan=2 tag=REVIEW_REVIEW_API -->
# PLAN local-G06 - REVIEW_REVIEW_API
## 이 파일을 읽는 구현 에이전트에게
이 작업은 직전 code-review FAIL의 Required issue만 좁게 해결한다. 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채워야 완료된다. 구현 에이전트는 검증을 실행하고 active 파일을 유지한 채 리뷰 준비를 보고한다. 종결, 로그 rename, `complete.log`, task directory archive는 code-review 스킬 전용이다. 선택된 SDD 결정이나 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-http-webhook-consumer-readiness.md`
- Task ids:
- `receiver`: Gito `branch.updated` HTTP callback endpoint를 추가한다.
- `idempotency`: `X-Gito-Delivery`와 revision key 기준으로 duplicate delivery를 중복 enqueue 없이 처리한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/agent-ops-mcp-control-plane/gito-http-webhook-consumer-readiness/SDD.md`
- Acceptance scenarios:
- `S01`: task=`receiver`; evidence=`HTTP handler unit/integration test, invalid signature test`
- `S03`: task=`idempotency`; evidence=`duplicate delivery/revision idempotency test`
- Completion mode: spec-check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/plan_local_G06_1.log`
- Archived review: `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/code_review_local_G06_1.log`
- Verdict: FAIL
- Issue summary:
- Required: `services/core/internal/http/gito_webhook.go:96` validates `changed_files` only as an array, not as a list of objects, so `{"changed_files":["not-an-object"]}` can still enter the accepted/handler path even though the SDD and `gitoevents.DecodeBranchUpdatedPayload` contract reject non-object items.
- Suggested: 없음
- Nit: 없음
- Affected files:
- `services/core/internal/http/gito_webhook.go`
- `services/core/internal/http/gito_webhook_test.go`
- Verification evidence:
- `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server` - PASS.
- `cd services/core && go test -count=1 ./...` - PASS.
- Existing HTTP receiver tests are insufficient because they cover `changed_files` as a non-list string but not a list containing non-object elements.
- Roadmap/spec carryover:
- Roadmap Targets remain `receiver` and `idempotency`.
- Spec Targets remain S01 and S03. This follow-up directly repairs S01; S03 should remain green.
- Narrow reread allowed if needed:
- `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/plan_local_G06_1.log`
- `agent-task/m-gito-http-webhook-consumer-readiness/02+01_http_receiver_idempotency/code_review_local_G06_1.log`
## 범위 결정 근거
- 포함: HTTP receiver `changed_files` item-type validation and the matching receiver test proving handler is not called.
- 제외: Gito outbound producer/subscription, scan/enqueue bridge behavior, config/docs, delivery/revision idempotency semantics, and unrelated roadmap/SDD edits.
- 새 package는 만들지 않는다. 기존 local validator 안에서 `gitoevents.decodeChangedFiles`의 item object check와 같은 의미를 최소 구현한다.
## 구현 체크리스트
- [ ] `validateBranchUpdatedPayload`가 `changed_files` present 시 배열 여부뿐 아니라 각 element가 JSON object인지 검증하고, non-object element를 400 reject하도록 보강한다.
- [ ] `changed_files` 배열 안의 non-object item이 400이고 handler를 호출하지 않음을 검증하는 HTTP receiver test를 추가하며 기존 wrong body type, malformed string, accepted/duplicate tests가 계속 통과하게 한다.
- [ ] `cd services/core && go test -count=1 ./internal/http`를 실행한다.
- [ ] `cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server`를 실행한다.
- [ ] `cd services/core && go test -count=1 ./...`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_REVIEW_API-1] `changed_files` element contract validation
### 문제
현재 HTTP receiver validator는 `changed_files`가 JSON array인지까지만 확인한다. 하지만 SDD와 `gitoevents.DecodeBranchUpdatedPayload` 기준은 `changed_files`가 present이면 list of objects여야 한다. 따라서 아래 payload는 현재 HTTP path에서 invalid로 거부되어야 하지만 통과할 수 있다.
```json
{"repo_id":"target-repo","branch":"develop","changed_files":["not-an-object"]}
```
### 해결 방법
- `services/core/internal/http/gito_webhook.go`의 `validateBranchUpdatedPayload`에서 `p.ChangedFiles.([]interface{})`를 얻은 뒤 각 item이 `map[string]interface{}`인지 확인한다.
- non-object item 발견 시 `payload changed_files[<index>] is not an object`처럼 원인을 알 수 있는 error를 반환한다.
- 검증 실패는 기존 path처럼 HTTP 400과 handler call 없음으로 종료한다.
- `gitoevents` package import cycle을 만들지 않는다.
### 테스트 결정
- `services/core/internal/http/gito_webhook_test.go`에 `TestReceiveGitoWebhookRejectsMalformedChangedFileItemWithoutHandlerCall`을 추가한다.
- payload는 signed `branch.updated` 요청으로 만들고 `changed_files`에 string element를 넣는다.
- 기대값은 HTTP 400, fake handler call count 0이다.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/http
```
예상: PASS. Go test cache output은 허용하지 않는다.
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/http ./internal/config ./cmd/server
cd services/core && go test -count=1 ./...
```
예상: 두 명령 모두 PASS. Go test cache output은 허용하지 않는다.

View file

@ -117,12 +117,23 @@ func run(logger *slog.Logger) error {
workflowService.SetEnqueuer(taskScheduler) workflowService.SetEnqueuer(taskScheduler)
// Gito branch event consumer: drives Plane-origin Milestone creation sync // Build a shared Gito bridge when either the proto-socket runner or the HTTP
// from a develop push. Started only when the endpoint, repo, local develop // webhook consumer is enabled. Both share the same scanner/bridge assembly.
// checkout, and Todo state id are all configured; otherwise the Core server var gitoBridge *gitosync.Bridge
// behaves exactly as before. Cancelled with the root context on shutdown. if cfg.GitoBranchEventsEnabled() || cfg.GitoWebhookConsumerEnabled() {
var err error
gitoBridge, err = newGitoBridge(cfg, planeClient, taskScheduler, logger)
if err != nil {
return err
}
}
// Gito branch event consumer (proto-socket): drives Plane-origin Milestone
// creation sync from a develop push. Started only when the endpoint, repo,
// local develop checkout, and Todo state id are all configured; otherwise the
// Core server behaves exactly as before. Cancelled with the root context on shutdown.
if cfg.GitoBranchEventsEnabled() { if cfg.GitoBranchEventsEnabled() {
gitoRunner, err := newGitoRunner(cfg, planeClient, taskScheduler, logger) gitoRunner, err := newGitoRunner(cfg, gitoBridge, logger)
if err != nil { if err != nil {
return err return err
} }
@ -152,6 +163,16 @@ func run(logger *slog.Logger) error {
AgentAssigneeID: cfg.PlaneAgentAssigneeID, AgentAssigneeID: cfg.PlaneAgentAssigneeID,
SelfActorID: cfg.PlaneSelfActorID, SelfActorID: cfg.PlaneSelfActorID,
}) })
if cfg.GitoWebhookConsumerEnabled() {
handler.SetGitoWebhookConfig(apphttp.GitoWebhookConfig{
Secret: cfg.GitoWebhookSecret,
RepoID: cfg.GitoRepoID,
Branch: cfg.GitoBranch,
})
handler.SetGitoBranchEventHandler(&gitoBridgeHTTPAdapter{bridge: gitoBridge})
logger.Info("gito http webhook consumer enabled",
"repo_id", cfg.GitoRepoID, "branch", cfg.GitoBranch)
}
protosocket.NewTaskChannels(workflowService).Register(protoSocketServer.Dispatcher()) protosocket.NewTaskChannels(workflowService).Register(protoSocketServer.Dispatcher())
@ -195,10 +216,11 @@ func run(logger *slog.Logger) error {
return nil return nil
} }
// newGitoRunner assembles the Gito branch event consumer: an exec-backed develop // newGitoBridge assembles the shared Gito bridge: an exec-backed develop scanner,
// scanner, the Plane work item reader, the scheduler enqueuer, and an in-memory // the Plane work item reader, the scheduler enqueuer, and an in-memory
// duplicate-revision guard, wired into a proto-socket runner. // duplicate-revision guard. Both the proto-socket runner and the HTTP webhook
func newGitoRunner(cfg config.Config, reader *plane.Client, enqueuer *scheduler.Client, logger *slog.Logger) (*gitoevents.Client, error) { // consumer call this and share the resulting bridge.
func newGitoBridge(cfg config.Config, reader *plane.Client, enqueuer *scheduler.Client, logger *slog.Logger) (*gitosync.Bridge, error) {
scanner, err := gitosync.NewBranchRevisionScanner(gitosync.ExecCommandRunner{}, gitosync.ScannerConfig{ scanner, err := gitosync.NewBranchRevisionScanner(gitosync.ExecCommandRunner{}, gitosync.ScannerConfig{
DevelopRepoPath: cfg.GitoDevelopRepoPath, DevelopRepoPath: cfg.GitoDevelopRepoPath,
RemoteName: cfg.GitoRemoteName, RemoteName: cfg.GitoRemoteName,
@ -207,13 +229,30 @@ func newGitoRunner(cfg config.Config, reader *plane.Client, enqueuer *scheduler.
if err != nil { if err != nil {
return nil, err return nil, err
} }
bridge, err := gitosync.NewBridge(scanner, reader, enqueuer, nil, gitosync.BridgeConfig{ return gitosync.NewBridge(scanner, reader, enqueuer, nil, gitosync.BridgeConfig{
RepoID: cfg.GitoRepoID, RepoID: cfg.GitoRepoID,
Branch: cfg.GitoBranch, Branch: cfg.GitoBranch,
TodoStateID: cfg.RoadmapCreationTodoStateID, TodoStateID: cfg.RoadmapCreationTodoStateID,
}, logger) }, logger)
if err != nil { }
return nil, err
} // newGitoRunner wires a pre-built bridge into a proto-socket runner.
func newGitoRunner(cfg config.Config, bridge *gitosync.Bridge, logger *slog.Logger) (*gitoevents.Client, error) {
return gitosync.NewRunner(cfg.GitoProtoSocketURL, cfg.GitoRepoID, cfg.GitoBranch, bridge, logger) return gitosync.NewRunner(cfg.GitoProtoSocketURL, cfg.GitoRepoID, cfg.GitoBranch, bridge, logger)
} }
// gitoBridgeHTTPAdapter adapts *gitosync.Bridge to apphttp.GitoBranchEventHandler.
// The http package defines its own event struct to avoid an import cycle between
// internal/http → internal/gitoevents → internal/protosocket ← protosocket tests → internal/http.
type gitoBridgeHTTPAdapter struct {
bridge *gitosync.Bridge
}
func (a *gitoBridgeHTTPAdapter) Handle(ctx context.Context, ev apphttp.GitoBranchUpdatedEvent) error {
return a.bridge.Handle(ctx, gitoevents.BranchUpdatedEvent{
RepoID: ev.RepoID,
Branch: ev.Branch,
Before: ev.Before,
After: ev.After,
})
}

View file

@ -41,6 +41,7 @@ type Config struct {
ProtoSocketHeartbeatIntervalSec int ProtoSocketHeartbeatIntervalSec int
ProtoSocketHeartbeatWaitSec int ProtoSocketHeartbeatWaitSec int
GitoProtoSocketURL string GitoProtoSocketURL string
GitoWebhookSecret string
GitoRepoID string GitoRepoID string
GitoBranch string GitoBranch string
GitoDevelopRepoPath string GitoDevelopRepoPath string
@ -59,6 +60,16 @@ func (c Config) GitoBranchEventsEnabled() bool {
c.RoadmapCreationTodoStateID != "" c.RoadmapCreationTodoStateID != ""
} }
// GitoWebhookConsumerEnabled reports whether the Gito HTTP webhook receiver
// should be wired. Requires secret, repo id, develop repo path, and todo state
// id. Branch defaults to "develop" and is not required.
func (c Config) GitoWebhookConsumerEnabled() bool {
return c.GitoWebhookSecret != "" &&
c.GitoRepoID != "" &&
c.GitoDevelopRepoPath != "" &&
c.RoadmapCreationTodoStateID != ""
}
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getEnv("APP_ENV", "local"), AppEnv: getEnv("APP_ENV", "local"),
@ -96,6 +107,7 @@ func Load() Config {
ProtoSocketHeartbeatIntervalSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_INTERVAL_SEC", 30), ProtoSocketHeartbeatIntervalSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_INTERVAL_SEC", 30),
ProtoSocketHeartbeatWaitSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_WAIT_SEC", 10), ProtoSocketHeartbeatWaitSec: getEnvInt("PROTO_SOCKET_HEARTBEAT_WAIT_SEC", 10),
GitoProtoSocketURL: os.Getenv("GITO_PROTO_SOCKET_URL"), GitoProtoSocketURL: os.Getenv("GITO_PROTO_SOCKET_URL"),
GitoWebhookSecret: os.Getenv("GITO_WEBHOOK_SECRET"),
GitoRepoID: os.Getenv("GITO_REPO_ID"), GitoRepoID: os.Getenv("GITO_REPO_ID"),
GitoBranch: getEnv("GITO_BRANCH", "develop"), GitoBranch: getEnv("GITO_BRANCH", "develop"),
GitoDevelopRepoPath: os.Getenv("GITO_DEVELOP_REPO_PATH"), GitoDevelopRepoPath: os.Getenv("GITO_DEVELOP_REPO_PATH"),

View file

@ -274,6 +274,44 @@ func TestConfigLoadsPlaneWebhookDispatchEnv(t *testing.T) {
} }
} }
func TestConfigGitoWebhookDefaultsAndDisabled(t *testing.T) {
cfg := Load()
if cfg.GitoWebhookSecret != "" {
t.Fatalf("GitoWebhookSecret default: got %q, want empty", cfg.GitoWebhookSecret)
}
if cfg.GitoWebhookConsumerEnabled() {
t.Fatal("GitoWebhookConsumerEnabled: want false when secret is unset")
}
}
func TestConfigGitoWebhookConsumerEnabledRequiresInputs(t *testing.T) {
t.Setenv("GITO_WEBHOOK_SECRET", "webhook-secret")
if Load().GitoWebhookConsumerEnabled() {
t.Fatal("GitoWebhookConsumerEnabled: want false when repo id is unset")
}
t.Setenv("GITO_REPO_ID", "nomadcode")
if Load().GitoWebhookConsumerEnabled() {
t.Fatal("GitoWebhookConsumerEnabled: want false when develop repo path is unset")
}
t.Setenv("GITO_DEVELOP_REPO_PATH", "/srv/develop")
if Load().GitoWebhookConsumerEnabled() {
t.Fatal("GitoWebhookConsumerEnabled: want false when todo state id is unset")
}
t.Setenv("ROADMAP_CREATION_TODO_STATE_ID", "state-todo")
cfg := Load()
if cfg.GitoWebhookSecret != "webhook-secret" {
t.Fatalf("GitoWebhookSecret: got %q", cfg.GitoWebhookSecret)
}
if !cfg.GitoWebhookConsumerEnabled() {
t.Fatal("GitoWebhookConsumerEnabled: want true when all inputs set")
}
}
func TestConfigJiraFieldsAreEmptyByDefault(t *testing.T) { func TestConfigJiraFieldsAreEmptyByDefault(t *testing.T) {
cfg := Load() cfg := Load()

View file

@ -73,42 +73,50 @@ func BuildSubscribeEnvelope(id, repoID, branch string) protosocket.Envelope {
} }
} }
// DecodeBranchUpdatedEnvelope validates the envelope channel/action and decodes // DecodeBranchUpdatedPayload decodes a `branch.updated` base payload into a
// its payload into a BranchUpdatedEvent. It rejects wrong channel/action and // BranchUpdatedEvent. It validates required fields (repo_id, branch) and the
// malformed payloads with a descriptive error instead of returning a partial // type field, and decodes optional fields without requiring them. This function
// event. // has no envelope knowledge so it can be shared between proto-socket envelope
func DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent, error) { // and raw HTTP JSON body consumers.
var ev BranchUpdatedEvent //
// Constraints:
if env.Channel != EventChannel { //
return ev, fmt.Errorf("gitoevents: unexpected channel %q, want %q", env.Channel, EventChannel) // - type must be "branch.updated" if present
} // - repo_id and branch are required
if env.Action != BranchUpdatedAction { // - changed_files must be a list of objects if present
return ev, fmt.Errorf("gitoevents: unexpected action %q, want %q", env.Action, BranchUpdatedAction) func DecodeBranchUpdatedPayload(payload map[string]any) (BranchUpdatedEvent, error) {
} if payload == nil {
if env.Payload == nil { return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: nil payload")
return ev, fmt.Errorf("gitoevents: missing payload")
} }
ev.ID = stringField(env.Payload, "id") ev := BranchUpdatedEvent{}
ev.Type = stringField(env.Payload, "type")
ev.Provider = stringField(env.Payload, "provider")
ev.DeliveryID = stringField(env.Payload, "delivery_id")
ev.RepoID = stringField(env.Payload, "repo_id")
ev.Branch = stringField(env.Payload, "branch")
ev.Before = stringField(env.Payload, "before")
ev.After = stringField(env.Payload, "after")
ev.ObservedAt = stringField(env.Payload, "observed_at")
ev.CreatedAt = stringField(env.Payload, "created_at")
ev.ID = stringField(payload, "id")
ev.Type = stringField(payload, "type")
ev.Provider = stringField(payload, "provider")
ev.DeliveryID = stringField(payload, "delivery_id")
ev.RepoID = stringField(payload, "repo_id")
ev.Branch = stringField(payload, "branch")
ev.Before = stringField(payload, "before")
ev.After = stringField(payload, "after")
ev.ObservedAt = stringField(payload, "observed_at")
ev.CreatedAt = stringField(payload, "created_at")
// Validate required fields
if ev.RepoID == "" { if ev.RepoID == "" {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing repo_id") return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing required field repo_id")
} }
if ev.Branch == "" { if ev.Branch == "" {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing branch") return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload missing required field branch")
} }
files, err := decodeChangedFiles(env.Payload["changed_files"]) // Validate type field if present
if ev.Type != "" && ev.Type != BranchUpdatedAction {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: payload type %q, want %q", ev.Type, BranchUpdatedAction)
}
// Decode changed_files (optional but must be valid if present)
files, err := decodeChangedFiles(payload["changed_files"])
if err != nil { if err != nil {
return BranchUpdatedEvent{}, err return BranchUpdatedEvent{}, err
} }
@ -117,6 +125,24 @@ func DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent,
return ev, nil return ev, nil
} }
// DecodeBranchUpdatedEnvelope validates the envelope channel/action and decodes
// its payload into a BranchUpdatedEvent. It rejects wrong channel/action and
// malformed payloads with a descriptive error instead of returning a partial
// event.
func DecodeBranchUpdatedEnvelope(env protosocket.Envelope) (BranchUpdatedEvent, error) {
if env.Channel != EventChannel {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: unexpected channel %q, want %q", env.Channel, EventChannel)
}
if env.Action != BranchUpdatedAction {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: unexpected action %q, want %q", env.Action, BranchUpdatedAction)
}
if env.Payload == nil {
return BranchUpdatedEvent{}, fmt.Errorf("gitoevents: missing payload")
}
return DecodeBranchUpdatedPayload(env.Payload)
}
// IsTarget reports whether the event matches the watched repo and branch. // IsTarget reports whether the event matches the watched repo and branch.
func (e BranchUpdatedEvent) IsTarget(repoID, branch string) bool { func (e BranchUpdatedEvent) IsTarget(repoID, branch string) bool {
if branch == "" { if branch == "" {

View file

@ -1,6 +1,7 @@
package gitoevents package gitoevents
import ( import (
"strings"
"testing" "testing"
"github.com/nomadcode/nomadcode-core/internal/protosocket" "github.com/nomadcode/nomadcode-core/internal/protosocket"
@ -183,6 +184,202 @@ func TestDecodeBranchUpdatedEnvelopeRejectsMissingRepoID(t *testing.T) {
} }
} }
func TestDecodeBranchUpdatedPayloadAcceptsBasePayloadWithoutNomadCodeFields(t *testing.T) {
// Gito base JSON shape: minimal fields without NomadCode-specific extensions.
payload := map[string]any{
"id": "event-123",
"type": "branch.updated",
"provider": "forgejo",
"repo_id": "nomadcode",
"branch": "develop",
"before": "old-sha",
"after": "new-sha",
"changed_files": []any{
map[string]any{
"path": "agent-roadmap/phase/example/milestones/example.md",
"change_type": "modified",
},
map[string]any{
"path": "README.md",
"change_type": "modified",
},
},
"observed_at": "2026-06-13T00:00:00Z",
"created_at": "2026-06-13T00:00:00Z",
}
ev, err := DecodeBranchUpdatedPayload(payload)
if err != nil {
t.Fatalf("decode base payload: unexpected error: %v", err)
}
if ev.RepoID != "nomadcode" || ev.Branch != "develop" || ev.After != "new-sha" {
t.Fatalf("decoded event: got %+v", ev)
}
if ev.ID != "event-123" || ev.Provider != "forgejo" {
t.Fatalf("id/provider: got %+v", ev)
}
if len(ev.ChangedFiles) != 2 {
t.Fatalf("changed_files: got %d, want 2", len(ev.ChangedFiles))
}
if ev.DeliveryID != "" {
t.Fatalf("delivery_id should be empty for minimal payload, got %q", ev.DeliveryID)
}
}
func TestDecodeBranchUpdatedPayloadRejectsWrongType(t *testing.T) {
payload := map[string]any{
"id": "event-456",
"type": "task.status.changed",
"repo_id": "nomadcode",
"branch": "develop",
"after": "new-sha",
}
_, err := DecodeBranchUpdatedPayload(payload)
if err == nil {
t.Fatal("expected error for wrong type")
}
if !strings.Contains(err.Error(), "type") {
t.Fatalf("error should mention type validation: %v", err)
}
}
func TestDecodeBranchUpdatedPayloadRejectsMalformedChangedFiles(t *testing.T) {
payload := map[string]any{
"id": "event-789",
"type": "branch.updated",
"repo_id": "nomadcode",
"branch": "develop",
"after": "new-sha",
"changed_files": "not-a-list",
}
_, err := DecodeBranchUpdatedPayload(payload)
if err == nil {
t.Fatal("expected error for malformed changed_files")
}
if !strings.Contains(err.Error(), "changed_files") {
t.Fatalf("error should mention changed_files: %v", err)
}
}
func TestDecodeBranchUpdatedPayloadRejectsMissingRepoID(t *testing.T) {
payload := map[string]any{
"type": "branch.updated",
"branch": "develop",
"after": "new-sha",
}
_, err := DecodeBranchUpdatedPayload(payload)
if err == nil {
t.Fatal("expected error for missing repo_id")
}
}
func TestDecodeBranchUpdatedPayloadRejectsMissingBranch(t *testing.T) {
payload := map[string]any{
"type": "branch.updated",
"repo_id": "nomadcode",
"after": "new-sha",
}
_, err := DecodeBranchUpdatedPayload(payload)
if err == nil {
t.Fatal("expected error for missing branch")
}
}
func TestDecodeBranchUpdatedPayloadEmptyChangedFiles(t *testing.T) {
payload := map[string]any{
"id": "event-empty",
"type": "branch.updated",
"repo_id": "nomadcode",
"branch": "develop",
"after": "new-sha",
"changed_files": []any{},
}
ev, err := DecodeBranchUpdatedPayload(payload)
if err != nil {
t.Fatalf("decode empty changed_files: unexpected error: %v", err)
}
if ev.ChangedFiles == nil {
t.Fatalf("changed_files should be empty slice, got nil")
}
if len(ev.ChangedFiles) != 0 {
t.Fatalf("changed_files: got %d, want 0", len(ev.ChangedFiles))
}
}
func TestDecodeBranchUpdatedPayloadNilPayload(t *testing.T) {
_, err := DecodeBranchUpdatedPayload(nil)
if err == nil {
t.Fatal("expected error for nil payload")
}
}
func TestDecodeBranchUpdatedPayloadAllowsNilChangedFiles(t *testing.T) {
payload := map[string]any{
"id": "event-nil-cf",
"type": "branch.updated",
"repo_id": "nomadcode",
"branch": "develop",
"after": "new-sha",
"provider": "forgejo",
}
ev, err := DecodeBranchUpdatedPayload(payload)
if err != nil {
t.Fatalf("decode with nil changed_files: unexpected error: %v", err)
}
if ev.ChangedFiles != nil {
t.Fatalf("changed_files should be nil, got %+v", ev.ChangedFiles)
}
}
func TestDecodeBranchUpdatedPayloadHandlesMalformedChangedFileItem(t *testing.T) {
payload := map[string]any{
"id": "event-bad-item",
"type": "branch.updated",
"repo_id": "nomadcode",
"branch": "develop",
"after": "new-sha",
"changed_files": []any{
"not-an-object",
},
}
_, err := DecodeBranchUpdatedPayload(payload)
if err == nil {
t.Fatal("expected error for malformed changed_files item")
}
if !strings.Contains(err.Error(), "changed_files") {
t.Fatalf("error should mention changed_files: %v", err)
}
}
func TestDecodeBranchUpdatedPayloadWithoutTypeField(t *testing.T) {
// type field is optional; when absent it should be accepted.
payload := map[string]any{
"id": "event-no-type",
"repo_id": "nomadcode",
"branch": "develop",
"after": "new-sha",
"provider": "forgejo",
}
ev, err := DecodeBranchUpdatedPayload(payload)
if err != nil {
t.Fatalf("decode without type: unexpected error: %v", err)
}
if ev.Type != "" {
t.Fatalf("type should be empty when absent, got %q", ev.Type)
}
if ev.RepoID != "nomadcode" || ev.Branch != "develop" {
t.Fatalf("repo/branch: got %+v", ev)
}
}
func TestIsTarget(t *testing.T) { func TestIsTarget(t *testing.T) {
ev := BranchUpdatedEvent{RepoID: "nomadcode", Branch: "develop"} ev := BranchUpdatedEvent{RepoID: "nomadcode", Branch: "develop"}

View file

@ -93,10 +93,15 @@ func validateBranchUpdatedPayload(p *gitoWebhookPayload) error {
// changed_files must be a list of objects if present // changed_files must be a list of objects if present
if p.ChangedFiles != nil { if p.ChangedFiles != nil {
_, ok := p.ChangedFiles.([]interface{}) changedFiles, ok := p.ChangedFiles.([]interface{})
if !ok { if !ok {
return errors.New("payload changed_files is not a list") return errors.New("payload changed_files is not a list")
} }
for i, item := range changedFiles {
if _, ok := item.(map[string]interface{}); !ok {
return fmt.Errorf("payload changed_files[%d] is not an object", i)
}
}
} }
return nil return nil
@ -216,13 +221,3 @@ func validGitoWebhookSignature(secret string, body []byte, signature string) boo
} }
return hmac.Equal(actual, expected) return hmac.Equal(actual, expected)
} }
func missingGitoFields(p gitoWebhookPayload) string {
if p.RepoID == "" && p.Branch == "" {
return "repo_id and branch are required"
}
if p.RepoID == "" {
return "repo_id is required"
}
return "branch is required"
}

View file

@ -206,3 +206,25 @@ func TestReceiveGitoWebhookRejectsMalformedChangedFilesWithoutHandlerCall(t *tes
t.Fatalf("expected handler not called, got %d", fake.callCount) t.Fatalf("expected handler not called, got %d", fake.callCount)
} }
} }
func TestReceiveGitoWebhookRejectsMalformedChangedFileItemWithoutHandlerCall(t *testing.T) {
fake := &fakeGitoBranchEventHandler{}
h := newGitoHandlerForTest()
h.SetGitoBranchEventHandler(fake)
// changed_files 가 list 이지만 element 가 object 가 아닌 경우
malformedItemBody := `{"repo_id":"target-repo","branch":"develop","changed_files":["not-an-object"]}`
req := httptest.NewRequest(http.MethodPost, "/api/integrations/gito/webhook", strings.NewReader(malformedItemBody))
req.Header.Set("X-Gito-Signature", testGitoSignature("secret", malformedItemBody))
req.Header.Set("X-Gito-Event", "branch.updated")
rec := httptest.NewRecorder()
h.ReceiveGitoWebhook(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if fake.callCount != 0 {
t.Fatalf("expected handler not called, got %d", fake.callCount)
}
}

View file

@ -26,6 +26,24 @@ type WorkItemTaskCreator interface {
CreateTaskFromWorkItem(ctx context.Context, input workitempipeline.CreateTaskInput) (storage.Task, error) CreateTaskFromWorkItem(ctx context.Context, input workitempipeline.CreateTaskInput) (storage.Task, error)
} }
// GitoWebhookConfig carries the non-secret gate identifiers for the Gito
// HTTP webhook receiver.
type GitoWebhookConfig struct {
Secret string
RepoID string
Branch string
}
type gitoWebhookConfig struct {
secret string
repoID string
branch string
}
func (c gitoWebhookConfig) ready() bool {
return c.secret != ""
}
type Handler struct { type Handler struct {
db *pgxpool.Pool db *pgxpool.Pool
workflow *workflow.Service workflow *workflow.Service
@ -34,6 +52,9 @@ type Handler struct {
logger *slog.Logger logger *slog.Logger
planeWebhookSecret string planeWebhookSecret string
planeDispatch planeWebhookDispatch planeDispatch planeWebhookDispatch
gitoConfig gitoWebhookConfig
gitoEventHandler GitoBranchEventHandler
gitoDelivery *gitoDeliveryStore
} }
type webhookTaskEnqueuer interface { type webhookTaskEnqueuer interface {
@ -81,6 +102,7 @@ func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, binder wo
webhookEnqueuer: workflowService, webhookEnqueuer: workflowService,
workItemProviders: make(map[workitem.ProviderID]WorkItemTaskCreator), workItemProviders: make(map[workitem.ProviderID]WorkItemTaskCreator),
logger: logger, logger: logger,
gitoDelivery: newGitoDeliveryStore(),
} }
if workflowService != nil { if workflowService != nil {
for _, provider := range providers { for _, provider := range providers {
@ -175,6 +197,18 @@ func (b projectSyncBinder) ReserveWorkspaceSlot(ctx context.Context, projectSync
return projectsync.SlotFromDBRecord(slot), nil return projectsync.SlotFromDBRecord(slot), nil
} }
func (h *Handler) SetGitoWebhookConfig(cfg GitoWebhookConfig) {
h.gitoConfig = gitoWebhookConfig{
secret: strings.TrimSpace(cfg.Secret),
repoID: strings.TrimSpace(cfg.RepoID),
branch: strings.TrimSpace(cfg.Branch),
}
}
func (h *Handler) SetGitoBranchEventHandler(handler GitoBranchEventHandler) {
h.gitoEventHandler = handler
}
func (h *Handler) registerWorkItemProvider(id workitem.ProviderID, creator WorkItemTaskCreator) { func (h *Handler) registerWorkItemProvider(id workitem.ProviderID, creator WorkItemTaskCreator) {
trimmed := strings.TrimSpace(string(id)) trimmed := strings.TrimSpace(string(id))
if trimmed == "" || creator == nil { if trimmed == "" || creator == nil {

View file

@ -63,6 +63,7 @@ func withWebhookEnqueuer(enqueuer webhookTaskEnqueuer) handlerOpt {
func newHandlerForTest(opts ...handlerOpt) *Handler { func newHandlerForTest(opts ...handlerOpt) *Handler {
h := &Handler{ h := &Handler{
workItemProviders: make(map[workitem.ProviderID]WorkItemTaskCreator), workItemProviders: make(map[workitem.ProviderID]WorkItemTaskCreator),
gitoDelivery: newGitoDeliveryStore(),
} }
for _, opt := range opts { for _, opt := range opts {
opt(h) opt(h)

View file

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