mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
1204a31a73
MCP had no PR-link action: the `field` description sent agents to a human, because `pad github link` reads the local branch and asks `gh`, and a field write of github_pr is refused (BUG-2696). An agent that opened a PR could not record it on the item. - `pad github link <ref> --pr-number N --pr-url U [--pr-title --pr-state --pr-branch --pr-repo]` links a PR the caller names. Nothing is fetched from GitHub, and it needs neither a checkout nor `gh`. Without the flags the command behaves as before. Under --format json, link and unlink answer with the item. - pad_item.action=link-pr / unlink-pr, on both transports. Remote PATCHes the typed github_pr / clear_github_pr members, and unlink-pr refuses an item with no link, as the CLI does. The catalog's `ref` is passed as the commands' item-ref positional. - github link / unlink leave the remote CLI-only table; github status stays. The BUG-2696 guard is re-aimed: these two must be routed, and no remaining hint may advertise the github_pr field write. ToolSurfaceVersion 0.95, additive (0.93 is #2096, 0.94 is #2097). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
104 lines
4.1 KiB
Go
104 lines
4.1 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// dispatchGitHubLink is pad_item.action=link-pr on the remote door
|
|
// (TASK-3611): the PR metadata the agent supplied, written through the typed
|
|
// github_pr member (BUG-2696) as `pad github link --pr-number ...` writes it.
|
|
// Nothing is fetched from GitHub. The server validates the number and URL.
|
|
func (d *HTTPHandlerDispatcher) dispatchGitHubLink(ctx context.Context, input map[string]any, user *models.User) (*CallToolResult, error) {
|
|
const cmdKey = "github link"
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), nil
|
|
}
|
|
ref, _ := input["item_ref"].(string)
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required", "Pass `ref=<TASK-N>`, the item to link the PR to."), nil
|
|
}
|
|
n, ok := input["pr_number"].(float64)
|
|
// Any positive whole number the CLI's --pr-number takes; the bound is
|
|
// only where a JSON number stops being an exact integer (codex r2: a
|
|
// MaxInt32 cap refused what the CLI accepts).
|
|
if !ok || n <= 0 || n != math.Trunc(n) || n > 1<<53 {
|
|
return validationFailedResult(cmdKey, "pr_number must be a positive whole number", "Pass `pr_number=<N>`, the PR's number."), nil
|
|
}
|
|
prURL, _ := input["pr_url"].(string)
|
|
if strings.TrimSpace(prURL) == "" {
|
|
return validationFailedResult(cmdKey, "pr_url is required", "Pass `pr_url=<https://...>`, the PR's URL."), nil
|
|
}
|
|
str := func(k string) string { v, _ := input[k].(string); return v }
|
|
body, err := json.Marshal(models.ItemUpdate{GitHubPR: &models.ItemGitHubPR{
|
|
Number: int(n),
|
|
URL: prURL,
|
|
Title: str("pr_title"),
|
|
State: str("pr_state"),
|
|
Branch: str("pr_branch"),
|
|
Repo: str("pr_repo"),
|
|
}})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "encode body", err), nil
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, githubItemPath(workspace, ref), body)
|
|
}
|
|
|
|
// dispatchGitHubUnlink is pad_item.action=unlink-pr on the remote door: the
|
|
// typed clear_github_pr member, as `pad github unlink` sends it.
|
|
func (d *HTTPHandlerDispatcher) dispatchGitHubUnlink(ctx context.Context, input map[string]any, user *models.User) (*CallToolResult, error) {
|
|
const cmdKey = "github unlink"
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), nil
|
|
}
|
|
ref, _ := input["item_ref"].(string)
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required", "Pass `ref=<TASK-N>`, the item to unlink."), nil
|
|
}
|
|
// `pad github unlink` refuses an item with no PR link; so does this door
|
|
// (TASK-3611 parity). A read that fails falls through to the PATCH, whose
|
|
// refusal (not found, forbidden) is the server's own and is classified.
|
|
if item, ferr := d.fetchItem(ctx, user, workspace, ref); ferr == nil {
|
|
var fields map[string]json.RawMessage
|
|
_ = json.Unmarshal([]byte(item.Fields), &fields)
|
|
if _, linked := fields["github_pr"]; !linked {
|
|
return validationFailedResult(cmdKey, "item "+ref+" has no linked PR", "There is nothing to unlink."), nil
|
|
}
|
|
}
|
|
body, err := json.Marshal(models.ItemUpdate{ClearGitHubPR: true})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "encode body", err), nil
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, githubItemPath(workspace, ref), body)
|
|
}
|
|
|
|
func githubItemPath(workspace, ref string) string {
|
|
return "/api/v1/workspaces/" + url.PathEscape(workspace) + "/items/" + url.PathEscape(ref)
|
|
}
|
|
|
|
// refAsItemRef dispatches cmdPath with the catalog's `ref` under `item_ref`,
|
|
// the name `pad github link [item-ref]` / `unlink <item-ref>` give their
|
|
// positional (TASK-3611): every other pad_item action takes `ref`, so the
|
|
// catalog keeps that name and translates here, for both transports.
|
|
func refAsItemRef(cmdPath []string) ActionFn {
|
|
return func(ctx context.Context, input map[string]any, env ActionEnv) (*CallToolResult, error) {
|
|
out := make(map[string]any, len(input))
|
|
for k, v := range input {
|
|
out[k] = v
|
|
}
|
|
if v, ok := out["ref"]; ok {
|
|
out["item_ref"] = v
|
|
delete(out, "ref")
|
|
}
|
|
return env.Dispatch(ctx, cmdPath, out)
|
|
}
|
|
}
|