Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

174 lines
6.0 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/astquery"
"github.com/zzet/gortex/internal/review"
)
// postReviewFindings is the package-level seam over review.PostFindings. It
// defaults to the real (forge-network-backed) post; a test swaps it for a
// closure that records the call and returns a canned result, so the post_review
// handler is exercised end-to-end with no network. Keep this the only
// indirection over the post call.
var postReviewFindings = review.PostFindings
// handlePostReview runs (or accepts) a review for a pull request and posts the
// findings as inline review comments. It honours the secret-redaction and
// public-repo gates in review.PostFindings:
//
// - every comment body is run through RedactSecrets before any payload is
// built or any request is sent;
// - posting to a public / fork PR requires allow_public (config or the flag);
// - dry_run returns the would-post (redacted) payloads without a network call.
//
// Findings are sourced either from an explicit `findings` JSON array (the gated
// findings from a prior review / review_pack call) or, when absent, by running
// the deterministic review rulepack over the changeset / pasted diff.
func (s *Server) handlePostReview(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.graph == nil {
return mcp.NewToolResultError("no graph available — index a repo first"), nil
}
number := req.GetInt("number", 0)
if number <= 0 {
return mcp.NewToolResultError("number is required (the PR / MR number to post on)"), nil
}
repo := strings.TrimSpace(req.GetString("repo", ""))
repoRoot, _ := s.diffRepoScope(ctx, repo)
findings, err := s.postReviewFindingsFor(ctx, req, repoRoot)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
target := review.PostTarget{
Provider: strings.TrimSpace(req.GetString("provider", "github")),
Owner: strings.TrimSpace(req.GetString("owner", "")),
Repo: strings.TrimSpace(req.GetString("repo_name", "")),
PRNumber: number,
// Public / fork status is caller-supplied (the forge-identity lookup that
// would derive it is a network call out of scope here). Default false →
// the public-repo gate refuses unless the caller opts in.
Public: req.GetBool("public", false),
}
opts := review.NewPostOptions()
opts.DryRun = req.GetBool("dry_run", false)
opts.Summary = strings.TrimSpace(req.GetString("summary", ""))
opts.AsSingleReview = true
// allow_public defaults to the configured review.post.allow_public, overridden
// to true by an explicit confirm_public.
opts.AllowPublic = s.reviewAllowPublic(repo) || req.GetBool("confirm_public", false)
// refuse_on_secret defaults on (drop a finding that quoted a secret); a caller
// can opt to post the redacted version instead.
opts.RefuseOnSecret = req.GetBool("refuse_on_secret", true)
res, err := postReviewFindings(ctx, repoRoot, target, findings, opts)
if err != nil {
// The public-repo gate is a structured refusal, not a tool failure.
return s.respondJSONOrTOON(ctx, req, map[string]any{
"error": err.Error(),
"posted": res.Posted,
})
}
payload := map[string]any{
"posted": res.Posted,
"skipped": res.Skipped,
"redacted": res.Redacted,
"dry_run": opts.DryRun,
"total": len(findings),
}
if res.ReviewURL != "" {
payload["review_url"] = res.ReviewURL
}
if opts.DryRun {
payload["payloads"] = res.Payloads
}
return s.respondJSONOrTOON(ctx, req, payload)
}
// reviewAllowPublic reads the configured review.post.allow_public gate for the
// given repo prefix. No config manager (or no review block) reports false — the
// safe default so a misconfigured token never leaks comments to a public PR.
func (s *Server) reviewAllowPublic(repo string) bool {
if s.configManager == nil {
return false
}
cfg := s.configManager.GetRepoConfig(repo)
if cfg == nil {
return false
}
return cfg.Review.Post.AllowPublic
}
// postReviewFindingsFor resolves the findings to post: an explicit `findings`
// JSON array (gated findings from a prior review call) takes precedence;
// otherwise the deterministic review rulepack runs over the changeset / pasted
// diff and its report findings are used.
func (s *Server) postReviewFindingsFor(ctx context.Context, req mcp.CallToolRequest, repoRoot string) ([]review.Finding, error) {
if raw := strings.TrimSpace(req.GetString("findings", "")); raw != "" {
var findings []review.Finding
if err := json.Unmarshal([]byte(raw), &findings); err != nil {
return nil, fmt.Errorf("invalid findings JSON: %v", err)
}
return findings, nil
}
diffText := strings.TrimSpace(req.GetString("diff", ""))
scope, baseRef := siblingDiffScope(req)
if repoRoot == "" && diffText == "" {
return nil, fmt.Errorf("no findings supplied and no changeset to review (set `findings` or a repo / diff)")
}
var (
rulepack []astquery.Match
impact map[string]*analysis.ImpactResult
)
repoPrefix := s.diffJoinPrefix(repoRoot)
var changedFiles []string
if diffText == "" {
diff, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, baseRef)
if err != nil {
return nil, err
}
allowedRepos, err := s.resolveRepoFilter(ctx, req)
if err != nil {
return nil, err
}
rulepack = s.reviewRulepackMatches(ctx, diff.ChangedFiles, allowedRepos)
impact = s.reviewImpact(diff.ChangedSymbols)
changedFiles = diff.ChangedFiles
}
suppStore, suppRepoKey := s.reviewSuppressions()
report, err := review.Run(ctx, s.graph, nil, review.Options{
RepoRoot: repoRoot,
RepoPrefix: repoPrefix,
CoverageKnown: s.coverageKnownForDiff(repoPrefix, changedFiles),
Scope: scope,
BaseRef: baseRef,
Diff: diffText,
RulepackMatches: rulepack,
Impact: impact,
Suppressions: suppStore,
RepoKey: suppRepoKey,
})
if err != nil {
return nil, err
}
if report == nil {
return nil, nil
}
return report.Findings, nil
}