feat(agentic): expose PR close as MCP tool

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 16:38:21 +00:00
parent afc7b063ee
commit 4ff21338ee
4 changed files with 177 additions and 77 deletions

View file

@ -371,17 +371,18 @@ func (s *PrepSubsystem) cmdPRClose(options core.Options) core.Result {
return core.Result{Value: core.E("agentic.cmdPRClose", "repo and number are required", nil), OK: false}
}
var pr pullRequestView
err := s.forge.Client().Patch(ctx, core.Sprintf("/api/v1/repos/%s/%s/pulls/%d", org, repo, num), &forge_types.EditPullRequestOption{
State: "closed",
}, &pr)
_, output, err := s.closePR(ctx, nil, ClosePRInput{
Org: org,
Repo: repo,
Number: int(num),
})
if err != nil {
core.Print(nil, "error: %v", err)
return core.Result{Value: err, OK: false}
}
core.Print(nil, "closed %s/%s#%d", org, repo, num)
return core.Result{OK: true}
core.Print(nil, "closed %s/%s#%d", output.Org, output.Repo, output.Number)
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) cmdRepoGet(options core.Options) core.Result {

View file

@ -181,6 +181,22 @@ type ListPRsOutput struct {
PRs []PRInfo `json:"prs"`
}
// input := agentic.ClosePRInput{Org: "core", Repo: "go-io", Number: 12}
type ClosePRInput struct {
Org string `json:"org,omitempty"`
Repo string `json:"repo"`
Number int `json:"number"`
}
// out := agentic.ClosePROutput{Success: true, Repo: "go-io", Number: 12, State: "closed"}
type ClosePROutput struct {
Success bool `json:"success"`
Org string `json:"org,omitempty"`
Repo string `json:"repo"`
Number int `json:"number"`
State string `json:"state,omitempty"`
}
// pr := agentic.PRInfo{Repo: "go-io", Number: 12, Title: "Migrate pkg/fs", Branch: "agent/migrate-fs"}
type PRInfo struct {
Repo string `json:"repo"`
@ -202,6 +218,13 @@ func (s *PrepSubsystem) registerListPRsTool(server *mcp.Server) {
}, s.listPRs)
}
func (s *PrepSubsystem) registerClosePRTool(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_close_pr",
Description: "Close a pull request on Forge by repository and pull request number.",
}, s.closePR)
}
func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, input ListPRsInput) (*mcp.CallToolResult, ListPRsOutput, error) {
if s.forgeToken == "" {
return nil, ListPRsOutput{}, core.E("listPRs", "no Forge token configured", nil)
@ -253,6 +276,44 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp
}, nil
}
func (s *PrepSubsystem) closePR(ctx context.Context, _ *mcp.CallToolRequest, input ClosePRInput) (*mcp.CallToolResult, ClosePROutput, error) {
if s.forgeToken == "" {
return nil, ClosePROutput{}, core.E("closePR", "no Forge token configured", nil)
}
if s.forge == nil {
return nil, ClosePROutput{}, core.E("closePR", "forge client is not configured", nil)
}
if input.Repo == "" || input.Number <= 0 {
return nil, ClosePROutput{}, core.E("closePR", "repo and number are required", nil)
}
org := input.Org
if org == "" {
org = "core"
}
var pr pullRequestView
err := s.forge.Client().Patch(ctx, core.Sprintf("/api/v1/repos/%s/%s/pulls/%d", org, input.Repo, input.Number), &forge_types.EditPullRequestOption{
State: "closed",
}, &pr)
if err != nil {
return nil, ClosePROutput{}, core.E("closePR", core.Concat("failed to close PR ", core.Sprint(input.Number)), err)
}
state := pr.State
if state == "" {
state = "closed"
}
return nil, ClosePROutput{
Success: true,
Org: org,
Repo: input.Repo,
Number: input.Number,
State: state,
}, nil
}
func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) {
var pullRequests []pullRequestView
err := s.forge.Client().Get(ctx, core.Sprintf("/api/v1/repos/%s/%s/pulls?limit=50&page=1", org, repo), &pullRequests)

View file

@ -63,11 +63,11 @@ func TestPr_ForgeCreatePR_Good_Success(t *testing.T) {
srv := mockPRForgeServer(t)
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
prURL, prNum, err := s.forgeCreatePR(
@ -90,11 +90,11 @@ func TestPr_ForgeCreatePR_Bad_ServerError(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, _, err := s.forgeCreatePR(
@ -111,9 +111,9 @@ func TestPr_ForgeCreatePR_Bad_ServerError(t *testing.T) {
func TestPr_CreatePR_Bad_NoWorkspace(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, _, err := s.createPR(context.Background(), nil, CreatePRInput{})
@ -124,9 +124,9 @@ func TestPr_CreatePR_Bad_NoWorkspace(t *testing.T) {
func TestPr_CreatePR_Bad_NoToken(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, _, err := s.createPR(context.Background(), nil, CreatePRInput{
@ -142,9 +142,9 @@ func TestPr_CreatePR_Bad_WorkspaceNotFound(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, _, err := s.createPR(context.Background(), nil, CreatePRInput{
@ -174,9 +174,9 @@ func TestPr_CreatePR_Good_DryRun(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, out, err := s.createPR(context.Background(), nil, CreatePRInput{
@ -209,9 +209,9 @@ func TestPr_CreatePR_Good_CustomTitle(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, out, err := s.createPR(context.Background(), nil, CreatePRInput{
@ -223,14 +223,51 @@ func TestPr_CreatePR_Good_CustomTitle(t *testing.T) {
assert.Equal(t, "Custom PR title", out.Title)
}
func TestPr_ClosePR_Good_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPatch, r.Method)
assert.Equal(t, "/api/v1/repos/core/test-repo/pulls/7", r.URL.Path)
bodyResult := core.ReadAll(r.Body)
assert.True(t, bodyResult.OK)
assert.Contains(t, bodyResult.Value.(string), `"state":"closed"`)
w.Write([]byte(core.JSONMarshalString(map[string]any{
"number": 7,
"state": "closed",
})))
}))
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.closePR(context.Background(), nil, ClosePRInput{
Repo: "test-repo",
Number: 7,
})
require.NoError(t, err)
assert.True(t, out.Success)
assert.Equal(t, "core", out.Org)
assert.Equal(t, "test-repo", out.Repo)
assert.Equal(t, 7, out.Number)
assert.Equal(t, "closed", out.State)
}
// --- listPRs ---
func TestPr_ListPRs_Bad_NoToken(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, _, err := s.listPRs(context.Background(), nil, ListPRsInput{})
@ -252,11 +289,11 @@ func TestPr_CommentOnIssue_Good_PostsComment(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
s.commentOnIssue(context.Background(), "core", "go-io", 42, "Test comment")
@ -319,11 +356,11 @@ func TestPr_CommentOnIssue_Bad(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
// Should not panic even on server error
@ -345,11 +382,11 @@ func TestPr_CommentOnIssue_Ugly(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
longComment := strings.Repeat("This is a very long comment with details. ", 1000)
@ -385,9 +422,9 @@ func TestPr_CreatePR_Ugly(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, out, err := s.createPR(context.Background(), nil, CreatePRInput{
@ -418,11 +455,11 @@ func TestPr_ForgeCreatePR_Ugly(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
// Should not panic — may return zero values for missing fields
@ -453,11 +490,11 @@ func TestPr_ListPRs_Ugly(t *testing.T) {
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),
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.listPRs(context.Background(), nil, ListPRsInput{
@ -474,11 +511,11 @@ func TestPr_ListRepoPRs_Good(t *testing.T) {
srv := mockPRForgeServer(t)
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
prs, err := s.listRepoPRs(context.Background(), "core", "test-repo", "open")
@ -496,11 +533,11 @@ func TestPr_ListRepoPRs_Bad(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, err := s.listRepoPRs(context.Background(), "core", "go-io", "open")
@ -516,11 +553,11 @@ func TestPr_ListRepoPRs_Ugly(t *testing.T) {
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),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
prs, err := s.listRepoPRs(context.Background(), "core", "empty-repo", "open")

View file

@ -348,6 +348,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) {
s.registerResumeTool(server)
s.registerCreatePRTool(server)
s.registerListPRsTool(server)
s.registerClosePRTool(server)
s.registerEpicTool(server)
s.registerMirrorTool(server)
s.registerRemoteDispatchTool(server)