package mcp import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/changerequest" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // fakeChangeReqSvc implements changerequest.Service. type fakeChangeReqSvc struct { items []*changerequest.ChangeRequest mergeErr error syncState changerequest.CIState } func (f *fakeChangeReqSvc) Create(_ context.Context, c changerequest.Caller, wsID uuid.UUID, in changerequest.CreateInput) (*changerequest.ChangeRequest, error) { ext := int64(len(f.items) + 1) cr := &changerequest.ChangeRequest{ ID: uuid.New(), ProjectID: testPID, WorkspaceID: wsID, Number: len(f.items) + 1, Title: in.Title, SourceBranch: in.SourceBranch, TargetBranch: firstNonEmpty(in.TargetBranch, "main"), State: changerequest.StateOpen, ReviewVerdict: changerequest.VerdictPending, CIState: changerequest.CIUnknown, Provider: "gitea", ExternalID: &ext, ExternalURL: "http://h/pr", CreatedBy: c.UserID, } f.items = append(f.items, cr) return cr, nil } func (f *fakeChangeReqSvc) Get(_ context.Context, _ changerequest.Caller, id uuid.UUID) (*changerequest.ChangeRequest, error) { for _, cr := range f.items { if cr.ID == id { return cr, nil } } return nil, errs.New(errs.CodeNotFound, "not found") } func (f *fakeChangeReqSvc) GetByNumber(_ context.Context, _ changerequest.Caller, _ string, n int) (*changerequest.ChangeRequest, error) { for _, cr := range f.items { if cr.Number == n { return cr, nil } } return nil, errs.New(errs.CodeNotFound, "not found") } func (f *fakeChangeReqSvc) ListByWorkspace(_ context.Context, _ changerequest.Caller, wsID uuid.UUID) ([]*changerequest.ChangeRequest, error) { out := make([]*changerequest.ChangeRequest, 0, len(f.items)) for _, cr := range f.items { if cr.WorkspaceID == wsID { out = append(out, cr) } } return out, nil } func (f *fakeChangeReqSvc) Review(_ context.Context, _ changerequest.Caller, id uuid.UUID, v changerequest.Verdict, _ string) (*changerequest.ChangeRequest, error) { cr, err := f.Get(context.Background(), changerequest.Caller{}, id) if err != nil { return nil, err } cr.ReviewVerdict = v return cr, nil } func (f *fakeChangeReqSvc) Merge(_ context.Context, _ changerequest.Caller, id uuid.UUID, _ string) (*changerequest.ChangeRequest, error) { if f.mergeErr != nil { return nil, f.mergeErr } cr, err := f.Get(context.Background(), changerequest.Caller{}, id) if err != nil { return nil, err } cr.State = changerequest.StateMerged cr.MergeCommitSHA = "merged-sha" return cr, nil } func (f *fakeChangeReqSvc) SyncStatus(_ context.Context, _ changerequest.Caller, id uuid.UUID) (*changerequest.ChangeRequest, error) { cr, err := f.Get(context.Background(), changerequest.Caller{}, id) if err != nil { return nil, err } if f.syncState != "" { cr.CIState = f.syncState } return cr, nil } func firstNonEmpty(a, b string) string { if a != "" { return a } return b } // crTestDeps mirrors wsTestDeps but with a change-request service attached. func crTestDeps() (ServerDeps, *fakeChangeReqSvc) { deps := testDeps() deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{{ ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "Workspace 1", DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(), }}} deps.Worktrees = &fakeWorktreeSvc{} deps.GitOps = &fakeGitOpsSvc{} crSvc := &fakeChangeReqSvc{} deps.ChangeReqs = crSvc return deps, crSvc } func TestCreatePullRequest_HappyPath(t *testing.T) { deps, crSvc := crTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ "workspace_id": testWSID.String(), "source_branch": "feature", "title": "Add X", }) require.NoError(t, err) require.False(t, result.IsError) var out changeRequestOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) assert.Equal(t, "feature", out.ChangeRequest.SourceBranch) require.Len(t, crSvc.items, 1) } func TestCreatePullRequest_ScopeDenied(t *testing.T) { deps, _ := crTestDeps() result, err := callTool(ctxWithAuth(Scope{Tools: []string{"git_diff"}}), deps, "create_pull_request", map[string]any{ "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", }) require.NoError(t, err) assert.True(t, result.IsError, "token scope without create_pull_request must deny") } func TestGetPRStatus(t *testing.T) { deps, crSvc := crTestDeps() crSvc.syncState = changerequest.CISuccess // seed via create _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "get_pr_status", map[string]any{ "workspace_id": testWSID.String(), "number": 1, }) require.NoError(t, err) require.False(t, result.IsError) var out changeRequestOut unmarshalStructured(t, result, &out) assert.Equal(t, string(changerequest.CISuccess), out.ChangeRequest.CIState) } func TestMergePullRequest_GateError(t *testing.T) { deps, crSvc := crTestDeps() crSvc.mergeErr = errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved") _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "merge_pull_request", map[string]any{ "workspace_id": testWSID.String(), "number": 1, }) require.NoError(t, err) assert.True(t, result.IsError, "gated merge must surface as an MCP error") } func TestMergePullRequest_Allowed(t *testing.T) { deps, _ := crTestDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "merge_pull_request", map[string]any{ "workspace_id": testWSID.String(), "number": 1, "method": "squash", }) require.NoError(t, err) require.False(t, result.IsError) var out changeRequestOut unmarshalStructured(t, result, &out) assert.Equal(t, string(changerequest.StateMerged), out.ChangeRequest.State) } func TestListPullRequests(t *testing.T) { deps, _ := crTestDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pull_requests", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) var out listPRsOut unmarshalStructured(t, result, &out) require.Len(t, out.ChangeRequests, 1) }