From 8e6d53171825aa5051d0ad721a40e6711ad3551e Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 20 Jun 2026 14:52:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(provider):=20ProviderAdapter=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A5=BC=20abstraction?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=8B=A4=EC=A4=91=20provider=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=9C=BC=EB=A1=9C=20=ED=99=95=EC=9E=A5=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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별 동작을 검증한다 --- .../PHASE.md | 2 +- .../milestones/change-request-abstraction.md | 29 ++- .../contracts/notes/gito-control-plane.md | 31 ++- services/core/internal/controlplane/router.go | 6 +- .../core/internal/controlplane/router_test.go | 1 + services/core/internal/core/model.go | 90 ++++++- services/core/internal/core/model_test.go | 64 +++++ services/core/internal/events/events.go | 1 + services/core/internal/events/events_test.go | 20 ++ services/core/internal/provider/provider.go | 151 ++++++++++- .../core/internal/provider/provider_test.go | 240 ++++++++++++++++++ 11 files changed, 613 insertions(+), 22 deletions(-) create mode 100644 services/core/internal/events/events_test.go diff --git a/agent-roadmap/phase/provider-change-request-integration/PHASE.md b/agent-roadmap/phase/provider-change-request-integration/PHASE.md index c6c32db..88f44f9 100644 --- a/agent-roadmap/phase/provider-change-request-integration/PHASE.md +++ b/agent-roadmap/phase/provider-change-request-integration/PHASE.md @@ -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로 표현한다. diff --git a/agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md b/agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md index 47bf9c1..f5ecef6 100644 --- a/agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md +++ b/agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md @@ -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 - 확인 필요: 없음 - diff --git a/packages/contracts/notes/gito-control-plane.md b/packages/contracts/notes/gito-control-plane.md index ea7b8d6..d6d5ac5 100644 --- a/packages/contracts/notes/gito-control-plane.md +++ b/packages/contracts/notes/gito-control-plane.md @@ -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 diff --git a/services/core/internal/controlplane/router.go b/services/core/internal/controlplane/router.go index 9c40fbe..a0a2d59 100644 --- a/services/core/internal/controlplane/router.go +++ b/services/core/internal/controlplane/router.go @@ -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"}, }, }, diff --git a/services/core/internal/controlplane/router_test.go b/services/core/internal/controlplane/router_test.go index 23473cd..65e3b57 100644 --- a/services/core/internal/controlplane/router_test.go +++ b/services/core/internal/controlplane/router_test.go @@ -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", diff --git a/services/core/internal/core/model.go b/services/core/internal/core/model.go index 3de675f..f77c7d6 100644 --- a/services/core/internal/core/model.go +++ b/services/core/internal/core/model.go @@ -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 { diff --git a/services/core/internal/core/model_test.go b/services/core/internal/core/model_test.go index e57a4b9..0f2a023 100644 --- a/services/core/internal/core/model_test.go +++ b/services/core/internal/core/model_test.go @@ -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) + } +} diff --git a/services/core/internal/events/events.go b/services/core/internal/events/events.go index 7b695cd..5618179 100644 --- a/services/core/internal/events/events.go +++ b/services/core/internal/events/events.go @@ -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" ) diff --git a/services/core/internal/events/events_test.go b/services/core/internal/events/events_test.go new file mode 100644 index 0000000..d6f99ab --- /dev/null +++ b/services/core/internal/events/events_test.go @@ -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) + } +} diff --git a/services/core/internal/provider/provider.go b/services/core/internal/provider/provider.go index 60a168e..5bf3548 100644 --- a/services/core/internal/provider/provider.go +++ b/services/core/internal/provider/provider.go @@ -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 { diff --git a/services/core/internal/provider/provider_test.go b/services/core/internal/provider/provider_test.go index 2e759a4..0b0c99b 100644 --- a/services/core/internal/provider/provider_test.go +++ b/services/core/internal/provider/provider_test.go @@ -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.