feat: webhook adapter wiring - router/runtime/provider refactor

- Move old task files to archive
- Update router.go with new adapter wiring
- Update runtime.go with provider integration
- Refactor provider.go with new abstraction
- Update provider tests
This commit is contained in:
toki 2026-06-19 19:45:53 +09:00
parent fc460e01e0
commit f3bbb4e579
11 changed files with 860 additions and 36 deletions

View file

@ -0,0 +1,160 @@
<!-- task=m-provider-adapter-foundation/01_webhook_adapter_contract plan=2 tag=REVIEW_REVIEW_API -->
# Code Review Reference - REVIEW_REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked SDD/Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-19
task=m-provider-adapter-foundation/01_webhook_adapter_contract, plan=2, tag=REVIEW_REVIEW_API
## Archive Evidence Snapshot
- 이전 plan log:
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/plan_local_G05_0.log`
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/plan_local_G03_1.log`
- 이전 review log:
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/code_review_local_G05_0.log`
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/code_review_local_G03_1.log`
- 이전 판정:
- `code_review_local_G05_0.log`: FAIL
- `code_review_local_G03_1.log`: FAIL
- Required 요약:
- `services/core/internal/provider/provider_test.go:214`와 `services/core/internal/provider/provider_test.go:247`이 `ReceivedAt`을 exact timestamp로 비교하지 않고 non-zero만 확인한다.
- Suggested 요약: 없음
- Nit 요약: 없음
- 영향 파일: `services/core/internal/provider/provider_test.go`
- 검증 근거:
- `cd services/core && go test -count=1 ./internal/provider` 통과
- `cd services/core && go test -count=1 ./...` 통과
- 허용된 archive 재확인 경로: 위의 이전 plan/review log 네 파일만 필요 시 좁게 읽는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G02.md` -> `code_review_local_G02_N.log`, `PLAN-local-G02.md` -> `plan_local_G02_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-adapter-foundation/01_webhook_adapter_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-provider-adapter-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_API-1] ReceivedAt Preservation Assertion | [x] |
## 구현 체크리스트
- [x] `TestWebhookAdapterContractRequestDelivery`에서 verifier request의 `ReceivedAt`을 `now`와 `Equal` 비교한다.
- [x] `TestWebhookAdapterContractRequestDelivery`에서 normalizer request의 `ReceivedAt`을 `now`와 `Equal` 비교한다.
- [x] `cd services/core && go test -count=1 ./internal/provider`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
계획과 동일하게 구현했습니다. 추가 변경 사항은 없습니다.
## 주요 설계 결정
기존 테스트 구조를 유지하고, `IsZero()` 체크를 `Equal(now)` 체크로 교체하는 최소 변경만으로 구현했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- verifier request의 `ReceivedAt`이 `now`와 `Equal` 비교되는지 확인한다.
- normalizer request의 `ReceivedAt`이 `now`와 `Equal` 비교되는지 확인한다.
- 다른 API나 runtime 동작 변경이 새로 생기지 않았는지 확인한다.
## 검증 결과
각 중간 검증 및 최종 검증 명령 실행 후 출력을 기록합니다.
### REVIEW_REVIEW_API-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/provider
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.003s
```
### 최종 검증
```text
$ cd services/core && go test -count=1 ./...
? git.toki-labs.com/toki/gito/services/core/cmd/server [no test files]
? git.toki-labs.com/toki/gito/services/core/cmd/worker [no test files]
ok git.toki-labs.com/toki/gito/services/core/internal/config 0.004s
ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.840s
ok git.toki-labs.com/toki/gito/services/core/internal/core 0.007s
? git.toki-labs.com/toki/gito/services/core/internal/events [no test files]
ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 1.281s
ok git.toki-labs.com/toki/gito/services/core/internal/protosocket 0.008s
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.007s
ok git.toki-labs.com/toki/gito/services/core/internal/provider/forgejo 0.007s
ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.008s
ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.010s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS complete.log를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,172 @@
<!-- task=m-provider-adapter-foundation/01_webhook_adapter_contract plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service setup, generic scope conflicts, loop exhaustion, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only the linked SDD/Milestone lock decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-19
task=m-provider-adapter-foundation/01_webhook_adapter_contract, plan=1, tag=REVIEW_API
## Archive Evidence Snapshot
- 이전 plan log: `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/plan_local_G05_0.log`
- 이전 review log: `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/code_review_local_G05_0.log`
- 이전 판정: FAIL
- Required 요약:
- `services/core/internal/provider/provider_test.go:79`의 fake adapter가 `WebhookRequest`를 기록하지 않아 `TestWebhookAdapterContractRequestDelivery`가 provider/event/delivery/payload/header/query가 verifier와 normalizer에 실제 전달됐는지 검증하지 못한다.
- Suggested 요약: 없음
- Nit 요약: 없음
- 영향 파일: `services/core/internal/provider/provider_test.go`
- 검증 근거:
- `cd services/core && go test -count=1 ./internal/provider` 통과
- `cd services/core && go test -count=1 ./internal/provider ./internal/controlplane` 통과
- `cd services/core && go test ./...` 통과
- 허용된 archive 재확인 경로: 위의 이전 plan/review log 두 파일만 필요 시 좁게 읽는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G03.md` -> `code_review_local_G03_N.log`, `PLAN-local-G03.md` -> `plan_local_G03_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-provider-adapter-foundation/01_webhook_adapter_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-provider-adapter-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Provider Webhook Request Delivery Test | [x] |
## 구현 체크리스트
- [x] fake webhook adapter가 verifier와 normalizer 각각에서 받은 `WebhookRequest`를 기록하도록 `services/core/internal/provider/provider_test.go`를 보강한다.
- [x] `TestWebhookAdapterContractRequestDelivery`에서 provider, event type, delivery id, payload, header, query, receivedAt이 verifier와 normalizer 양쪽에 보존되어 전달됐는지 assert한다.
- [x] `cd services/core && go test -count=1 ./internal/provider`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- 계획에서는 `fakeWebhookAdapter`에 `ProviderID.IsValid()` 존재 여부를 체크했으나, `ProviderID` 타입에 `IsValid()` 메서드가 없어 빈 문자열 체크(`== ""`)로 대체했다.
- 이외에는 계획과 동일하게 구현했다.
## 주요 설계 결정
- `fakeWebhookAdapter`에 `verifyReq WebhookRequest`와 `normalizeReq WebhookRequest` 필드를 추가하여, verifier와 normalizer가 각각 받은 request를 별도 저장하도록 했다.
- 테스트에서 `verifyReq`와 `normalizeReq`를 개별적으로 검증하여, request metadata가 verifier와 normalizer 양쪽에 실제로 전달됐는지 확인했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- fake adapter가 verifier와 normalizer 각각의 request를 별도로 기록하는지 확인한다.
- request metadata assertion이 provider, event type, delivery id, payload, headers, query, receivedAt을 모두 포함하는지 확인한다.
- provider API 타입이나 router/runtime 동작 변경이 새로 생기지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_API-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/provider
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.002s
```
### 최종 검증
```text
$ cd services/core && go test -count=1 ./internal/provider
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.002s
```
### 최종 검증 (전체 ./...)
```text
$ cd services/core && go test -count=1 ./...
? git.toki-labs.com/toki/gito/services/core/cmd/server [no test files]
? git.toki-labs.com/toki/gito/services/core/cmd/worker [no test files]
ok git.toki-labs.com/toki/gito/services/core/internal/config 0.009s
ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.713s
ok git.toki-labs.com/toki/gito/services/core/internal/core 0.006s
? git.toki-labs.com/toki/gito/services/core/internal/events [no test files]
ok git.toki-labs.com/toki/gito/services/core/internal/gitengine 1.127s
ok git.toki-labs.com/toki/gito/services/core/internal/protosocket 0.007s
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.006s
ok git.toki-labs.com/toki/gito/services/core/internal/provider/forgejo 0.006s
ok git.toki-labs.com/toki/gito/services/core/internal/storage 0.008s
ok git.toki-labs.com/toki/gito/services/core/internal/worker 0.013s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `services/core/internal/provider/provider_test.go:214`와 `services/core/internal/provider/provider_test.go:247`은 `ReceivedAt`이 0이 아닌지만 확인합니다. 이번 follow-up plan은 verifier와 normalizer 양쪽에서 `ReceivedAt`이 보존되어 전달됐는지 assert하라고 요구했으므로, 두 request 모두 `now`와 `Equal` 비교해야 합니다.
- 다음 단계: FAIL 후속 plan/review를 같은 task 디렉터리에 작성한다.

View file

@ -33,36 +33,57 @@ task=m-provider-adapter-foundation/01_webhook_adapter_contract, plan=0, tag=API
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Provider Webhook Contract | [ ] |
| [API-1] Provider Webhook Contract | [x] |
## 구현 체크리스트
- [ ] 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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] provider webhook request, verification hook, normalization candidate, adapter interface를 `services/core/internal/provider/provider.go`에 추가한다.
- [x] fake adapter 기반 provider package 테스트를 추가해 interface 구현, empty payload 보존, candidate type/revision 표현을 검증한다.
- [x] `cd services/core && go test -count=1 ./internal/provider`를 실행한다.
- [x] 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을 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block을 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나 유지 사유를 확인한다.
- [x] FAIL이고 user-review gate가 트리거되지 않았으므로 다음 active `PLAN-local-G03.md`와 `CODE_REVIEW-local-G03.md`를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
계획에 명시된 모든 항목을 따랐다. 추가 변경 사항 없음.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
1. **`WebhookEvent` 유지**: 기존 `WebhookEvent` struct를 제거하지 않고 새로운 `WebhookRequest`와 함께 유지했다. 이는 기존 API 호환성을 유지하기 위함이다.
2. **`Runtime.HandleProviderWebhook` 시그니처 변경**: `provider.WebhookEvent` → `provider.WebhookRequest`로 변경하여 headers, query, receivedAt를 직접 전달받을 수 있게 했다.
3. **router.go `handleProviderWebhook` 업데이트**: HTTP request의 `r.Header`를 `map[string][]string`으로, `r.URL.Query()`를 그대로 전달하여 `WebhookRequest`를 구성했다.
4. **`WebhookCandidate`는 string으로 `Type` 정의**: `events` package 의존을 피하기 위해 normalized event type을 string으로 유지했다.
## 검증 결과
### API-1 중간 검증
```text
$ cd services/core && go test -count=1 ./internal/provider
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.006s
```
### 최종 검증
```text
$ cd services/core && go test -count=1 ./internal/provider ./internal/controlplane
ok git.toki-labs.com/toki/gito/services/core/internal/provider 0.006s
ok git.toki-labs.com/toki/gito/services/core/internal/controlplane 0.651s
```
## 사용자 리뷰 요청
@ -83,24 +104,21 @@ _기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직
- 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?**
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `services/core/internal/provider/provider_test.go:79`의 fake adapter가 `WebhookRequest`를 기록하지 않고, `TestWebhookAdapterContractRequestDelivery`도 provider/event/delivery/payload/header/query가 verifier와 normalizer에 실제 전달됐는지 검증하지 않습니다. 계획의 테스트 목표가 "request의 provider/event/delivery/payload/header/query가 verifier와 normalizer로 전달됨을 확인"하는 것이므로, fake adapter에 `verifyReq`/`normalizeReq`를 저장하고 테스트에서 모든 request 필드를 assert하도록 보강해야 합니다.
- 다음 단계: FAIL 후속 plan/review를 같은 task 디렉터리에 작성한다.

View file

@ -0,0 +1,38 @@
# Complete - m-provider-adapter-foundation/01_webhook_adapter_contract
## 완료 일시
2026-06-19
## 요약
Provider webhook adapter contract foundation을 3회 리뷰 루프 끝에 PASS로 완료했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | FAIL | fake adapter가 verifier/normalizer request 전달 보존을 검증하지 못해 follow-up 생성 |
| `plan_local_G03_1.log` | `code_review_local_G03_1.log` | FAIL | `ReceivedAt` 보존을 exact timestamp로 검증하지 못해 follow-up 생성 |
| `plan_local_G02_2.log` | `code_review_local_G02_2.log` | PASS | verifier/normalizer 양쪽의 `ReceivedAt` 보존 검증까지 완료 |
## 구현/정리 내용
- `provider.WebhookRequest`, `WebhookCandidate`, `WebhookVerifier`, `WebhookNormalizer`, `WebhookAdapter` 계약을 추가했다.
- generic provider callback path가 `provider.WebhookRequest`를 생성하도록 `runtime`/`router` 타입 사용을 갱신했다.
- fake webhook adapter 기반 테스트로 verifier/normalizer request metadata와 candidate 표현을 검증했다.
- follow-up에서 verifier/normalizer 양쪽 request의 `ReceivedAt`이 원본 timestamp와 동일하게 전달되는지 `Equal(now)` assertion으로 보강했다.
## 최종 검증
- `cd services/core && go test -count=1 ./internal/provider` - PASS; `ok git.toki-labs.com/toki/gito/services/core/internal/provider`
- `cd services/core && go test -count=1 ./...` - PASS; core module 전체 package 통과
- `git diff --check` - PASS; whitespace error 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,80 @@
<!-- task=m-provider-adapter-foundation/01_webhook_adapter_contract plan=2 tag=REVIEW_REVIEW_API -->
# Provider Webhook Adapter Contract Follow-up Plan
## 이 파일을 읽는 구현 에이전트에게
이 계획의 구현 완료는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운 뒤에만 성립한다. 구현 중 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경, secret, 서비스 준비, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan 사유로 기록한다.
## Archive Evidence Snapshot
- 이전 plan log:
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/plan_local_G05_0.log`
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/plan_local_G03_1.log`
- 이전 review log:
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/code_review_local_G05_0.log`
- `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/code_review_local_G03_1.log`
- 이전 판정:
- `code_review_local_G05_0.log`: FAIL
- `code_review_local_G03_1.log`: FAIL
- Required 요약:
- `services/core/internal/provider/provider_test.go:214`와 `services/core/internal/provider/provider_test.go:247`이 `ReceivedAt`을 exact timestamp로 비교하지 않고 non-zero만 확인한다.
- Suggested 요약: 없음
- Nit 요약: 없음
- 영향 파일: `services/core/internal/provider/provider_test.go`
- 검증 근거:
- `cd services/core && go test -count=1 ./internal/provider` 통과
- `cd services/core && go test -count=1 ./...` 통과
- 허용된 archive 재확인 경로: 위의 이전 plan/review log 네 파일만 필요 시 좁게 읽는다.
## 범위 결정 근거
- 이 follow-up은 직전 Required 하나만 닫는다.
- provider API 타입, router/runtime 동작, Forgejo webhook wiring은 변경하지 않는다.
- Roadmap Targets와 Spec Targets는 이전 plan에 없었으므로 이 follow-up에서도 주장하지 않는다.
## 구현 체크리스트
- [x] `TestWebhookAdapterContractRequestDelivery`에서 verifier request의 `ReceivedAt`을 `now`와 `Equal` 비교한다.
- [x] `TestWebhookAdapterContractRequestDelivery`에서 normalizer request의 `ReceivedAt`을 `now`와 `Equal` 비교한다.
- [x] `cd services/core && go test -count=1 ./internal/provider`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_REVIEW_API-1] ReceivedAt Preservation Assertion
### 문제
직전 구현은 verifier와 normalizer가 받은 request를 기록하지만, `ReceivedAt`은 0이 아닌지만 확인한다. 이러면 hook이 timestamp를 다른 non-zero 값으로 바꾸는 구현도 테스트가 통과할 수 있어 "보존" 요구가 잠기지 않는다.
### 해결 방법
- `fake.verifyReq.ReceivedAt.Equal(now)`를 assert한다.
- `fake.normalizeReq.ReceivedAt.Equal(now)`를 assert한다.
- 실패 메시지는 실제 값과 기대 값을 함께 보여준다.
### 테스트 작성
- 기존 `TestWebhookAdapterContractRequestDelivery`만 보강한다.
- 새 테스트 파일은 만들지 않는다.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/provider
```
기대 결과: exit 0.
## 수정 파일 요약
| 파일 | 항목 |
| --- | --- |
| `services/core/internal/provider/provider_test.go` | REVIEW_REVIEW_API-1 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/provider
```
기대 결과: exit 0.

View file

@ -0,0 +1,76 @@
<!-- task=m-provider-adapter-foundation/01_webhook_adapter_contract plan=1 tag=REVIEW_API -->
# Provider Webhook Adapter Contract Follow-up Plan
## 이 파일을 읽는 구현 에이전트에게
이 계획의 구현 완료는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채운 뒤에만 성립한다. 구현 중 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청` 섹션을 채우고 멈춘다. 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경, secret, 서비스 준비, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan 사유로 기록한다.
## Archive Evidence Snapshot
- 이전 plan log: `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/plan_local_G05_0.log`
- 이전 review log: `agent-task/m-provider-adapter-foundation/01_webhook_adapter_contract/code_review_local_G05_0.log`
- 이전 판정: FAIL
- Required 요약:
- `services/core/internal/provider/provider_test.go:79`의 fake adapter가 `WebhookRequest`를 기록하지 않아 `TestWebhookAdapterContractRequestDelivery`가 provider/event/delivery/payload/header/query가 verifier와 normalizer에 실제 전달됐는지 검증하지 못한다.
- Suggested 요약: 없음
- Nit 요약: 없음
- 영향 파일: `services/core/internal/provider/provider_test.go`
- 검증 근거:
- `cd services/core && go test -count=1 ./internal/provider` 통과
- `cd services/core && go test -count=1 ./internal/provider ./internal/controlplane` 통과
- `cd services/core && go test ./...` 통과
- 허용된 archive 재확인 경로: 위의 이전 plan/review log 두 파일만 필요 시 좁게 읽는다.
## 범위 결정 근거
- 이 follow-up은 이전 리뷰의 Required 하나만 닫는다.
- provider API 타입, router/runtime 동작, Forgejo webhook wiring은 변경하지 않는다.
- Roadmap Targets와 Spec Targets는 이전 plan에 없었으므로 이 follow-up에서도 주장하지 않는다.
## 구현 체크리스트
- [x] fake webhook adapter가 verifier와 normalizer 각각에서 받은 `WebhookRequest`를 기록하도록 `services/core/internal/provider/provider_test.go`를 보강한다.
- [x] `TestWebhookAdapterContractRequestDelivery`에서 provider, event type, delivery id, payload, header, query, receivedAt이 verifier와 normalizer 양쪽에 보존되어 전달됐는지 assert한다.
- [x] `cd services/core && go test -count=1 ./internal/provider`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_API-1] Provider Webhook Request Delivery Test
### 문제
`fakeWebhookAdapter.VerifyWebhook`와 `fakeWebhookAdapter.NormalizeWebhook`는 `WebhookRequest` 인자를 받지만 기록하지 않는다. 그래서 현재 테스트는 request literal을 메서드에 넘겨도 verifier/normalizer hook이 그 request metadata를 실제로 받았는지 확인하지 못한다.
### 해결 방법
- fake adapter에 `verifyReq WebhookRequest`와 `normalizeReq WebhookRequest` 필드를 추가한다.
- 각 메서드에서 받은 request를 해당 필드에 저장한다.
- 테스트에서 두 저장값 모두에 대해 `Provider`, `EventType`, `ExternalID`, `Payload`, `Headers`, `Query`, `ReceivedAt`을 assert한다.
- 기존 candidate type/revision 검증은 유지한다.
### 테스트 작성
- 기존 `TestWebhookAdapterContractRequestDelivery`를 보강한다.
- 새 테스트 파일은 만들지 않는다.
### 중간 검증
```bash
cd services/core && go test -count=1 ./internal/provider
```
기대 결과: exit 0.
## 수정 파일 요약
| 파일 | 항목 |
| --- | --- |
| `services/core/internal/provider/provider_test.go` | REVIEW_API-1 |
## 최종 검증
```bash
cd services/core && go test -count=1 ./internal/provider
```
기대 결과: exit 0.

View file

@ -221,11 +221,14 @@ func handleProviderWebhook(runtime *Runtime) http.HandlerFunc {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read payload"})
return
}
record, err := runtime.HandleProviderWebhook(r.Context(), provider.WebhookEvent{
Provider: providerID,
record, err := runtime.HandleProviderWebhook(r.Context(), provider.WebhookRequest{
Provider: provider.ProviderID(providerID),
EventType: providerWebhookEventName(r),
ExternalID: providerWebhookDeliveryID(r),
Payload: body,
Headers: map[string][]string(r.Header),
Query: r.URL.Query(),
ReceivedAt: time.Now().UTC(),
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})

View file

@ -652,8 +652,8 @@ 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)
func (r *Runtime) HandleProviderWebhook(ctx context.Context, input provider.WebhookRequest) (EventRecord, error) {
providerName := strings.TrimSpace(string(input.Provider))
if providerName == "" {
return EventRecord{}, fmt.Errorf("provider is required")
}
@ -662,7 +662,10 @@ func (r *Runtime) HandleProviderWebhook(ctx context.Context, input provider.Webh
eventType = "unknown"
}
externalID := strings.TrimSpace(input.ExternalID)
receivedAt := time.Now().UTC().Truncate(time.Microsecond)
receivedAt := input.ReceivedAt
if receivedAt.IsZero() {
receivedAt = time.Now().UTC().Truncate(time.Microsecond)
}
record := EventRecord{
ID: "event-" + newID(),
Type: events.ProviderWebhook,

View file

@ -64,3 +64,41 @@ type WebhookEvent struct {
ExternalID string
Payload []byte
}
// WebhookRequest represents a provider webhook with all metadata needed
// for signature verification and normalization.
type WebhookRequest struct {
Provider ProviderID
EventType string
ExternalID string
Payload []byte
Headers map[string][]string
Query map[string][]string
ReceivedAt time.Time
}
// WebhookCandidate represents a provider-neutral normalization candidate
// that contains minimal metadata without requiring provider secrets.
type WebhookCandidate struct {
Type string
Provider ProviderID
ExternalID string
Revision *core.RevisionEvent
}
// WebhookVerifier defines the interface for verifying webhook signatures.
type WebhookVerifier interface {
VerifyWebhook(ctx context.Context, req WebhookRequest) error
}
// WebhookNormalizer defines the interface for normalizing webhook events
// into provider-neutral candidates.
type WebhookNormalizer interface {
NormalizeWebhook(ctx context.Context, req WebhookRequest) ([]WebhookCandidate, error)
}
// WebhookAdapter combines verification and normalization capabilities.
type WebhookAdapter interface {
WebhookVerifier
WebhookNormalizer
}

View file

@ -1,6 +1,12 @@
package provider
import "testing"
import (
"context"
"testing"
"time"
"git.toki-labs.com/toki/gito/services/core/internal/core"
)
func TestConfigNormalizedTrimsIdentityFields(t *testing.T) {
config := Config{
@ -59,3 +65,233 @@ func TestConfigSupportsCapability(t *testing.T) {
t.Fatal("did not expect change request support")
}
}
// --- Fake adapter for testing ---
// fakeWebhookAdapter is a test implementation of WebhookAdapter.
type fakeWebhookAdapter struct {
verified bool
verifyErr error
candidates []WebhookCandidate
normalizeErr error
// Recorded requests for verifier and normalizer.
verifyReq WebhookRequest
normalizeReq WebhookRequest
}
func (f *fakeWebhookAdapter) VerifyWebhook(ctx context.Context, req WebhookRequest) error {
if f.verifyErr != nil {
return f.verifyErr
}
f.verified = true
f.verifyReq = req
return nil
}
func (f *fakeWebhookAdapter) NormalizeWebhook(ctx context.Context, req WebhookRequest) ([]WebhookCandidate, error) {
if f.normalizeErr != nil {
return nil, f.normalizeErr
}
f.normalizeReq = req
return f.candidates, nil
}
// verifyWebhookAdapter ensures WebhookAdapter compile-time assertion.
var _ WebhookAdapter = (*fakeWebhookAdapter)(nil)
func TestWebhookAdapterContractCompileTime(t *testing.T) {
// fakeWebhookAdapter is already assigned to var _ WebhookAdapter.
// This test passes if the file compiles.
}
func TestWebhookAdapterContractRequestDelivery(t *testing.T) {
revision := &core.RevisionEvent{
RepoID: "test-repo",
Branch: "main",
Before: "abc123",
After: "def456",
ObservedAt: time.Now().UTC(),
}
now := time.Now().UTC()
req := WebhookRequest{
Provider: ProviderForgejo,
EventType: "push",
ExternalID: "delivery-123",
Payload: []byte(`{"ref":"refs/heads/main"}`),
Headers: map[string][]string{
"X-Forgejo-Signature": {"sha256=abc"},
},
Query: map[string][]string{
"repo_id": {"test-repo"},
},
ReceivedAt: now,
}
fake := &fakeWebhookAdapter{
candidates: []WebhookCandidate{
{
Type: "branch.updated",
Provider: ProviderForgejo,
ExternalID: "delivery-123",
Revision: revision,
},
},
}
ctx := context.Background()
// Verify delivery
if err := fake.VerifyWebhook(ctx, req); err != nil {
t.Fatalf("VerifyWebhook: %v", err)
}
if !fake.verified {
t.Fatal("expected verified to be true")
}
// Normalize delivery
candidates, err := fake.NormalizeWebhook(ctx, req)
if err != nil {
t.Fatalf("NormalizeWebhook: %v", err)
}
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
c := candidates[0]
if c.Type != "branch.updated" {
t.Fatalf("Type: got %q want %q", c.Type, "branch.updated")
}
if c.Provider != ProviderForgejo {
t.Fatalf("Provider: got %q want %q", c.Provider, ProviderForgejo)
}
if c.ExternalID != "delivery-123" {
t.Fatalf("ExternalID: got %q want %q", c.ExternalID, "delivery-123")
}
if c.Revision == nil {
t.Fatal("expected Revision to be set")
}
if c.Revision.RepoID != "test-repo" {
t.Fatalf("Revision.RepoID: got %q want %q", c.Revision.RepoID, "test-repo")
}
if c.Revision.Branch != "main" {
t.Fatalf("Revision.Branch: got %q want %q", c.Revision.Branch, "main")
}
// Verify that the request was actually delivered to the verifier.
if fake.verifyReq.Provider == "" {
t.Fatal("expected verifyReq.Provider to be set")
}
if fake.verifyReq.Provider != ProviderForgejo {
t.Fatalf("verifyReq.Provider: got %q want %q", fake.verifyReq.Provider, ProviderForgejo)
}
if fake.verifyReq.EventType != "push" {
t.Fatalf("verifyReq.EventType: got %q want %q", fake.verifyReq.EventType, "push")
}
if fake.verifyReq.ExternalID != "delivery-123" {
t.Fatalf("verifyReq.ExternalID: got %q want %q", fake.verifyReq.ExternalID, "delivery-123")
}
if string(fake.verifyReq.Payload) != `{"ref":"refs/heads/main"}` {
t.Fatalf("verifyReq.Payload: got %q", fake.verifyReq.Payload)
}
if len(fake.verifyReq.Headers) != 1 {
t.Fatalf("verifyReq.Headers: got %d entries, want 1", len(fake.verifyReq.Headers))
}
if sigs, ok := fake.verifyReq.Headers["X-Forgejo-Signature"]; !ok {
t.Fatal("verifyReq.Headers['X-Forgejo-Signature'] not found")
} else if len(sigs) != 1 || sigs[0] != "sha256=abc" {
t.Fatalf("verifyReq.Headers['X-Forgejo-Signature']: got %v, want [sha256=abc]", sigs)
}
if len(fake.verifyReq.Query) != 1 {
t.Fatalf("verifyReq.Query: got %d entries, want 1", len(fake.verifyReq.Query))
}
if repos, ok := fake.verifyReq.Query["repo_id"]; !ok {
t.Fatal("verifyReq.Query['repo_id'] not found")
} else if len(repos) != 1 || repos[0] != "test-repo" {
t.Fatalf("verifyReq.Query['repo_id']: got %v, want [test-repo]", repos)
}
if !fake.verifyReq.ReceivedAt.Equal(now) {
t.Fatalf("verifyReq.ReceivedAt: got %v, want %v", fake.verifyReq.ReceivedAt, now)
}
// Verify that the request was actually delivered to the normalizer.
if fake.normalizeReq.Provider != ProviderForgejo {
t.Fatalf("normalizeReq.Provider: got %q want %q", fake.normalizeReq.Provider, ProviderForgejo)
}
if fake.normalizeReq.EventType != "push" {
t.Fatalf("normalizeReq.EventType: got %q want %q", fake.normalizeReq.EventType, "push")
}
if fake.normalizeReq.ExternalID != "delivery-123" {
t.Fatalf("normalizeReq.ExternalID: got %q want %q", fake.normalizeReq.ExternalID, "delivery-123")
}
if string(fake.normalizeReq.Payload) != `{"ref":"refs/heads/main"}` {
t.Fatalf("normalizeReq.Payload: got %q", fake.normalizeReq.Payload)
}
if len(fake.normalizeReq.Headers) != 1 {
t.Fatalf("normalizeReq.Headers: got %d entries, want 1", len(fake.normalizeReq.Headers))
}
if sigs, ok := fake.normalizeReq.Headers["X-Forgejo-Signature"]; !ok {
t.Fatal("normalizeReq.Headers['X-Forgejo-Signature'] not found")
} else if len(sigs) != 1 || sigs[0] != "sha256=abc" {
t.Fatalf("normalizeReq.Headers['X-Forgejo-Signature']: got %v, want [sha256=abc]", sigs)
}
if len(fake.normalizeReq.Query) != 1 {
t.Fatalf("normalizeReq.Query: got %d entries, want 1", len(fake.normalizeReq.Query))
}
if repos, ok := fake.normalizeReq.Query["repo_id"]; !ok {
t.Fatal("normalizeReq.Query['repo_id'] not found")
} else if len(repos) != 1 || repos[0] != "test-repo" {
t.Fatalf("normalizeReq.Query['repo_id']: got %v, want [test-repo]", repos)
}
if !fake.normalizeReq.ReceivedAt.Equal(now) {
t.Fatalf("normalizeReq.ReceivedAt: got %v, want %v", fake.normalizeReq.ReceivedAt, now)
}
}
func TestWebhookRequestEmptyPayloadPreservation(t *testing.T) {
req := WebhookRequest{
Provider: ProviderGitHub,
EventType: "pull_request",
ExternalID: "",
Payload: []byte{},
Headers: nil,
Query: nil,
ReceivedAt: time.Time{},
}
if len(req.Payload) != 0 {
t.Fatalf("expected empty payload, got %d bytes", len(req.Payload))
}
if req.ExternalID != "" {
t.Fatalf("expected empty ExternalID, got %q", req.ExternalID)
}
if !req.ReceivedAt.IsZero() {
t.Fatalf("expected zero ReceivedAt")
}
}
func TestWebhookCandidateNoSecretRequirement(t *testing.T) {
// WebhookCandidate must not expose any secret-related fields.
// This is verified by the struct definition itself:
// - Type string
// - Provider ProviderID
// - ExternalID string
// - Revision *core.RevisionEvent
// No secret, signature, or token fields.
c := WebhookCandidate{
Type: "branch.updated",
Provider: ProviderForgejo,
ExternalID: "delivery-1",
}
// Verify the struct has no secret-related fields by construction.
// If any secret field were added, compilation would still pass,
// but the test ensures the current minimal metadata contract.
if c.Type == "" {
t.Fatal("Type should be settable")
}
if c.Provider == "" {
t.Fatal("Provider should be settable")
}
}