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:
parent
09e55a61d1
commit
fc460e01e0
9 changed files with 870 additions and 22 deletions
|
|
@ -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를 만든다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -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?**
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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?**
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue