feat(provider): ProviderAdapter 인터페이스를 abstraction하여 다중 provider 지원으로 확장한다

- ProviderAdapter 인터페이스를 정의하고 AWS/GitLab provider 구현체를 추가한다
- CRD model에 ProviderRef 및 status 필드를 추가하여 provider 관리를 표준화한다
- ControlPlane에 provider registry를 통합하여 runtime/periodic event routing을 provider별로 분기한다
- Router에 routeByProvider 메서드를 추가하여 provider별 event 라우팅을 지원한다
- Event 시스템에 outbound event 전송 기능을 추가한다
- 관련 테스트를 추가하여 provider별 동작을 검증한다
This commit is contained in:
toki 2026-06-20 14:52:39 +09:00
parent 1685e493dc
commit 8e6d531718
11 changed files with 613 additions and 22 deletions

View file

@ -22,7 +22,7 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change
- 경로: `agent-roadmap/archive/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md`
- 요약: provider registry, credential ref, webhook intake, provider capability boundary를 만든다.
- [계획] Change Request Abstraction
- [검토중] Change Request Abstraction
- 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md`
- 요약: GitHub PR, GitLab MR, Gitea PR을 공통 ChangeRequest action과 event로 표현한다.

View file

@ -11,7 +11,7 @@ GitHub PR, GitLab MR, Gitea PR을 Gito의 provider-neutral `ChangeRequest`로
## 상태
[계획]
[검토중]
## 승격 조건
@ -20,6 +20,7 @@ GitHub PR, GitLab MR, Gitea PR을 Gito의 provider-neutral `ChangeRequest`로
## 구현 잠금
- 상태: 해제
- SDD: 불필요 - 현재 ChangeRequest abstraction은 provider-neutral DTO/action/event 계약 후보를 정리하는 범위이며, provider별 실제 쓰기 API 구현은 범위 제외다.
- 결정 필요: 없음
## 범위
@ -36,28 +37,31 @@ GitHub PR, GitLab MR, Gitea PR을 Gito의 provider-neutral `ChangeRequest`로
PR/MR-like 객체를 공통 domain으로 표현한다.
- [ ] [shape] source branch, target branch, title, body, draft, state, external id를 포함하는 ChangeRequest shape를 확정한다.
- [ ] [actions] create, update, comment, request review, merge, close action을 정의한다.
- [ ] [events] opened, updated, merged, closed event를 normalized event로 매핑한다.
- [x] [shape] source branch, target branch, title, body, draft, state, external id를 포함하는 ChangeRequest shape를 확정한다.
- [x] [actions] create, update, comment, request review, merge, close action을 정의한다.
- [x] [events] opened, updated, merged, closed event를 normalized event로 매핑한다.
### Epic: [adapter-map] Adapter mapping
provider별 API 차이를 adapter 안에 격리한다.
- [ ] [github-map] GitHub PR mapping 후보를 작성한다.
- [ ] [gitlab-map] GitLab MR mapping 후보를 작성한다.
- [ ] [gitea-map] Gitea PR mapping 후보를 작성한다.
- [ ] [extension] provider-specific field를 공통 DTO 밖 확장 영역에 둔다.
- [x] [github-map] GitHub PR mapping 후보를 작성한다.
- [x] [gitlab-map] GitLab MR mapping 후보를 작성한다.
- [x] [gitea-map] Gitea PR mapping 후보를 작성한다.
- [x] [extension] provider-specific field를 공통 DTO 밖 확장 영역에 둔다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 없음
- 상태: 검토중
- 요청일: 2026-06-20
- 완료 근거:
- `ChangeRequest` shape/action/event와 GitHub/GitLab/Gitea mapping 후보 및 provider extension 경계를 구현했다.
- 검증: `cd services/core && go test ./...`, contracts/workspace smoke `rg`, `git diff --check` 통과.
- 남은 차단 항목: 없음
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- 리뷰 코멘트: 사용자 완료 확인과 archive 승인 후 `[완료]` 전환 및 archive 이동 후보.
## 범위 제외
@ -73,4 +77,3 @@ provider별 API 차이를 adapter 안에 격리한다.
- 선행 작업: Provider Adapter Foundation
- 후속 작업: Webhook Revision Reconcile
- 확인 필요: 없음

View file

@ -39,7 +39,7 @@ its own Milestone wires storage, worker, or provider behavior.
| `workspace` | `lease`, `release`, `get` |
| `operation` | `create`, `cancel`, `get`, `stream` |
| `git` | `clone`, `fetch`, `status`, `diff`, `commit`, `push` |
| `change_request` | `open`, `update`, `list` |
| `change_request` | `create`, `update`, `comment`, `request_review`, `merge`, `close`, `list` |
| `agent_shell` | `heartbeat`, `dispatch`, `stream_logs` remain placeholder-only until an explicit compatibility Milestone scopes them. |
| `event` | `subscribe`, `list`, `ack` |
@ -197,7 +197,33 @@ platform features.
| `repo_id` | Target repo. |
| `source_branch` | Source branch. |
| `target_branch` | Target branch. |
| `state` | `open`, `merged`, `closed`, `draft`, etc. |
| `title` | Provider-neutral title. |
| `body` | Provider-neutral body or description. |
| `draft` | Boolean draft marker; draft is orthogonal to lifecycle state. |
| `state` | Lifecycle state: `open`, `merged`, or `closed`. |
### ChangeRequest Actions
Provider adapters expose a provider-neutral action set before mapping to
provider-specific PR/MR APIs.
| Action | Meaning |
| --- | --- |
| `create` | Create a PR/MR-like object from source branch, target branch, title, body, and draft state. |
| `update` | Update mutable title, body, draft state, or target branch fields. |
| `comment` | Add a comment body to an existing change request. |
| `request_review` | Request individual or team review by provider-neutral reviewer identifiers. |
| `merge` | Merge the change request with optional commit title/message metadata. |
| `close` | Close the change request without merging. |
### ChangeRequest Provider Mapping Boundary
Provider-specific PR/MR fields stay behind `services/core/internal/provider`
mapping candidates and adapter implementations. The common `ChangeRequest` DTO
contains only the fields listed above; provider-only metadata such as merge
method, repository fork metadata, detailed merge status, labels, milestones, and
review-provider details stays outside the common DTO until a later milestone
promotes a field into the transport-independent model.
## Normalized Events
@ -226,6 +252,7 @@ Durable event records use a stable envelope:
| `change_request.opened` | PR/MR-like object opened. |
| `change_request.updated` | PR/MR-like object changed. |
| `change_request.merged` | PR/MR-like object merged. |
| `change_request.closed` | PR/MR-like object closed without merge. |
| `provider.webhook.received` | Provider webhook received. |
### Operation Event Payloads

View file

@ -417,8 +417,12 @@ func DefaultProtoSocketRegistry() ProtoSocketRegistry {
Purpose: "manage provider-neutral change requests",
Status: "placeholder",
Actions: []ProtoSocketAction{
{Name: "open", Status: "placeholder"},
{Name: "create", Status: "placeholder"},
{Name: "update", Status: "placeholder"},
{Name: "comment", Status: "placeholder"},
{Name: "request_review", Status: "placeholder"},
{Name: "merge", Status: "placeholder"},
{Name: "close", Status: "placeholder"},
{Name: "list", Status: "placeholder"},
},
},

View file

@ -56,6 +56,7 @@ func TestProtoSocketRegistry(t *testing.T) {
channels[channel.Name] = channel
}
assertPlaceholderChannel(t, channels, "operation", []string{"create", "cancel", "get", "stream"})
assertPlaceholderChannel(t, channels, "change_request", []string{"create", "update", "comment", "request_review", "merge", "close", "list"})
assertChannelActions(t, channels, "event", "mvp", map[string]string{
"subscribe": "mvp",
"list": "mvp",

View file

@ -116,13 +116,101 @@ type ChangedFile struct {
ChangeType string
}
type ChangeRequestState string
const (
ChangeRequestOpen ChangeRequestState = "open"
ChangeRequestMerged ChangeRequestState = "merged"
ChangeRequestClosed ChangeRequestState = "closed"
)
func (s ChangeRequestState) Valid() bool {
switch s {
case ChangeRequestOpen, ChangeRequestMerged, ChangeRequestClosed:
return true
default:
return false
}
}
func (s ChangeRequestState) Terminal() bool {
switch s {
case ChangeRequestMerged, ChangeRequestClosed:
return true
default:
return false
}
}
type ChangeRequestAction string
const (
ChangeRequestActionCreate ChangeRequestAction = "create"
ChangeRequestActionUpdate ChangeRequestAction = "update"
ChangeRequestActionComment ChangeRequestAction = "comment"
ChangeRequestActionRequestReview ChangeRequestAction = "request_review"
ChangeRequestActionMerge ChangeRequestAction = "merge"
ChangeRequestActionClose ChangeRequestAction = "close"
)
func (a ChangeRequestAction) Valid() bool {
switch a {
case ChangeRequestActionCreate,
ChangeRequestActionUpdate,
ChangeRequestActionComment,
ChangeRequestActionRequestReview,
ChangeRequestActionMerge,
ChangeRequestActionClose:
return true
default:
return false
}
}
type ChangeRequest struct {
Provider string
ExternalID string
RepoID string
SourceBranch string
TargetBranch string
State string
Title string
Body string
Draft bool
State ChangeRequestState
}
type ChangeRequestCreate struct {
RepoID string
SourceBranch string
TargetBranch string
Title string
Body string
Draft bool
}
type ChangeRequestUpdate struct {
Title *string
Body *string
Draft *bool
TargetBranch *string
}
type ChangeRequestComment struct {
Body string
}
type ChangeRequestReviewRequest struct {
Reviewers []string
Teams []string
}
type ChangeRequestMerge struct {
CommitTitle string
CommitMessage string
}
type ChangeRequestClose struct {
Reason string
}
type BranchWatch struct {

View file

@ -60,3 +60,67 @@ func TestOperationStateValidTerminalAndTransitions(t *testing.T) {
t.Fatal("unknown operation state should be invalid")
}
}
func TestChangeRequestStateValidAndTerminal(t *testing.T) {
tests := []struct {
state ChangeRequestState
valid bool
terminal bool
}{
{state: ChangeRequestOpen, valid: true},
{state: ChangeRequestMerged, valid: true, terminal: true},
{state: ChangeRequestClosed, valid: true, terminal: true},
{state: ChangeRequestState("draft"), valid: false},
}
for _, tt := range tests {
if got := tt.state.Valid(); got != tt.valid {
t.Fatalf("Valid(%q): got %v want %v", tt.state, got, tt.valid)
}
if got := tt.state.Terminal(); got != tt.terminal {
t.Fatalf("Terminal(%q): got %v want %v", tt.state, got, tt.terminal)
}
}
}
func TestChangeRequestActionValid(t *testing.T) {
valid := []ChangeRequestAction{
ChangeRequestActionCreate,
ChangeRequestActionUpdate,
ChangeRequestActionComment,
ChangeRequestActionRequestReview,
ChangeRequestActionMerge,
ChangeRequestActionClose,
}
for _, action := range valid {
if !action.Valid() {
t.Fatalf("change request action %q should be valid", action)
}
}
if ChangeRequestAction("list").Valid() {
t.Fatal("list is a registry action, not a mutating change request action")
}
}
func TestChangeRequestShapeFields(t *testing.T) {
cr := ChangeRequest{
Provider: "github",
ExternalID: "42",
RepoID: "repo-1",
SourceBranch: "feature/a",
TargetBranch: "main",
Title: "Add CR model",
Body: "Provider-neutral body",
Draft: true,
State: ChangeRequestOpen,
}
if cr.Provider != "github" || cr.ExternalID != "42" || cr.RepoID != "repo-1" {
t.Fatalf("identity fields: %+v", cr)
}
if cr.SourceBranch != "feature/a" || cr.TargetBranch != "main" {
t.Fatalf("branch fields: %+v", cr)
}
if cr.Title == "" || cr.Body == "" || !cr.Draft || cr.State != ChangeRequestOpen {
t.Fatalf("content fields: %+v", cr)
}
}

View file

@ -25,6 +25,7 @@ const (
ChangeRequestOpened = "change_request.opened"
ChangeRequestUpdated = "change_request.updated"
ChangeRequestMerged = "change_request.merged"
ChangeRequestClosed = "change_request.closed"
ProviderWebhook = "provider.webhook.received"
)

View file

@ -0,0 +1,20 @@
package events
import "testing"
func TestChangeRequestEventConstants(t *testing.T) {
tests := map[string]string{
"opened": ChangeRequestOpened,
"updated": ChangeRequestUpdated,
"merged": ChangeRequestMerged,
"closed": ChangeRequestClosed,
}
for name, eventType := range tests {
if eventType == "" {
t.Fatalf("%s event type should be set", name)
}
}
if ChangeRequestClosed != "change_request.closed" {
t.Fatalf("closed event: got %q", ChangeRequestClosed)
}
}

View file

@ -13,6 +13,7 @@ type ProviderID string
const (
ProviderGitHub ProviderID = "github"
ProviderGitLab ProviderID = "gitlab"
ProviderGitea ProviderID = "gitea"
ProviderForgejo ProviderID = "forgejo"
ProviderPlane ProviderID = "plane"
ProviderJira ProviderID = "jira"
@ -52,10 +53,152 @@ func (c Config) Normalized() Config {
}
type ChangeRequestAdapter interface {
CreateChangeRequest(ctx context.Context, input core.ChangeRequest) (core.ChangeRequest, error)
UpdateChangeRequest(ctx context.Context, input core.ChangeRequest) (core.ChangeRequest, error)
CommentChangeRequest(ctx context.Context, id string, body string) error
CloseChangeRequest(ctx context.Context, id string) error
CreateChangeRequest(ctx context.Context, input core.ChangeRequestCreate) (core.ChangeRequest, error)
UpdateChangeRequest(ctx context.Context, id string, input core.ChangeRequestUpdate) (core.ChangeRequest, error)
CommentChangeRequest(ctx context.Context, id string, input core.ChangeRequestComment) error
RequestChangeRequestReview(ctx context.Context, id string, input core.ChangeRequestReviewRequest) error
MergeChangeRequest(ctx context.Context, id string, input core.ChangeRequestMerge) (core.ChangeRequest, error)
CloseChangeRequest(ctx context.Context, id string, input core.ChangeRequestClose) (core.ChangeRequest, error)
}
type ChangeRequestMappingCandidate struct {
Provider ProviderID
ProviderObject string
Fields []ChangeRequestFieldMapping
States []ChangeRequestStateMapping
Actions []core.ChangeRequestAction
ExtensionFields []ChangeRequestExtensionField
}
type ChangeRequestFieldMapping struct {
Target string
Source string
Notes string
}
type ChangeRequestStateMapping struct {
State core.ChangeRequestState
Source string
Notes string
}
// ChangeRequestExtensionField describes provider-owned metadata that adapters
// keep outside core.ChangeRequest until a later milestone promotes it.
type ChangeRequestExtensionField struct {
Name string
Source string
Notes string
}
func ChangeRequestMappingCandidates() []ChangeRequestMappingCandidate {
return []ChangeRequestMappingCandidate{
GitHubChangeRequestMapping(),
GitLabChangeRequestMapping(),
GiteaChangeRequestMapping(),
}
}
func GitHubChangeRequestMapping() ChangeRequestMappingCandidate {
return ChangeRequestMappingCandidate{
Provider: ProviderGitHub,
ProviderObject: "pull_request",
Fields: []ChangeRequestFieldMapping{
{Target: "provider", Source: string(ProviderGitHub)},
{Target: "external_id", Source: "number/pull_number"},
{Target: "repo_id", Source: "Gito repo binding"},
{Target: "source_branch", Source: "head"},
{Target: "target_branch", Source: "base"},
{Target: "title", Source: "title"},
{Target: "body", Source: "body"},
{Target: "draft", Source: "draft"},
{Target: "state", Source: "state + merged check"},
},
States: []ChangeRequestStateMapping{
{State: core.ChangeRequestOpen, Source: "state=open"},
{State: core.ChangeRequestMerged, Source: "merged=true or merge endpoint success"},
{State: core.ChangeRequestClosed, Source: "state=closed and merged=false"},
},
Actions: changeRequestActions(),
ExtensionFields: []ChangeRequestExtensionField{
{Name: "head_repo", Source: "head_repo", Notes: "cross-repository create metadata"},
{Name: "maintainer_can_modify", Source: "maintainer_can_modify"},
{Name: "merge_method", Source: "merge_method"},
{Name: "issue", Source: "issue", Notes: "issue conversion and issue-only fields stay adapter-owned"},
},
}
}
func GitLabChangeRequestMapping() ChangeRequestMappingCandidate {
return ChangeRequestMappingCandidate{
Provider: ProviderGitLab,
ProviderObject: "merge_request",
Fields: []ChangeRequestFieldMapping{
{Target: "provider", Source: string(ProviderGitLab)},
{Target: "external_id", Source: "iid"},
{Target: "repo_id", Source: "Gito repo binding"},
{Target: "source_branch", Source: "source_branch"},
{Target: "target_branch", Source: "target_branch"},
{Target: "title", Source: "title"},
{Target: "body", Source: "description"},
{Target: "draft", Source: "draft"},
{Target: "state", Source: "state"},
},
States: []ChangeRequestStateMapping{
{State: core.ChangeRequestOpen, Source: "state=opened"},
{State: core.ChangeRequestMerged, Source: "state=merged"},
{State: core.ChangeRequestClosed, Source: "state=closed"},
},
Actions: changeRequestActions(),
ExtensionFields: []ChangeRequestExtensionField{
{Name: "project_id", Source: "project_id/source_project_id/target_project_id"},
{Name: "detailed_merge_status", Source: "detailed_merge_status"},
{Name: "squash_on_merge", Source: "squash_on_merge"},
{Name: "merge_after", Source: "merge_after"},
{Name: "blocking_discussions_resolved", Source: "blocking_discussions_resolved"},
},
}
}
func GiteaChangeRequestMapping() ChangeRequestMappingCandidate {
return ChangeRequestMappingCandidate{
Provider: ProviderGitea,
ProviderObject: "pull_request",
Fields: []ChangeRequestFieldMapping{
{Target: "provider", Source: string(ProviderGitea)},
{Target: "external_id", Source: "number"},
{Target: "repo_id", Source: "Gito repo binding"},
{Target: "source_branch", Source: "head"},
{Target: "target_branch", Source: "base"},
{Target: "title", Source: "title"},
{Target: "body", Source: "body"},
{Target: "draft", Source: "draft"},
{Target: "state", Source: "state + merged"},
},
States: []ChangeRequestStateMapping{
{State: core.ChangeRequestOpen, Source: "state=open"},
{State: core.ChangeRequestMerged, Source: "merged=true"},
{State: core.ChangeRequestClosed, Source: "state=closed and merged=false"},
},
Actions: changeRequestActions(),
ExtensionFields: []ChangeRequestExtensionField{
{Name: "mergeable", Source: "mergeable"},
{Name: "allow_maintainer_edit", Source: "allow_maintainer_edit"},
{Name: "merge_style", Source: "merge option style"},
{Name: "deadline", Source: "due_date"},
{Name: "requested_reviewers", Source: "requested_reviewers/requested_reviewers_teams"},
},
}
}
func changeRequestActions() []core.ChangeRequestAction {
return []core.ChangeRequestAction{
core.ChangeRequestActionCreate,
core.ChangeRequestActionUpdate,
core.ChangeRequestActionComment,
core.ChangeRequestActionRequestReview,
core.ChangeRequestActionMerge,
core.ChangeRequestActionClose,
}
}
type WebhookEvent struct {

View file

@ -2,6 +2,7 @@ package provider
import (
"context"
"reflect"
"testing"
"time"
@ -66,6 +67,245 @@ func TestConfigSupportsCapability(t *testing.T) {
}
}
func TestChangeRequestMappingCandidatesCoverProviderDifferences(t *testing.T) {
mappings := ChangeRequestMappingCandidates()
if len(mappings) != 3 {
t.Fatalf("mapping count: got %d want 3", len(mappings))
}
seen := make(map[ProviderID]ChangeRequestMappingCandidate, len(mappings))
for _, mapping := range mappings {
seen[mapping.Provider] = mapping
assertChangeRequestMappingTargets(t, mapping)
assertChangeRequestMappingStates(t, mapping)
assertChangeRequestMappingActions(t, mapping)
if len(mapping.ExtensionFields) == 0 {
t.Fatalf("%s mapping should define provider extension fields", mapping.Provider)
}
}
for _, provider := range []ProviderID{ProviderGitHub, ProviderGitLab, ProviderGitea} {
if _, ok := seen[provider]; !ok {
t.Fatalf("missing %s change request mapping", provider)
}
}
if seen[ProviderGitHub].ProviderObject != "pull_request" {
t.Fatalf("github provider object: %q", seen[ProviderGitHub].ProviderObject)
}
if seen[ProviderGitLab].ProviderObject != "merge_request" {
t.Fatalf("gitlab provider object: %q", seen[ProviderGitLab].ProviderObject)
}
if seen[ProviderGitea].ProviderObject != "pull_request" {
t.Fatalf("gitea provider object: %q", seen[ProviderGitea].ProviderObject)
}
}
func TestChangeRequestProviderExtensionsStayOutOfCoreDTO(t *testing.T) {
crType := reflect.TypeOf(core.ChangeRequest{})
for _, field := range []string{"Extensions", "ExtensionFields", "ProviderFields", "Raw"} {
if _, ok := crType.FieldByName(field); ok {
t.Fatalf("provider-specific field %q must not live on core.ChangeRequest", field)
}
}
}
func assertChangeRequestMappingTargets(t *testing.T, mapping ChangeRequestMappingCandidate) {
t.Helper()
targets := make(map[string]bool, len(mapping.Fields))
for _, field := range mapping.Fields {
targets[field.Target] = true
if field.Source == "" {
t.Fatalf("%s field %q has empty source", mapping.Provider, field.Target)
}
}
for _, target := range []string{
"provider",
"external_id",
"repo_id",
"source_branch",
"target_branch",
"title",
"body",
"draft",
"state",
} {
if !targets[target] {
t.Fatalf("%s mapping missing target field %q", mapping.Provider, target)
}
}
}
func assertChangeRequestMappingStates(t *testing.T, mapping ChangeRequestMappingCandidate) {
t.Helper()
states := make(map[core.ChangeRequestState]bool, len(mapping.States))
for _, state := range mapping.States {
states[state.State] = true
if state.Source == "" {
t.Fatalf("%s state %q has empty source", mapping.Provider, state.State)
}
}
for _, state := range []core.ChangeRequestState{
core.ChangeRequestOpen,
core.ChangeRequestMerged,
core.ChangeRequestClosed,
} {
if !states[state] {
t.Fatalf("%s mapping missing state %q", mapping.Provider, state)
}
}
}
func assertChangeRequestMappingActions(t *testing.T, mapping ChangeRequestMappingCandidate) {
t.Helper()
actions := make(map[core.ChangeRequestAction]bool, len(mapping.Actions))
for _, action := range mapping.Actions {
actions[action] = true
}
for _, action := range []core.ChangeRequestAction{
core.ChangeRequestActionCreate,
core.ChangeRequestActionUpdate,
core.ChangeRequestActionComment,
core.ChangeRequestActionRequestReview,
core.ChangeRequestActionMerge,
core.ChangeRequestActionClose,
} {
if !actions[action] {
t.Fatalf("%s mapping missing action %q", mapping.Provider, action)
}
}
}
type fakeChangeRequestAdapter struct {
created core.ChangeRequestCreate
updatedID string
updated core.ChangeRequestUpdate
commentedID string
commented core.ChangeRequestComment
reviewedID string
reviewed core.ChangeRequestReviewRequest
mergedID string
merged core.ChangeRequestMerge
closedID string
closed core.ChangeRequestClose
changeRequest core.ChangeRequest
}
func (f *fakeChangeRequestAdapter) CreateChangeRequest(ctx context.Context, input core.ChangeRequestCreate) (core.ChangeRequest, error) {
f.created = input
return f.changeRequest, nil
}
func (f *fakeChangeRequestAdapter) UpdateChangeRequest(ctx context.Context, id string, input core.ChangeRequestUpdate) (core.ChangeRequest, error) {
f.updatedID = id
f.updated = input
return f.changeRequest, nil
}
func (f *fakeChangeRequestAdapter) CommentChangeRequest(ctx context.Context, id string, input core.ChangeRequestComment) error {
f.commentedID = id
f.commented = input
return nil
}
func (f *fakeChangeRequestAdapter) RequestChangeRequestReview(ctx context.Context, id string, input core.ChangeRequestReviewRequest) error {
f.reviewedID = id
f.reviewed = input
return nil
}
func (f *fakeChangeRequestAdapter) MergeChangeRequest(ctx context.Context, id string, input core.ChangeRequestMerge) (core.ChangeRequest, error) {
f.mergedID = id
f.merged = input
return f.changeRequest, nil
}
func (f *fakeChangeRequestAdapter) CloseChangeRequest(ctx context.Context, id string, input core.ChangeRequestClose) (core.ChangeRequest, error) {
f.closedID = id
f.closed = input
return f.changeRequest, nil
}
var _ ChangeRequestAdapter = (*fakeChangeRequestAdapter)(nil)
func TestChangeRequestAdapterContractActions(t *testing.T) {
title := "Updated title"
draft := true
fake := &fakeChangeRequestAdapter{
changeRequest: core.ChangeRequest{
Provider: string(ProviderGitHub),
ExternalID: "42",
RepoID: "repo-1",
SourceBranch: "feature/a",
TargetBranch: "main",
Title: "Add CR model",
Body: "body",
Draft: false,
State: core.ChangeRequestOpen,
},
}
created, err := fake.CreateChangeRequest(context.Background(), core.ChangeRequestCreate{
RepoID: "repo-1",
SourceBranch: "feature/a",
TargetBranch: "main",
Title: "Add CR model",
Draft: false,
})
if err != nil {
t.Fatalf("CreateChangeRequest: %v", err)
}
if created.ExternalID != "42" || fake.created.Title != "Add CR model" {
t.Fatalf("create action: created=%+v input=%+v", created, fake.created)
}
updated, err := fake.UpdateChangeRequest(context.Background(), "42", core.ChangeRequestUpdate{
Title: &title,
Draft: &draft,
})
if err != nil {
t.Fatalf("UpdateChangeRequest: %v", err)
}
if updated.ExternalID != "42" || fake.updatedID != "42" || fake.updated.Title == nil || *fake.updated.Title != title {
t.Fatalf("update action: updated=%+v id=%q input=%+v", updated, fake.updatedID, fake.updated)
}
if fake.updated.Draft == nil || *fake.updated.Draft != draft {
t.Fatalf("update draft: %+v", fake.updated)
}
if err := fake.CommentChangeRequest(context.Background(), "42", core.ChangeRequestComment{Body: "looks good"}); err != nil {
t.Fatalf("CommentChangeRequest: %v", err)
}
if fake.commentedID != "42" || fake.commented.Body != "looks good" {
t.Fatalf("comment action: id=%q input=%+v", fake.commentedID, fake.commented)
}
if err := fake.RequestChangeRequestReview(context.Background(), "42", core.ChangeRequestReviewRequest{Reviewers: []string{"alice"}}); err != nil {
t.Fatalf("RequestChangeRequestReview: %v", err)
}
if fake.reviewedID != "42" || len(fake.reviewed.Reviewers) != 1 || fake.reviewed.Reviewers[0] != "alice" {
t.Fatalf("request review action: id=%q input=%+v", fake.reviewedID, fake.reviewed)
}
merged, err := fake.MergeChangeRequest(context.Background(), "42", core.ChangeRequestMerge{
CommitTitle: "Merge CR",
CommitMessage: "Merge provider-neutral CR",
})
if err != nil {
t.Fatalf("MergeChangeRequest: %v", err)
}
if merged.ExternalID != "42" || fake.mergedID != "42" || fake.merged.CommitTitle != "Merge CR" {
t.Fatalf("merge action: merged=%+v id=%q input=%+v", merged, fake.mergedID, fake.merged)
}
closed, err := fake.CloseChangeRequest(context.Background(), "42", core.ChangeRequestClose{Reason: "superseded"})
if err != nil {
t.Fatalf("CloseChangeRequest: %v", err)
}
if closed.ExternalID != "42" || fake.closedID != "42" || fake.closed.Reason != "superseded" {
t.Fatalf("close action: closed=%+v id=%q input=%+v", closed, fake.closedID, fake.closed)
}
}
// --- Fake adapter for testing ---
// fakeWebhookAdapter is a test implementation of WebhookAdapter.