feat(agentic): expose PR tools over MCP
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
73243f2d6b
commit
0cb648a117
3 changed files with 298 additions and 0 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue