diff --git a/agent-roadmap/phase/provider-change-request-integration/PHASE.md b/agent-roadmap/phase/provider-change-request-integration/PHASE.md index 276e625..a72afec 100644 --- a/agent-roadmap/phase/provider-change-request-integration/PHASE.md +++ b/agent-roadmap/phase/provider-change-request-integration/PHASE.md @@ -18,7 +18,7 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change - 경로: `agent-roadmap/archive/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 +- [진행중] Provider Adapter Foundation - 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md` - 요약: provider registry, credential ref, webhook intake, provider capability boundary를 만든다. diff --git a/agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md b/agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md index 2fd9718..0e6d11d 100644 --- a/agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md +++ b/agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md @@ -11,7 +11,7 @@ GitHub, GitLab, Forgejo, Plane, Jira 같은 외부 시스템을 provider adapter ## 상태 -[계획] +[진행중] ## 승격 조건 @@ -37,14 +37,14 @@ GitHub, GitLab, Forgejo, Plane, Jira 같은 외부 시스템을 provider adapter provider별 capability와 config를 등록한다. - [x] [provider-model] provider id, endpoint, credential ref, capabilities를 model로 정의한다. -- [ ] [provider-store] provider config를 저장하고 조회하는 store boundary를 만든다. +- [x] [provider-store] provider config를 저장하고 조회하는 store boundary를 만든다. - [x] [capability] ChangeRequest, webhook, checks, comment 같은 capability flag를 정의한다. ### Epic: [webhook] Webhook intake provider webhook을 normalized event 입력으로 받는다. -- [ ] [callback] provider callback REST endpoint 경계를 만든다. 검증: fake webhook이 `provider.webhook.received` event를 남긴다. +- [x] [callback] provider callback REST endpoint 경계를 만든다. 검증: fake webhook이 `provider.webhook.received` event를 남긴다. - [ ] [verify] webhook signature 검증 hook을 adapter별로 꽂을 수 있게 한다. - [ ] [normalize] provider payload를 provider-neutral event 후보로 변환한다. @@ -71,6 +71,8 @@ provider webhook을 normalized event 입력으로 받는다. - 표준선(선택): provider adapter는 platform API만 소유하고 Git object 상태는 Git engine으로 확인한다. - 결정 반영(2026-06-19): 현재 사용 중인 Forgejo webhook/revision path를 기존 baseline으로 인정하고, Provider Adapter Foundation에서 새 provider 우선순위는 GitHub로 둔다. - 결정 반영(2026-06-19): `credential_ref`는 SOPS로 관리되는 secret material을 가리키는 ref를 기준으로 한다. 런타임은 decrypt된 ignored env/local secret을 resolver로 읽고, tracked docs와 event/log에는 raw secret을 남기지 않는다. +- 작업현황 동기화(2026-06-19): `agent-task/archive/2026/06/m-provider-adapter-foundation/02+01_postgres_provider_store/complete.log`의 `Roadmap Completion` 근거로 `provider-store` 완료를 반영했다. +- 작업현황 동기화(2026-06-19): generic provider callback route와 `provider.webhook.received` event 기록을 추가하고 `cd services/core && go test ./...` 통과 근거로 `callback` 완료를 반영했다. - 선행 작업: Command Policy and Cancellation - 후속 작업: Change Request Abstraction - 확인 필요: 없음 diff --git a/agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/CODE_REVIEW-local-G05.md b/agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/CODE_REVIEW-local-G05.md new file mode 100644 index 0000000..335c88f --- /dev/null +++ b/agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/CODE_REVIEW-local-G05.md @@ -0,0 +1,106 @@ + + +# 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. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only. + +## 개요 + +date=2026-06-19 +task=m-provider-adapter-foundation/01_webhook_adapter_contract, plan=0, tag=API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-provider-adapter-foundation/01_webhook_adapter_contract/`로 이동한다. +4. PASS split 작업이면 이동 후 빈 active parent 유지 여부를 확인한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Provider Webhook Contract | [ ] | + +## 구현 체크리스트 + +- [ ] provider webhook request, verification hook, normalization candidate, adapter interface를 `services/core/internal/provider/provider.go`에 추가한다. +- [ ] fake adapter 기반 provider package 테스트를 추가해 interface 구현, empty payload 보존, candidate type/revision 표현을 검증한다. +- [ ] `cd services/core && go test -count=1 ./internal/provider`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block을 확인한다. +- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리를 archive로 이동한다. +- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나 유지 사유를 확인한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- provider package가 controlplane이나 provider-specific package를 import하지 않는지 확인한다. +- request/candidate 계약이 raw secret 저장을 요구하지 않는지 확인한다. +- dependent subtask `02+01_webhook_adapter_wiring`가 쓸 verifier/normalizer hook이 충분한지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### API-1 중간 검증 + +```text +$ cd services/core && go test -count=1 ./internal/provider +(output) +``` + +### 최종 검증 + +```text +$ cd services/core && go test -count=1 ./internal/provider ./internal/controlplane +(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?** diff --git a/agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/PLAN-local-G05.md b/agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/PLAN-local-G05.md new file mode 100644 index 0000000..dfb4170 --- /dev/null +++ b/agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/PLAN-local-G05.md @@ -0,0 +1,181 @@ + + +# Provider Webhook Adapter Contract Plan + +## 이 파일을 읽는 구현 에이전트에게 + +이 계획의 구현 완료는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운 뒤에만 성립한다. 구현 중 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경, secret, 서비스 준비, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan 사유로 기록한다. + +## 배경 + +현 마일스톤의 `webhook` 에픽에서 generic callback 경계는 작은 조각으로 추가되었지만, adapter별 signature 검증과 provider-neutral normalization은 아직 Forgejo 함수 호출에 묶여 있다. 먼저 provider 패키지에 webhook request, verification hook, normalization candidate 계약을 둬야 다음 wiring 작업이 기존 Forgejo path를 안전하게 갈아탈 수 있다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 채팅 질문은 금지이며, code-review가 검증 후 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.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` +- `services/core/go.mod` +- `services/core/internal/provider/provider.go` +- `services/core/internal/provider/provider_test.go` +- `services/core/internal/provider/forgejo/push.go` +- `services/core/internal/provider/forgejo/push_test.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/core/model.go` +- `services/core/internal/storage/storage.go` +- `services/core/internal/config/config.go` + +### 테스트 환경 규칙 + +- 선택 env: `local`. +- `agent-test/local/rules.md` 읽음. core 변경은 `agent-test/local/core-smoke.md`로 라우팅된다. +- 적용 명령: `cd services/core && go test ./...`. +- 보조 fresh 명령: `cd services/core && go test -count=1 ./internal/provider ./internal/provider/forgejo ./internal/controlplane`. +- 누락 또는 `<확인 필요>` 값: 없음. + +### 테스트 커버리지 공백 + +- provider webhook contract: 현재 `provider.WebhookEvent`는 payload container뿐이라 verifier/normalizer interface를 검증하는 테스트가 없다. 이번 plan에서 fake adapter compile-time/interface 테스트를 추가한다. +- Forgejo signature: `forgejo.VerifySignature` 함수 테스트는 있으나 adapter hook으로 검증되는지 확인하지 않는다. 후속 wiring plan에서 닫는다. +- Forgejo normalization: `forgejo.NormalizePush` 함수 테스트는 있으나 provider-neutral candidate 계약으로 변환되는지 확인하지 않는다. 후속 wiring plan에서 닫는다. + +### 심볼 참조 + +- renamed/removed symbol: none. +- 참조 확인: `WebhookEvent`, `VerifySignature`, `NormalizePush`, `ParsePushPayload`, `handleProviderWebhook`, `handleForgejoPush`, `HandleProviderWebhook`를 `rg --sort path`로 확인했다. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- 공유 API/foundation 변경과 router/Forgejo call-site rollout이 분리되므로 split gate가 적용된다. +- shared task group: `agent-task/m-provider-adapter-foundation/`. +- `01_webhook_adapter_contract`: provider package contract만 정의한다. predecessor 없음. +- `02+01_webhook_adapter_wiring`: 이 contract의 `complete.log` 이후 Forgejo/router wiring을 수행한다. + +### 범위 결정 근거 + +- 이 plan은 `services/core/internal/provider/**`의 interface와 contract tests만 대상으로 한다. +- `services/core/internal/controlplane/router.go` wiring, `/callbacks/forgejo/push` behavior migration, roadmap checkbox 갱신은 제외한다. 해당 변경은 dependent plan `02+01_webhook_adapter_wiring`에서 수행한다. +- 새 외부 dependency는 추가하지 않는다. `services/core/go.mod`에 필요한 표준 라이브러리만 사용 가능하다. + +### 빌드 등급 + +- `local-G05`: 내부 Go API foundation이며 파일 수와 영향은 작지만 후속 router wiring의 계약이 되므로 중간 수준의 정확도가 필요하다. + +## 구현 체크리스트 + +- [ ] provider webhook request, verification hook, normalization candidate, adapter interface를 `services/core/internal/provider/provider.go`에 추가한다. +- [ ] fake adapter 기반 provider package 테스트를 추가해 interface 구현, empty payload 보존, candidate type/revision 표현을 검증한다. +- [ ] `cd services/core && go test -count=1 ./internal/provider`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [API-1] Provider Webhook Contract + +### 문제 + +- [services/core/internal/provider/provider.go](/config/workspace/gito/services/core/internal/provider/provider.go:61)는 `WebhookEvent`에 provider/event/delivery/payload만 담고 있어 signature 검증에 필요한 header/query/received time을 전달할 수 없다. +- [services/core/internal/provider/forgejo/push.go](/config/workspace/gito/services/core/internal/provider/forgejo/push.go:69)와 [services/core/internal/provider/forgejo/push.go](/config/workspace/gito/services/core/internal/provider/forgejo/push.go:81)는 verifier/normalizer hook 없이 직접 함수로만 노출된다. + +Before: + +```go +// provider.go:61 +type WebhookEvent struct { + Provider string + EventType string + ExternalID string + Payload []byte +} +``` + +After: + +```go +type WebhookRequest struct { + Provider ProviderID + EventType string + ExternalID string + Payload []byte + Headers map[string][]string + Query map[string][]string + ReceivedAt time.Time +} + +type WebhookCandidate struct { + Type string + Provider ProviderID + ExternalID string + Revision *core.RevisionEvent +} + +type WebhookVerifier interface { + VerifyWebhook(ctx context.Context, req WebhookRequest) error +} + +type WebhookNormalizer interface { + NormalizeWebhook(ctx context.Context, req WebhookRequest) ([]WebhookCandidate, error) +} + +type WebhookAdapter interface { + WebhookVerifier + WebhookNormalizer +} +``` + +### 해결 방법 + +- `WebhookEvent`를 `WebhookRequest`로 대체하거나 type alias로 호환 shim을 둔다. 이미 추가된 `controlplane.HandleProviderWebhook` call site가 있으므로 가능하면 한 번에 `WebhookRequest`로 정리한다. +- `WebhookCandidate.Type`은 `events.BranchUpdated` 같은 normalized event type 문자열을 담되 provider package가 events package에 의존하지 않게 string으로 유지한다. +- `Headers`와 `Query`는 `net/http` 타입을 provider package에 직접 노출하지 않기 위해 `map[string][]string`으로 둔다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/provider/provider.go`: `WebhookRequest`, `WebhookCandidate`, `WebhookVerifier`, `WebhookNormalizer`, `WebhookAdapter` 추가. +- [ ] `services/core/internal/provider/provider_test.go`: fake adapter compile-time assertion과 request/candidate 보존 테스트 추가. +- [ ] `services/core/internal/controlplane/runtime.go`: `provider.WebhookEvent` 참조가 있으면 `provider.WebhookRequest`로 갱신. +- [ ] `services/core/internal/controlplane/router.go`: generic callback의 request 생성 타입만 갱신하고 동작은 바꾸지 않는다. + +### 테스트 작성 + +- 작성: `services/core/internal/provider/provider_test.go`에 `TestWebhookAdapterContract`를 추가한다. +- 목표: fake adapter가 `WebhookAdapter`를 구현하고, request의 provider/event/delivery/payload/header/query가 verifier와 normalizer로 전달되며, candidate가 raw secret을 요구하지 않는 최소 metadata만 표현할 수 있음을 확인한다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/provider +``` + +기대 결과: exit 0. + +## 수정 파일 요약 + +| 파일 | 항목 | +| --- | --- | +| `services/core/internal/provider/provider.go` | API-1 | +| `services/core/internal/provider/provider_test.go` | API-1 | +| `services/core/internal/controlplane/runtime.go` | API-1 | +| `services/core/internal/controlplane/router.go` | API-1 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./internal/provider ./internal/controlplane +``` + +기대 결과: exit 0. Go test cache는 이 plan의 중간 확인에는 허용하지 않으므로 `-count=1`을 사용한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-provider-adapter-foundation/02+01_webhook_adapter_wiring/CODE_REVIEW-local-G06.md b/agent-task/m-provider-adapter-foundation/02+01_webhook_adapter_wiring/CODE_REVIEW-local-G06.md new file mode 100644 index 0000000..4beacdb --- /dev/null +++ b/agent-task/m-provider-adapter-foundation/02+01_webhook_adapter_wiring/CODE_REVIEW-local-G06.md @@ -0,0 +1,132 @@ + + +# 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. +> If predecessor `01_webhook_adapter_contract` has no `complete.log`, do not implement; record dependency blocker in implementation-owned sections. +> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-19 +task=m-provider-adapter-foundation/02+01_webhook_adapter_wiring, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md` +- Task ids: + - `verify`: webhook signature 검증 hook을 adapter별로 꽂을 수 있게 한다. + - `normalize`: provider payload를 provider-neutral event 후보로 변환한다. +- Completion mode: 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-provider-adapter-foundation/02+01_webhook_adapter_wiring/`로 이동한다. +4. PASS이고 task group이 `m-provider-adapter-foundation`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Forgejo Webhook Adapter | [ ] | +| [API-2] Router Adapter Wiring | [ ] | + +## 구현 체크리스트 + +- [ ] `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/complete.log`가 있는지 확인하고 없으면 구현하지 않고 dependency 대기 상태를 기록한다. +- [ ] Forgejo webhook adapter가 provider contract의 verifier/normalizer를 구현하도록 `services/core/internal/provider/forgejo/push.go`와 테스트를 갱신한다. +- [ ] `/callbacks/forgejo/push`가 adapter verification hook과 normalization candidate를 통해 기존 `branch.updated` event를 발행하도록 `services/core/internal/controlplane/router.go`와 테스트를 갱신한다. +- [ ] generic `/callbacks/providers/fake` receipt-only behavior와 raw payload non-leak 테스트가 계속 통과하는지 확인한다. +- [ ] `cd services/core && go test -count=1 ./internal/provider/forgejo ./internal/controlplane`와 `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_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block을 확인한다. +- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리를 archive로 이동한다. +- [ ] PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고하고 roadmap 직접 수정은 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나 유지 사유를 확인한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `01_webhook_adapter_contract/complete.log` 이후 구현되었는지 확인한다. +- `/callbacks/forgejo/push` contract status/response/idempotency behavior가 유지되는지 확인한다. +- adapter candidate가 raw secret이나 raw webhook payload를 event/list response에 노출하지 않는지 확인한다. +- `Roadmap Targets`의 `verify`, `normalize`가 PASS 근거로 충분한지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### API-1 중간 검증 + +```text +$ cd services/core && go test -count=1 ./internal/provider/forgejo +(output) +``` + +### API-2 중간 검증 + +```text +$ cd services/core && go test -count=1 ./internal/controlplane -run 'TestForgejoPush|TestProviderCallbackRecordsReceivedWebhookEvent' +(output) +``` + +### 최종 검증 + +```text +$ test -f agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/complete.log +(output) + +$ cd services/core && go test -count=1 ./internal/provider/forgejo ./internal/controlplane +(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?** diff --git a/agent-task/m-provider-adapter-foundation/02+01_webhook_adapter_wiring/PLAN-local-G06.md b/agent-task/m-provider-adapter-foundation/02+01_webhook_adapter_wiring/PLAN-local-G06.md new file mode 100644 index 0000000..a1c6ba0 --- /dev/null +++ b/agent-task/m-provider-adapter-foundation/02+01_webhook_adapter_wiring/PLAN-local-G06.md @@ -0,0 +1,233 @@ + + +# Provider Webhook Adapter Wiring Plan + +## 이 파일을 읽는 구현 에이전트에게 + +이 계획은 `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/complete.log`가 생긴 뒤 구현한다. 구현 완료는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운 뒤에만 성립한다. 선택된 SDD 결정 또는 Milestone lock 결정이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. + +## 배경 + +`/callbacks/forgejo/push`는 현재 config secret, Forgejo signature 함수, push parser, branch revision normalization을 라우터 안에서 직접 호출한다. provider adapter foundation의 남은 `verify`와 `normalize` task를 닫으려면 이 경계를 provider webhook adapter hook으로 옮기고, 기존 Forgejo contract 응답과 테스트를 유지해야 한다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 SDD 결정 또는 선택된 Milestone lock 결정이 실구현을 차단할 때만 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 채팅 질문은 금지이며, code-review가 검증 후 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md` +- Task ids: + - `verify`: webhook signature 검증 hook을 adapter별로 꽂을 수 있게 한다. + - `normalize`: provider payload를 provider-neutral event 후보로 변환한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.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` +- `services/core/go.mod` +- `services/core/internal/provider/provider.go` +- `services/core/internal/provider/provider_test.go` +- `services/core/internal/provider/forgejo/push.go` +- `services/core/internal/provider/forgejo/push_test.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/core/model.go` +- `services/core/internal/storage/storage.go` +- `services/core/internal/config/config.go` + +### 테스트 환경 규칙 + +- 선택 env: `local`. +- `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다. +- 필수 검증: `cd services/core && go test ./...`. +- 이 plan의 fresh 중간 검증: `cd services/core && go test -count=1 ./internal/provider/forgejo ./internal/controlplane`. +- PostgreSQL이나 외부 provider 호출은 기본 local smoke에 포함하지 않는다. + +### 테스트 커버리지 공백 + +- `verify`: 현재 `TestVerifySignature`와 `TestForgejoPushRequiresSignatureBeforeIgnoringUnsupportedEvent`는 있으나 adapter interface를 통한 검증 hook은 없다. 이 plan에서 Forgejo adapter hook 테스트와 router behavior test로 닫는다. +- `normalize`: 현재 `TestNormalizePushPayload`는 직접 함수만 검증한다. 이 plan에서 `NormalizeWebhook`이 `branch.updated` candidate를 만들고 tag/unsupported event는 side-effect 없는 후보 없음으로 처리하는 테스트를 추가한다. +- 기존 callback small work: `TestProviderCallbackRecordsReceivedWebhookEvent`가 fake provider receipt event를 검증한다. 이 plan은 그 endpoint의 raw payload non-leak 동작을 보존해야 한다. + +### 심볼 참조 + +- renamed/removed symbol: none. +- call-site candidates: + - `services/core/internal/controlplane/router.go:254` direct `forgejo.VerifySignature`. + - `services/core/internal/controlplane/router.go:262` direct `forgejo.ParsePushPayload`. + - `services/core/internal/controlplane/router.go:267` direct `forgejo.NormalizePush`. + - `services/core/internal/provider/forgejo/push_test.go:89` direct signature unit test. + - `services/core/internal/provider/forgejo/push_test.go:14` direct normalization unit test. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- 이 subtask는 `02+01_webhook_adapter_wiring`이며 predecessor `01_webhook_adapter_contract`에 의존한다. +- predecessor 상태: 같은 task group의 active sibling plan `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/PLAN-local-G05.md`가 존재하지만 `complete.log`는 아직 없다. 구현은 해당 `complete.log`가 생긴 뒤 시작한다. +- API foundation과 router/Forgejo rollout이 분리되어 있어 이 dependent plan이 적절하다. + +### 범위 결정 근거 + +- 이 plan은 Forgejo adapter hook과 existing callback wiring만 다룬다. +- GitHub/GitLab/Gitea/Plane/Jira 실제 provider 구현, PR/MR 생성, checks/comment API, durable provider webhook receipt store는 범위 제외다. +- `/callbacks/providers/fake` receipt endpoint는 callback task에서 추가된 동작을 보존하되, adapter registry가 없는 provider는 receipt-only로 남긴다. + +### 빌드 등급 + +- `local-G06`: HTTP callback behavior와 existing Forgejo contract를 유지하면서 adapter hook으로 재배선하므로 bounded local work지만 회귀 위험이 있다. + +## 구현 체크리스트 + +- [ ] `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/complete.log`가 있는지 확인하고 없으면 구현하지 않고 dependency 대기 상태를 기록한다. +- [ ] Forgejo webhook adapter가 provider contract의 verifier/normalizer를 구현하도록 `services/core/internal/provider/forgejo/push.go`와 테스트를 갱신한다. +- [ ] `/callbacks/forgejo/push`가 adapter verification hook과 normalization candidate를 통해 기존 `branch.updated` event를 발행하도록 `services/core/internal/controlplane/router.go`와 테스트를 갱신한다. +- [ ] generic `/callbacks/providers/fake` receipt-only behavior와 raw payload non-leak 테스트가 계속 통과하는지 확인한다. +- [ ] `cd services/core && go test -count=1 ./internal/provider/forgejo ./internal/controlplane`와 `cd services/core && go test ./...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +1. `01_webhook_adapter_contract` PASS 후 `complete.log` 확인. +2. Forgejo adapter hook 구현. +3. Router wiring 교체. +4. Focused tests 후 전체 core tests. + +## [API-1] Forgejo Webhook Adapter + +### 문제 + +- [services/core/internal/provider/forgejo/push.go](/config/workspace/gito/services/core/internal/provider/forgejo/push.go:69)는 signature 검증 함수만 제공해 adapter별 hook으로 꽂을 수 없다. +- [services/core/internal/provider/forgejo/push.go](/config/workspace/gito/services/core/internal/provider/forgejo/push.go:81)는 `core.RevisionEvent`를 직접 반환해 provider-neutral candidate 목록을 표현하지 못한다. + +Before: + +```go +// push.go:69 +func VerifySignature(secret string, body []byte, signature string) bool { ... } + +// push.go:81 +func NormalizePush(payload PushPayload, repoID string, observedAt time.Time) (core.RevisionEvent, error) { ... } +``` + +After: + +```go +type WebhookAdapter struct { + Secret string +} + +func (a WebhookAdapter) VerifyWebhook(ctx context.Context, req provider.WebhookRequest) error { ... } +func (a WebhookAdapter) NormalizeWebhook(ctx context.Context, req provider.WebhookRequest) ([]provider.WebhookCandidate, error) { ... } +``` + +### 해결 방법 + +- `VerifySignature`, `ParsePushPayload`, `NormalizePush`는 기존 tests와 call-site 안정성을 위해 유지한다. +- `WebhookAdapter.VerifyWebhook`은 secret이 비어 있으면 nil, secret이 있으면 Forgejo signature header를 검증하고 실패 시 sentinel error를 반환한다. +- `WebhookAdapter.NormalizeWebhook`은 event type이 비어 있거나 `push`이면 payload를 parse하고 branch ref만 `events.BranchUpdated` candidate로 반환한다. unsupported event와 non-branch ref는 empty candidate slice로 처리한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/provider/forgejo/push.go`: adapter type과 methods 추가. +- [ ] `services/core/internal/provider/forgejo/push_test.go`: compile-time interface assertion, signature hook success/fail, push normalization candidate, unsupported event empty candidate, tag ref empty candidate 테스트 추가. + +### 테스트 작성 + +- 작성: `TestWebhookAdapterVerifiesSignature`, `TestWebhookAdapterNormalizesPushCandidate`, `TestWebhookAdapterIgnoresUnsupportedEvent`. +- 목표: adapter hook이 기존 Forgejo helper와 같은 semantic을 갖고 raw secret을 candidate에 넣지 않는지 확인한다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/provider/forgejo +``` + +기대 결과: exit 0. + +## [API-2] Router Adapter Wiring + +### 문제 + +- [services/core/internal/controlplane/router.go](/config/workspace/gito/services/core/internal/controlplane/router.go:253)는 라우터가 config secret과 Forgejo signature 함수를 직접 안다. +- [services/core/internal/controlplane/router.go](/config/workspace/gito/services/core/internal/controlplane/router.go:262)는 라우터가 Forgejo payload parse와 revision normalization을 직접 호출한다. + +Before: + +```go +// router.go:253 +if strings.TrimSpace(cfg.ForgejoWebhookSecret) != "" && + !forgejo.VerifySignature(cfg.ForgejoWebhookSecret, body, r.Header.Get("X-Forgejo-Signature")) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid forgejo signature"}) + return +} +payload, err := forgejo.ParsePushPayload(body) +... +revision, err := forgejo.NormalizePush(payload, r.URL.Query().Get("repo_id"), time.Now().UTC()) +``` + +After: + +```go +adapter := forgejo.WebhookAdapter{Secret: cfg.ForgejoWebhookSecret} +req := provider.WebhookRequest{Provider: provider.ProviderForgejo, EventType: webhookEventName(r), ExternalID: r.Header.Get("X-Forgejo-Delivery"), Payload: body, Headers: headerMap(r.Header), Query: queryMap(r.URL.Query()), ReceivedAt: time.Now().UTC()} +if err := adapter.VerifyWebhook(r.Context(), req); err != nil { ...401... } +candidates, err := adapter.NormalizeWebhook(r.Context(), req) +``` + +### 해결 방법 + +- `handleForgejoPush`에서 request metadata를 `provider.WebhookRequest`로 구성한다. +- adapter verification error는 기존 contract처럼 `401`로 응답한다. +- candidate가 없으면 기존 unsupported/non-branch path처럼 `202 accepted, matched=false`로 응답한다. +- candidate type이 `branch.updated`이면 `runtime.HandleRevision`을 호출하고 기존 response shape을 유지한다. +- generic provider callback receipt-only route는 그대로 둔다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/controlplane/router.go`: Forgejo direct helper calls를 adapter call로 교체한다. +- [ ] `services/core/internal/controlplane/router_test.go`: 기존 Forgejo tests를 유지하고 adapter path가 signature-before-ignore, watched branch, unwatched branch, fake receipt non-leak을 모두 만족하는지 확인한다. + +### 테스트 작성 + +- 작성: 기존 router tests 보강. 새 test가 필요하면 `TestForgejoPushUsesAdapterNormalization`처럼 branch.updated candidate path를 검증한다. +- 목표: `/callbacks/forgejo/push` 응답 shape과 status가 contract와 동일하고, `/callbacks/providers/fake`는 adapter 없이 `provider.webhook.received`만 남긴다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/controlplane -run 'TestForgejoPush|TestProviderCallbackRecordsReceivedWebhookEvent' +``` + +기대 결과: exit 0. + +## 수정 파일 요약 + +| 파일 | 항목 | +| --- | --- | +| `services/core/internal/provider/forgejo/push.go` | API-1 | +| `services/core/internal/provider/forgejo/push_test.go` | API-1 | +| `services/core/internal/controlplane/router.go` | API-2 | +| `services/core/internal/controlplane/router_test.go` | API-2 | + +## 최종 검증 + +```bash +test -f agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/complete.log +cd services/core && go test -count=1 ./internal/provider/forgejo ./internal/controlplane +cd services/core && go test ./... +``` + +기대 결과: 첫 명령 exit 0, 두 Go test 명령 모두 exit 0. 전체 `go test ./...`는 repository baseline smoke이며 Go test cache 허용 가능하다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/core/internal/controlplane/router.go b/services/core/internal/controlplane/router.go index 797eae0..93a4de7 100644 --- a/services/core/internal/controlplane/router.go +++ b/services/core/internal/controlplane/router.go @@ -11,6 +11,7 @@ import ( "git.toki-labs.com/toki/gito/services/core/internal/config" "git.toki-labs.com/toki/gito/services/core/internal/protosocket" + "git.toki-labs.com/toki/gito/services/core/internal/provider" "git.toki-labs.com/toki/gito/services/core/internal/provider/forgejo" "git.toki-labs.com/toki/gito/services/core/internal/storage" ) @@ -46,6 +47,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/providers/", handleProviderWebhook(runtime)) mux.HandleFunc("/callbacks/forgejo/push", handleForgejoPush(cfg, runtime)) mux.HandleFunc(cfg.ProtoSocketPath, func(w http.ResponseWriter, r *http.Request) { if isWebSocketUpgrade(r) { @@ -201,6 +203,41 @@ func handleEvents(runtime *Runtime) http.HandlerFunc { } } +func handleProviderWebhook(runtime *Runtime) http.HandlerFunc { + const prefix = "/callbacks/providers/" + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + providerID := strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/") + if providerID == "" || strings.Contains(providerID, "/") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider is required"}) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read payload"}) + return + } + record, err := runtime.HandleProviderWebhook(r.Context(), provider.WebhookEvent{ + Provider: providerID, + EventType: providerWebhookEventName(r), + ExternalID: providerWebhookDeliveryID(r), + Payload: body, + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{ + "accepted": true, + "event": eventRecordPayload(record), + }) + } +} + func handleForgejoPush(cfg config.Config, runtime *Runtime) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -260,6 +297,24 @@ func webhookEventName(r *http.Request) string { return "" } +func providerWebhookEventName(r *http.Request) string { + for _, header := range []string{"X-Gito-Provider-Event", "X-GitHub-Event", "X-Forgejo-Event", "X-Gitea-Event", "X-Gogs-Event", "X-GitLab-Event"} { + if value := strings.TrimSpace(r.Header.Get(header)); value != "" { + return strings.ToLower(value) + } + } + return "" +} + +func providerWebhookDeliveryID(r *http.Request) string { + for _, header := range []string{"X-Gito-Delivery", "X-GitHub-Delivery", "X-Forgejo-Delivery", "X-Gitea-Delivery", "X-Gogs-Delivery", "X-GitLab-Event-UUID"} { + if value := strings.TrimSpace(r.Header.Get(header)); value != "" { + return value + } + } + return "" +} + func isWebSocketUpgrade(r *http.Request) bool { return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") @@ -400,26 +455,37 @@ func webhookSubscriptionPayload(subscription WebhookSubscription) map[string]any } func eventRecordPayload(record EventRecord) map[string]any { - changedFiles := make([]any, 0, len(record.Revision.ChangedFiles)) - for _, file := range record.Revision.ChangedFiles { - changedFiles = append(changedFiles, map[string]any{ - "path": file.Path, - "change_type": file.ChangeType, - }) - } - return map[string]any{ + payload := map[string]any{ "id": record.ID, "type": record.Type, "provider": record.Provider, "delivery_id": record.DeliveryID, - "revision": map[string]any{ + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + } + if record.Type == "branch.updated" { + changedFiles := make([]any, 0, len(record.Revision.ChangedFiles)) + for _, file := range record.Revision.ChangedFiles { + changedFiles = append(changedFiles, map[string]any{ + "path": file.Path, + "change_type": file.ChangeType, + }) + } + payload["revision"] = map[string]any{ "repo_id": record.Revision.RepoID, "branch": record.Revision.Branch, "before": record.Revision.Before, "after": record.Revision.After, "changed_files": changedFiles, "observed_at": record.Revision.ObservedAt.UTC().Format(time.RFC3339Nano), - }, - "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + } } + if record.Type == "provider.webhook.received" && record.Webhook != nil { + payload["webhook"] = map[string]any{ + "event_type": record.Webhook.EventType, + "external_id": record.Webhook.ExternalID, + "payload_size": record.Webhook.PayloadSize, + "received_at": record.Webhook.ReceivedAt.UTC().Format(time.RFC3339Nano), + } + } + return payload } diff --git a/services/core/internal/controlplane/router_test.go b/services/core/internal/controlplane/router_test.go index 69879d0..fe87cd1 100644 --- a/services/core/internal/controlplane/router_test.go +++ b/services/core/internal/controlplane/router_test.go @@ -201,6 +201,72 @@ func TestForgejoPushRequiresSignatureBeforeIgnoringUnsupportedEvent(t *testing.T } } +func TestProviderCallbackRecordsReceivedWebhookEvent(t *testing.T) { + router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) + + req := httptest.NewRequest(http.MethodPost, "/callbacks/providers/fake", bytes.NewBufferString(`{ + "action": "ping", + "token": "top-secret" + }`)) + req.Header.Set("X-Gito-Provider-Event", "ping") + req.Header.Set("X-Gito-Delivery", "fake-delivery-1") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte("top-secret")) || + bytes.Contains(rec.Body.Bytes(), []byte(`"token"`)) { + t.Fatalf("provider callback response leaked raw payload: %s", rec.Body.String()) + } + var created map[string]any + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode callback response: %v", err) + } + event, ok := created["event"].(map[string]any) + if !ok { + t.Fatalf("missing event: %#v", created) + } + if event["type"] != "provider.webhook.received" || + event["provider"] != "fake" || + event["delivery_id"] != "fake-delivery-1" { + t.Fatalf("unexpected event: %#v", event) + } + webhook, ok := event["webhook"].(map[string]any) + if !ok { + t.Fatalf("missing webhook metadata: %#v", event) + } + if webhook["event_type"] != "ping" || webhook["external_id"] != "fake-delivery-1" { + t.Fatalf("unexpected webhook metadata: %#v", webhook) + } + if _, ok := webhook["payload_size"].(float64); !ok { + t.Fatalf("payload_size missing: %#v", webhook) + } + + eventsReq := httptest.NewRequest(http.MethodGet, "/api/events", nil) + eventsRec := httptest.NewRecorder() + router.ServeHTTP(eventsRec, eventsReq) + if eventsRec.Code != http.StatusOK { + t.Fatalf("events status: got %d", eventsRec.Code) + } + if bytes.Contains(eventsRec.Body.Bytes(), []byte("top-secret")) || + bytes.Contains(eventsRec.Body.Bytes(), []byte(`"token"`)) { + t.Fatalf("event list leaked raw payload: %s", eventsRec.Body.String()) + } + var listed map[string][]map[string]any + if err := json.NewDecoder(eventsRec.Body).Decode(&listed); err != nil { + t.Fatalf("decode events response: %v", err) + } + if len(listed["events"]) != 1 { + t.Fatalf("events: %#v", listed) + } + if listed["events"][0]["type"] != "provider.webhook.received" { + t.Fatalf("unexpected listed event: %#v", listed["events"][0]) + } +} + func TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef(t *testing.T) { router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) diff --git a/services/core/internal/controlplane/runtime.go b/services/core/internal/controlplane/runtime.go index 0b6bbe1..f7a43e5 100644 --- a/services/core/internal/controlplane/runtime.go +++ b/services/core/internal/controlplane/runtime.go @@ -20,6 +20,7 @@ import ( "git.toki-labs.com/toki/gito/services/core/internal/events" "git.toki-labs.com/toki/gito/services/core/internal/gitengine" "git.toki-labs.com/toki/gito/services/core/internal/protosocket" + "git.toki-labs.com/toki/gito/services/core/internal/provider" "git.toki-labs.com/toki/gito/services/core/internal/storage" "git.toki-labs.com/toki/gito/services/core/internal/worker" ) @@ -53,13 +54,21 @@ type BranchWatch struct { } type EventRecord struct { - ID string `json:"id"` - Type string `json:"type"` - Provider string `json:"provider"` - DeliveryID string `json:"delivery_id,omitempty"` - Duplicate bool `json:"duplicate,omitempty"` - Revision core.RevisionEvent `json:"revision"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Type string `json:"type"` + Provider string `json:"provider"` + DeliveryID string `json:"delivery_id,omitempty"` + Duplicate bool `json:"duplicate,omitempty"` + Revision core.RevisionEvent `json:"revision"` + Webhook *ProviderWebhookRecord `json:"webhook,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ProviderWebhookRecord struct { + EventType string + ExternalID string + PayloadSize int + ReceivedAt time.Time } type ProviderPollRevision struct { @@ -643,6 +652,43 @@ func (r *Runtime) HandleRevision(ctx context.Context, provider, deliveryID strin return record, true, nil } +func (r *Runtime) HandleProviderWebhook(ctx context.Context, input provider.WebhookEvent) (EventRecord, error) { + providerName := strings.TrimSpace(input.Provider) + if providerName == "" { + return EventRecord{}, fmt.Errorf("provider is required") + } + eventType := strings.TrimSpace(input.EventType) + if eventType == "" { + eventType = "unknown" + } + externalID := strings.TrimSpace(input.ExternalID) + receivedAt := time.Now().UTC().Truncate(time.Microsecond) + record := EventRecord{ + ID: "event-" + newID(), + Type: events.ProviderWebhook, + Provider: providerName, + DeliveryID: externalID, + Webhook: &ProviderWebhookRecord{ + EventType: eventType, + ExternalID: externalID, + PayloadSize: len(input.Payload), + ReceivedAt: receivedAt, + }, + CreatedAt: receivedAt, + } + + r.mu.Lock() + r.records = append(r.records, record) + r.mu.Unlock() + + if r.broadcaster != nil { + if err := r.broadcaster.BroadcastEnvelope(ctx, providerWebhookEnvelope(record)); err != nil { + return record, err + } + } + return record, nil +} + func (r *Runtime) HandleProviderPollRevision(ctx context.Context, poll ProviderPollRevision) (EventRecord, bool, error) { provider := strings.TrimSpace(poll.Provider) if provider == "" { @@ -824,6 +870,22 @@ func branchUpdatedEnvelope(record EventRecord) protosocket.Envelope { }) } +func providerWebhookEnvelope(record EventRecord) protosocket.Envelope { + payload := map[string]any{ + "id": record.ID, + "type": record.Type, + "provider": record.Provider, + "delivery_id": record.DeliveryID, + "created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano), + } + if record.Webhook != nil { + payload["event_type"] = record.Webhook.EventType + payload["payload_size"] = record.Webhook.PayloadSize + payload["received_at"] = record.Webhook.ReceivedAt.UTC().Format(time.RFC3339Nano) + } + return protosocket.NewEventEnvelope(events.ProviderWebhook, payload) +} + func stableWatchKey(repoID, branch, provider string) string { replacer := strings.NewReplacer("/", "-", " ", "-", "_", "-", ".", "-") value := strings.ToLower(provider + "-" + repoID + "-" + branch)