feat: webhook-revision-reconcile milestone implementation and roadmap update
- Add SDD documentation for webhook-revision-reconcile phase - Add task breakdown for webhook-revision-reconcile - Update ROADMAP.md with latest phase progress - Update PHASE.md files for agent-shell-iop-execution and provider-change-request-integration - Update milestone details for iop-agent-run-bridge and webhook-revision-reconcile - Implement controlplane router and runtime changes - Add router tests
This commit is contained in:
parent
06f1c658fb
commit
64dd8a6664
13 changed files with 1274 additions and 43 deletions
|
|
@ -11,15 +11,15 @@ Gito는 GitHub, GitLab, Gitea 같은 플랫폼에 묶이지 않는 Git control p
|
|||
검토중 또는 진행중 Phase는 계획 Phase보다 위에 두어, 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
|
||||
스케치 Phase는 아직 구현 가능한 계획이 아니므로 계획 Phase보다 아래에 둔다.
|
||||
|
||||
- [진행중] Provider Change Request Integration
|
||||
- 경로: `agent-roadmap/phase/provider-change-request-integration/PHASE.md`
|
||||
- 요약: NomadCode branch event wakeup을 위한 outbound HTTP webhook delivery를 최우선으로 두고, GitHub PR, GitLab MR, Gitea PR 같은 provider 기능과 webhook delivery를 ChangeRequest 추상화와 adapter 구조로 통합한다.
|
||||
- [진행중] Control Plane Foundation
|
||||
- 경로: `agent-roadmap/phase/control-plane-foundation/PHASE.md`
|
||||
- 요약: platformless Git control plane의 모듈 경계, repo/workspace/operation/event 원장, 외부 HTTP webhook/REST와 내부 proto-socket 경계를 build 가능한 baseline으로 닫는다.
|
||||
- [진행중] Agent Shell IOP Execution
|
||||
- [계획] Agent Shell IOP Execution
|
||||
- 경로: `agent-roadmap/phase/agent-shell-iop-execution/PHASE.md`
|
||||
- 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop`로 지정해 Gito Git control plane과 연결한다.
|
||||
- [계획] Provider Change Request Integration
|
||||
- 경로: `agent-roadmap/phase/provider-change-request-integration/PHASE.md`
|
||||
- 요약: GitHub PR, GitLab MR, Gitea PR 같은 provider 기능과 webhook delivery를 ChangeRequest 추상화와 adapter 구조로 통합한다.
|
||||
- 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop`로 지정해 Gito Git control plane과 연결한다. NomadCode webhook 대응 뒤 IOP bridge 보완을 재개한다.
|
||||
- [계획] Control Surface Operations UX
|
||||
- 경로: `agent-roadmap/phase/control-surface-operations-ux/PHASE.md`
|
||||
- 요약: Flutter control surface와 운영 관찰 화면을 통해 repository, operation, runner, event 흐름을 다룰 수 있게 한다.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
[계획]
|
||||
|
||||
## 목표
|
||||
|
||||
|
|
@ -19,9 +19,9 @@ IOP 같은 sibling/external runtime 연결은 HTTP dispatch/callback 모델로
|
|||
- 경로: `agent-roadmap/archive/phase/agent-shell-iop-execution/milestones/agent-shell-package-iop-backend-boundary.md`
|
||||
- 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop` 프로젝트로 지정한다.
|
||||
|
||||
- [진행중] IOP Agent Run Bridge
|
||||
- [계획] IOP Agent Run Bridge
|
||||
- 경로: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md`
|
||||
- 요약: IOP backend가 수행한 agent run 결과를 Gito의 Git operation과 event 원장으로 수렴시키는 production bridge를 마무리한다.
|
||||
- 요약: IOP backend가 수행한 agent run 결과를 Gito의 Git operation과 event 원장으로 수렴시키는 production bridge를 NomadCode webhook 대응 이후 재개한다.
|
||||
|
||||
- [계획] Command Policy and Cancellation
|
||||
- 경로: `agent-roadmap/phase/agent-shell-iop-execution/milestones/command-policy-and-cancellation.md`
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
[계획]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -85,6 +85,7 @@ agent run 결과를 Git revision과 operation event로 수렴한다.
|
|||
- 표준선(선택): IOP run 결과는 Git 상태와 operation event로 검증한다. Gito는 IOP CLI/secret 주입 세부를 직접 소유하지 않고 HTTP dispatch/callback과 `credential_ref`/`secret_ref` 경계만 다룬다.
|
||||
- 선행 작업: Agent Shell Package and IOP Backend Boundary
|
||||
- 후속 작업: Command Policy and Cancellation
|
||||
- 확인 필요: production worker/runtime 연결 보완 후 완료 리뷰와 archive 승인
|
||||
- 확인 필요: NomadCode webhook 대응 후 production worker/runtime 연결 보완, 완료 리뷰, archive 승인
|
||||
- 우선순위 조정: 2026-06-17 NomadCode webhook 요청 대응을 최우선 작업으로 올리면서 이 Milestone은 `[계획]`으로 되돌렸다.
|
||||
- 방향성 정리: 외부 연결 규약에서 proto-socket을 제거하고 GitHub webhook과 유사한 HTTP webhook/callback 모델로 정렬했다.
|
||||
- 완료 근거: `[input]`과 `[state]`는 `packages/contracts/notes/gito-control-plane.md`의 `IOP Agent Run Bridge` 섹션에서 transport-independent `AgentRunInput`과 run state mapping으로 정리했다. `[invoke]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log`의 PASS Roadmap Completion으로 확인했다. `[result]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log`의 PASS Roadmap Completion으로 확인했다. `[dirty-guard]`는 `services/core/internal/worker/runner.go`의 dirty workspace failure policy와 `TestRunnerRunOnceAgentRunFailsWhenGitEvidenceIsDirty` 통과로 확인했다. `[commit-push]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/03_commit_push_policy/complete.log`의 PASS Roadmap Completion으로 확인했다. `[revision-event]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/04+03_revision_scan_event/complete.log`의 PASS Roadmap Completion과 `TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState`, `TestScanOperationEventAllowsNullOperationID`, `cd services/core && go test ./...` 통과로 확인했다.
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[진행중]
|
||||
|
||||
## 목표
|
||||
|
||||
GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral ChangeRequest 인터페이스로 묶는다. 외부 연동은 GitHub webhook과 유사한 HTTP webhook/callback 모델로 맞추고, Webhook은 빠른 wakeup signal로 사용하되 최종 상태 판정은 Git revision/diff와 provider read API로 재검증한다.
|
||||
GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral ChangeRequest 인터페이스로 묶는다. 외부 연동은 GitHub webhook과 유사한 HTTP webhook/callback 모델로 맞추고, Webhook은 빠른 wakeup signal로 사용하되 최종 상태 판정은 Git revision/diff와 provider read API로 재검증한다. 현재 최우선 slice는 NomadCode가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 받을 수 있게 하는 것이다.
|
||||
|
||||
## Milestone 흐름
|
||||
|
||||
|
|
@ -14,6 +14,10 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change
|
|||
완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
|
||||
스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다.
|
||||
|
||||
- [진행중] Webhook Revision Reconcile
|
||||
- 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- 요약: NomadCode branch event wakeup 대응을 최우선으로 두고 provider webhook, outbound consumer webhook, polling, Git revision scan을 같은 변경 event로 정규화하고 재검증한다.
|
||||
|
||||
- [계획] Provider Adapter Foundation
|
||||
- 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md`
|
||||
- 요약: provider registry, credential ref, webhook intake, provider capability boundary를 만든다.
|
||||
|
|
@ -22,10 +26,6 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change
|
|||
- 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md`
|
||||
- 요약: GitHub PR, GitLab MR, Gitea PR을 공통 ChangeRequest action과 event로 표현한다.
|
||||
|
||||
- [계획] Webhook Revision Reconcile
|
||||
- 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- 요약: provider webhook, outbound consumer webhook, polling, Git revision scan을 같은 변경 event로 정규화하고 재검증한다.
|
||||
|
||||
## Phase 경계
|
||||
|
||||
- Git 자체 조작은 provider adapter가 아니라 platformless Git engine이 맡는다.
|
||||
|
|
@ -33,3 +33,4 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change
|
|||
- provider별 credential 원문은 tracked 파일에 저장하지 않고 credential ref로만 연결한다.
|
||||
- provider webhook만으로 상태를 확정하지 않고 Git revision 또는 provider read API로 확인한다.
|
||||
- 외부 consumer event delivery는 HTTP webhook/callback으로 제공하며 proto-socket을 외부 연결 규약이나 보조 transport로 제공하지 않는다.
|
||||
- NomadCode branch event webhook delivery는 ChangeRequest/PR/MR abstraction을 기다리지 않는 우선 slice로 처리한다.
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@
|
|||
|
||||
## 목표
|
||||
|
||||
provider webhook, outbound consumer webhook, provider polling, Git revision scan을 같은 변경 입력으로 정규화한다. Webhook은 빠른 wakeup signal이지만 최종 판정은 Git revision/diff 또는 provider read API로 재검증해 중복 적용과 누락을 줄인다. 외부 event delivery는 GitHub webhook과 유사한 HTTP callback 방식으로 제공하고, proto-socket은 외부 연결 규약에서 제외한다.
|
||||
NomadCode가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 받을 수 있게 하는 대응을 최우선으로 진행한다. 이후 provider webhook, outbound consumer webhook, provider polling, Git revision scan을 같은 변경 입력으로 정규화한다. Webhook은 빠른 wakeup signal이지만 최종 판정은 Git revision/diff 또는 provider read API로 재검증해 중복 적용과 누락을 줄인다. 외부 event delivery는 GitHub webhook과 유사한 HTTP callback 방식으로 제공하고, proto-socket은 외부 연결 규약에서 제외한다.
|
||||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[진행중]
|
||||
|
||||
## 승격 조건
|
||||
|
||||
|
|
@ -20,14 +20,25 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan
|
|||
## 구현 잠금
|
||||
|
||||
- 상태: 해제
|
||||
- SDD: 필요
|
||||
- SDD 문서: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- 잠금 해제 조건:
|
||||
- [x] SDD 잠금이 해제되어 있다.
|
||||
- [x] SDD 사용자 리뷰가 없거나 승인/해결되었다.
|
||||
- [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다.
|
||||
- [x] Evidence Map이 plan의 `Spec Targets`와 완료 시 `Spec Completion`으로 검증 가능하게 연결되어 있다.
|
||||
- 결정 필요: 없음
|
||||
- 해소 근거:
|
||||
- SDD 상태는 `[승인됨]`이고 SDD 잠금은 해제되어 있다.
|
||||
- `agent-contract/provided/gito-forgejo-branch-events-v1.md`의 NomadCode interop 확인 기준을 그대로 따른다.
|
||||
|
||||
## 범위
|
||||
|
||||
- webhook received event
|
||||
- branch updated event
|
||||
- NomadCode `branch.updated` consumer webhook wakeup interop
|
||||
- consumer webhook subscription과 HTTP delivery
|
||||
- delivery idempotency, signature, retry/backoff
|
||||
- branch updated event
|
||||
- webhook received event
|
||||
- provider polling fallback
|
||||
- revision mismatch conflict
|
||||
- idempotency key와 expected revision
|
||||
|
|
@ -35,6 +46,15 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan
|
|||
|
||||
## 기능
|
||||
|
||||
### Epic: [delivery] Webhook delivery
|
||||
|
||||
정규화된 event를 NomadCode 같은 외부 consumer가 제공한 HTTP endpoint로 전달한다.
|
||||
|
||||
- [ ] [subscription] consumer endpoint, event type, repo/branch filter, `secret_ref`를 등록하는 형식을 정의한다.
|
||||
- [ ] [deliver] `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. 검증: fake consumer endpoint가 delivery id와 payload를 수신한다.
|
||||
- [ ] [nomadcode-wakeup] NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다. 검증: `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`
|
||||
- [ ] [retry] delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다.
|
||||
|
||||
### Epic: [sources] Change sources
|
||||
|
||||
여러 입력을 하나의 change event model로 정규화한다.
|
||||
|
|
@ -43,14 +63,6 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan
|
|||
- [ ] [poll-source] provider polling fallback shape를 정의한다.
|
||||
- [ ] [git-source] Git revision scan event와 provider event를 같은 identity로 연결한다.
|
||||
|
||||
### Epic: [delivery] Webhook delivery
|
||||
|
||||
정규화된 event를 외부 consumer가 제공한 HTTP endpoint로 전달한다.
|
||||
|
||||
- [ ] [subscription] consumer endpoint, event type, repo/branch filter, `secret_ref`를 등록하는 형식을 정의한다.
|
||||
- [ ] [deliver] `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. 검증: fake consumer endpoint가 delivery id와 payload를 수신한다.
|
||||
- [ ] [retry] delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다.
|
||||
|
||||
### Epic: [reconcile] Reconcile policy
|
||||
|
||||
변경 적용 전 재검증과 conflict 처리를 수행한다.
|
||||
|
|
@ -79,8 +91,8 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan
|
|||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `services/core/internal/events/`, `services/core/internal/provider/`, `services/core/internal/gitengine/`, `services/core/internal/worker/`
|
||||
- 표준선(선택): webhook은 빠른 신호이고 확정은 Git revision/diff 또는 provider read API로 수행한다. 외부 event delivery는 HTTP webhook/callback이며 proto-socket은 내부 control/runtime 규약으로만 남긴다.
|
||||
- 선행 작업: Change Request Abstraction
|
||||
- 후속 작업: Flutter Control Surface Baseline
|
||||
- 확인 필요: 없음
|
||||
- 관련 경로: `services/core/internal/controlplane/`, `services/core/internal/events/`, `services/core/internal/provider/`, `services/core/internal/gitengine/`, `services/core/internal/worker/`, `agent-contract/provided/gito-forgejo-branch-events-v1.md`
|
||||
- 표준선(선택): webhook은 빠른 신호이고 확정은 Git revision/diff 또는 provider read API로 수행한다. 외부 event delivery는 HTTP webhook/callback이며 proto-socket은 내부 control/runtime 규약으로만 남긴다. NomadCode 전용 필수 필드는 base payload에 넣지 않는다.
|
||||
- 선행 작업: Forgejo Branch Event MVP와 `gito.forgejo-branch-events.v1` 계약
|
||||
- 후속 작업: Change Request Abstraction, Flutter Control Surface Baseline
|
||||
- 확인 필요: SDD 작성/승인 후 구현 계획 생성
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
# SDD: Webhook Revision Reconcile
|
||||
|
||||
## 위치
|
||||
|
||||
- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- Phase: `agent-roadmap/phase/provider-change-request-integration/PHASE.md`
|
||||
|
||||
## 상태
|
||||
|
||||
[승인됨]
|
||||
|
||||
## SDD 잠금
|
||||
|
||||
- 상태: 해제
|
||||
- 사용자 리뷰: 없음
|
||||
- 잠금 항목:
|
||||
- 없음
|
||||
|
||||
## 문제 / 비목표
|
||||
|
||||
- 문제: NomadCode가 Gito의 `branch.updated` event를 generic HTTP webhook wakeup으로 받을 수 있어야 한다. 동시에 webhook delivery는 최종 source of truth가 아니며, delivery idempotency, retry, signature, revision 재검증 경계를 구현 전에 고정해야 한다.
|
||||
- 비목표:
|
||||
- NomadCode 전용 필수 payload field를 base schema에 추가하지 않는다.
|
||||
- provider별 모든 webhook payload를 이 Milestone에서 지원하지 않는다.
|
||||
- webhook delivery 성공을 NomadCode 또는 다른 consumer의 상태 적용 완료 증거로 취급하지 않는다.
|
||||
- proto-socket, gRPC, Redis Streams fanout을 external consumer delivery 경로로 추가하지 않는다.
|
||||
- UI conflict resolution 화면은 이 Milestone에서 다루지 않는다.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
| 영역 | 기준 | 메모 |
|
||||
|------|------|------|
|
||||
| Roadmap | `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` | Milestone 목표, 범위, 기능 Task 완료 기준 |
|
||||
| Code | `services/core/internal/controlplane/`, `services/core/internal/events/`, `services/core/internal/worker/` | REST subscription/delivery surface, normalized event, durable delivery/outbox 구현 기준 |
|
||||
| External Provider | Forgejo push webhook, Gito outbound HTTP webhook consumer | Forgejo push는 wakeup source, consumer delivery는 `branch.updated` wakeup signal |
|
||||
| User Decision | 없음 | 기존 `gito.forgejo-branch-events.v1` 계약을 따른다 |
|
||||
|
||||
## State Machine
|
||||
|
||||
| 상태 | 진입 조건 | 다음 상태 | 근거 |
|
||||
|------|-----------|-----------|------|
|
||||
| webhook-received | Forgejo push callback이 유효 payload로 수신된다 | event-recorded 또는 ignored | `POST /callbacks/forgejo/push`, watch match 여부 |
|
||||
| ignored | event가 unsupported 또는 unwatched branch에 해당한다 | 없음 | response `accepted=true`, `matched=false` |
|
||||
| event-recorded | watched branch의 before/after revision이 normalized `branch.updated`로 기록된다 | delivery-pending | durable event/outbox record |
|
||||
| delivery-pending | matching webhook subscription이 존재한다 | delivery-sending | subscription event/repo/branch filter |
|
||||
| delivery-sending | worker가 pending delivery를 HTTP POST로 전송한다 | delivery-succeeded 또는 delivery-retryable 또는 delivery-failed | target HTTP response, timeout, signature config |
|
||||
| delivery-succeeded | consumer endpoint가 2xx를 반환한다 | 없음 | delivery attempt record |
|
||||
| delivery-retryable | consumer endpoint가 429 또는 5xx를 반환하거나 transient network failure가 발생한다 | delivery-pending 또는 delivery-failed | retry/backoff policy |
|
||||
| delivery-failed | consumer endpoint가 non-retryable response를 반환하거나 retry budget이 소진된다 | 없음 | delivery attempt state |
|
||||
| reconcile-conflict | expected revision이 현재 source of truth와 맞지 않는다 | dry-run-result 또는 없음 | Git revision scan 또는 provider read API |
|
||||
| applied | idempotency key로 중복 적용이 차단되고 change apply가 성공한다 | 없음 | apply result and idempotency record |
|
||||
|
||||
## Interface Contract
|
||||
|
||||
- 계약 원문: `agent-contract/provided/gito-forgejo-branch-events-v1.md`
|
||||
- 입력:
|
||||
- Forgejo callback: `POST /callbacks/forgejo/push`
|
||||
- branch watch: `POST /api/listeners/branches`
|
||||
- webhook subscription: `POST /api/webhook-subscriptions`
|
||||
- subscription filter: `events`, `repo_id`, `branch`
|
||||
- signing reference: `secret_ref`
|
||||
- revision identity: `repo_id`, `branch`, `before`, `after`
|
||||
- 출력:
|
||||
- normalized event: `branch.updated`
|
||||
- webhook headers: `X-Gito-Event`, `X-Gito-Delivery`, optional `X-Gito-Signature`
|
||||
- consumer payload: `id`, `type`, `provider`, `delivery_id`, `repo_id`, `branch`, `before`, `after`, `changed_files`, `observed_at`, `created_at`
|
||||
- delivery attempt state: pending, sending, succeeded, retryable, failed
|
||||
- 금지:
|
||||
- raw secret, token, password, credential 값을 tracked docs, event payload, log에 남기지 않는다.
|
||||
- NomadCode 전용 필수 field를 base `branch.updated` payload에 추가하지 않는다.
|
||||
- provider callback이 NomadCode, Plane, roadmap source of truth를 직접 변경하지 않는다.
|
||||
- consumer webhook delivery를 최종 상태 증거로 취급하지 않는다.
|
||||
- external consumer delivery에 proto-socket을 요구하지 않는다.
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
| ID | Milestone Task | Given | When | Then |
|
||||
|----|----------------|-------|------|------|
|
||||
| S01 | `subscription` | consumer endpoint, event type, repo/branch filter, `secret_ref`가 입력된다 | webhook subscription을 등록한다 | subscription id와 filter가 저장되고 raw secret은 저장되지 않는다 |
|
||||
| S02 | `deliver` | watched `nomadcode/develop` branch에서 `branch.updated` event가 기록되고 matching subscription이 있다 | delivery worker가 event를 처리한다 | fake consumer가 signed HTTP POST, delivery id, base payload를 수신한다 |
|
||||
| S03 | `nomadcode-wakeup` | NomadCode dev consumer가 generic `branch.updated` endpoint를 제공한다 | Gito가 matching delivery를 전송한다 | consumer가 NomadCode 전용 필수 field 없이 wakeup으로 매핑할 수 있다 |
|
||||
| S04 | `retry` | consumer가 429 또는 5xx를 반환한다 | delivery attempt가 기록된다 | retry/backoff 상태가 PostgreSQL 기준으로 남고 delivery idempotency가 유지된다 |
|
||||
| S05 | `webhook-source` | Forgejo push callback이 들어온다 | payload가 watch와 match된다 | `provider.webhook.received` 또는 equivalent wakeup source event와 normalized branch event가 추적 가능하다 |
|
||||
| S06 | `poll-source` | provider webhook이 누락되거나 지연된다 | polling fallback shape를 사용한다 | 같은 revision identity로 change source를 표현할 수 있다 |
|
||||
| S07 | `git-source` | Git revision scan이 before/after revision을 알고 있다 | provider event와 scan result를 reconcile한다 | 동일 repo/branch/revision identity로 중복 이벤트를 피한다 |
|
||||
| S08 | `expected-rev` | expected revision과 현재 branch revision이 다르다 | reconcile을 시도한다 | apply 없이 conflict로 멈춘다 |
|
||||
| S09 | `dry-run` | event와 현재 Git/provider 상태가 주어진다 | dry-run을 실행한다 | drift, conflict, proposal이 관찰 가능한 결과로 반환된다 |
|
||||
| S10 | `apply` | 같은 event 또는 idempotency key가 반복 처리된다 | apply를 두 번 요청한다 | side effect는 한 번만 실행되고 중복은 idempotent 결과로 수렴한다 |
|
||||
|
||||
## Evidence Map
|
||||
|
||||
| Scenario | Required Evidence | `agent-task` 연결 | `Spec Completion` 기대 |
|
||||
|----------|-------------------|------------------|---------------------------|
|
||||
| S01 | subscription create/list test, secret value grep | `agent-task/m-webhook-revision-reconcile/...` | `subscription` task가 contract filter와 `secret_ref` storage boundary를 만족했다 |
|
||||
| S02 | `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1` | `agent-task/m-webhook-revision-reconcile/...` | `deliver` task가 fake consumer delivery id, headers, payload 수신을 증명했다 |
|
||||
| S03 | NomadCode interop smoke 또는 fake NomadCode consumer test | `agent-task/m-webhook-revision-reconcile/...` | `nomadcode-wakeup` task가 generic `branch.updated`만으로 wakeup mapping을 증명했다 |
|
||||
| S04 | delivery retry/backoff unit or integration test with PostgreSQL store | `agent-task/m-webhook-revision-reconcile/...` | `retry` task가 retryable/non-retryable status와 delivery idempotency를 증명했다 |
|
||||
| S05 | Forgejo callback test and event inspection evidence | `agent-task/m-webhook-revision-reconcile/...` | `webhook-source` task가 provider callback을 wakeup source로 저장/추적함을 증명했다 |
|
||||
| S06 | polling fallback shape contract or unit test | `agent-task/m-webhook-revision-reconcile/...` | `poll-source` task가 polling source를 동일 change model로 표현함을 증명했다 |
|
||||
| S07 | revision identity dedupe/reconcile test | `agent-task/m-webhook-revision-reconcile/...` | `git-source` task가 Git revision scan과 provider event identity 연결을 증명했다 |
|
||||
| S08 | expected revision mismatch test | `agent-task/m-webhook-revision-reconcile/...` | `expected-rev` task가 mismatch conflict stop을 증명했다 |
|
||||
| S09 | reconcile dry-run test with drift/conflict/proposal output | `agent-task/m-webhook-revision-reconcile/...` | `dry-run` task가 apply 없이 observable proposal 결과를 증명했다 |
|
||||
| S10 | idempotent apply test with repeated event/key | `agent-task/m-webhook-revision-reconcile/...` | `apply` task가 repeated processing side effect once를 증명했다 |
|
||||
|
||||
## Cross-repo Dependencies
|
||||
|
||||
- NomadCode는 external consumer로 `branch.updated` delivery를 수신한다. 현재 SDD는 `.agent-roadmap-sync/locks.yaml` entry를 요구하지 않는다.
|
||||
- 계약 기준은 `agent-contract/provided/gito-forgejo-branch-events-v1.md`이다.
|
||||
|
||||
<!-- 필요한 경우:
|
||||
- `<project>:agent-roadmap/phase/<phase>/milestones/<milestone>.md`
|
||||
- `.agent-roadmap-sync/locks.yaml` entry: `<id>`
|
||||
-->
|
||||
|
||||
## Drift Check
|
||||
|
||||
- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다.
|
||||
- [x] Evidence Map이 plan/code-review/complete.log에서 검증 가능하다.
|
||||
- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다.
|
||||
- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다.
|
||||
|
||||
## 사용자 리뷰 이력
|
||||
|
||||
- 없음
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 표준선: webhook은 빠른 wakeup signal이며 최종 판정은 Git revision/diff 또는 provider read API로 수행한다. 외부 event delivery는 HTTP webhook/callback이고, proto-socket은 내부 control/runtime 규약으로만 남긴다.
|
||||
- 후속 SDD: 없음
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<!-- task=m-webhook-revision-reconcile/01_http_delivery 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-17
|
||||
task=m-webhook-revision-reconcile/01_http_delivery, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- Task ids:
|
||||
- `deliver`: `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다.
|
||||
- `nomadcode-wakeup`: NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Spec Targets
|
||||
|
||||
- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- Acceptance scenarios:
|
||||
- `S02`: task=`deliver`; evidence=`cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`
|
||||
- `S03`: task=`nomadcode-wakeup`; evidence=`fake NomadCode consumer test receives generic branch.updated without NomadCode-only required fields`
|
||||
- Completion mode: spec-check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/01_http_delivery/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] HTTP Branch Updated Delivery | [ ] |
|
||||
| [API-2] NomadCode Wakeup Compatibility | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Runtime에 matching webhook subscription을 찾아 HTTP POST delivery를 수행하는 경로를 추가하고, `deliver` 검증인 `TestWebhookDeliveryBranchUpdated`를 작성한다.
|
||||
- [ ] secret resolver 또는 package-private test hook으로 `secret_ref`를 raw secret 저장 없이 HMAC 서명에 연결하고, 응답/로그에 secret 값이 노출되지 않게 한다.
|
||||
- [ ] fake NomadCode consumer가 base `branch.updated` payload만으로 wakeup mapping 가능한지 검증하고, NomadCode 전용 필수 field가 없음을 assertion한다.
|
||||
- [ ] `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`를 실행한다.
|
||||
- [ ] `cd services/core && go test ./...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
|
||||
- [ ] `.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-webhook-revision-reconcile/01_http_delivery/`를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/01_http_delivery/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-webhook-revision-reconcile/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Matching subscription filter가 event/repo/branch를 정확히 적용하는지 확인한다.
|
||||
- Signature가 raw `secret_ref` 저장이나 로그 노출 없이 resolved secret으로만 계산되는지 확인한다.
|
||||
- Payload가 contract base schema이고 NomadCode 전용 필수 field를 추가하지 않았는지 확인한다.
|
||||
- `TestWebhookDeliveryBranchUpdated` 출력이 실제 fake consumer delivery를 증명하는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
|
||||
### API-1 중간 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1
|
||||
(output)
|
||||
|
||||
$ cd services/core && go test ./...
|
||||
(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.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## Sections and Ownership
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/finalization |
|
||||
| Roadmap Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS |
|
||||
| Spec Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS |
|
||||
| 구현 항목별 완료 여부 | Implementing agent checks boxes only | Item names stay fixed |
|
||||
| 구현 체크리스트 | Implementing agent checks boxes only | Text/order stay fixed |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders with actual content |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected SDD/Milestone lock decision blocks |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Reviewer focus |
|
||||
| 검증 결과 | Implementing agent | Fill command output only |
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
<!-- task=m-webhook-revision-reconcile/01_http_delivery plan=0 tag=API -->
|
||||
|
||||
# Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막으면 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행 명령, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service 차단, 일반 범위 변경, 검증 공백은 사용자 리뷰가 아니라 검증 결과나 일반 follow-up으로 남긴다.
|
||||
|
||||
## 배경
|
||||
|
||||
`subscription` 등록 표면은 현재 작업 패스에서 `/api/webhook-subscriptions`와 in-memory registry로 추가되었다. 남은 delivery slice는 watched `branch.updated` record가 matching subscription으로 HTTP POST되는 경로와 NomadCode-style generic wakeup 검증이다. 이 plan은 PostgreSQL retry/outbox 상태를 만들지 않고, deterministic fake consumer test로 S02/S03만 완료 대상으로 삼는다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 직접 사용자에게 묻지 않는다. 선택된 SDD 결정 또는 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록하고, code-review가 검증해 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- Task ids:
|
||||
- `deliver`: `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다.
|
||||
- `nomadcode-wakeup`: NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Spec Targets
|
||||
|
||||
- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- Acceptance scenarios:
|
||||
- `S02`: task=`deliver`; evidence=`cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`
|
||||
- `S03`: task=`nomadcode-wakeup`; evidence=`fake NomadCode consumer test receives generic branch.updated without NomadCode-only required fields`
|
||||
- Completion mode: spec-check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/private/rules.md`
|
||||
- `agent-ops/rules/common/rules-roadmap.md`
|
||||
- `agent-ops/skills/common/router.md`
|
||||
- `agent-ops/skills/common/plan/SKILL.md`
|
||||
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/provider-change-request-integration/PHASE.md`
|
||||
- `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- `agent-contract/index.md`
|
||||
- `agent-contract/provided/gito-forgejo-branch-events-v1.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/core-smoke.md`
|
||||
- `agent-test/local/contracts-smoke.md`
|
||||
- `agent-test/local/workspace-ops-smoke.md`
|
||||
- `agent-ops/rules/project/domain/core/rules.md`
|
||||
- `agent-ops/rules/project/domain/contracts/rules.md`
|
||||
- `services/core/go.mod`
|
||||
- `services/core/cmd/server/main.go`
|
||||
- `services/core/cmd/worker/main.go`
|
||||
- `services/core/internal/core/model.go`
|
||||
- `services/core/internal/controlplane/router.go`
|
||||
- `services/core/internal/controlplane/router_test.go`
|
||||
- `services/core/internal/controlplane/runtime.go`
|
||||
- `services/core/internal/controlplane/runtime_test.go`
|
||||
- `services/core/internal/controlplane/binary_interop_test.go`
|
||||
- `services/core/internal/events/events.go`
|
||||
- `services/core/internal/storage/storage.go`
|
||||
- `services/core/internal/storage/storage_test.go`
|
||||
- `services/core/internal/storage/postgres.go`
|
||||
- `services/core/internal/storage/postgres_test.go`
|
||||
- `services/core/internal/worker/http_invoker.go`
|
||||
- `services/core/internal/worker/http_invoker_test.go`
|
||||
- `services/core/internal/worker/runner.go`
|
||||
- `services/core/migrations/00001_initial.sql`
|
||||
- `.gitignore`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- test_env: `local`.
|
||||
- 적용 규칙: `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
|
||||
- 적용 명령: core behavior change는 `cd services/core && go test ./...`가 필수다.
|
||||
- SDD S02 지정 검증은 `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`이다.
|
||||
- `contracts-smoke`, `workspace-ops-smoke`도 읽었지만 이 plan의 구현 파일은 `services/core/**`와 `agent-task/**`다. 계약 문서 자체는 수정하지 않는다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `subscription` create/list는 `TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef`와 `TestWebhookSubscriptionValidatesTargetAndEvents`로 현재 커버된다.
|
||||
- `branch.updated` event 생성은 `TestForgejoPushCreatesWatchedBranchEvent`와 runtime tests로 커버된다.
|
||||
- HTTP consumer delivery, `X-Gito-Event`, `X-Gito-Delivery`, signature, NomadCode-neutral payload는 아직 테스트가 없다. 이 plan에서 `TestWebhookDeliveryBranchUpdated`를 추가한다.
|
||||
- Retry/backoff/failed state는 이 plan에서 제외하고 `02+01_retry_delivery_store`가 다룬다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- renamed/removed symbol: none.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split policy 평가 결과, delivery dispatch와 retry persistence는 상태 저장/스키마 위험이 달라 분리한다.
|
||||
- shared task group: `agent-task/m-webhook-revision-reconcile`.
|
||||
- `01_http_delivery`: no runtime predecessor. HTTP dispatch와 NomadCode-style fake consumer 검증.
|
||||
- `02+01_retry_delivery_store`: depends on `01_http_delivery`; predecessor `01`은 아직 `complete.log`가 없으므로 이 plan 완료 전 구현하지 않는다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- `subscription`은 현재 working tree의 small-task 구현으로 처리되었으므로 이 plan의 Roadmap Targets에 넣지 않는다.
|
||||
- PostgreSQL delivery attempts, retry/backoff, failed state, worker scheduling은 `02+01_retry_delivery_store`로 제외한다.
|
||||
- provider polling, reconcile dry-run/apply, Git source dedupe는 delivery epic 밖 작업이므로 제외한다.
|
||||
- external proto-socket consumer delivery는 SDD 비목표이므로 추가하지 않는다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- build/review: `local-G05`. Public REST/event behavior지만 변경 범위가 controlplane runtime/router/test로 좁고 `httptest`로 deterministic 검증 가능하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Runtime에 matching webhook subscription을 찾아 HTTP POST delivery를 수행하는 경로를 추가하고, `deliver` 검증인 `TestWebhookDeliveryBranchUpdated`를 작성한다.
|
||||
- [ ] secret resolver 또는 package-private test hook으로 `secret_ref`를 raw secret 저장 없이 HMAC 서명에 연결하고, 응답/로그에 secret 값이 노출되지 않게 한다.
|
||||
- [ ] fake NomadCode consumer가 base `branch.updated` payload만으로 wakeup mapping 가능한지 검증하고, NomadCode 전용 필수 field가 없음을 assertion한다.
|
||||
- [ ] `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`를 실행한다.
|
||||
- [ ] `cd services/core && go test ./...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## [API-1] HTTP Branch Updated Delivery
|
||||
|
||||
### 문제
|
||||
|
||||
`router.go:47`에 `/api/webhook-subscriptions` 등록 endpoint가 있고 `runtime.go:155`에 subscription registry가 있지만, `runtime.go:232`의 `HandleRevision`은 matching subscription으로 HTTP delivery를 보내지 않는다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// services/core/internal/controlplane/runtime.go:252
|
||||
r.mu.Lock()
|
||||
matched := r.matchesLocked(provider, revision)
|
||||
if matched {
|
||||
r.records = append(r.records, record)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
if !matched {
|
||||
return record, false, nil
|
||||
}
|
||||
if r.broadcaster != nil {
|
||||
if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
|
||||
return record, true, err
|
||||
}
|
||||
}
|
||||
return record, true, nil
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
`Runtime`에 webhook delivery dependencies를 추가한다. 기본 HTTP client는 timeout이 있는 `http.Client`를 사용하고, 테스트는 package-private setter 또는 constructor option으로 fake secret resolver/client를 주입한다. Matching rule은 subscription `events`가 record type을 포함하고, `repo_id`/`branch`가 비어 있거나 record와 같을 때만 허용한다. Delivery body는 계약의 base payload만 사용하고 header는 `X-Gito-Event`, `X-Gito-Delivery`, `Content-Type: application/json`, resolved secret이 있을 때 `X-Gito-Signature`를 붙인다.
|
||||
|
||||
After shape:
|
||||
|
||||
```go
|
||||
// services/core/internal/controlplane/runtime.go
|
||||
type WebhookSecretResolver interface {
|
||||
ResolveWebhookSecret(ctx context.Context, secretRef string) (string, bool, error)
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
// existing fields...
|
||||
webhookClient *http.Client
|
||||
secretResolver WebhookSecretResolver
|
||||
}
|
||||
|
||||
func (r *Runtime) deliverWebhooks(ctx context.Context, record EventRecord) error {
|
||||
for _, subscription := range r.matchingWebhookSubscriptions(record) {
|
||||
if err := r.postWebhook(ctx, subscription, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Call `deliverWebhooks` after successful branch watch match and before returning success. If delivery fails in this first slice, return an error so the Forgejo callback surfaces `500` and durable retry remains for the dependent plan.
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/controlplane/runtime.go`: add HTTP client/secret resolver dependency, matching helper, payload builder, HMAC signer, synchronous delivery call.
|
||||
- [ ] `services/core/internal/controlplane/router.go`: no route shape change unless a package-private test constructor is needed.
|
||||
- [ ] `services/core/internal/controlplane/runtime_test.go`: add fake consumer/secret resolver test.
|
||||
- [ ] `services/core/internal/controlplane/router_test.go`: update only if HTTP-route end-to-end coverage is easier here.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
Write `TestWebhookDeliveryBranchUpdated` in `services/core/internal/controlplane/runtime_test.go` or `router_test.go`. Assertions: fake consumer receives one POST, headers include `X-Gito-Event=branch.updated`, non-empty `X-Gito-Delivery`, optional valid `X-Gito-Signature`, body contains `id`, `type`, `provider`, `delivery_id`, `repo_id`, `branch`, `before`, `after`, `changed_files`, `observed_at`, `created_at`.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1
|
||||
```
|
||||
|
||||
Expected: test exits 0 and fake consumer assertions pass.
|
||||
|
||||
## [API-2] NomadCode Wakeup Compatibility
|
||||
|
||||
### 문제
|
||||
|
||||
SDD S03 requires a NomadCode dev consumer to map generic `branch.updated` without NomadCode-only required fields. Current delivery code has no fake NomadCode consumer test, and the payload builder does not yet exist.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// services/core/internal/controlplane/runtime.go:594
|
||||
type outboxBroadcaster struct {
|
||||
store storage.OperationEventStore
|
||||
}
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
Keep the delivery payload consumer-neutral. In `TestWebhookDeliveryBranchUpdated`, implement the fake consumer as a NomadCode-style wakeup handler: it accepts only `type`, `repo_id`, `branch`, `before`, `after`, `changed_files`, timestamps, and delivery headers. Add a negative assertion that no `nomadcode_*`, `plane_*`, or required consumer-specific field appears in the top-level payload.
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/controlplane/runtime.go`: payload builder must not add consumer-specific fields.
|
||||
- [ ] `services/core/internal/controlplane/runtime_test.go`: fake consumer maps generic event to wakeup and asserts no consumer-specific required fields.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
Use the same `TestWebhookDeliveryBranchUpdated` so S02 and S03 share the actual interop path. Do not write a separate mock that bypasses delivery.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1
|
||||
```
|
||||
|
||||
Expected: test exits 0 and the fake consumer wakeup mapping is observed.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `services/core/internal/controlplane/runtime.go` | API-1, API-2 |
|
||||
| `services/core/internal/controlplane/runtime_test.go` | API-1, API-2 |
|
||||
| `services/core/internal/controlplane/router.go` | API-1 if constructor wiring is needed |
|
||||
| `services/core/internal/controlplane/router_test.go` | API-1 if route-level test is chosen |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1
|
||||
cd services/core && go test ./...
|
||||
```
|
||||
|
||||
Expected: both commands exit 0. Cached output is acceptable for the full package smoke; the SDD-specific command uses `-count=1`.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<!-- task=m-webhook-revision-reconcile/02+01_retry_delivery_store 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-17
|
||||
task=m-webhook-revision-reconcile/02+01_retry_delivery_store, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- Task ids:
|
||||
- `retry`: delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Spec Targets
|
||||
|
||||
- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- Acceptance scenarios:
|
||||
- `S04`: task=`retry`; evidence=`delivery retry/backoff unit or integration test with PostgreSQL store`
|
||||
- Completion mode: spec-check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` → `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/02+01_retry_delivery_store/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Durable Webhook Delivery Store | [ ] |
|
||||
| [API-2] Retry Classification And Backoff | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] 선행 `01_http_delivery`의 `complete.log` 존재를 확인하고 경로를 `검증 결과`에 기록한다.
|
||||
- [ ] outbound webhook delivery 상태 모델과 store interface를 추가한다.
|
||||
- [ ] PostgreSQL migration과 `PgStore` 구현으로 pending/sending/succeeded/retryable/failed 상태, attempt count, next_attempt_at, last_error/status_code를 기록한다.
|
||||
- [ ] delivery response classifier를 추가해 2xx는 succeeded, 429/5xx/transport는 retryable with backoff, 400/401/403/404/410은 failed로 기록한다.
|
||||
- [ ] 같은 event/subscription 조합이 중복 enqueue되어도 delivery idempotency가 유지되는 테스트를 작성한다.
|
||||
- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1`를 실행한다.
|
||||
- [ ] `cd services/core && go test ./...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다.
|
||||
- [ ] `.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-webhook-revision-reconcile/02+01_retry_delivery_store/`를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/02+01_retry_delivery_store/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-webhook-revision-reconcile/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 연결 대상: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `provider_deliveries`와 outbound `webhook_deliveries` 책임이 섞이지 않았는지 확인한다.
|
||||
- Retryable/non-retryable classification이 contract와 SDD 상태 머신을 따르는지 확인한다.
|
||||
- Duplicate event/subscription enqueue가 idempotent인지 확인한다.
|
||||
- PostgreSQL migration snippets와 integration test evidence가 실제 schema를 반영하는지 확인한다.
|
||||
- `01_http_delivery` predecessor completion evidence가 기록되어 있는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
|
||||
### API-1 중간 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -run TestPostgresWebhookDeliveryStoreRecordsRetryState ./internal/storage -v -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -run TestWebhookDeliveryRetryBackoffRecordsState ./internal/controlplane -v -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1
|
||||
(output)
|
||||
|
||||
$ cd services/core && go test ./...
|
||||
(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.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## Sections and Ownership
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/finalization |
|
||||
| Roadmap Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS |
|
||||
| Spec Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS |
|
||||
| 구현 항목별 완료 여부 | Implementing agent checks boxes only | Item names stay fixed |
|
||||
| 구현 체크리스트 | Implementing agent checks boxes only | Text/order stay fixed |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders with actual content |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected SDD/Milestone lock decision blocks |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Reviewer focus |
|
||||
| 검증 결과 | Implementing agent | Fill command output only |
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
<!-- task=m-webhook-revision-reconcile/02+01_retry_delivery_store plan=0 tag=API -->
|
||||
|
||||
# Plan - API
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 이 task directory 이름의 `+01`은 runtime dependency다. `agent-task/m-webhook-revision-reconcile/01_http_delivery/complete.log` 또는 matching archive `complete.log`가 없으면 구현을 시작하지 말고 review stub에 dependency blocker를 기록한다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막으면 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행 명령, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.
|
||||
|
||||
## 배경
|
||||
|
||||
`retry`는 단순 HTTP dispatch가 아니라 delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 남겨야 하는 storage-backed task다. 기존 `provider_deliveries`는 Forgejo/provider callback dedupe용이며 outbound consumer webhook attempt 상태를 표현하지 않는다. 이 plan은 `01_http_delivery`가 HTTP delivery path를 만든 뒤 그 경로를 durable queue/attempt state로 확장한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 직접 사용자에게 묻지 않는다. 선택된 SDD 결정 또는 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록하고, code-review가 검증해 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- Task ids:
|
||||
- `retry`: delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Spec Targets
|
||||
|
||||
- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- Acceptance scenarios:
|
||||
- `S04`: task=`retry`; evidence=`delivery retry/backoff unit or integration test with PostgreSQL store`
|
||||
- Completion mode: spec-check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/private/rules.md`
|
||||
- `agent-ops/rules/common/rules-roadmap.md`
|
||||
- `agent-ops/skills/common/router.md`
|
||||
- `agent-ops/skills/common/plan/SKILL.md`
|
||||
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/provider-change-request-integration/PHASE.md`
|
||||
- `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
|
||||
- `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
|
||||
- `agent-contract/index.md`
|
||||
- `agent-contract/provided/gito-forgejo-branch-events-v1.md`
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/core-smoke.md`
|
||||
- `agent-test/local/contracts-smoke.md`
|
||||
- `agent-test/local/workspace-ops-smoke.md`
|
||||
- `agent-ops/rules/project/domain/core/rules.md`
|
||||
- `agent-ops/rules/project/domain/contracts/rules.md`
|
||||
- `services/core/go.mod`
|
||||
- `services/core/cmd/server/main.go`
|
||||
- `services/core/cmd/worker/main.go`
|
||||
- `services/core/internal/core/model.go`
|
||||
- `services/core/internal/controlplane/router.go`
|
||||
- `services/core/internal/controlplane/router_test.go`
|
||||
- `services/core/internal/controlplane/runtime.go`
|
||||
- `services/core/internal/controlplane/runtime_test.go`
|
||||
- `services/core/internal/events/events.go`
|
||||
- `services/core/internal/storage/storage.go`
|
||||
- `services/core/internal/storage/storage_test.go`
|
||||
- `services/core/internal/storage/postgres.go`
|
||||
- `services/core/internal/storage/postgres_test.go`
|
||||
- `services/core/internal/worker/runner.go`
|
||||
- `services/core/migrations/00001_initial.sql`
|
||||
- `.gitignore`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- test_env: `local`.
|
||||
- 적용 규칙: `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다.
|
||||
- 적용 명령: core behavior change는 `cd services/core && go test ./...`가 필수다.
|
||||
- PostgreSQL-dependent tests may skip when `GITO_TEST_DATABASE_URL` is unset; skipped integration prerequisite must be reported with residual risk.
|
||||
- 이 plan은 migration/storage 변경이 있으므로 targeted store tests와 full core smoke를 모두 둔다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `ProviderDeliveryStore.RecordOnce`는 provider callback dedupe만 커버한다.
|
||||
- Outbound webhook delivery attempt status, retryable/non-retryable classification, backoff time, retry budget, failed terminal state는 아직 모델/스토어/테스트가 없다.
|
||||
- `operation_events` outbox pending/published는 generic event publish 용도이며 per-subscription delivery attempt 상태를 대체하지 않는다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- renamed/removed symbol: none. 추가 interface가 생기면 `storage.Store`, `storage.Boundary`, `storage.PgStore`, test fakes 전체를 갱신해야 한다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split policy 평가 결과, 이 작업은 storage/migration/attempt state를 다루므로 `01_http_delivery`와 분리했다.
|
||||
- task group: `agent-task/m-webhook-revision-reconcile`.
|
||||
- current subtask: `02+01_retry_delivery_store`.
|
||||
- predecessor `01`: missing in active path now. Required completion file before implementation: `agent-task/m-webhook-revision-reconcile/01_http_delivery/complete.log`, or matching archived path `agent-task/archive/*/*/m-webhook-revision-reconcile/01_http_delivery/complete.log`.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- HTTP payload/header construction itself는 `01_http_delivery` 책임이다. 이 task는 successful/failed HTTP 결과를 durable delivery state로 남기고 retry scheduling이 가능한 store boundary를 만든다.
|
||||
- Real background worker loop wiring은 최소 범위로만 포함한다. 별도 daemon scheduling이 필요해지면 follow-up으로 남기되, S04 증거는 store-level retry/backoff test로 닫는다.
|
||||
- Provider polling/reconcile dry-run/apply는 delivery epic 밖이므로 제외한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- build/review: `cloud-G06`. PostgreSQL schema, storage interface, state machine, retry semantics가 얽혀 있고 review가 migration/test evidence를 함께 판단해야 한다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
1. `01_http_delivery` PASS 후 `complete.log`가 존재하는지 확인한다.
|
||||
2. Outbound delivery 상태 모델과 storage interface를 추가한다.
|
||||
3. Migration/Postgres store를 구현한다.
|
||||
4. Runtime delivery path를 store-backed enqueue/attempt recording으로 연결한다.
|
||||
5. retry/backoff tests와 full smoke를 실행한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] 선행 `01_http_delivery`의 `complete.log` 존재를 확인하고 경로를 `검증 결과`에 기록한다.
|
||||
- [ ] outbound webhook delivery 상태 모델과 store interface를 추가한다.
|
||||
- [ ] PostgreSQL migration과 `PgStore` 구현으로 pending/sending/succeeded/retryable/failed 상태, attempt count, next_attempt_at, last_error/status_code를 기록한다.
|
||||
- [ ] delivery response classifier를 추가해 2xx는 succeeded, 429/5xx/transport는 retryable with backoff, 400/401/403/404/410은 failed로 기록한다.
|
||||
- [ ] 같은 event/subscription 조합이 중복 enqueue되어도 delivery idempotency가 유지되는 테스트를 작성한다.
|
||||
- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1`를 실행한다.
|
||||
- [ ] `cd services/core && go test ./...`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## [API-1] Durable Webhook Delivery Store
|
||||
|
||||
### 문제
|
||||
|
||||
`storage.go:13`의 `Store`에는 outbound webhook delivery store가 없다. `postgres.go:851`의 `ProviderDeliveryStore`는 provider callback dedupe만 기록하며 retry status, attempt count, next attempt time, response status를 저장하지 않는다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// services/core/internal/storage/storage.go:13
|
||||
type Store interface {
|
||||
Ping(ctx context.Context) error
|
||||
Repos() RepoStore
|
||||
WorkspaceLeases() WorkspaceLeaseStore
|
||||
Operations() OperationStore
|
||||
OperationEvents() OperationEventStore
|
||||
BranchWatches() BranchWatchStore
|
||||
RevisionCursors() RevisionCursorStore
|
||||
ProviderDeliveries() ProviderDeliveryStore
|
||||
AgentRunInputs() AgentRunInputStore
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// services/core/internal/storage/postgres.go:851
|
||||
func (s *pgProviderDeliveryStore) RecordOnce(ctx context.Context, delivery core.ProviderDelivery) (DeliveryResult, error) {
|
||||
const insert = `
|
||||
INSERT INTO provider_deliveries
|
||||
(id, provider, delivery_id, dedupe_key, event_id, repo_id, branch, revision, created_at)
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
새 outbound 모델을 `core`에 추가하고 provider callback dedupe와 분리한다.
|
||||
|
||||
After shape:
|
||||
|
||||
```go
|
||||
// services/core/internal/core/model.go
|
||||
type WebhookDeliveryStatus string
|
||||
|
||||
const (
|
||||
WebhookDeliveryPending WebhookDeliveryStatus = "pending"
|
||||
WebhookDeliverySending WebhookDeliveryStatus = "sending"
|
||||
WebhookDeliverySucceeded WebhookDeliveryStatus = "succeeded"
|
||||
WebhookDeliveryRetryable WebhookDeliveryStatus = "retryable"
|
||||
WebhookDeliveryFailed WebhookDeliveryStatus = "failed"
|
||||
)
|
||||
|
||||
type WebhookDelivery struct {
|
||||
ID string
|
||||
EventID string
|
||||
SubscriptionID string
|
||||
Status WebhookDeliveryStatus
|
||||
AttemptCount int
|
||||
NextAttemptAt time.Time
|
||||
LastStatusCode int
|
||||
LastError string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Add `WebhookDeliveryStore` to `storage.Store` with enqueue/pick/record methods. Update `Boundary`, `PgStore`, `storage_test.go` fakes, and `runtime_test.go` fake store.
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/core/model.go`: add webhook delivery status/model and status validation helper.
|
||||
- [ ] `services/core/internal/storage/storage.go`: add `WebhookDeliveryStore` interface and `WebhookDeliveries()` method.
|
||||
- [ ] `services/core/internal/storage/postgres.go`: add `pgWebhookDeliveryStore`.
|
||||
- [ ] `services/core/migrations/00001_initial.sql`: add `webhook_deliveries` table and indexes.
|
||||
- [ ] `services/core/internal/storage/storage_test.go`: update compile fakes and migration snippets.
|
||||
- [ ] `services/core/internal/storage/postgres_test.go`: add Postgres integration test; skip cleanly without `GITO_TEST_DATABASE_URL`.
|
||||
- [ ] `services/core/internal/controlplane/runtime_test.go`: update `fakeStore`.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
Write `TestPostgresWebhookDeliveryStoreRecordsRetryState` in `postgres_test.go`. Assertions: first enqueue creates pending, duplicate enqueue returns existing/no duplicate, pick moves to sending, retryable record increments attempt count and sets `next_attempt_at`, terminal failed and succeeded states are persisted.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -run TestPostgresWebhookDeliveryStoreRecordsRetryState ./internal/storage -v -count=1
|
||||
```
|
||||
|
||||
Expected: exits 0 when `GITO_TEST_DATABASE_URL` is set; otherwise skips with the existing PostgreSQL prerequisite message.
|
||||
|
||||
## [API-2] Retry Classification And Backoff
|
||||
|
||||
### 문제
|
||||
|
||||
After `01_http_delivery`, HTTP delivery can fail, but S04 requires retry/backoff and failed state to be recorded. There is no classifier for 429/5xx vs non-retryable 4xx, and no retry budget.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// services/core/internal/controlplane/runtime.go:310
|
||||
if r.broadcaster != nil {
|
||||
if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil {
|
||||
// Rollback delivery record on broadcast failure
|
||||
if r.store.ProviderDeliveries() != nil && dedupeKey != "" {
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
Keep provider event broadcast separate from outbound webhook delivery. Wrap the HTTP delivery result from `01_http_delivery` in a classifier:
|
||||
|
||||
```go
|
||||
func classifyWebhookDelivery(resp *http.Response, err error, attempt int, now time.Time) webhookDeliveryOutcome {
|
||||
switch {
|
||||
case err != nil:
|
||||
return retryableOutcome(now, attempt)
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return succeededOutcome()
|
||||
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
|
||||
return retryableOutcome(now, attempt)
|
||||
default:
|
||||
return failedOutcome(resp.StatusCode)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use bounded exponential backoff with deterministic unit tests. Do not log raw target URL, raw secret, or private response bodies.
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/controlplane/runtime.go`: record delivery outcome through `WebhookDeliveryStore`.
|
||||
- [ ] `services/core/internal/controlplane/runtime_test.go`: add fake store tests for retryable, failed, succeeded outcomes.
|
||||
- [ ] `services/core/internal/storage/storage.go`: expose status recording primitives needed by runtime.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
Write `TestWebhookDeliveryRetryBackoffRecordsState` in `runtime_test.go`. Use `httptest.Server` returning 500 then 200 or just a deterministic fake transport. Assert attempt count, retryable state, `next_attempt_at` after now, and no duplicate delivery row for repeated event/subscription enqueue.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -run TestWebhookDeliveryRetryBackoffRecordsState ./internal/controlplane -v -count=1
|
||||
```
|
||||
|
||||
Expected: exits 0 and records retryable state plus next attempt time.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `services/core/internal/core/model.go` | API-1 |
|
||||
| `services/core/internal/storage/storage.go` | API-1, API-2 |
|
||||
| `services/core/internal/storage/postgres.go` | API-1 |
|
||||
| `services/core/migrations/00001_initial.sql` | API-1 |
|
||||
| `services/core/internal/storage/storage_test.go` | API-1 |
|
||||
| `services/core/internal/storage/postgres_test.go` | API-1 |
|
||||
| `services/core/internal/controlplane/runtime.go` | API-2 |
|
||||
| `services/core/internal/controlplane/runtime_test.go` | API-2 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1
|
||||
cd services/core && go test ./...
|
||||
```
|
||||
|
||||
Expected: both commands exit 0. PostgreSQL-specific test may skip only when `GITO_TEST_DATABASE_URL` is unset; record the skip output and residual risk. Cached output is acceptable for the full package smoke, but targeted retry tests use `-count=1`.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -44,6 +44,7 @@ func newRouterWithStore(cfg config.Config, logger *slog.Logger, store storage.St
|
|||
})
|
||||
})
|
||||
mux.HandleFunc("/api/listeners/branches", handleBranchListeners(runtime))
|
||||
mux.HandleFunc("/api/webhook-subscriptions", handleWebhookSubscriptions(runtime))
|
||||
mux.HandleFunc("/api/events", handleEvents(runtime))
|
||||
mux.HandleFunc("/callbacks/forgejo/push", handleForgejoPush(cfg, runtime))
|
||||
mux.HandleFunc(cfg.ProtoSocketPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -150,6 +151,45 @@ func handleBranchListeners(runtime *Runtime) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func handleWebhookSubscriptions(runtime *Runtime) http.HandlerFunc {
|
||||
type request struct {
|
||||
Name string `json:"name"`
|
||||
TargetURL string `json:"target_url"`
|
||||
Events []string `json:"events"`
|
||||
RepoID string `json:"repo_id"`
|
||||
Branch string `json:"branch"`
|
||||
SecretRef string `json:"secret_ref"`
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"subscriptions": webhookSubscriptionsPayload(runtime.ListWebhookSubscriptions())})
|
||||
case http.MethodPost:
|
||||
var input request
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid webhook subscription payload"})
|
||||
return
|
||||
}
|
||||
subscription, err := runtime.RegisterWebhookSubscription(
|
||||
input.Name,
|
||||
input.TargetURL,
|
||||
input.Events,
|
||||
input.RepoID,
|
||||
input.Branch,
|
||||
input.SecretRef,
|
||||
)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"subscription": webhookSubscriptionPayload(subscription)})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleEvents(runtime *Runtime) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
|
@ -340,6 +380,25 @@ func eventRecordsPayload(records []EventRecord) []any {
|
|||
return payload
|
||||
}
|
||||
|
||||
func webhookSubscriptionsPayload(subscriptions []WebhookSubscription) []any {
|
||||
payload := make([]any, 0, len(subscriptions))
|
||||
for _, subscription := range subscriptions {
|
||||
payload = append(payload, webhookSubscriptionPayload(subscription))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func webhookSubscriptionPayload(subscription WebhookSubscription) map[string]any {
|
||||
return map[string]any{
|
||||
"id": subscription.ID,
|
||||
"name": subscription.Name,
|
||||
"events": subscription.Events,
|
||||
"repo_id": subscription.RepoID,
|
||||
"branch": subscription.Branch,
|
||||
"created_at": subscription.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func eventRecordPayload(record EventRecord) map[string]any {
|
||||
changedFiles := make([]any, 0, len(record.Revision.ChangedFiles))
|
||||
for _, file := range record.Revision.ChangedFiles {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,89 @@ func TestForgejoPushRequiresSignatureBeforeIgnoringUnsupportedEvent(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef(t *testing.T) {
|
||||
router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{
|
||||
"name": "nomadcode-develop-wakeup",
|
||||
"target_url": "https://consumer.example.invalid/gito/events",
|
||||
"events": ["branch.updated"],
|
||||
"repo_id": "nomadcode",
|
||||
"branch": "develop",
|
||||
"secret_ref": "secret://gito/webhooks/nomadcode"
|
||||
}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("secret://gito/webhooks/nomadcode")) ||
|
||||
bytes.Contains(rec.Body.Bytes(), []byte("secret_ref")) ||
|
||||
bytes.Contains(rec.Body.Bytes(), []byte("consumer.example.invalid")) {
|
||||
t.Fatalf("subscription response leaked private subscription input: %s", rec.Body.String())
|
||||
}
|
||||
var created map[string]map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode subscription response: %v", err)
|
||||
}
|
||||
subscription := created["subscription"]
|
||||
if subscription["id"] == "" {
|
||||
t.Fatalf("missing subscription id: %#v", subscription)
|
||||
}
|
||||
if subscription["name"] != "nomadcode-develop-wakeup" ||
|
||||
subscription["repo_id"] != "nomadcode" ||
|
||||
subscription["branch"] != "develop" {
|
||||
t.Fatalf("unexpected subscription payload: %#v", subscription)
|
||||
}
|
||||
events, ok := subscription["events"].([]any)
|
||||
if !ok || len(events) != 1 || events[0] != "branch.updated" {
|
||||
t.Fatalf("events: %#v", subscription["events"])
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/webhook-subscriptions", nil)
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status: got %d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
if bytes.Contains(listRec.Body.Bytes(), []byte("secret://gito/webhooks/nomadcode")) ||
|
||||
bytes.Contains(listRec.Body.Bytes(), []byte("secret_ref")) ||
|
||||
bytes.Contains(listRec.Body.Bytes(), []byte("consumer.example.invalid")) {
|
||||
t.Fatalf("subscription list leaked private subscription input: %s", listRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSubscriptionValidatesTargetAndEvents(t *testing.T) {
|
||||
router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{
|
||||
"name": "invalid",
|
||||
"target_url": "file:///tmp/event",
|
||||
"events": ["branch.updated"]
|
||||
}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{
|
||||
"name": "missing-events",
|
||||
"target_url": "https://consumer.example.invalid/gito/events"
|
||||
}`))
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing events status: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeRegistersConnectionFilter(t *testing.T) {
|
||||
dispatcher := protosocket.NewDispatcher()
|
||||
runtime := NewRuntime(nil)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -24,11 +25,12 @@ type EventBroadcaster interface {
|
|||
}
|
||||
|
||||
type Runtime struct {
|
||||
mu sync.Mutex
|
||||
watches map[string]BranchWatch
|
||||
records []EventRecord
|
||||
broadcaster EventBroadcaster
|
||||
store storage.Store
|
||||
mu sync.Mutex
|
||||
watches map[string]BranchWatch
|
||||
subscriptions map[string]WebhookSubscription
|
||||
records []EventRecord
|
||||
broadcaster EventBroadcaster
|
||||
store storage.Store
|
||||
}
|
||||
|
||||
type BranchWatch struct {
|
||||
|
|
@ -49,10 +51,22 @@ type EventRecord struct {
|
|||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type WebhookSubscription struct {
|
||||
ID string
|
||||
Name string
|
||||
TargetURL string
|
||||
Events []string
|
||||
RepoID string
|
||||
Branch string
|
||||
SecretRef string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewRuntime(broadcaster EventBroadcaster) *Runtime {
|
||||
return &Runtime{
|
||||
watches: make(map[string]BranchWatch),
|
||||
broadcaster: broadcaster,
|
||||
watches: make(map[string]BranchWatch),
|
||||
subscriptions: make(map[string]WebhookSubscription),
|
||||
broadcaster: broadcaster,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,9 +74,10 @@ func NewRuntime(broadcaster EventBroadcaster) *Runtime {
|
|||
// When store is nil the behavior is identical to NewRuntime.
|
||||
func NewRuntimeWithStore(broadcaster EventBroadcaster, store storage.Store) *Runtime {
|
||||
return &Runtime{
|
||||
watches: make(map[string]BranchWatch),
|
||||
broadcaster: broadcaster,
|
||||
store: store,
|
||||
watches: make(map[string]BranchWatch),
|
||||
subscriptions: make(map[string]WebhookSubscription),
|
||||
broadcaster: broadcaster,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +152,73 @@ func (r *Runtime) ListBranchWatches() []BranchWatch {
|
|||
return watches
|
||||
}
|
||||
|
||||
func (r *Runtime) RegisterWebhookSubscription(name, targetURL string, events []string, repoID, branch, secretRef string) (WebhookSubscription, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
targetURL = strings.TrimSpace(targetURL)
|
||||
repoID = strings.TrimSpace(repoID)
|
||||
branch = strings.TrimSpace(branch)
|
||||
secretRef = strings.TrimSpace(secretRef)
|
||||
events = normalizeEventNames(events)
|
||||
if targetURL == "" {
|
||||
return WebhookSubscription{}, fmt.Errorf("target_url is required")
|
||||
}
|
||||
parsedTarget, err := url.Parse(targetURL)
|
||||
if err != nil || parsedTarget.Host == "" || (parsedTarget.Scheme != "http" && parsedTarget.Scheme != "https") {
|
||||
return WebhookSubscription{}, fmt.Errorf("target_url must be an http or https URL")
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return WebhookSubscription{}, fmt.Errorf("events are required")
|
||||
}
|
||||
if name == "" {
|
||||
name = "webhook-subscription"
|
||||
}
|
||||
|
||||
subscription := WebhookSubscription{
|
||||
ID: "webhook-sub-" + newID(),
|
||||
Name: name,
|
||||
TargetURL: targetURL,
|
||||
Events: events,
|
||||
RepoID: repoID,
|
||||
Branch: branch,
|
||||
SecretRef: secretRef,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.subscriptions[subscription.ID] = subscription
|
||||
return subscription, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) ListWebhookSubscriptions() []WebhookSubscription {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
subscriptions := make([]WebhookSubscription, 0, len(r.subscriptions))
|
||||
for _, subscription := range r.subscriptions {
|
||||
subscriptions = append(subscriptions, subscription)
|
||||
}
|
||||
sort.Slice(subscriptions, func(i, j int) bool {
|
||||
return subscriptions[i].ID < subscriptions[j].ID
|
||||
})
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
func normalizeEventNames(events []string) []string {
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
result := make([]string, 0, len(events))
|
||||
for _, event := range events {
|
||||
event = strings.TrimSpace(event)
|
||||
if event == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[event]; ok {
|
||||
continue
|
||||
}
|
||||
seen[event] = struct{}{}
|
||||
result = append(result, event)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Runtime) ListEvents() []EventRecord {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
|
|
|||
Loading…
Reference in a new issue