返回 DeepSeek-Reasonix
diff.go
根目录 / cmd / e2ebench / diff.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "sort"
10 "strings"
11 "time"
12 "unicode/utf8"
13
14 "reasonix/internal/ablation"
15 "reasonix/internal/shellparse"
16 )
17
18 type diffOpts struct {
19 bin, model, repo, base, testCmd, profile string
20 ablate ablation.Set
21 maxSteps, timeoutSec, attempts int
22 }
23
24 type testRef struct{ name, pkg string }
25
26 // pinResult records whether one generated test fails when the PR's source is
27 // reverted (so it pins the change) and, if so, whether it failed by assertion
28 // (strong: it checks the new behavior) or only by compile error (weak: it just
29 // references a symbol the PR added).
30 type pinResult struct {
31 testRef
32 pins bool
33 byAssertion bool
34 }
35
36 // runDiff asks the agent to write tests covering what the PR changed, grades
37 // them against the repo's own tests, and — because the agent is stochastic —
38 // retries up to o.attempts times until a run passes, keeping the best result.
39 func runDiff(o diffOpts) string {
40 srcFiles := changedGoFiles(o.repo, o.base, false)
41 if len(srcFiles) == 0 {
42 profile := o.profile
43 if profile == "" {
44 profile = benchmarkProfileBaseline
45 }
46 return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
47 }
48 pkgs := packagesOf(srcFiles)
49 prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
50
51 attempts := o.attempts
52 if attempts < 1 {
53 attempts = 1
54 }
55 var best diffReport
56 made := 0
57 for i := 1; i <= attempts; i++ {
58 if i > 1 {
59 resetTree(o.repo)
60 }
61 r := runOnce(o, srcFiles, pkgs, prompt)
62 made = i
63 if i == 1 || better(r, best) {
64 best = r
65 }
66 if best.passed {
67 break // stop at the first passing run; attempts is a retry budget
68 }
69 }
70 best.attempt, best.attempts = made, attempts
71 return renderDiff(best)
72 }
73
74 // runOnce does one agent run + grade: generate tests, check they pass on HEAD,
75 // differential-check each against the reverted source, measure changed-line
76 // coverage, and confirm the agent didn't break the build anywhere.
77 func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
78 metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json")
79 _ = os.Remove(metricsPath)
80 defer os.Remove(metricsPath)
81
82 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second)
83 defer cancel()
84
85 args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)}
86 if o.model != "" {
87 args = append(args, "--model", o.model)
88 }
89 args = appendBenchmarkProfileArgs(args, o.profile)
90 if !o.ablate.Empty() {
91 args = append(args, "--ablate", o.ablate.String())
92 }
93 args = append(args, prompt)
94 cmd := exec.CommandContext(ctx, o.bin, args...)
95 cmd.Dir = o.repo
96 cmd.Stdout = os.Stderr
97 cmd.Stderr = os.Stderr
98 cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
99 runErr := cmd.Run()
100
101 // The agent's new files are untracked, so `git diff HEAD` would miss them;
102 // intent-to-add surfaces them as additions without committing.
103 _ = exec.Command("git", "-C", o.repo, "add", "-AN").Run()
104
105 m, _ := readMetrics(metricsPath)
106 testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go")
107 refs := parseNewTests(testDiff)
108 sourceTouched := len(changedGoFilesWorktree(o.repo, false))
109 testsPass, testOut := runTests(o.repo, o.testCmd, pkgs)
110
111 var pins []pinResult
112 var mut mutationResult
113 covered, coverTotal := 0, 0
114 if len(refs) > 0 && testsPass {
115 covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
116 pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
117 mut = runMutation(o.repo, o.base, srcFiles, refs)
118 }
119 buildOK, buildOut := goBuildAll(o.repo)
120
121 passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
122 return diffReport{
123 srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
124 newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
125 pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
126 buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
127 passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
128 }
129 }
130
131 // better reports whether candidate a is a stronger result than b: a pass beats a
132 // fail, then more assertion-pins, then more pins, then higher changed-line
133 // coverage.
134 func better(a, b diffReport) bool {
135 if a.passed != b.passed {
136 return a.passed
137 }
138 if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y {
139 return x > y
140 }
141 if x, y := countPins(a.pins), countPins(b.pins); x != y {
142 return x > y
143 }
144 if a.mut.caught != b.mut.caught {
145 return a.mut.caught > b.mut.caught
146 }
147 return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal)
148 }
149
150 func ratio(n, d int) float64 {
151 if d == 0 {
152 return 0
153 }
154 return float64(n) / float64(d)
155 }
156
157 // resetTree restores the PR-head tree between attempts, dropping the previous
158 // attempt's generated tests but keeping the provider config the workflow wrote.
159 func resetTree(repo string) {
160 _ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run()
161 _ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run()
162 }
163
164 func goBuildAll(repo string) (bool, string) {
165 cmd := exec.Command("go", "build", "./...")
166 cmd.Dir = repo
167 cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
168 out, err := cmd.CombinedOutput()
169 return err == nil, string(out)
170 }
171
172 func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string {
173 var b strings.Builder
174 b.WriteString("You are in a Go repository. This pull request changed these source files:\n")
175 for _, f := range srcFiles {
176 fmt.Fprintf(&b, " - %s\n", f)
177 }
178 b.WriteString("\nUnified diff of the change:\n```diff\n")
179 b.WriteString(diffText)
180 b.WriteString("\n```\n\n")
181 b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ")
182 b.WriteString("Add them to the appropriate *_test.go files in the same packages (")
183 b.WriteString(strings.Join(pkgs, ", "))
184 b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ")
185 b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ")
186 b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.")
187 return b.String()
188 }
189
190 type diffReport struct {
191 srcFiles, pkgs []string
192 addedTestLines int
193 newTests []testRef
194 sourceTouched int
195 testsPass bool
196 pins []pinResult
197 mut mutationResult
198 covered, coverTotal int
199 buildOK bool
200 buildOut string
201 failing []string
202 passed bool
203 profile string
204 attempt, attempts int
205 m runMetrics
206 runErr error
207 testOut string
208 testDiff string
209 }
210
211 func renderDiff(r diffReport) string {
212 var b strings.Builder
213 result := "❌ fail"
214 if r.passed {
215 result = "✅ pass"
216 }
217 profile := r.profile
218 if profile == "" {
219 profile = benchmarkProfileBaseline
220 }
221 fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
222 fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
223
224 pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
225 fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
226 fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests))
227 fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines)
228 fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass))
229 fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r))
230 if pinned > 0 {
231 fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert)
232 }
233 fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r))
234 fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r))
235 fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK))
236 fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched)
237 fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens))
238 fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
239 fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
240 fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
241 if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
242 fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
243 fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
244 fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
245 fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
246 if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
247 fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
248 }
249 }
250 if len(r.failing) > 0 {
251 fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `"))
252 }
253 if r.attempts > 1 {
254 status := "none passed"
255 if r.passed {
256 status = "passed"
257 }
258 fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status)
259 }
260
261 fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", "))
262 if r.attempts <= 1 {
263 fmt.Fprintf(&b, "\n<sub>Single stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.</sub>\n")
264 }
265 if !r.buildOK && strings.TrimSpace(r.buildOut) != "" {
266 fmt.Fprintf(&b, "\n<details><summary>go build ./... output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.buildOut, 40))
267 }
268 if r.sourceTouched > 0 {
269 fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched)
270 }
271
272 if len(r.pins) > 0 {
273 fmt.Fprintf(&b, "\n<details><summary>Per-test differential</summary>\n\n| Test | Package | Pins the change? |\n|---|---|---|\n")
274 for _, p := range r.pins {
275 fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p))
276 }
277 fmt.Fprintf(&b, "\n</details>\n")
278 }
279 if strings.TrimSpace(r.testDiff) != "" {
280 fmt.Fprintf(&b, "\n<details><summary>Generated tests (review the assertions)</summary>\n\n```diff\n%s\n```\n</details>\n", truncateFor(r.testDiff, 20000))
281 }
282 if !r.testsPass && strings.TrimSpace(r.testOut) != "" {
283 fmt.Fprintf(&b, "\n<details><summary>go test output (tail)</summary>\n\n```\n%s\n```\n</details>\n", tail(r.testOut, 60))
284 }
285 if r.runErr != nil {
286 fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
287 }
288 fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
289 return b.String()
290 }
291
292 func differentialCell(r diffReport) string {
293 if !(len(r.newTests) > 0 && r.testsPass) {
294 return "n/a (tests not green)"
295 }
296 return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins))
297 }
298
299 func coverageCell(r diffReport) string {
300 if r.coverTotal == 0 {
301 return "n/a"
302 }
303 return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal)
304 }
305
306 func mutationCell(r diffReport) string {
307 if r.mut.total == 0 {
308 return "n/a"
309 }
310 cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total))
311 if len(r.mut.survivors) > 0 {
312 cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `"))
313 }
314 return cell
315 }
316
317 func pinCell(p pinResult) string {
318 switch {
319 case p.pins && p.byAssertion:
320 return "✅ by assertion"
321 case p.pins:
322 return "⚠️ by compile only"
323 default:
324 return "❌ no (passes on old code)"
325 }
326 }
327
328 // differentialPerTest reverts the PR's changed source to base (deleting files
329 // new in the PR), runs each generated test on its own against the old code, and
330 // restores the source. A test that fails on the old code pins the change.
331 func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
332 for _, f := range srcFiles {
333 if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
334 _ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
335 }
336 }
337 // Restore source even on panic; a tree left on `base` would mask the PR for later steps.
338 restored := false
339 defer func() {
340 if restored {
341 return
342 }
343 for _, f := range srcFiles {
344 _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
345 }
346 }()
347
348 out := make([]pinResult, 0, len(refs))
349 for _, r := range refs {
350 cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
351 cmd.Dir = repo
352 cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
353 raw, err := cmd.CombinedOutput()
354 out = append(out, pinResult{
355 testRef: r,
356 pins: err != nil,
357 byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name),
358 })
359 }
360 for _, f := range srcFiles {
361 _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
362 }
363 restored = true
364 return out
365 }
366
367 // changedLineCoverage runs the affected packages with a coverage profile and
368 // reports how many of the PR's changed source statement-lines the (new+existing)
369 // tests actually execute. covered/total are over changed lines that fall inside
370 // a coverage block; lines that aren't statements are ignored.
371 func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) {
372 profile := filepath.Join(repo, ".e2e-cover.out")
373 defer os.Remove(profile)
374 args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...)
375 cmd := exec.Command("go", args...)
376 cmd.Dir = repo
377 _ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran
378
379 blocks := parseCoverProfile(repo, profile)
380 for file, lines := range changedLineSet(repo, base, srcFiles) {
381 fileBlocks := blocks[file]
382 for ln := range lines {
383 for _, blk := range fileBlocks {
384 if ln >= blk.start && ln <= blk.end {
385 total++
386 if blk.count > 0 {
387 covered++
388 }
389 break
390 }
391 }
392 }
393 }
394 return covered, total
395 }
396
397 type coverBlock struct {
398 start, end, count int
399 }
400
401 // parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path
402 // (the profile uses module-qualified paths; we match by repo-relative suffix).
403 func parseCoverProfile(repo, path string) map[string][]coverBlock {
404 data, err := os.ReadFile(path)
405 if err != nil {
406 return nil
407 }
408 out := map[string][]coverBlock{}
409 for _, ln := range strings.Split(string(data), "\n") {
410 if ln == "" || strings.HasPrefix(ln, "mode:") {
411 continue
412 }
413 colon := strings.LastIndexByte(ln, ':')
414 if colon < 0 {
415 continue
416 }
417 modPath, rest := ln[:colon], ln[colon+1:]
418 var sl, sc, el, ec, nstmt, count int
419 if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil {
420 continue
421 }
422 rel := repoRelFromModulePath(modPath)
423 out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count})
424 }
425 return out
426 }
427
428 // repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
429 // "internal/agent/foo.go" by dropping the first path element (the module root).
430 func repoRelFromModulePath(p string) string {
431 // Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
432 prefix := "reasonix/"
433 if strings.HasPrefix(p, prefix) {
434 return p[len(prefix):]
435 }
436 if i := strings.IndexByte(p, '/'); i >= 0 {
437 return p[i+1:]
438 }
439 return p
440 }
441
442 // changedLineSet returns, per repo-relative source file, the set of new line
443 // numbers the PR added or changed (from a zero-context diff).
444 func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool {
445 args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...)
446 diff := gitOut(repo, args...)
447 out := map[string]map[int]bool{}
448 file := ""
449 newLine := 0
450 for _, ln := range strings.Split(diff, "\n") {
451 // '-' (deletion) lines are intentionally unhandled: they don't advance the
452 // new-side line counter, so they fall through with no case.
453 switch {
454 case strings.HasPrefix(ln, "+++ b/"):
455 file = strings.TrimPrefix(ln, "+++ b/")
456 out[file] = map[int]bool{}
457 case strings.HasPrefix(ln, "@@"):
458 // @@ -a,b +c,d @@ — start collecting at new-side line c.
459 // Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
460 if plus := strings.Index(ln, "+"); plus >= 0 {
461 num := ln[plus+1:]
462 end := len(num)
463 for i := 0; i < len(num); i++ {
464 if num[i] < '0' || num[i] > '9' {
465 end = i
466 break
467 }
468 }
469 _, _ = fmt.Sscanf(num[:end], "%d", &newLine)
470 }
471 case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
472 if file != "" {
473 out[file][newLine] = true
474 }
475 newLine++
476 }
477 }
478 return out
479 }
480
481 func countPins(ps []pinResult) int {
482 n := 0
483 for _, p := range ps {
484 if p.pins {
485 n++
486 }
487 }
488 return n
489 }
490
491 func countAssertionPins(ps []pinResult) int {
492 n := 0
493 for _, p := range ps {
494 if p.pins && p.byAssertion {
495 n++
496 }
497 }
498 return n
499 }
500
501 // parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/
502 // Benchmark functions the agent added, each tagged with its package directory.
503 func parseNewTests(diff string) []testRef {
504 var refs []testRef
505 pkg := ""
506 for _, ln := range strings.Split(diff, "\n") {
507 if strings.HasPrefix(ln, "+++ b/") {
508 pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/")))
509 continue
510 }
511 if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") {
512 continue
513 }
514 body := strings.TrimSpace(ln[1:])
515 if !strings.HasPrefix(body, "func ") {
516 continue
517 }
518 sig := strings.TrimPrefix(body, "func ")
519 // Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
520 var name string
521 if sig[0] == '(' {
522 close := strings.IndexByte(sig, ')')
523 if close < 0 {
524 continue
525 }
526 rest := strings.TrimSpace(sig[close+1:])
527 methodParen := strings.IndexByte(rest, '(')
528 if methodParen <= 0 {
529 continue
530 }
531 name = rest[:methodParen]
532 } else {
533 funcParen := strings.IndexByte(sig, '(')
534 if funcParen <= 0 {
535 continue
536 }
537 name = sig[:funcParen]
538 }
539 if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
540 refs = append(refs, testRef{name: name, pkg: pkg})
541 }
542 }
543 return refs
544 }
545
546 func countAdded(diff string) int {
547 n := 0
548 for _, ln := range strings.Split(diff, "\n") {
549 if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") {
550 n++
551 }
552 }
553 return n
554 }
555
556 // failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines.
557 func failingTestNames(out string) []string {
558 var names []string
559 seen := map[string]bool{}
560 for _, ln := range strings.Split(out, "\n") {
561 ln = strings.TrimSpace(ln)
562 if !strings.HasPrefix(ln, "--- FAIL:") {
563 continue
564 }
565 rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:")))
566 if len(rest) > 0 && !seen[rest[0]] {
567 seen[rest[0]] = true
568 names = append(names, rest[0])
569 }
570 }
571 return names
572 }
573
574 func runTests(repo, testCmd string, pkgs []string) (bool, string) {
575 test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
576 if err != nil {
577 return false, "invalid test command: " + err.Error()
578 }
579 fields := test.Argv
580 if len(fields) == 0 {
581 fields = []string{"go", "test"}
582 }
583 args := append(fields[1:], pkgs...)
584 cmd := exec.Command(fields[0], args...)
585 cmd.Dir = repo
586 if len(test.Env) > 0 {
587 cmd.Env = append(os.Environ(), test.Env...)
588 }
589 cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
590 out, err := cmd.CombinedOutput()
591 return err == nil, string(out)
592 }
593
594 // changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
595 // when includeTests is false (we want the source under test).
596 func changedGoFiles(repo, base string, includeTests bool) []string {
597 return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests)
598 }
599
600 func changedGoFilesWorktree(repo string, includeTests bool) []string {
601 return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests)
602 }
603
604 func filterGo(out string, includeTests bool) []string {
605 var keep []string
606 for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) {
607 if strings.HasSuffix(f, "_test.go") && !includeTests {
608 continue
609 }
610 keep = append(keep, f)
611 }
612 sort.Strings(keep)
613 return keep
614 }
615
616 func packagesOf(files []string) []string {
617 seen := map[string]bool{}
618 var pkgs []string
619 for _, f := range files {
620 dir := "./" + filepath.ToSlash(filepath.Dir(f))
621 if !seen[dir] {
622 seen[dir] = true
623 pkgs = append(pkgs, dir)
624 }
625 }
626 sort.Strings(pkgs)
627 return pkgs
628 }
629
630 func gitOut(repo string, args ...string) string {
631 cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
632 out, _ := cmd.Output()
633 return string(out)
634 }
635
636 func truncate(s string) string { return truncateFor(s, 12000) }
637
638 func truncateFor(s string, max int) string {
639 if max <= 0 || len(s) <= max {
640 return s
641 }
642 // Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
643 cut := max
644 for cut > 0 && !utf8.RuneStart(s[cut]) {
645 cut--
646 }
647 return s[:cut] + "\n…(truncated)…"
648 }
649
650 func tail(s string, n int) string {
651 lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
652 if len(lines) > n {
653 lines = lines[len(lines)-n:]
654 }
655 return strings.Join(lines, "\n")
656 }
657
658 func passFail(ok bool) string {
659 if ok {
660 return "pass"
661 }
662 return "fail"
663 }
664
664 lines GO