feat: provider-adapter-foundation milestone implementation

- Add agent-task for provider adapter foundation
- Update roadmap PHASE.md and milestone for provider-adapter-foundation
- Implement router.go, router_test.go, runtime.go controlplane changes
This commit is contained in:
toki 2026-06-19 17:41:24 +09:00
parent 09e55a61d1
commit fc460e01e0
9 changed files with 870 additions and 22 deletions

View file

@ -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를 만든다.

View file

@ -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
- 확인 필요: 없음

View file

@ -0,0 +1,106 @@
<!-- task=m-provider-adapter-foundation/01_webhook_adapter_contract 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.
> 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?**

View file

@ -0,0 +1,181 @@
<!-- task=m-provider-adapter-foundation/01_webhook_adapter_contract plan=0 tag=API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,132 @@
<!-- task=m-provider-adapter-foundation/02+01_webhook_adapter_wiring 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.
> 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 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?**

View file

@ -0,0 +1,233 @@
<!-- task=m-provider-adapter-foundation/02+01_webhook_adapter_wiring plan=0 tag=API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

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

View file

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

View file

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