Files
pad/internal/mcp/dispatch_http_github.go
T
xarmian 1204a31a73 feat(mcp): pad_item link-pr links a PR from supplied metadata, and unlink-pr removes it (TASK-3611) (#2098)
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
2026-10-12 04:11:26 -04:00

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)
}
}