You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/changerequest"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// registerChangeRequestTools registers the change-request (PR) tools.
|
||||
//
|
||||
// Gate boundary (spec decision): create_pull_request / merge_pull_request /
|
||||
// get_pr_status / list_pull_requests are exposed, but review approval is NOT an
|
||||
// MCP tool — it is HTTP/web-only so an agent cannot self-approve its own PR and
|
||||
// bypass the merge gate. merge_pull_request calls ChangeReqs.Merge, which
|
||||
// hard-blocks unless review_verdict=='approved' (surfaced as a FailedPrecondition
|
||||
// MCP error). Operators may further exclude create/merge from agent system-token
|
||||
// scopes via the token whitelist.
|
||||
func registerChangeRequestTools(srv *mcpsdk.Server, deps ServerDeps) {
|
||||
registerCreatePullRequest(srv, deps)
|
||||
registerGetPRStatus(srv, deps)
|
||||
registerMergePullRequest(srv, deps)
|
||||
registerListPullRequests(srv, deps)
|
||||
}
|
||||
|
||||
// ===== shared DTO =====
|
||||
|
||||
type changeRequestDetail struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
State string `json:"state"`
|
||||
ReviewVerdict string `json:"review_verdict"`
|
||||
CIState string `json:"ci_state"`
|
||||
ExternalID int64 `json:"external_id,omitempty"`
|
||||
ExternalURL string `json:"external_url,omitempty"`
|
||||
MergeSHA string `json:"merge_commit_sha,omitempty"`
|
||||
}
|
||||
|
||||
func changeRequestDetailFrom(cr *changerequest.ChangeRequest) changeRequestDetail {
|
||||
d := changeRequestDetail{
|
||||
ID: cr.ID.String(), ProjectID: cr.ProjectID.String(), WorkspaceID: cr.WorkspaceID.String(),
|
||||
Number: cr.Number, Title: cr.Title,
|
||||
SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch,
|
||||
State: string(cr.State), ReviewVerdict: string(cr.ReviewVerdict), CIState: string(cr.CIState),
|
||||
ExternalURL: cr.ExternalURL, MergeSHA: cr.MergeCommitSHA,
|
||||
}
|
||||
if cr.ExternalID != nil {
|
||||
d.ExternalID = *cr.ExternalID
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolvePRByNumber scopes the workspace (CheckToolFromCtx-style project gate)
|
||||
// and finds the change request whose per-project number matches within that
|
||||
// workspace. Returns NotFound when no such CR exists in scope.
|
||||
func resolvePRByNumber(ctx context.Context, deps ServerDeps, workspaceID string, number int) (*changerequest.ChangeRequest, error) {
|
||||
c, err := deps.Caller.Resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, _, err := scopedWorkspace(ctx, deps, c, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, cr := range list {
|
||||
if cr.Number == number {
|
||||
return cr, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "change request not found in workspace")
|
||||
}
|
||||
|
||||
// ===== create_pull_request =====
|
||||
|
||||
type createPRIn struct {
|
||||
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
|
||||
SourceBranch string `json:"source_branch" jsonschema:"branch to merge from (non-empty)"`
|
||||
TargetBranch string `json:"target_branch,omitempty" jsonschema:"branch to merge into (defaults to workspace default branch)"`
|
||||
Title string `json:"title" jsonschema:"PR title (non-empty)"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RequirementID string `json:"requirement_id,omitempty" jsonschema:"optional linked requirement UUID"`
|
||||
IssueID string `json:"issue_id,omitempty" jsonschema:"optional linked issue UUID"`
|
||||
}
|
||||
type changeRequestOut struct {
|
||||
OK bool `json:"ok"`
|
||||
ChangeRequest changeRequestDetail `json:"change_request"`
|
||||
}
|
||||
|
||||
func registerCreatePullRequest(srv *mcpsdk.Server, deps ServerDeps) {
|
||||
mcpsdk.AddTool(srv,
|
||||
&mcpsdk.Tool{Name: "create_pull_request", Description: "Open a pull request on the git host from a workspace branch and persist it as a change request. The PR must be approved by a human (web only) before it can be merged."},
|
||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) {
|
||||
if err := CheckToolFromCtx(ctx, "create_pull_request"); err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
c, err := deps.Caller.Resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
input := changerequest.CreateInput{
|
||||
SourceBranch: in.SourceBranch, TargetBranch: in.TargetBranch,
|
||||
Title: in.Title, Description: in.Description,
|
||||
}
|
||||
if in.RequirementID != "" {
|
||||
id, perr := uuid.Parse(in.RequirementID)
|
||||
if perr != nil {
|
||||
return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "requirement_id parse")
|
||||
}
|
||||
input.RequirementID = &id
|
||||
}
|
||||
if in.IssueID != "" {
|
||||
id, perr := uuid.Parse(in.IssueID)
|
||||
if perr != nil {
|
||||
return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "issue_id parse")
|
||||
}
|
||||
input.IssueID = &id
|
||||
}
|
||||
cr, err := deps.ChangeReqs.Create(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID, input)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ===== get_pr_status =====
|
||||
|
||||
type prNumberIn struct {
|
||||
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
|
||||
Number int `json:"number" jsonschema:"per-project change-request number"`
|
||||
}
|
||||
|
||||
func registerGetPRStatus(srv *mcpsdk.Server, deps ServerDeps) {
|
||||
mcpsdk.AddTool(srv,
|
||||
&mcpsdk.Tool{Name: "get_pr_status", Description: "Get a change request by number, refreshing its CI/merge state from the git host."},
|
||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in prNumberIn) (*mcpsdk.CallToolResult, changeRequestOut, error) {
|
||||
if err := CheckToolFromCtx(ctx, "get_pr_status"); err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
c, err := deps.Caller.Resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
cr, err = deps.ChangeReqs.SyncStatus(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ===== merge_pull_request (HARD-GATED) =====
|
||||
|
||||
type mergePRIn struct {
|
||||
WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"`
|
||||
Number int `json:"number" jsonschema:"per-project change-request number"`
|
||||
Method string `json:"method,omitempty" jsonschema:"merge method: merge|squash|rebase"`
|
||||
}
|
||||
|
||||
func registerMergePullRequest(srv *mcpsdk.Server, deps ServerDeps) {
|
||||
mcpsdk.AddTool(srv,
|
||||
&mcpsdk.Tool{Name: "merge_pull_request", Description: "Merge a change request on the git host. HARD-BLOCKED unless review_verdict=='approved' (approval is human/web-only); returns a failed_precondition error otherwise."},
|
||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) {
|
||||
if err := CheckToolFromCtx(ctx, "merge_pull_request"); err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
c, err := deps.Caller.Resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
cr, err = deps.ChangeReqs.Merge(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID, in.Method)
|
||||
if err != nil {
|
||||
return nil, changeRequestOut{}, err
|
||||
}
|
||||
return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ===== list_pull_requests =====
|
||||
|
||||
type listPRsOut struct {
|
||||
ChangeRequests []changeRequestDetail `json:"change_requests"`
|
||||
}
|
||||
|
||||
func registerListPullRequests(srv *mcpsdk.Server, deps ServerDeps) {
|
||||
mcpsdk.AddTool(srv,
|
||||
&mcpsdk.Tool{Name: "list_pull_requests", Description: "List change requests (PRs) of a workspace."},
|
||||
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, listPRsOut, error) {
|
||||
if err := CheckToolFromCtx(ctx, "list_pull_requests"); err != nil {
|
||||
return nil, listPRsOut{}, err
|
||||
}
|
||||
c, err := deps.Caller.Resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, listPRsOut{}, err
|
||||
}
|
||||
w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, listPRsOut{}, err
|
||||
}
|
||||
out, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID)
|
||||
if err != nil {
|
||||
return nil, listPRsOut{}, err
|
||||
}
|
||||
res := listPRsOut{ChangeRequests: make([]changeRequestDetail, 0, len(out))}
|
||||
for _, cr := range out {
|
||||
res.ChangeRequests = append(res.ChangeRequests, changeRequestDetailFrom(cr))
|
||||
}
|
||||
return nil, res, nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user