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

261 lines
8.6 KiB
Go

package review
import (
"context"
"errors"
"fmt"
"strings"
"github.com/zzet/gortex/internal/forge"
)
// ErrPublicPostBlocked is returned when PostFindings is asked to post to a
// public or fork pull request without the AllowPublic opt-in. Posting to a
// public / fork PR makes the comments world-readable, so it requires an explicit
// confirmation (config review.post.allow_public or the --confirm-public flag).
var ErrPublicPostBlocked = errors.New("posting to a public/fork PR requires --confirm-public")
// PostTarget identifies the pull request a review is posted to. Public carries
// the public / fork signal: when true the comments are world-readable, so the
// AllowPublic opt-in is required. The caller (CLI / MCP) determines Public from
// the forge / repo identity; PostFindings only consumes it.
type PostTarget struct {
Provider string // "github" (default) — the forge backend
Owner string
Repo string
PRNumber int
CommitSHA string // optional HEAD sha the review is anchored to
Public bool // the target PR is on a public or fork repo (world-readable)
}
// PostOptions tunes how findings are posted.
type PostOptions struct {
// DryRun builds and returns the would-post payloads without any network
// call. The dry-run payloads are redacted identically to the live path, so a
// planted secret never appears even in a dry run.
DryRun bool
// Summary is the top-level review summary body posted alongside the
// inline comments.
Summary string
// AsSingleReview batches every inline comment into one forge review
// (GitHub createReview) rather than per-comment discussions. Always true for
// the GitHub backend.
AsSingleReview bool
// AllowPublic permits posting to a public / fork PR. Off by default so a
// misconfigured token never leaks comments to a world-readable thread.
AllowPublic bool
// RefuseOnSecret, when true (the default via NewPostOptions), skips any
// finding whose body still carried a secret rather than posting it with the
// secret redacted. When false the finding is posted with the secret replaced
// by the placeholder.
RefuseOnSecret bool
}
// NewPostOptions returns PostOptions with RefuseOnSecret defaulted on — the
// safe default is to drop a finding that quoted a secret rather than post a
// redacted version of it.
func NewPostOptions() PostOptions {
return PostOptions{RefuseOnSecret: true}
}
// PostResult reports the outcome of a post. Payloads is populated only on a dry
// run (the would-post per-comment payloads, already redacted).
type PostResult struct {
// Posted is the number of inline comments actually posted (or that would be
// posted on a dry run).
Posted int `json:"posted"`
// Skipped is the number of findings dropped because their body still carried
// a secret and RefuseOnSecret was set.
Skipped int `json:"skipped"`
// Redacted is the number of findings whose body had a secret redacted out
// before it was built into a payload.
Redacted int `json:"redacted"`
// ReviewURL is the posted review's URL (live path only).
ReviewURL string `json:"review_url,omitempty"`
// Payloads carries the per-comment would-post payloads on a dry run.
Payloads []map[string]any `json:"payloads,omitempty"`
}
// postReviewComments is the forge-poster seam. It is a package var so a test can
// stub the post without a network call (and so a future forge backend can be
// swapped in). It maps onto the L0 forge free function by exact name.
var postReviewComments = forge.PostReviewComments
// PostFindings maps gated findings onto forge inline review comments and posts
// them, after a mandatory secret-redaction and public-repo gate.
//
// The pipeline, in order:
//
// 1. Public / fork gate: when target.Public is true and opts.AllowPublic is
// false, return ErrPublicPostBlocked without building or sending anything.
// 2. Per finding: render its comment body, run RedactSecrets over it FIRST.
// When a secret was found and RefuseOnSecret is set, skip the finding
// (counted in Skipped); otherwise keep it with the secret replaced by the
// placeholder (counted in Redacted).
// 3. Build a forge.ReviewComment per surviving finding (Side="RIGHT",
// StartLine carrying the multi-line range start).
// 4. On a dry run, return the would-post payloads (already redacted) without a
// network call. Otherwise post the batch via the forge poster.
//
// repoDir is the working tree the forge layer resolves the owner/name slug and
// token from; target.PRNumber selects the PR.
func PostFindings(ctx context.Context, repoDir string, target PostTarget, findings []Finding, opts PostOptions) (PostResult, error) {
var res PostResult
// Public / fork gate — refuse before any payload is built or sent.
if target.Public && !opts.AllowPublic {
return res, ErrPublicPostBlocked
}
comments := make([]forge.ReviewComment, 0, len(findings))
for _, f := range findings {
clean, hits := RedactSecrets(RenderCommentBody(f))
if hits > 0 {
if opts.RefuseOnSecret {
// Drop the finding entirely rather than post a redacted body.
res.Skipped++
continue
}
res.Redacted++
}
c := findingToReviewComment(f)
c.Body = clean
comments = append(comments, c)
res.Posted++
}
if opts.DryRun {
res.Payloads = make([]map[string]any, 0, len(comments))
for _, c := range comments {
res.Payloads = append(res.Payloads, reviewCommentPayload(c))
}
return res, nil
}
if len(comments) == 0 {
// Nothing survived redaction — there is nothing to post, and the forge
// layer would reject an empty review.
return res, nil
}
if err := postReviewComments(ctx, repoDir, target.PRNumber, comments); err != nil {
// Clean degradation: report nothing as posted, surface the forge error.
res.Posted = 0
return res, err
}
if target.Owner != "" && target.Repo != "" && target.PRNumber > 0 {
res.ReviewURL = fmt.Sprintf("https://github.com/%s/%s/pull/%d", target.Owner, target.Repo, target.PRNumber)
}
return res, nil
}
// findingToReviewComment adapts a review.Finding onto the L0 forge.ReviewComment
// by exact name. The anchor is the new side (Side="RIGHT"); Line is the comment
// line (the finding's end line for a multi-line range) and StartLine is set only
// when the range spans more than one line, clamped so start_line < line.
func findingToReviewComment(f Finding) forge.ReviewComment {
end := f.EndLine
if end == 0 {
end = f.Line
}
if end == 0 {
end = f.StartLine
}
start := f.StartLine
if start == 0 {
start = f.Line
}
// Clamp / swap so start_line <= line.
if start > end {
start, end = end, start
}
c := forge.ReviewComment{
Path: f.File,
Line: end,
Side: "RIGHT",
Body: f.Body,
}
// StartLine is meaningful only for a true multi-line range.
if start > 0 && start < end {
c.StartLine = start
}
return c
}
// reviewCommentPayload projects a forge.ReviewComment onto the dry-run payload
// map — the GitHub createReview comment shape ({path, side, line, start_line?,
// body}). start_line is omitted for a single-line comment.
func reviewCommentPayload(c forge.ReviewComment) map[string]any {
m := map[string]any{
"path": c.Path,
"side": c.Side,
"line": c.Line,
"body": c.Body,
}
if c.StartLine > 0 && c.StartLine < c.Line {
m["start_line"] = c.StartLine
}
return m
}
// RenderCommentBody renders a finding into the markdown body of an inline review
// comment: a severity badge + category + confidence header, the finding's body
// (or message when no body was generated), an optional suggestion, and a
// machine-readable footer carrying the finding's identity key so an already-
// posted finding can be deduplicated on a later run.
func RenderCommentBody(f Finding) string {
var b strings.Builder
badge := severityBadge(f.Severity)
header := badge
if f.Category != "" {
header += " · " + f.Category
}
if f.Confidence > 0 {
header += fmt.Sprintf(" · confidence %.0f%%", f.Confidence*100)
}
b.WriteString("**")
b.WriteString(header)
b.WriteString("**\n\n")
body := strings.TrimSpace(f.Body)
if body == "" {
body = strings.TrimSpace(f.Message)
}
if body != "" {
b.WriteString(body)
b.WriteString("\n")
}
if s := strings.TrimSpace(f.Suggestion); s != "" {
b.WriteString("\n**Suggestion:** ")
b.WriteString(s)
b.WriteString("\n")
}
key := f.IdentityKey
if key == "" {
key = IdentityKey(f)
}
b.WriteString("\n<!-- gortex-finding: ")
b.WriteString(key)
b.WriteString(" -->")
return b.String()
}
// severityBadge renders a finding's severity as a short markdown badge.
func severityBadge(s Severity) string {
switch normalizeSeverity(string(s)) {
case SevCritical:
return "[CRITICAL]"
case SevError:
return "[ERROR]"
case SevWarning:
return "[WARNING]"
default:
return "[INFO]"
}
}