From 0cb648a117f36cef3ff82fe87c0aabfac1224d1d Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:34:05 +0000 Subject: [PATCH] feat(agentic): expose PR tools over MCP Co-Authored-By: Virgil --- pkg/agentic/pr.go | 150 +++++++++++++++++++++++++++++++++++++++++ pkg/agentic/pr_test.go | 147 ++++++++++++++++++++++++++++++++++++++++ pkg/agentic/prep.go | 1 + 3 files changed, 298 insertions(+) diff --git a/pkg/agentic/pr.go b/pkg/agentic/pr.go index 93e0387..97afcb3 100644 --- a/pkg/agentic/pr.go +++ b/pkg/agentic/pr.go @@ -37,6 +37,38 @@ func (s *PrepSubsystem) registerCreatePRTool(server *mcp.Server) { }, s.createPR) } +// input := agentic.PRGetInput{Org: "core", Repo: "go-io", Number: 42} +type PRGetInput struct { + Org string `json:"org,omitempty"` + Repo string `json:"repo"` + Number int `json:"number"` +} + +// out := agentic.PRGetOutput{Success: true, PR: agentic.PRInfo{Repo: "go-io", Number: 42}} +type PRGetOutput struct { + Success bool `json:"success"` + PR PRInfo `json:"pr"` +} + +// input := agentic.PRMergeInput{Org: "core", Repo: "go-io", Number: 42, Method: "squash"} +type PRMergeInput struct { + Org string `json:"org,omitempty"` + Repo string `json:"repo"` + Number int `json:"number"` + Method string `json:"method,omitempty"` +} + +// out := agentic.PRMergeOutput{Success: true, Repo: "go-io", Number: 42, State: "merged"} +type PRMergeOutput struct { + Success bool `json:"success"` + Org string `json:"org,omitempty"` + Repo string `json:"repo"` + Number int `json:"number"` + Method string `json:"method,omitempty"` + State string `json:"state,omitempty"` + PR PRInfo `json:"pr,omitempty"` +} + func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, input CreatePRInput) (*mcp.CallToolResult, CreatePROutput, error) { if input.Workspace == "" { return nil, CreatePROutput{}, core.E("createPR", "workspace is required", nil) @@ -132,6 +164,124 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in }, nil } +func (s *PrepSubsystem) registerPRTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_pr_get", + Description: "Read a pull request from Forge by repository and pull request number.", + }, s.prGet) + + mcp.AddTool(server, &mcp.Tool{ + Name: "pr_get", + Description: "Read a pull request from Forge by repository and pull request number.", + }, s.prGet) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_pr_list", + Description: "List pull requests across Forge repos. Filter by org, repo, and state.", + }, s.prList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "pr_list", + Description: "List pull requests across Forge repos. Filter by org, repo, and state.", + }, s.prList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_pr_merge", + Description: "Merge a pull request on Forge by repository and pull request number.", + }, s.prMerge) + + mcp.AddTool(server, &mcp.Tool{ + Name: "pr_merge", + Description: "Merge a pull request on Forge by repository and pull request number.", + }, s.prMerge) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_pr_close", + Description: "Close a pull request on Forge by repository and pull request number.", + }, s.closePR) + + mcp.AddTool(server, &mcp.Tool{ + Name: "pr_close", + Description: "Close a pull request on Forge by repository and pull request number.", + }, s.closePR) +} + +func (s *PrepSubsystem) prGet(ctx context.Context, _ *mcp.CallToolRequest, input PRGetInput) (*mcp.CallToolResult, PRGetOutput, error) { + if s.forgeToken == "" { + return nil, PRGetOutput{}, core.E("prGet", "no Forge token configured", nil) + } + if input.Repo == "" || input.Number <= 0 { + return nil, PRGetOutput{}, core.E("prGet", "repo and number are required", nil) + } + + org := input.Org + if org == "" { + org = "core" + } + + var pr pullRequestView + err := s.forge.Client().Get(ctx, core.Sprintf("/api/v1/repos/%s/%s/pulls/%d", org, input.Repo, input.Number), &pr) + if err != nil { + return nil, PRGetOutput{}, core.E("prGet", core.Concat("failed to read PR ", core.Sprint(input.Number)), err) + } + + return nil, PRGetOutput{ + Success: true, + PR: PRInfo{ + Repo: input.Repo, + Number: int(pullRequestNumber(pr)), + Title: pr.Title, + State: pr.State, + Author: pullRequestAuthor(pr), + Branch: pr.Head.Ref, + Base: pr.Base.Ref, + Mergeable: pr.Mergeable, + URL: pr.HTMLURL, + }, + }, nil +} + +func (s *PrepSubsystem) prList(ctx context.Context, _ *mcp.CallToolRequest, input ListPRsInput) (*mcp.CallToolResult, ListPRsOutput, error) { + return s.listPRs(ctx, nil, input) +} + +func (s *PrepSubsystem) prMerge(ctx context.Context, _ *mcp.CallToolRequest, input PRMergeInput) (*mcp.CallToolResult, PRMergeOutput, error) { + if s.forgeToken == "" { + return nil, PRMergeOutput{}, core.E("prMerge", "no Forge token configured", nil) + } + if input.Repo == "" || input.Number <= 0 { + return nil, PRMergeOutput{}, core.E("prMerge", "repo and number are required", nil) + } + + org := input.Org + if org == "" { + org = "core" + } + method := input.Method + if method == "" { + method = "merge" + } + + if err := s.forge.Pulls.Merge(ctx, org, input.Repo, int64(input.Number), method); err != nil { + return nil, PRMergeOutput{}, core.E("prMerge", core.Concat("failed to merge PR ", core.Sprint(input.Number)), err) + } + + output := PRMergeOutput{ + Success: true, + Org: org, + Repo: input.Repo, + Number: input.Number, + Method: method, + State: "merged", + } + + if _, prOutput, err := s.prGet(ctx, nil, PRGetInput{Org: org, Repo: input.Repo, Number: input.Number}); err == nil { + output.PR = prOutput.PR + } + + return nil, output, nil +} + func (s *PrepSubsystem) buildPRBody(workspaceStatus *WorkspaceStatus) string { b := core.NewBuilder() b.WriteString("## Summary\n\n") diff --git a/pkg/agentic/pr_test.go b/pkg/agentic/pr_test.go index 81bf1c9..6c9feab 100644 --- a/pkg/agentic/pr_test.go +++ b/pkg/agentic/pr_test.go @@ -13,6 +13,7 @@ import ( core "dappco.re/go/core" "dappco.re/go/core/forge" forge_types "dappco.re/go/core/forge/types" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -260,6 +261,152 @@ func TestPr_ClosePR_Good_Success(t *testing.T) { assert.Equal(t, "closed", out.State) } +func TestPr_RegisterPRTools_Good_RegistersPRAliases(t *testing.T) { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "0.1.0"}, &mcpsdk.ServerOptions{ + Capabilities: &mcpsdk.ServerCapabilities{ + Tools: &mcpsdk.ToolCapabilities{ListChanged: true}, + }, + }) + + s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})} + s.registerPRTools(server) + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0.1.0"}, nil) + clientTransport, serverTransport := mcpsdk.NewInMemoryTransports() + + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + result, err := clientSession.ListTools(context.Background(), nil) + require.NoError(t, err) + + var toolNames []string + for _, tool := range result.Tools { + toolNames = append(toolNames, tool.Name) + } + + assert.Contains(t, toolNames, "agentic_pr_get") + assert.Contains(t, toolNames, "pr_get") + assert.Contains(t, toolNames, "agentic_pr_list") + assert.Contains(t, toolNames, "pr_list") + assert.Contains(t, toolNames, "agentic_pr_merge") + assert.Contains(t, toolNames, "pr_merge") + assert.Contains(t, toolNames, "agentic_pr_close") + assert.Contains(t, toolNames, "pr_close") +} + +func TestPr_PRGet_Good_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/core/test-repo/pulls/42", r.URL.Path) + + _, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{ + "number": 42, + "title": "Fix login", + "state": "open", + "mergeable": true, + "html_url": "https://forge.test/core/test-repo/pulls/42", + "head": map[string]any{"ref": "agent/fix-login"}, + "base": map[string]any{"ref": "dev"}, + "user": map[string]any{"login": "codex"}, + }))) + })) + t.Cleanup(srv.Close) + + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge(srv.URL, "test-token"), + forgeURL: srv.URL, + forgeToken: "test-token", + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + + _, out, err := s.prGet(context.Background(), nil, PRGetInput{ + Repo: "test-repo", + Number: 42, + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.Equal(t, "test-repo", out.PR.Repo) + assert.Equal(t, 42, out.PR.Number) + assert.Equal(t, "Fix login", out.PR.Title) + assert.Equal(t, "open", out.PR.State) + assert.Equal(t, "agent/fix-login", out.PR.Branch) +} + +func TestPr_PRGet_Bad_NoToken(t *testing.T) { + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forgeToken: "", + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + + _, _, err := s.prGet(context.Background(), nil, PRGetInput{ + Repo: "test-repo", + Number: 42, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no Forge token") +} + +func TestPr_PRMerge_Good_Success(t *testing.T) { + mergeCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/merge") { + mergeCalled = true + assert.Equal(t, "/api/v1/repos/core/test-repo/pulls/42/merge", r.URL.Path) + _, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{ + "number": 42, + "title": "Fix login", + "state": "closed", + "head": map[string]any{"ref": "agent/fix-login"}, + "base": map[string]any{"ref": "dev"}, + }))) + return + } + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{ + "number": 42, + "title": "Fix login", + "state": "closed", + "head": map[string]any{"ref": "agent/fix-login"}, + "base": map[string]any{"ref": "dev"}, + }))) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + s := &PrepSubsystem{ + ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}), + forge: forge.NewForge(srv.URL, "test-token"), + forgeURL: srv.URL, + forgeToken: "test-token", + backoff: make(map[string]time.Time), + failCount: make(map[string]int), + } + + _, out, err := s.prMerge(context.Background(), nil, PRMergeInput{ + Repo: "test-repo", + Number: 42, + Method: "merge", + }) + require.NoError(t, err) + assert.True(t, out.Success) + assert.True(t, mergeCalled) + assert.Equal(t, "test-repo", out.Repo) + assert.Equal(t, 42, out.Number) + assert.Equal(t, "merged", out.State) +} + // --- listPRs --- func TestPr_ListPRs_Bad_NoToken(t *testing.T) { diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 3bc2a54..195c0f6 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -394,6 +394,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { s.registerIssueTools(server) s.registerMessageTools(server) s.registerSprintTools(server) + s.registerPRTools(server) s.registerContentTools(server) s.registerLanguageTools(server)