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:
parent
1685e493dc
commit
8e6d531718
11 changed files with 613 additions and 22 deletions
|
|
@ -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로 표현한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 확인 필요: 없음
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
20
services/core/internal/events/events_test.go
Normal file
20
services/core/internal/events/events_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue