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
174 lines
6.0 KiB
Go
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
|
|
}
|