Build & push image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]> Co-committed-by: Steve Dudenhoeffer <[email protected]>
297 lines
9.9 KiB
Go
297 lines
9.9 KiB
Go
package main
|
||
|
||
// Inline PR review. After the consensus comment is rendered, Gadfly also posts a
|
||
// single Gitea pull review (state COMMENT — advisory, NEVER request-changes or
|
||
// approve) whose inline comments anchor consensus findings to the exact changed
|
||
// lines. The issue comment stays the ranked overview; the review puts each
|
||
// finding next to the code it's about — the "reviewer integrated with Gitea" the
|
||
// project wanted, without ever blocking a merge.
|
||
//
|
||
// All of this is best-effort: disabled by GADFLY_INLINE_REVIEW=0 or when the diff
|
||
// / API creds aren't available, only anchors findings that land on a line present
|
||
// in the diff (Gitea rejects comments off the diff), and any error is logged to
|
||
// stderr without touching the consensus comment (already on stdout) or the exit
|
||
// code.
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// inlineReviewMarker tags our review body so a re-run can delete the previous
|
||
// one instead of stacking duplicate inline comments.
|
||
inlineReviewMarker = "<!-- gadfly-inline-review -->"
|
||
// maxInlineComments caps how many inline comments one review carries, so a
|
||
// huge diff can't produce a wall of annotations. Clusters are pre-sorted by
|
||
// agreement×severity, so the cap keeps the most important.
|
||
maxInlineComments = 25
|
||
inlineReviewHTTPTimeout = 20 * time.Second
|
||
)
|
||
|
||
// reviewComment is one inline comment in a Gitea pull review. Field names match
|
||
// the Gitea API EXACTLY (new_position = line in the new/head file).
|
||
type reviewComment struct {
|
||
Path string `json:"path"`
|
||
Body string `json:"body"`
|
||
NewPosition int `json:"new_position"`
|
||
}
|
||
|
||
// createReview is the POST /pulls/{n}/reviews body. event is ALWAYS "COMMENT".
|
||
type createReview struct {
|
||
Body string `json:"body"`
|
||
Event string `json:"event"`
|
||
Comments []reviewComment `json:"comments"`
|
||
}
|
||
|
||
// postInlineReview posts one COMMENT-state pull review with inline comments for
|
||
// consensus findings on changed lines. No-op + best-effort (see file comment).
|
||
func postInlineReview(clusters []cluster) {
|
||
if strings.EqualFold(strings.TrimSpace(os.Getenv("GADFLY_INLINE_REVIEW")), "0") {
|
||
return
|
||
}
|
||
api := strings.TrimRight(strings.TrimSpace(os.Getenv("GITEA_API")), "/")
|
||
token := strings.TrimSpace(os.Getenv("GITEA_TOKEN"))
|
||
pr := strings.TrimSpace(os.Getenv("GADFLY_PR"))
|
||
diffPath := strings.TrimSpace(os.Getenv("GADFLY_DIFF_FILE"))
|
||
if api == "" || token == "" || pr == "" || diffPath == "" {
|
||
return
|
||
}
|
||
diff, err := os.ReadFile(diffPath)
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "gadfly: inline review: read diff:", err)
|
||
return
|
||
}
|
||
|
||
comments := inlineComments(clusters, parseDiffNewLines(string(diff)))
|
||
if len(comments) == 0 {
|
||
return // nothing anchors to a changed line; the consensus comment covers it
|
||
}
|
||
|
||
client := &http.Client{Timeout: inlineReviewHTTPTimeout}
|
||
base := fmt.Sprintf("%s/pulls/%s/reviews", api, pr)
|
||
deletePriorReviews(client, base, token) // avoid stacking on re-runs
|
||
|
||
body := fmt.Sprintf("%s\n🪰 **Gadfly consensus review** — %d inline finding%s on changed lines. See the consensus comment for the full ranked summary.\n\n<sub>Advisory only — does not block merge.</sub>",
|
||
inlineReviewMarker, len(comments), plural(len(comments)))
|
||
if err := giteaSend(client, http.MethodPost, base, token, createReview{Body: body, Event: "COMMENT", Comments: comments}); err != nil {
|
||
fmt.Fprintln(os.Stderr, "gadfly: inline review post:", err)
|
||
}
|
||
}
|
||
|
||
// inlineComments builds inline comments for the clusters that anchor to a line
|
||
// present in the diff, in priority order (clusters are pre-sorted), capped.
|
||
func inlineComments(clusters []cluster, addable map[string]map[int]bool) []reviewComment {
|
||
var out []reviewComment
|
||
for _, c := range clusters {
|
||
path := normPath(c.file)
|
||
anchor := anchorLine(addable[path], c.line, c.maxLine)
|
||
if anchor == 0 {
|
||
continue
|
||
}
|
||
out = append(out, reviewComment{Path: path, NewPosition: anchor, Body: inlineBody(c)})
|
||
if len(out) >= maxInlineComments {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// anchorLine returns the first line in [lo,hi] that is an added line in the diff,
|
||
// or 0 if none. Scanning the cluster's whole span (not just its representative
|
||
// line) anchors a finding whose min line is just outside the diff but whose span
|
||
// still overlaps a changed line.
|
||
func anchorLine(added map[int]bool, lo, hi int) int {
|
||
if added == nil || lo <= 0 {
|
||
return 0
|
||
}
|
||
if hi < lo { // single-line cluster (maxLine unset)
|
||
hi = lo
|
||
}
|
||
for ln := lo; ln <= hi; ln++ {
|
||
if added[ln] {
|
||
return ln
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// inlineBody renders one inline comment: severity + title, who flagged it, detail.
|
||
func inlineBody(c cluster) string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "%s **%s**", sevIcon(c.severity), strings.TrimSpace(c.title))
|
||
fmt.Fprintf(&b, "\n\n_%s · flagged by %d model%s_", lensList(c.lenses), len(c.models), plural(len(c.models)))
|
||
if d := strings.TrimSpace(c.detail); d != "" {
|
||
fmt.Fprintf(&b, "\n\n%s", d)
|
||
}
|
||
b.WriteString("\n\n<sub>🪰 Gadfly · advisory</sub>")
|
||
return b.String()
|
||
}
|
||
|
||
// parseDiffNewLines returns, per file, the set of NEW-file line numbers that were
|
||
// ADDED in the unified diff — the safest lines for an inline comment to anchor to
|
||
// (Gitea reliably accepts comments on added lines). Context lines are walked to
|
||
// keep the line counter correct but are NOT recorded: anchoring only to added
|
||
// lines avoids the all-or-nothing review POST being rejected for an off-change
|
||
// anchor. Hunk lengths from the @@ header bound each hunk, so a content line that
|
||
// happens to start with "+++ " or "@@" is still read as content, not a header.
|
||
func parseDiffNewLines(diff string) map[string]map[int]bool {
|
||
out := map[string]map[int]bool{}
|
||
var file string
|
||
var newLine, oldRem, newRem int
|
||
inHunk := false
|
||
for _, line := range strings.Split(diff, "\n") {
|
||
if inHunk && (newRem > 0 || oldRem > 0) {
|
||
switch {
|
||
case strings.HasPrefix(line, "+"):
|
||
record(out, file, newLine) // added line — anchorable
|
||
newLine++
|
||
newRem--
|
||
case strings.HasPrefix(line, "-"):
|
||
oldRem--
|
||
case strings.HasPrefix(line, "\\"): // "\ No newline at end of file"
|
||
default: // context line (leading space, or an empty line): advance, don't record
|
||
newLine++
|
||
newRem--
|
||
oldRem--
|
||
}
|
||
if newRem <= 0 && oldRem <= 0 {
|
||
inHunk = false
|
||
}
|
||
continue
|
||
}
|
||
switch {
|
||
case strings.HasPrefix(line, "+++ "):
|
||
file = normPath(strings.TrimPrefix(line, "+++ "))
|
||
if file == "/dev/null" {
|
||
file = ""
|
||
}
|
||
case strings.HasPrefix(line, "@@"):
|
||
if m := hunkRe.FindStringSubmatch(line); m != nil && file != "" {
|
||
newLine, _ = strconv.Atoi(m[3])
|
||
oldRem = atoiOr(m[2], 1)
|
||
newRem = atoiOr(m[4], 1)
|
||
inHunk = newRem > 0 || oldRem > 0
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// hunkRe captures a unified-diff hunk header's old/new start+length:
|
||
// @@ -<oldStart>[,<oldLen>] +<newStart>[,<newLen>] @@
|
||
var hunkRe = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
|
||
|
||
// normPath trims a unified-diff path down to a repo-relative one: strip a single
|
||
// leading "a/" or "b/" prefix (and any "./"), and surrounding whitespace. Applied
|
||
// to BOTH the diff paths and a finding's file so they match even when a model
|
||
// writes "./pkg/x.go" or the diff carries the "b/" prefix.
|
||
func normPath(p string) string {
|
||
p = strings.TrimSpace(p)
|
||
p = strings.TrimPrefix(p, "./")
|
||
if strings.HasPrefix(p, "a/") || strings.HasPrefix(p, "b/") {
|
||
p = p[2:]
|
||
}
|
||
return p
|
||
}
|
||
|
||
func record(out map[string]map[int]bool, file string, line int) {
|
||
if file == "" {
|
||
return
|
||
}
|
||
if out[file] == nil {
|
||
out[file] = map[int]bool{}
|
||
}
|
||
out[file][line] = true
|
||
}
|
||
|
||
// atoiOr parses s, returning def when s is empty or unparseable. Used for the
|
||
// optional hunk-length fields (absent => length 1).
|
||
func atoiOr(s string, def int) int {
|
||
if s == "" {
|
||
return def
|
||
}
|
||
if n, err := strconv.Atoi(s); err == nil {
|
||
return n
|
||
}
|
||
return def
|
||
}
|
||
|
||
// deletePriorReviews removes our previous inline reviews (matched by the body
|
||
// marker) so a re-run replaces rather than stacks. Best-effort and quiet.
|
||
func deletePriorReviews(client *http.Client, base, token string) {
|
||
const perPage = 50
|
||
for page := 1; page <= 10; page++ { // bound the scan, but page past 50 so a stale marked review isn't missed
|
||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s?limit=%d&page=%d", base, perPage, page), nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
req.Header.Set("Authorization", "token "+token)
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
io.Copy(io.Discard, resp.Body)
|
||
resp.Body.Close()
|
||
return
|
||
}
|
||
var reviews []struct {
|
||
ID int `json:"id"`
|
||
Body string `json:"body"`
|
||
}
|
||
_ = json.NewDecoder(resp.Body).Decode(&reviews)
|
||
resp.Body.Close()
|
||
for _, r := range reviews {
|
||
if !strings.Contains(r.Body, inlineReviewMarker) {
|
||
continue
|
||
}
|
||
dreq, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/%d", base, r.ID), nil)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
dreq.Header.Set("Authorization", "token "+token)
|
||
if dresp, err := client.Do(dreq); err == nil {
|
||
io.Copy(io.Discard, dresp.Body)
|
||
dresp.Body.Close()
|
||
}
|
||
}
|
||
if len(reviews) < perPage {
|
||
return // last page
|
||
}
|
||
}
|
||
}
|
||
|
||
// giteaSend marshals payload and sends it with Gitea's "token" auth scheme,
|
||
// treating a non-2xx response as an error (with a snippet of the body).
|
||
func giteaSend(client *http.Client, method, url, token string, payload any) error {
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req, err := http.NewRequest(method, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "token "+token)
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("%s %s: status %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||
}
|
||
return nil
|
||
}
|