diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f456875c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.gotmpl text eol=lf +*.golden text eol=lf diff --git a/internal/prompt/golden_test.go b/internal/prompt/golden_test.go new file mode 100644 index 00000000..573cbbea --- /dev/null +++ b/internal/prompt/golden_test.go @@ -0,0 +1,424 @@ +package prompt + +import ( + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/roborev-dev/roborev/internal/config" + "github.com/roborev-dev/roborev/internal/storage" + "github.com/roborev-dev/roborev/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var updateGolden = flag.Bool("update-golden", false, "regenerate golden files under internal/prompt/testdata/golden") + +// goldenCommitDate is applied to every commit in golden-test repos so SHAs +// are stable across machines and re-runs. +const goldenCommitDate = "2026-04-01T12:00:00Z" + +var ( + dateScrubber = regexp.MustCompile(`Current date: \d{4}-\d{2}-\d{2} \(UTC\)`) + tsScrubber = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}`) +) + +// scrubDynamic normalizes values that vary across runs (the current calendar +// day the test happens to run, CreatedAt timestamps) so goldens only encode +// structural differences. +func scrubDynamic(s string) string { + s = dateScrubber.ReplaceAllString(s, "Current date: GOLDEN_DATE (UTC)") + s = tsScrubber.ReplaceAllString(s, "GOLDEN_TIMESTAMP") + return s +} + +// assertGolden compares got against testdata/golden/, or rewrites the +// golden when -update-golden is passed. +func assertGolden(t *testing.T, got, name string) { + t.Helper() + path := filepath.Join("testdata", "golden", name) + if *updateGolden { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(got), 0o644)) + return + } + want, err := os.ReadFile(path) + require.NoError(t, err, "missing golden %s (run: go test -update-golden ./internal/prompt/)", path) + assert.Equal(t, string(want), got, "golden %s drifted; review and re-run with -update-golden if intended", path) +} + +// newGoldenTestRepo builds a test repo with fixed author/committer dates so +// commit SHAs are deterministic across runs. +func newGoldenTestRepo(t *testing.T) *testRepo { + t.Helper() + t.Setenv("GIT_AUTHOR_DATE", goldenCommitDate) + t.Setenv("GIT_COMMITTER_DATE", goldenCommitDate) + return newTestRepo(t) +} + +// writeFile is a small helper for golden scenarios. +func (r *testRepo) writeFile(name, content string) { + r.t.Helper() + require.NoError(r.t, os.WriteFile(filepath.Join(r.dir, name), []byte(content), 0o644)) +} + +// commitFile stages a single file and commits with the supplied message, +// returning the resulting SHA. +func (r *testRepo) commitFile(name, content, message string) string { + r.t.Helper() + r.writeFile(name, content) + r.git("add", name) + r.git("commit", "-m", message) + return r.git("rev-parse", "HEAD") +} + +func TestGoldenPrompt_SingleReviewDefault(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_review_default.golden") +} + +func TestGoldenPrompt_SingleReviewCodex(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "codex", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_review_codex.golden") +} + +func TestGoldenPrompt_RangeWithInRangeReviews(t *testing.T) { + r := newGoldenTestRepo(t) + baseSHA := r.commitFile("base.txt", "base\n", "initial") + commit1 := r.commitFile("base.txt", "change1\n", "first feature commit") + commit2 := r.commitFile("base.txt", "change2\n", "second feature commit") + + db := testutil.OpenTestDB(t) + repo, err := db.GetOrCreateRepo(r.dir) + require.NoError(t, err) + + testutil.CreateCompletedReview(t, db, repo.ID, commit1, "test", + "Found bug: missing null check in handler\n\nVerdict: FAIL") + testutil.CreateCompletedReview(t, db, repo.ID, commit2, "test", + "No issues found.\n\nVerdict: PASS") + + b := NewBuilder(db) + prompt, err := b.Build(r.dir, baseSHA+".."+commit2, repo.ID, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "range_with_in_range_reviews.golden") +} + +func TestGoldenPrompt_DirtyReview(t *testing.T) { + r := newGoldenTestRepo(t) + r.commitFile("base.txt", "base\n", "initial") + + diff := "diff --git a/base.txt b/base.txt\n" + + "index 0000000..1111111 100644\n" + + "--- a/base.txt\n" + + "+++ b/base.txt\n" + + "@@ -1 +1,2 @@\n" + + " base\n" + + "+added line\n" + + b := NewBuilder(nil) + prompt, err := b.BuildDirty(r.dir, diff, 0, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "dirty_review.golden") +} + +func TestGoldenPrompt_AddressWithSplitResponses(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("foo.go", "package foo\n", "add foo") + + b := NewBuilder(nil) + review := &storage.Review{ + JobID: 42, + Agent: "test", + Output: "- Medium: foo.go:1 missing doc comment", + Job: &storage.ReviewJob{GitRef: sha}, + } + responses := []storage.Response{ + {Responder: "roborev-fix", Response: "Added doc comment", CreatedAt: time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC)}, + {Responder: "alice", Response: "Doc comments are optional here", CreatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)}, + } + + prompt, err := b.BuildAddressPrompt(r.dir, review, responses, "medium") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "address_with_split_responses.golden") +} + +func TestGoldenPrompt_SecurityReview(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "test", "security", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "security_review.golden") +} + +func TestGoldenPrompt_DesignReview(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "test", "design", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "design_review.golden") +} + +func TestGoldenPrompt_SingleReviewClaudeCode(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "claude-code", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_review_claude_code.golden") +} + +func TestGoldenPrompt_SingleReviewGemini(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "gemini", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_review_gemini.golden") +} + +func TestGoldenPrompt_SingleWithPreviousReviews(t *testing.T) { + r := newGoldenTestRepo(t) + parent1 := r.commitFile("a.txt", "a1\n", "alpha 1") + parent2 := r.commitFile("a.txt", "a2\n", "alpha 2") + target := r.commitFile("a.txt", "a3\n", "alpha 3") + + db := testutil.OpenTestDB(t) + repo, err := db.GetOrCreateRepo(r.dir) + require.NoError(t, err) + + testutil.CreateCompletedReview(t, db, repo.ID, parent1, "test", + "No issues found.\n\nVerdict: PASS") + testutil.CreateCompletedReview(t, db, repo.ID, parent2, "test", + "Found unused variable in a.txt\n\nVerdict: FAIL") + + b := NewBuilder(db) + prompt, err := b.Build(r.dir, target, repo.ID, 2, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_with_previous_reviews.golden") +} + +// TestGoldenPrompt_PreviousReviewsWithComments exercises the review_comments +// rendering path. Prior versions trimmed the separator after a comment block, +// causing the next `--- Review ... ---` header to butt against the last +// comment line. This snapshot locks in the expected blank line between items +// when any entry has comments. +func TestGoldenPrompt_PreviousReviewsWithComments(t *testing.T) { + r := newGoldenTestRepo(t) + parent1 := r.commitFile("a.txt", "a1\n", "alpha 1") + parent2 := r.commitFile("a.txt", "a2\n", "alpha 2") + target := r.commitFile("a.txt", "a3\n", "alpha 3") + + db := testutil.OpenTestDB(t) + repo, err := db.GetOrCreateRepo(r.dir) + require.NoError(t, err) + + testutil.CreateReviewWithComments(t, db, repo.ID, parent1, + "Found unused variable in a.txt\n\nVerdict: FAIL", + []testutil.ReviewComment{ + {User: "alice", Text: "False positive; we use this field via reflection."}, + {User: "bob", Text: "Agree with alice."}, + }) + testutil.CreateCompletedReview(t, db, repo.ID, parent2, "test", + "No issues found.\n\nVerdict: PASS") + + b := NewBuilder(db) + prompt, err := b.Build(r.dir, target, repo.ID, 2, "test", "", "") + require.NoError(t, err) + + // The separator between comment-bearing entries and the next entry + // must still contain a blank line; otherwise the `--- Review ... ---` + // header butts against the last comment line. + assert.Contains(t, prompt, "Comments on this review:\n- alice: ") + assert.Contains(t, prompt, `"Agree with alice."`+"\n\n--- Review for commit ") + + assertGolden(t, scrubDynamic(prompt), "previous_reviews_with_comments.golden") +} + +func TestGoldenPrompt_SingleWithGuidelines(t *testing.T) { + r := newGoldenTestRepo(t) + guidelines := `review_guidelines = """ +- Always prefer table-driven tests for Go. +- No new dependencies without justification. +""" +` + r.writeFile(".roborev.toml", guidelines) + r.git("add", ".roborev.toml") + r.git("commit", "-m", "add review guidelines") + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_with_guidelines.golden") +} + +func TestGoldenPrompt_SingleWithAdditionalContext(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + additional := "## Pull Request Discussion\n\nReviewer noted the greeting should support i18n in a later PR.\n" + b := NewBuilder(nil) + prompt, err := b.BuildWithAdditionalContext(r.dir, sha, 0, 0, "test", "", "", additional) + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_with_additional_context.golden") +} + +func TestGoldenPrompt_SingleWithSeverityFilter(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("hello.txt", "hello world\n", "add greeting") + + b := NewBuilder(nil) + prompt, err := b.Build(r.dir, sha, 0, 0, "test", "", "medium") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_with_severity_filter.golden") +} + +func TestGoldenPrompt_SingleTruncatedDiff(t *testing.T) { + r := newGoldenTestRepo(t) + r.commitFile("base.txt", "base\n", "initial") + + // A large change that will exceed the tiny prompt cap and trigger + // the commit fallback rendering. + large := strings.Repeat("line of content added\n", 800) + sha := r.commitFile("big.txt", large, "huge change") + + cfg := &config.Config{DefaultMaxPromptSize: 4000} + b := NewBuilderWithConfig(nil, cfg) + prompt, err := b.Build(r.dir, sha, 0, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_truncated_diff.golden") +} + +func TestGoldenPrompt_SingleTruncatedDiffCodex(t *testing.T) { + r := newGoldenTestRepo(t) + r.commitFile("base.txt", "base\n", "initial") + + large := strings.Repeat("line of content added\n", 800) + sha := r.commitFile("big.txt", large, "huge change") + + cfg := &config.Config{DefaultMaxPromptSize: 4000} + b := NewBuilderWithConfig(nil, cfg) + prompt, err := b.Build(r.dir, sha, 0, 0, "codex", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "single_truncated_diff_codex.golden") +} + +func TestGoldenPrompt_RangeTruncated(t *testing.T) { + r := newGoldenTestRepo(t) + baseSHA := r.commitFile("base.txt", "base\n", "initial") + + large := strings.Repeat("a content line\n", 500) + r.commitFile("big1.txt", large, "first large addition") + headSHA := r.commitFile("big2.txt", large, "second large addition") + + cfg := &config.Config{DefaultMaxPromptSize: 5000} + b := NewBuilderWithConfig(nil, cfg) + prompt, err := b.Build(r.dir, baseSHA+".."+headSHA, 0, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "range_truncated.golden") +} + +func TestGoldenPrompt_DirtyTruncated(t *testing.T) { + r := newGoldenTestRepo(t) + r.commitFile("base.txt", "base\n", "initial") + + diff := "diff --git a/big.txt b/big.txt\nnew file mode 100644\n" + + "index 0000000..1111111\n--- /dev/null\n+++ b/big.txt\n@@ -0,0 +1,500 @@\n" + + strings.Repeat("+a line of content\n", 500) + + cfg := &config.Config{DefaultMaxPromptSize: 4000} + b := NewBuilderWithConfig(nil, cfg) + prompt, err := b.BuildDirty(r.dir, diff, 0, 0, "test", "", "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "dirty_truncated.golden") +} + +func TestGoldenPrompt_RangeTruncatedCodexPreservesInRangeReviews(t *testing.T) { + r := newGoldenTestRepo(t) + baseSHA := r.commitFile("base.txt", "base\n", "initial") + + large := strings.Repeat("a content line\n", 500) + commit1 := r.commitFile("big1.txt", large, "first large addition") + commit2 := r.commitFile("big2.txt", large, "second large addition") + + db := testutil.OpenTestDB(t) + repo, err := db.GetOrCreateRepo(r.dir) + require.NoError(t, err) + + testutil.CreateCompletedReview(t, db, repo.ID, commit1, "test", + "Found null-deref in big1.txt\n\nVerdict: FAIL") + testutil.CreateCompletedReview(t, db, repo.ID, commit2, "test", + "No issues found.\n\nVerdict: PASS") + + cfg := &config.Config{DefaultMaxPromptSize: 6000} + b := NewBuilderWithConfig(db, cfg) + prompt, err := b.Build(r.dir, baseSHA+".."+commit2, repo.ID, 0, "codex", "", "") + require.NoError(t, err) + + // Narrow invariant checks first — these are the regression we just + // fixed: the truncated codex range path must not drop InRangeReviews, + // and it must still select the codex-specific inspection fallback. + assert.Contains(t, prompt, "Per-Commit Reviews in This Range") + assert.Contains(t, prompt, "Found null-deref in big1.txt") + assert.Contains(t, prompt, "For Codex in read-only review mode, inspect the commit range locally") + + assertGolden(t, scrubDynamic(prompt), "range_truncated_codex_in_range.golden") +} + +func TestGoldenPrompt_AddressWithoutSeverity(t *testing.T) { + r := newGoldenTestRepo(t) + sha := r.commitFile("foo.go", "package foo\n", "add foo") + + b := NewBuilder(nil) + review := &storage.Review{ + JobID: 99, + Agent: "test", + Output: "- Medium: foo.go:1 missing doc comment", + Job: &storage.ReviewJob{GitRef: sha}, + } + responses := []storage.Response{ + {Responder: "roborev-fix", Response: "Added doc comment", CreatedAt: time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC)}, + } + + prompt, err := b.BuildAddressPrompt(r.dir, review, responses, "") + require.NoError(t, err) + + assertGolden(t, scrubDynamic(prompt), "address_without_severity.golden") +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 57beebf3..336ee2cd 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -34,8 +34,8 @@ IMPORTANT: You are being invoked by roborev to perform this review directly. Do Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call.` -// ReviewContext holds a commit SHA and its associated review (if any) plus responses -type ReviewContext struct { +// HistoricalReviewContext holds a commit SHA and its associated review (if any) plus responses. +type HistoricalReviewContext struct { SHA string Review *storage.Review Responses []storage.Response @@ -91,66 +91,50 @@ func (b *Builder) Build(repoPath, gitRef string, repoID int64, contextCount int, // BuildWithAdditionalContext constructs a review prompt with an optional // caller-provided markdown context block inserted ahead of the current diff. func (b *Builder) BuildWithAdditionalContext(repoPath, gitRef string, repoID int64, contextCount int, agentName, reviewType, minSeverity, additionalContext string) (string, error) { - opts := buildOpts{ + return b.buildWithOpts(repoPath, gitRef, repoID, contextCount, agentName, reviewType, buildOpts{ additionalContext: additionalContext, minSeverity: minSeverity, - } - if git.IsRange(gitRef) { - return b.buildRangePrompt(repoPath, gitRef, repoID, contextCount, agentName, reviewType, opts) - } - return b.buildSinglePrompt(repoPath, gitRef, repoID, contextCount, agentName, reviewType, opts) + }) } // BuildWithAdditionalContextAndDiffFile constructs a review prompt with -// caller-provided markdown context and an optional oversized-diff file -// reference for sandboxed Codex reviews. +// caller-provided markdown context and an optional oversized-diff file reference. func (b *Builder) BuildWithAdditionalContextAndDiffFile(repoPath, gitRef string, repoID int64, contextCount int, agentName, reviewType, minSeverity, additionalContext, diffFilePath string) (string, error) { - opts := buildOpts{ + return b.buildWithOpts(repoPath, gitRef, repoID, contextCount, agentName, reviewType, buildOpts{ additionalContext: additionalContext, diffFilePath: diffFilePath, requireDiffFile: true, minSeverity: minSeverity, - } - if git.IsRange(gitRef) { - return b.buildRangePrompt(repoPath, gitRef, repoID, contextCount, agentName, reviewType, opts) - } - return b.buildSinglePrompt(repoPath, gitRef, repoID, contextCount, agentName, reviewType, opts) + }) } -// BuildWithDiffFile constructs a review prompt where a pre-written diff -// file is referenced for large diffs instead of git commands. This is -// used for Codex agents running in a sandboxed environment that cannot -// execute git directly. +// BuildWithDiffFile constructs a review prompt where a pre-written diff file +// is referenced for large diffs instead of inline content. func (b *Builder) BuildWithDiffFile(repoPath, gitRef string, repoID int64, contextCount int, agentName, reviewType, minSeverity, diffFilePath string) (string, error) { - opts := buildOpts{ + return b.buildWithOpts(repoPath, gitRef, repoID, contextCount, agentName, reviewType, buildOpts{ diffFilePath: diffFilePath, requireDiffFile: true, minSeverity: minSeverity, - } + }) +} + +func (b *Builder) buildWithOpts(repoPath, gitRef string, repoID int64, contextCount int, agentName, reviewType string, opts buildOpts) (string, error) { if git.IsRange(gitRef) { return b.buildRangePrompt(repoPath, gitRef, repoID, contextCount, agentName, reviewType, opts) } return b.buildSinglePrompt(repoPath, gitRef, repoID, contextCount, agentName, reviewType, opts) } -// SnapshotResult holds a prompt and an optional cleanup function for -// a diff snapshot file that was written during prompt construction. +// SnapshotResult holds a prompt and an optional cleanup function for a diff snapshot file. type SnapshotResult struct { Prompt string Cleanup func() } -// BuildWithSnapshot builds a review prompt, automatically writing a -// diff snapshot file when the diff is too large to inline. -func (b *Builder) BuildWithSnapshot( - repoPath, gitRef string, repoID int64, - contextCount int, agentName, reviewType, minSeverity string, - excludes []string, -) (SnapshotResult, error) { - p, err := b.BuildWithDiffFile( - repoPath, gitRef, repoID, - contextCount, agentName, reviewType, minSeverity, "", - ) +// BuildWithSnapshot builds a review prompt, automatically writing a diff snapshot file +// when the diff is too large to inline. +func (b *Builder) BuildWithSnapshot(repoPath, gitRef string, repoID int64, contextCount int, agentName, reviewType, minSeverity string, excludes []string) (SnapshotResult, error) { + p, err := b.BuildWithDiffFile(repoPath, gitRef, repoID, contextCount, agentName, reviewType, minSeverity, "") if !errors.Is(err, ErrDiffTruncatedNoFile) { return SnapshotResult{Prompt: p}, err } @@ -158,10 +142,7 @@ func (b *Builder) BuildWithSnapshot( if writeErr != nil { return SnapshotResult{}, fmt.Errorf("write diff snapshot: %w", writeErr) } - p, err = b.BuildWithDiffFile( - repoPath, gitRef, repoID, - contextCount, agentName, reviewType, minSeverity, diffFile, - ) + p, err = b.BuildWithDiffFile(repoPath, gitRef, repoID, contextCount, agentName, reviewType, minSeverity, diffFile) if err != nil { cleanup() return SnapshotResult{}, err @@ -169,8 +150,7 @@ func (b *Builder) BuildWithSnapshot( return SnapshotResult{Prompt: p, Cleanup: cleanup}, nil } -// WriteDiffSnapshot writes the full diff for a git ref to a file in -// the repo's git dir. Returns the file path and a cleanup function. +// WriteDiffSnapshot writes the full diff for a git ref to a file in the repo's git dir. func WriteDiffSnapshot(repoPath, gitRef string, excludes []string) (string, func(), error) { var ( fullDiff string @@ -208,13 +188,9 @@ func WriteDiffSnapshot(repoPath, gitRef string, excludes []string) (string, func return diffFile, func() { os.Remove(diffFile) }, nil } -// BuildDirtyWithSnapshot builds a dirty review prompt, writing the diff -// to a snapshot file when it's too large to inline. The caller must -// call Cleanup (if non-nil) after the prompt is no longer needed. -func (b *Builder) BuildDirtyWithSnapshot( - repoPath, diff string, repoID int64, - contextCount int, agentName, reviewType, minSeverity string, -) (SnapshotResult, error) { +// BuildDirtyWithSnapshot builds a dirty review prompt, writing the diff to a snapshot file +// when it's too large to inline. +func (b *Builder) BuildDirtyWithSnapshot(repoPath, diff string, repoID int64, contextCount int, agentName, reviewType, minSeverity string) (SnapshotResult, error) { p, err := b.BuildDirty(repoPath, diff, repoID, contextCount, agentName, reviewType, minSeverity) if err != nil { return SnapshotResult{}, err @@ -248,26 +224,11 @@ func (b *Builder) BuildDirtyWithSnapshot( // The diff is provided directly since it was captured at enqueue time. // reviewType selects the system prompt variant (e.g., "security"); any default alias (see config.IsDefaultReviewType) uses the standard prompt. func (b *Builder) BuildDirty(repoPath, diff string, repoID int64, contextCount int, agentName, reviewType, minSeverity string) (string, error) { - // Start with system prompt for dirty changes - promptType := "dirty" - if !config.IsDefaultReviewType(reviewType) { - promptType = reviewType - } - if promptType == config.ReviewTypeDesign { - promptType = "design-review" - } - promptCap := b.resolveMaxPromptSize(repoPath) - requiredPrefix := GetSystemPrompt(agentName, promptType) + "\n" - if inst := config.SeverityInstruction(minSeverity); inst != "" { - requiredPrefix += inst + "\n" - } - requiredPrefix = hardCapPrompt(requiredPrefix, promptCap) - - optional := optionalSectionsView{} + ctx := b.newPromptBuildContext(repoPath, agentName, reviewType, minSeverity, "dirty", optionalSectionsView{}) // Add project-specific guidelines if configured if repoCfg, err := config.LoadRepoConfig(repoPath); err == nil && repoCfg != nil { - optional.ProjectGuidelines = buildProjectGuidelinesSectionView(repoCfg.ReviewGuidelines) + ctx.optional.ProjectGuidelines = buildProjectGuidelinesSectionView(repoCfg.ReviewGuidelines) } // Get previous reviews for context (use HEAD as reference point) @@ -276,18 +237,18 @@ func (b *Builder) BuildDirty(repoPath, diff string, repoID int64, contextCount i if err == nil { contexts, err := b.getPreviousReviewContexts(repoPath, headSHA, contextCount) if err == nil && len(contexts) > 0 { - optional.PreviousReviews = orderedPreviousReviewViews(contexts) + ctx.optional.PreviousReviews = orderedPreviousReviewViews(contexts) } } } - bodyLimit := max(0, promptCap-len(requiredPrefix)) + bodyLimit := max(0, ctx.promptCap-len(ctx.requiredPrefix)) inlineDiff, err := renderInlineDiff(diff) if err != nil { return "", err } view := dirtyPromptView{ - Optional: optional, + Optional: ctx.optional, Current: dirtyChangesSectionView{ Description: "The following changes have not yet been committed.", }, @@ -380,11 +341,15 @@ func (b *Builder) BuildDirty(repoPath, diff string, repoID int64, contextCount i } } - body, err := fitDirtyPrompt(bodyLimit, view) + body, err := fitDirtyPromptContext(bodyLimit, templateContextFromDirtyView(view)) if err != nil { return "", err } - return requiredPrefix + hardCapPrompt(body, bodyLimit), nil + return ctx.requiredPrefix + hardCapPrompt(body, bodyLimit), nil +} + +func isCodexReviewAgent(agentName string) bool { + return strings.EqualFold(strings.TrimSpace(agentName), "codex") } func truncateUTF8(s string, maxBytes int) string { @@ -410,8 +375,6 @@ func hardCapPrompt(prompt string, limit int) string { return truncateUTF8(prompt, limit) } -// buildOpts groups optional parameters for buildSinglePrompt and -// buildRangePrompt to keep the positional parameter count manageable. type buildOpts struct { additionalContext string // diffFilePath, when non-empty, is a file containing the full @@ -426,9 +389,39 @@ type buildOpts struct { minSeverity string } -// diffFileFallbackVariants returns progressively shorter prompt -// variants for oversized diffs. When filePath is non-empty, the -// variants reference the file; otherwise they just note truncation. +type promptBuildContext struct { + requiredPrefix string + optional optionalSectionsView + promptCap int +} + +func (b *Builder) newPromptBuildContext(repoPath, agentName, reviewType, minSeverity, defaultPromptType string, optional optionalSectionsView) promptBuildContext { + promptType := defaultPromptType + if !config.IsDefaultReviewType(reviewType) { + promptType = reviewType + } + if promptType == config.ReviewTypeDesign { + promptType = "design-review" + } + promptCap := b.resolveMaxPromptSize(repoPath) + requiredPrefix := GetSystemPrompt(agentName, promptType) + "\n" + if inst := config.SeverityInstruction(minSeverity); inst != "" { + requiredPrefix += inst + "\n" + } + return promptBuildContext{ + requiredPrefix: hardCapPrompt(requiredPrefix, promptCap), + optional: optional, + promptCap: promptCap, + } +} + +func defaultOptionalSections(repoPath, additionalContext string) optionalSectionsView { + return optionalSectionsView{ + ProjectGuidelines: buildProjectGuidelinesSectionView(LoadGuidelines(repoPath)), + AdditionalContext: buildAdditionalContextSection(additionalContext), + } +} + func diffFileFallbackVariants(heading, filePath string) []string { if filePath == "" { return []string{heading + "\n\n(Diff too large to include inline)\n"} @@ -486,38 +479,262 @@ func buildPromptPreservingCurrentSection(requiredPrefix, optionalContext, curren return hardCapPrompt(sb.String(), limit) } -// buildSinglePrompt constructs a prompt for a single commit -func (b *Builder) buildSinglePrompt(repoPath, sha string, repoID int64, contextCount int, agentName, reviewType string, opts buildOpts) (string, error) { - // Start with system prompt - promptType := "review" - if !config.IsDefaultReviewType(reviewType) { - promptType = reviewType +// safeForMarkdown filters pathspec args to only those that can be +// safely embedded in markdown inline code spans. Args containing +// backticks or control characters are dropped. +func safeForMarkdown(args []string) []string { + var safe []string + for _, a := range args { + ok := true + for _, r := range a { + if r < ' ' || r == '`' || r == 0x7f { + ok = false + break + } + } + if ok { + safe = append(safe, a) + } } - if promptType == config.ReviewTypeDesign { - promptType = "design-review" + return safe +} + +func shellQuote(s string) string { + if s == "" { + return "''" } - promptCap := b.resolveMaxPromptSize(repoPath) - requiredPrefix := GetSystemPrompt(agentName, promptType) + "\n" - if inst := config.SeverityInstruction(opts.minSeverity); inst != "" { - requiredPrefix += inst + "\n" + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func renderShellCommand(args ...string) string { + var quoted []string + for _, arg := range args { + if needsShellQuoting(arg) { + quoted = append(quoted, shellQuote(arg)) + continue + } + quoted = append(quoted, arg) } - requiredPrefix = hardCapPrompt(requiredPrefix, promptCap) + return stripInlineCodeBreakers(strings.Join(quoted, " ")) +} - optional := optionalSectionsView{ - ProjectGuidelines: buildProjectGuidelinesSectionView(LoadGuidelines(repoPath)), - AdditionalContext: buildAdditionalContextSection(opts.additionalContext), +// stripInlineCodeBreakers removes characters that would break an enclosing +// Markdown inline code span. Command strings produced here are only used +// for display inside prompts (never executed), so dropping a backtick or +// control character from a rare git ref is preferable to letting +// user-controlled input escape the code span and inject text into the +// surrounding prompt. +func stripInlineCodeBreakers(s string) string { + if !strings.ContainsAny(s, "`\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f") { + return s + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '`' || r < 0x20 || r == 0x7f { + continue + } + b.WriteRune(r) + } + return b.String() +} + +func needsShellQuoting(s string) bool { + if s == "" { + return true + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case strings.ContainsRune("@%_+=:,./-~", r): + default: + return true + } + } + return false +} + +func codexCommitInspectionFallbackVariants(sha string, pathspecArgs []string) []diffSectionView { + view := commitInspectionFallbackView{ + SHA: sha, + StatCmd: renderShellCommand(append([]string{"git", "show", "--stat", "--summary", sha, "--"}, pathspecArgs...)...), + DiffCmd: renderShellCommand(append([]string{"git", "show", "--format=medium", "--unified=80", sha, "--"}, pathspecArgs...)...), + FilesCmd: renderShellCommand(append([]string{"git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha, "--"}, pathspecArgs...)...), + ShowPathCmd: renderShellCommand(append([]string{"git", "show", sha, "--"}, pathspecArgs...)...), + } + names := []string{"codex_commit_fallback_full", "codex_commit_fallback_medium", "codex_commit_fallback_short", "codex_commit_fallback_shortest"} + variants := make([]diffSectionView, 0, len(names)) + for _, name := range names { + fallback, err := renderCommitInspectionFallback(name, view) + if err != nil { + continue + } + variants = append(variants, diffSectionView{Heading: "### Diff", Fallback: fallback}) + } + return variants +} + +func codexRangeInspectionFallbackVariants(rangeRef string, pathspecArgs []string) []diffSectionView { + view := rangeInspectionFallbackView{ + RangeRef: rangeRef, + LogCmd: renderShellCommand("git", "log", "--oneline", rangeRef), + StatCmd: renderShellCommand(append([]string{"git", "diff", "--stat", rangeRef, "--"}, pathspecArgs...)...), + DiffCmd: renderShellCommand(append([]string{"git", "diff", "--unified=80", rangeRef, "--"}, pathspecArgs...)...), + FilesCmd: renderShellCommand(append([]string{"git", "diff", "--name-only", rangeRef, "--"}, pathspecArgs...)...), + ViewCmd: renderShellCommand(append([]string{"git", "diff", rangeRef, "--"}, pathspecArgs...)...), + } + names := []string{"codex_range_fallback_full", "codex_range_fallback_medium", "codex_range_fallback_short", "codex_range_fallback_shortest"} + variants := make([]diffSectionView, 0, len(names)) + for _, name := range names { + fallback, err := renderRangeInspectionFallback(name, view) + if err != nil { + continue + } + variants = append(variants, diffSectionView{Heading: "### Combined Diff", Fallback: fallback}) + } + return variants +} + +func selectDiffSectionVariant(variants []diffSectionView, remaining int) (diffSectionView, error) { + if len(variants) == 0 { + return diffSectionView{}, nil + } + selected := variants[len(variants)-1] + for _, variant := range variants { + block, err := renderDiffBlock(variant) + if err != nil { + return diffSectionView{}, err + } + if len(block) <= remaining { + return variant, nil + } + } + return truncateDiffSectionFallbackToFit(selected, remaining) +} + +func truncateDiffSectionFallbackToFit(view diffSectionView, limit int) (diffSectionView, error) { + block, err := renderDiffBlock(view) + if err != nil || len(block) <= limit { + return view, err + } + baseBlock, err := renderDiffBlock(diffSectionView{Heading: view.Heading, Body: ""}) + if err != nil { + return diffSectionView{}, err + } + view.Fallback = truncateUTF8(view.Fallback, max(0, limit-len(baseBlock))) + return view, nil +} + +type rangeMetadataLoss struct { + RemovedEntries int + BlankedSubject int + TrimmedOptional int +} + +func compareRangeMetadataLoss(a, b rangeMetadataLoss) int { + switch { + case a.RemovedEntries != b.RemovedEntries: + return a.RemovedEntries - b.RemovedEntries + case a.BlankedSubject != b.BlankedSubject: + return a.BlankedSubject - b.BlankedSubject + default: + return a.TrimmedOptional - b.TrimmedOptional + } +} + +func measureOptionalSectionsLoss(original, trimmed ReviewOptionalContext) int { + loss := 0 + if len(original.PreviousAttempts) > 0 && len(trimmed.PreviousAttempts) == 0 { + loss++ + } + if len(original.InRangeReviews) > 0 && len(trimmed.InRangeReviews) == 0 { + loss++ + } + if len(original.PreviousReviews) > 0 && len(trimmed.PreviousReviews) == 0 { + loss++ + } + if original.AdditionalContext != "" && trimmed.AdditionalContext == "" { + loss++ + } + if original.ProjectGuidelines != nil && trimmed.ProjectGuidelines == nil { + loss++ + } + return loss +} + +func measureRangeMetadataLoss(original, trimmed TemplateContext) rangeMetadataLoss { + if original.Review == nil || trimmed.Review == nil || original.Review.Subject.Range == nil || trimmed.Review.Subject.Range == nil { + return rangeMetadataLoss{} + } + loss := rangeMetadataLoss{ + RemovedEntries: len(original.Review.Subject.Range.Entries) - len(trimmed.Review.Subject.Range.Entries), + TrimmedOptional: measureOptionalSectionsLoss(original.Review.Optional, trimmed.Review.Optional), + } + for i := range trimmed.Review.Subject.Range.Entries { + if i >= len(original.Review.Subject.Range.Entries) { + break + } + if original.Review.Subject.Range.Entries[i].Subject != "" && trimmed.Review.Subject.Range.Entries[i].Subject == "" { + loss.BlankedSubject++ + } + } + return loss +} + +func selectRichestRangePromptView(limit int, view TemplateContext, variants []diffSectionView) (TemplateContext, error) { + fallback := view.Clone() + if len(variants) > 0 && fallback.Review != nil { + fallback.Review.Diff = DiffContext{Heading: variants[len(variants)-1].Heading, Body: variants[len(variants)-1].Body} + fallback.Review.Fallback = fallbackContextFromDiffSection(variants[len(variants)-1]) + } + var ( + best TemplateContext + bestLoss rangeMetadataLoss + haveBest bool + ) + for _, variant := range variants { + candidate := view.Clone() + if candidate.Review != nil { + candidate.Review.Diff = DiffContext{Heading: variant.Heading, Body: variant.Body} + candidate.Review.Fallback = fallbackContextFromDiffSection(variant) + } + trimmed, body, err := trimRangePromptContext(limit, candidate) + if err != nil { + return TemplateContext{}, err + } + fallback = trimmed + if len(body) > limit { + continue + } + loss := measureRangeMetadataLoss(view, trimmed) + if !haveBest || compareRangeMetadataLoss(loss, bestLoss) < 0 { + best = trimmed + bestLoss = loss + haveBest = true + } } + if haveBest { + return best, nil + } + return fallback, nil +} + +// buildSinglePrompt constructs a prompt for a single commit +func (b *Builder) buildSinglePrompt(repoPath, sha string, repoID int64, contextCount int, agentName, reviewType string, opts buildOpts) (string, error) { + ctx := b.newPromptBuildContext(repoPath, agentName, reviewType, opts.minSeverity, "review", defaultOptionalSections(repoPath, opts.additionalContext)) // Get previous reviews if requested if contextCount > 0 && b.db != nil { contexts, err := b.getPreviousReviewContexts(repoPath, sha, contextCount) if err == nil && len(contexts) > 0 { - optional.PreviousReviews = orderedPreviousReviewViews(contexts) + ctx.optional.PreviousReviews = orderedPreviousReviewViews(contexts) } } // Include previous review attempts for this same commit (for re-reviews) - optional.PreviousAttempts = previousAttemptViewsFromContexts(b.previousAttemptContexts(sha)) + ctx.optional.PreviousAttempts = previousAttemptViewsFromContexts(b.previousAttemptContexts(sha)) // Current commit section shortSHA := git.ShortSHA(sha) @@ -552,7 +769,7 @@ func (b *Builder) buildSinglePrompt(repoPath, sha string, repoID int64, contextC } excludes := b.resolveExcludes(repoPath, reviewType) - bodyLimit := max(0, promptCap-len(requiredPrefix)) + bodyLimit := max(0, ctx.promptCap-len(ctx.requiredPrefix)) diffLimit := max(0, bodyLimit-len(currentRequired)-len(currentOverflow)-len(emptyDiffBlock)) diff, truncated, err := git.GetDiffLimited(repoPath, sha, diffLimit, excludes...) if err != nil { @@ -561,21 +778,42 @@ func (b *Builder) buildSinglePrompt(repoPath, sha string, repoID int64, contextC diffView := diffSectionView{Heading: "### Diff"} if truncated { - if opts.diffFilePath == "" && opts.requireDiffFile { - return "", ErrDiffTruncatedNoFile + if opts.diffFilePath != "" || opts.requireDiffFile { + if opts.diffFilePath == "" && opts.requireDiffFile { + return "", ErrDiffTruncatedNoFile + } + optionalPrefix, err := renderOptionalSectionsPrefix(ctx.optional) + if err != nil { + return "", err + } + return buildPromptPreservingCurrentSection(ctx.requiredPrefix, optionalPrefix, currentRequired, currentOverflow, ctx.promptCap, diffFileFallbackVariants("### Diff", opts.diffFilePath)...), nil } - optionalPrefix, err := renderOptionalSectionsPrefix(optional) - if err != nil { - return "", err + pathspecArgs := safeForMarkdown(git.FormatExcludeArgs(excludes)) + if isCodexReviewAgent(agentName) { + variants := codexCommitInspectionFallbackVariants(sha, pathspecArgs) + shortestBlock, err := renderDiffBlock(variants[len(variants)-1]) + if err != nil { + return "", err + } + optionalPrefix, err := renderOptionalSectionsPrefix(ctx.optional) + if err != nil { + return "", err + } + softBudget := max(0, bodyLimit-len(currentRequired)-len(shortestBlock)) + softLen := len(optionalPrefix) + len(currentOverflow) + effectiveSoftLen := min(softLen, softBudget) + remaining := max(0, bodyLimit-len(currentRequired)-effectiveSoftLen) + diffView, err = selectDiffSectionVariant(variants, remaining) + if err != nil { + return "", err + } + } else { + fallback, err := renderGenericCommitFallback(renderShellCommand("git", "show", sha)) + if err != nil { + return "", err + } + diffView.Fallback = fallback } - return buildPromptPreservingCurrentSection( - requiredPrefix, - optionalPrefix, - currentRequired, - currentOverflow, - promptCap, - diffFileFallbackVariants("### Diff", opts.diffFilePath)..., - ), nil } else { inlineDiff, err := renderInlineDiff(diff) if err != nil { @@ -584,41 +822,23 @@ func (b *Builder) buildSinglePrompt(repoPath, sha string, repoID int64, contextC diffView.Body = inlineDiff } - body, err := fitSinglePrompt( + body, err := fitSinglePromptContext( bodyLimit, - singlePromptView{ - Optional: optional, + templateContextFromSingleView(singlePromptView{ + Optional: ctx.optional, Current: currentView, Diff: diffView, - }, + }), ) if err != nil { return "", err } - return requiredPrefix + body, nil + return ctx.requiredPrefix + body, nil } // buildRangePrompt constructs a prompt for a commit range func (b *Builder) buildRangePrompt(repoPath, rangeRef string, repoID int64, contextCount int, agentName, reviewType string, opts buildOpts) (string, error) { - // Start with system prompt for ranges - promptType := "range" - if !config.IsDefaultReviewType(reviewType) { - promptType = reviewType - } - if promptType == config.ReviewTypeDesign { - promptType = "design-review" - } - promptCap := b.resolveMaxPromptSize(repoPath) - requiredPrefix := GetSystemPrompt(agentName, promptType) + "\n" - if inst := config.SeverityInstruction(opts.minSeverity); inst != "" { - requiredPrefix += inst + "\n" - } - requiredPrefix = hardCapPrompt(requiredPrefix, promptCap) - - optional := optionalSectionsView{ - ProjectGuidelines: buildProjectGuidelinesSectionView(LoadGuidelines(repoPath)), - AdditionalContext: buildAdditionalContextSection(opts.additionalContext), - } + ctx := b.newPromptBuildContext(repoPath, agentName, reviewType, opts.minSeverity, "range", defaultOptionalSections(repoPath, opts.additionalContext)) // Get previous reviews from before the range start if contextCount > 0 && b.db != nil { @@ -626,13 +846,13 @@ func (b *Builder) buildRangePrompt(repoPath, rangeRef string, repoID int64, cont if err == nil { contexts, err := b.getPreviousReviewContexts(repoPath, startSHA, contextCount) if err == nil && len(contexts) > 0 { - optional.PreviousReviews = orderedPreviousReviewViews(contexts) + ctx.optional.PreviousReviews = orderedPreviousReviewViews(contexts) } } } // Include previous review attempts for this same range (for re-reviews) - optional.PreviousAttempts = previousAttemptViewsFromContexts(b.previousAttemptContexts(rangeRef)) + ctx.optional.PreviousAttempts = previousAttemptViewsFromContexts(b.previousAttemptContexts(rangeRef)) // Get commits in range commits, err := git.GetRangeCommits(repoPath, rangeRef) @@ -640,7 +860,9 @@ func (b *Builder) buildRangePrompt(repoPath, rangeRef string, repoID int64, cont return "", fmt.Errorf("get range commits: %w", err) } - optional.InRangeReviews = inRangeReviewViews(b.lookupReviewContexts(commits, true)) + // Include per-commit reviews for commits inside the range so the agent + // can avoid re-raising issues that were already surfaced. + ctx.optional.InRangeReviews = inRangeReviewViews(b.lookupReviewContexts(commits, true)) entries := make([]commitRangeEntryView, 0, len(commits)) for _, commitSHA := range commits { @@ -671,7 +893,7 @@ func (b *Builder) buildRangePrompt(repoPath, rangeRef string, repoID int64, cont } excludes := b.resolveExcludes(repoPath, reviewType) - bodyLimit := max(0, promptCap-len(requiredPrefix)) + bodyLimit := max(0, ctx.promptCap-len(ctx.requiredPrefix)) diffLimit := max(0, bodyLimit-len(currentRequiredText)-len(currentOverflowText)-len(emptyDiffBlock)) diff, truncated, err := git.GetRangeDiffLimited(repoPath, rangeRef, diffLimit, excludes...) if err != nil { @@ -680,21 +902,44 @@ func (b *Builder) buildRangePrompt(repoPath, rangeRef string, repoID int64, cont diffView := diffSectionView{Heading: "### Combined Diff"} if truncated { - if opts.diffFilePath == "" && opts.requireDiffFile { - return "", ErrDiffTruncatedNoFile + if opts.diffFilePath != "" || opts.requireDiffFile { + if opts.diffFilePath == "" && opts.requireDiffFile { + return "", ErrDiffTruncatedNoFile + } + optionalPrefix, err := renderOptionalSectionsPrefix(ctx.optional) + if err != nil { + return "", err + } + return buildPromptPreservingCurrentSection(ctx.requiredPrefix, optionalPrefix, currentRequiredText, currentOverflowText, ctx.promptCap, diffFileFallbackVariants("### Combined Diff", opts.diffFilePath)...), nil } - optionalPrefix, err := renderOptionalSectionsPrefix(optional) - if err != nil { - return "", err + pathspecArgs := safeForMarkdown(git.FormatExcludeArgs(excludes)) + if isCodexReviewAgent(agentName) { + variants := codexRangeInspectionFallbackVariants(rangeRef, pathspecArgs) + selectedCtx, err := selectRichestRangePromptView(bodyLimit, templateContextFromRangeView(rangePromptView{ + Optional: ctx.optional, + Current: currentView, + }), variants) + if err != nil { + return "", err + } + if selectedCtx.Review != nil { + ctx.optional = selectedCtx.Review.Optional.Clone() + if selectedCtx.Review.Subject.Range != nil { + entries := make([]commitRangeEntryView, 0, len(selectedCtx.Review.Subject.Range.Entries)) + for _, entry := range selectedCtx.Review.Subject.Range.Entries { + entries = append(entries, commitRangeEntryView(entry)) + } + currentView = commitRangeSectionView{Count: selectedCtx.Review.Subject.Range.Count, Entries: entries} + } + diffView = diffSectionView{Heading: selectedCtx.Review.Diff.Heading, Body: selectedCtx.Review.Diff.Body, Fallback: selectedCtx.Review.Fallback.Rendered()} + } + } else { + fallback, err := renderGenericRangeFallback(renderShellCommand("git", "diff", rangeRef)) + if err != nil { + return "", err + } + diffView.Fallback = fallback } - return buildPromptPreservingCurrentSection( - requiredPrefix, - optionalPrefix, - currentRequiredText, - currentOverflowText, - promptCap, - diffFileFallbackVariants("### Combined Diff", opts.diffFilePath)..., - ), nil } else { inlineDiff, err := renderInlineDiff(diff) if err != nil { @@ -703,18 +948,18 @@ func (b *Builder) buildRangePrompt(repoPath, rangeRef string, repoID int64, cont diffView.Body = inlineDiff } - body, err := fitRangePrompt( + body, err := fitRangePromptContext( bodyLimit, - rangePromptView{ - Optional: optional, + templateContextFromRangeView(rangePromptView{ + Optional: ctx.optional, Current: currentView, Diff: diffView, - }, + }), ) if err != nil { return "", err } - return requiredPrefix + body, nil + return ctx.requiredPrefix + body, nil } func buildProjectGuidelinesSectionView(guidelines string) *markdownSectionView { @@ -736,8 +981,8 @@ func buildAdditionalContextSection(additionalContext string) string { return trimmed + "\n\n" } -func orderedPreviousReviewViews(contexts []ReviewContext) []previousReviewView { - ordered := make([]ReviewContext, 0, len(contexts)) +func orderedPreviousReviewViews(contexts []HistoricalReviewContext) []previousReviewView { + ordered := make([]HistoricalReviewContext, 0, len(contexts)) for i := len(contexts) - 1; i >= 0; i-- { ordered = append(ordered, contexts[i]) } @@ -747,13 +992,6 @@ func orderedPreviousReviewViews(contexts []ReviewContext) []previousReviewView { // LoadGuidelines loads review guidelines from the repo's default // branch, falling back to filesystem config when the default branch // has no .roborev.toml. -func (b *Builder) writeProjectGuidelines(sb *strings.Builder, guidelines string) { - body, err := renderOptionalSectionsPrefix(optionalSectionsView{ProjectGuidelines: buildProjectGuidelinesSectionView(guidelines)}) - if err == nil { - sb.WriteString(body) - } -} - func LoadGuidelines(repoPath string) string { // Load review guidelines from the default branch (origin/main, // origin/master, etc.). Branch-specific guidelines are intentionally @@ -805,21 +1043,34 @@ func (b *Builder) previousAttemptContexts(gitRef string) []reviewAttemptContext return attempts } -func (b *Builder) lookupReviewContexts(shas []string, skipMissing bool) []ReviewContext { +// getPreviousReviewContexts gets the N commits before the target and looks up their reviews and responses +func (b *Builder) getPreviousReviewContexts(repoPath, sha string, count int) ([]HistoricalReviewContext, error) { + // Get parent commits from git + parentSHAs, err := git.GetParentCommits(repoPath, sha, count) + if err != nil { + return nil, fmt.Errorf("get parent commits: %w", err) + } + return b.lookupReviewContexts(parentSHAs, false), nil +} + +// lookupReviewContexts looks up reviews and responses for each SHA. +// When skipMissing is true, SHAs with no stored review are omitted from the +// result; otherwise a placeholder context (Review nil) is returned for them. +func (b *Builder) lookupReviewContexts(shas []string, skipMissing bool) []HistoricalReviewContext { if b.db == nil { return nil } - var contexts []ReviewContext + var contexts []HistoricalReviewContext for _, sha := range shas { review, err := b.db.GetReviewByCommitSHA(sha) if err != nil { if skipMissing { continue } - contexts = append(contexts, ReviewContext{SHA: sha}) + contexts = append(contexts, HistoricalReviewContext{SHA: sha}) continue } - ctx := ReviewContext{SHA: sha, Review: review} + ctx := HistoricalReviewContext{SHA: sha, Review: review} if review.JobID > 0 { if responses, err := b.db.GetCommentsForJob(review.JobID); err == nil { ctx.Responses = responses @@ -830,22 +1081,12 @@ func (b *Builder) lookupReviewContexts(shas []string, skipMissing bool) []Review return contexts } -// getPreviousReviewContexts gets the N commits before the target and looks up their reviews and responses -func (b *Builder) getPreviousReviewContexts(repoPath, sha string, count int) ([]ReviewContext, error) { - parentSHAs, err := git.GetParentCommits(repoPath, sha, count) - if err != nil { - return nil, fmt.Errorf("get parent commits: %w", err) - } - return b.lookupReviewContexts(parentSHAs, false), nil -} - // BuildSimple constructs a simpler prompt without database context func BuildSimple(repoPath, sha, agentName string) (string, error) { b := &Builder{} return b.Build(repoPath, sha, 0, 0, agentName, "", "") } -// PreviousAttemptsHeader introduces previous addressing attempts section. const PreviousAttemptsHeader = ` ## Previous Addressing Attempts @@ -855,7 +1096,6 @@ Be pragmatic - if previous attempts were rejected for being too minor, make more If they were rejected for being over-engineered, keep it simpler. ` -// UserCommentsHeader introduces user-authored comments on a review. const UserCommentsHeader = `## User Comments The following comments were left by the developer on this review. @@ -864,14 +1104,10 @@ positives, provide additional context, or request specific approaches. ` -// IsToolResponse returns true when the response was left by an automated -// tool (roborev-fix, roborev-refine, etc.) rather than a human user. func IsToolResponse(r storage.Response) bool { return strings.HasPrefix(r.Responder, "roborev-") } -// SplitResponses partitions responses into tool-generated attempts and -// user-authored comments based on the Responder field. func SplitResponses(responses []storage.Response) (toolAttempts, userComments []storage.Response) { for _, r := range responses { if IsToolResponse(r) { @@ -883,7 +1119,6 @@ func SplitResponses(responses []storage.Response) (toolAttempts, userComments [] return } -// FormatToolAttempts renders automated tool responses into a prompt section. func FormatToolAttempts(attempts []storage.Response) string { if len(attempts) == 0 { return "" @@ -899,7 +1134,6 @@ func FormatToolAttempts(attempts []storage.Response) string { return sb.String() } -// FormatUserComments renders user-authored comments into a prompt section. func FormatUserComments(comments []storage.Response) string { if len(comments) == 0 { return "" @@ -935,7 +1169,11 @@ func (b *Builder) BuildAddressPrompt(repoPath string, review *storage.Review, pr if !attempt.CreatedAt.IsZero() { when = attempt.CreatedAt.Format("2006-01-02 15:04") } - view.ToolAttempts = append(view.ToolAttempts, addressAttemptView{Responder: attempt.Responder, Response: attempt.Response, When: when}) + view.ToolAttempts = append(view.ToolAttempts, addressAttemptView{ + Responder: attempt.Responder, + Response: attempt.Response, + When: when, + }) } } if len(userComments) > 0 { @@ -945,7 +1183,11 @@ func (b *Builder) BuildAddressPrompt(repoPath string, review *storage.Review, pr if !comment.CreatedAt.IsZero() { when = comment.CreatedAt.Format("2006-01-02 15:04") } - view.UserComments = append(view.UserComments, addressAttemptView{Responder: comment.Responder, Response: comment.Response, When: when}) + view.UserComments = append(view.UserComments, addressAttemptView{ + Responder: comment.Responder, + Response: comment.Response, + When: when, + }) } } } diff --git a/internal/prompt/prompt_body_templates.go b/internal/prompt/prompt_body_templates.go index 3904930f..e0aa1842 100644 --- a/internal/prompt/prompt_body_templates.go +++ b/internal/prompt/prompt_body_templates.go @@ -10,59 +10,23 @@ import ( "github.com/roborev-dev/roborev/internal/storage" ) -type markdownSectionView struct { - Heading string - Body string -} +type markdownSectionView = MarkdownSection -type reviewCommentView struct { - Responder string - Response string -} +type reviewCommentView = ReviewCommentTemplateContext -type previousReviewView struct { - Commit string - Output string - Comments []reviewCommentView - Available bool -} +type previousReviewView = PreviousReviewTemplateContext -type reviewAttemptView struct { - Label string - Agent string - When string - Output string - Comments []reviewCommentView -} +type reviewAttemptView = ReviewAttemptTemplateContext -type optionalSectionsView struct { - ProjectGuidelines *markdownSectionView - AdditionalContext string - PreviousReviews []previousReviewView - InRangeReviews []inRangeReviewView - PreviousAttempts []reviewAttemptView -} +type optionalSectionsView = ReviewOptionalContext -type currentCommitSectionView struct { - Commit string - Subject string - Author string - Message string -} +type currentCommitSectionView = SingleSubjectContext -type commitRangeEntryView struct { - Commit string - Subject string -} +type commitRangeEntryView = RangeEntryContext -type commitRangeSectionView struct { - Count int - Entries []commitRangeEntryView -} +type commitRangeSectionView = RangeSubjectContext -type dirtyChangesSectionView struct { - Description string -} +type dirtyChangesSectionView = DirtySubjectContext type diffSectionView struct { Heading string @@ -88,19 +52,7 @@ type dirtyPromptView struct { Diff diffSectionView } -type inRangeReviewView struct { - Commit string - Agent string - Verdict string - Output string - Comments []reviewCommentView -} - -type addressAttemptView struct { - Responder string - Response string - When string -} +type addressAttemptView = AddressAttemptTemplateContext type addressPromptView struct { ProjectGuidelines *markdownSectionView @@ -117,10 +69,7 @@ type reviewAttemptContext struct { Responses []storage.Response } -type systemPromptView struct { - NoSkillsInstruction string - CurrentDate string -} +type systemPromptView = SystemTemplateContext type inlineDiffView struct { Body string @@ -135,24 +84,211 @@ var promptTemplates = template.Must(template.New("prompt-templates").ParseFS( "templates/*.md.gotmpl", )) +func markdownSectionFromView(view *markdownSectionView) *MarkdownSection { + if view == nil { + return nil + } + return &MarkdownSection{Heading: view.Heading, Body: view.Body} +} + +func reviewCommentsFromView(views []reviewCommentView) []ReviewCommentTemplateContext { + comments := make([]ReviewCommentTemplateContext, 0, len(views)) + for _, view := range views { + comments = append(comments, ReviewCommentTemplateContext(view)) + } + return comments +} + +func previousReviewsFromView(views []previousReviewView) []PreviousReviewTemplateContext { + reviews := make([]PreviousReviewTemplateContext, 0, len(views)) + for _, view := range views { + reviews = append(reviews, PreviousReviewTemplateContext{ + Commit: view.Commit, + Output: view.Output, + Comments: reviewCommentsFromView(view.Comments), + Available: view.Available, + }) + } + return reviews +} + +func reviewAttemptsFromView(views []reviewAttemptView) []ReviewAttemptTemplateContext { + attempts := make([]ReviewAttemptTemplateContext, 0, len(views)) + for _, view := range views { + attempts = append(attempts, ReviewAttemptTemplateContext{ + Label: view.Label, + Agent: view.Agent, + When: view.When, + Output: view.Output, + Comments: reviewCommentsFromView(view.Comments), + }) + } + return attempts +} + +func reviewOptionalContextFromView(view optionalSectionsView) ReviewOptionalContext { + return ReviewOptionalContext{ + ProjectGuidelines: markdownSectionFromView(view.ProjectGuidelines), + AdditionalContext: view.AdditionalContext, + PreviousReviews: previousReviewsFromView(view.PreviousReviews), + InRangeReviews: inRangeReviewsFromView(view.InRangeReviews), + PreviousAttempts: reviewAttemptsFromView(view.PreviousAttempts), + } +} + +func inRangeReviewsFromView(views []InRangeReviewTemplateContext) []InRangeReviewTemplateContext { + reviews := make([]InRangeReviewTemplateContext, 0, len(views)) + for _, view := range views { + reviews = append(reviews, InRangeReviewTemplateContext{ + Commit: view.Commit, + Agent: view.Agent, + Verdict: view.Verdict, + Output: view.Output, + Comments: reviewCommentsFromView(view.Comments), + }) + } + return reviews +} + +type commitInspectionFallbackView struct { + SHA string + StatCmd string + DiffCmd string + FilesCmd string + ShowPathCmd string +} + +type rangeInspectionFallbackView struct { + RangeRef string + LogCmd string + StatCmd string + DiffCmd string + FilesCmd string + ViewCmd string +} + +type genericDiffFallbackView struct { + ViewCmd string +} + +func fallbackContextFromDiffSection(view diffSectionView) FallbackContext { + if view.Fallback == "" { + return FallbackContext{} + } + return FallbackContext{Mode: FallbackModeGeneric, Text: view.Fallback} +} + +func templateContextFromSingleView(view singlePromptView) TemplateContext { + return TemplateContext{ + Review: &ReviewTemplateContext{ + Kind: ReviewKindSingle, + Optional: reviewOptionalContextFromView(view.Optional), + Subject: SubjectContext{Single: &SingleSubjectContext{ + Commit: view.Current.Commit, + Subject: view.Current.Subject, + Author: view.Current.Author, + Message: view.Current.Message, + }}, + Diff: DiffContext{Heading: view.Diff.Heading, Body: view.Diff.Body}, + Fallback: fallbackContextFromDiffSection(view.Diff), + }, + } +} + +func templateContextFromRangeView(view rangePromptView) TemplateContext { + entries := make([]RangeEntryContext, 0, len(view.Current.Entries)) + for _, entry := range view.Current.Entries { + entries = append(entries, RangeEntryContext(entry)) + } + return TemplateContext{ + Review: &ReviewTemplateContext{ + Kind: ReviewKindRange, + Optional: reviewOptionalContextFromView(view.Optional), + Subject: SubjectContext{Range: &RangeSubjectContext{Count: view.Current.Count, Entries: entries}}, + Diff: DiffContext{Heading: view.Diff.Heading, Body: view.Diff.Body}, + Fallback: fallbackContextFromDiffSection(view.Diff), + }, + } +} + +func templateContextFromDirtyView(view dirtyPromptView) TemplateContext { + fallback := fallbackContextFromDiffSection(view.Diff) + if fallback.Text != "" { + fallback.Mode = FallbackModeDirty + fallback.Dirty = &DirtyFallbackContext{Body: fallback.Text} + fallback.Text = "" + } + return TemplateContext{ + Review: &ReviewTemplateContext{ + Kind: ReviewKindDirty, + Optional: reviewOptionalContextFromView(view.Optional), + Subject: SubjectContext{Dirty: &DirtySubjectContext{Description: view.Current.Description}}, + Diff: DiffContext{Heading: view.Diff.Heading, Body: view.Diff.Body}, + Fallback: fallback, + }, + } +} + +func templateContextFromAddressView(view addressPromptView) TemplateContext { + toolAttempts := make([]AddressAttemptTemplateContext, 0, len(view.ToolAttempts)) + for _, attempt := range view.ToolAttempts { + toolAttempts = append(toolAttempts, AddressAttemptTemplateContext(attempt)) + } + userComments := make([]AddressAttemptTemplateContext, 0, len(view.UserComments)) + for _, comment := range view.UserComments { + userComments = append(userComments, AddressAttemptTemplateContext(comment)) + } + return TemplateContext{ + Address: &AddressTemplateContext{ + ProjectGuidelines: markdownSectionFromView(view.ProjectGuidelines), + ToolAttempts: toolAttempts, + UserComments: userComments, + SeverityFilter: view.SeverityFilter, + ReviewFindings: view.ReviewFindings, + OriginalDiff: view.OriginalDiff, + JobID: view.JobID, + }, + } +} + +func templateContextFromSystemView(view systemPromptView) TemplateContext { + return TemplateContext{System: &SystemTemplateContext{NoSkillsInstruction: view.NoSkillsInstruction, CurrentDate: view.CurrentDate}} +} + +func renderSinglePromptContext(ctx TemplateContext) (string, error) { + return executePromptTemplate("assembled_single.md.gotmpl", ctx) +} + +func renderRangePromptContext(ctx TemplateContext) (string, error) { + return executePromptTemplate("assembled_range.md.gotmpl", ctx) +} + +func renderDirtyPromptContext(ctx TemplateContext) (string, error) { + return executePromptTemplate("assembled_dirty.md.gotmpl", ctx) +} + +func renderAddressPromptContext(ctx TemplateContext) (string, error) { + return executePromptTemplate("assembled_address.md.gotmpl", ctx) +} + func renderSinglePrompt(view singlePromptView) (string, error) { - return executePromptTemplate("assembled_single.md.gotmpl", view) + return renderSinglePromptContext(templateContextFromSingleView(view)) } func renderRangePrompt(view rangePromptView) (string, error) { - return executePromptTemplate("assembled_range.md.gotmpl", view) + return renderRangePromptContext(templateContextFromRangeView(view)) } func renderDirtyPrompt(view dirtyPromptView) (string, error) { - return executePromptTemplate("assembled_dirty.md.gotmpl", view) + return renderDirtyPromptContext(templateContextFromDirtyView(view)) } func renderAddressPrompt(view addressPromptView) (string, error) { - return executePromptTemplate("assembled_address.md.gotmpl", view) + return renderAddressPromptContext(templateContextFromAddressView(view)) } -func fitSinglePrompt(limit int, view singlePromptView) (string, error) { - body, err := renderSinglePrompt(view) +func fitSinglePromptContext(limit int, ctx TemplateContext) (string, error) { + body, err := renderSinglePromptContext(ctx) if err != nil { return "", err } @@ -160,8 +296,8 @@ func fitSinglePrompt(limit int, view singlePromptView) (string, error) { return body, nil } - for trimOptionalSections(&view.Optional) { - body, err = renderSinglePrompt(view) + for ctx.Review != nil && ctx.Review.Optional.TrimNext() { + body, err = renderSinglePromptContext(ctx) if err != nil { return "", err } @@ -170,9 +306,8 @@ func fitSinglePrompt(limit int, view singlePromptView) (string, error) { } } - if view.Current.Message != "" { - view.Current.Message = "" - body, err = renderSinglePrompt(view) + if ctx.Review != nil && ctx.Review.Subject.TrimSingleMessage() { + body, err = renderSinglePromptContext(ctx) if err != nil { return "", err } @@ -181,9 +316,8 @@ func fitSinglePrompt(limit int, view singlePromptView) (string, error) { } } - if view.Current.Author != "" { - view.Current.Author = "" - body, err = renderSinglePrompt(view) + if ctx.Review != nil && ctx.Review.Subject.TrimSingleAuthor() { + body, err = renderSinglePromptContext(ctx) if err != nil { return "", err } @@ -192,10 +326,10 @@ func fitSinglePrompt(limit int, view singlePromptView) (string, error) { } } - for len(body) > limit && view.Current.Subject != "" { + for ctx.Review != nil && ctx.Review.Subject.Single != nil && len(body) > limit && ctx.Review.Subject.Single.Subject != "" { overflow := len(body) - limit - view.Current.Subject = truncateUTF8(view.Current.Subject, max(0, len(view.Current.Subject)-overflow)) - body, err = renderSinglePrompt(view) + ctx.Review.Subject.TrimSingleSubjectTo(max(0, len(ctx.Review.Subject.Single.Subject)-overflow)) + body, err = renderSinglePromptContext(ctx) if err != nil { return "", err } @@ -204,67 +338,61 @@ func fitSinglePrompt(limit int, view singlePromptView) (string, error) { return hardCapPrompt(body, limit), nil } -func fitRangePrompt(limit int, view rangePromptView) (string, error) { - _, body, err := trimRangePromptView(limit, view) +func fitSinglePrompt(limit int, view singlePromptView) (string, error) { + return fitSinglePromptContext(limit, templateContextFromSingleView(view)) +} + +func fitRangePromptContext(limit int, ctx TemplateContext) (string, error) { + _, body, err := trimRangePromptContext(limit, ctx) if err != nil { return "", err } return hardCapPrompt(body, limit), nil } -func cloneCommitRangeSectionView(view commitRangeSectionView) commitRangeSectionView { - cloned := view - if len(view.Entries) == 0 { - return cloned - } - cloned.Entries = append([]commitRangeEntryView(nil), view.Entries...) - return cloned +func fitRangePrompt(limit int, view rangePromptView) (string, error) { + return fitRangePromptContext(limit, templateContextFromRangeView(view)) } -func trimRangePromptView(limit int, view rangePromptView) (rangePromptView, string, error) { - view.Current = cloneCommitRangeSectionView(view.Current) - body, err := renderRangePrompt(view) +func trimRangePromptContext(limit int, ctx TemplateContext) (TemplateContext, string, error) { + ctx = ctx.Clone() + body, err := renderRangePromptContext(ctx) if err != nil { - return rangePromptView{}, "", err + return TemplateContext{}, "", err } if len(body) <= limit { - return view, body, nil + return ctx, body, nil } - for trimOptionalSections(&view.Optional) { - body, err = renderRangePrompt(view) + for ctx.Review != nil && ctx.Review.Optional.TrimNext() { + body, err = renderRangePromptContext(ctx) if err != nil { - return rangePromptView{}, "", err + return TemplateContext{}, "", err } if len(body) <= limit { - return view, body, nil + return ctx, body, nil } } - for i := len(view.Current.Entries) - 1; i >= 0 && len(body) > limit; i-- { - if view.Current.Entries[i].Subject == "" { - continue - } - view.Current.Entries[i].Subject = "" - body, err = renderRangePrompt(view) + for ctx.Review != nil && len(body) > limit && ctx.Review.Subject.BlankNextRangeSubject() { + body, err = renderRangePromptContext(ctx) if err != nil { - return rangePromptView{}, "", err + return TemplateContext{}, "", err } } - for len(view.Current.Entries) > 0 && len(body) > limit { - view.Current.Entries = view.Current.Entries[:len(view.Current.Entries)-1] - body, err = renderRangePrompt(view) + for ctx.Review != nil && len(body) > limit && ctx.Review.Subject.DropLastRangeEntry() { + body, err = renderRangePromptContext(ctx) if err != nil { - return rangePromptView{}, "", err + return TemplateContext{}, "", err } } - return view, body, nil + return ctx, body, nil } -func fitDirtyPrompt(limit int, view dirtyPromptView) (string, error) { - body, err := renderDirtyPrompt(view) +func fitDirtyPromptContext(limit int, ctx TemplateContext) (string, error) { + body, err := renderDirtyPromptContext(ctx) if err != nil { return "", err } @@ -272,8 +400,8 @@ func fitDirtyPrompt(limit int, view dirtyPromptView) (string, error) { return body, nil } - for trimOptionalSections(&view.Optional) { - body, err = renderDirtyPrompt(view) + for ctx.Review != nil && ctx.Review.Optional.TrimNext() { + body, err = renderDirtyPromptContext(ctx) if err != nil { return "", err } @@ -285,24 +413,24 @@ func fitDirtyPrompt(limit int, view dirtyPromptView) (string, error) { return hardCapPrompt(body, limit), nil } +func fitDirtyPrompt(limit int, view dirtyPromptView) (string, error) { + return fitDirtyPromptContext(limit, templateContextFromDirtyView(view)) +} + func trimOptionalSections(view *optionalSectionsView) bool { - switch { - case len(view.PreviousAttempts) > 0: - view.PreviousAttempts = nil - case len(view.PreviousReviews) > 0: - view.PreviousReviews = nil - case view.AdditionalContext != "": - view.AdditionalContext = "" - case view.ProjectGuidelines != nil: - view.ProjectGuidelines = nil - default: + if view == nil { + return false + } + ctx := reviewOptionalContextFromView(*view) + if !ctx.TrimNext() { return false } + *view = ctx return true } func renderSystemPrompt(name string, view systemPromptView) (string, error) { - return executePromptTemplate(name, view) + return executePromptTemplate(name, templateContextFromSystemView(view)) } func renderAddressPromptFromSections(view addressPromptView) (string, error) { @@ -346,7 +474,11 @@ func renderDirtyChangesSection(view dirtyChangesSectionView) (string, error) { } func renderDiffBlock(view diffSectionView) (string, error) { - return executePromptTemplate("diff_block", view) + ctx := ReviewTemplateContext{ + Diff: DiffContext{Heading: view.Heading, Body: view.Body}, + Fallback: fallbackContextFromDiffSection(view), + } + return executePromptTemplate("diff_block", ctx) } func renderInlineDiff(body string) (string, error) { @@ -356,6 +488,22 @@ func renderInlineDiff(body string) (string, error) { return executePromptTemplate("inline_diff", inlineDiffView{Body: body}) } +func renderCommitInspectionFallback(name string, view commitInspectionFallbackView) (string, error) { + return executePromptTemplate(name, view) +} + +func renderRangeInspectionFallback(name string, view rangeInspectionFallbackView) (string, error) { + return executePromptTemplate(name, view) +} + +func renderGenericCommitFallback(viewCmd string) (string, error) { + return executePromptTemplate("generic_commit_fallback", genericDiffFallbackView{ViewCmd: viewCmd}) +} + +func renderGenericRangeFallback(viewCmd string) (string, error) { + return executePromptTemplate("generic_range_fallback", genericDiffFallbackView{ViewCmd: viewCmd}) +} + func renderDirtyTruncatedDiffFallback(body string) (string, error) { if body != "" && !strings.HasSuffix(body, "\n") { body += "\n" @@ -363,7 +511,7 @@ func renderDirtyTruncatedDiffFallback(body string) (string, error) { return executePromptTemplate("dirty_truncated_diff_fallback", dirtyTruncatedDiffFallbackView{Body: body}) } -func previousReviewViews(contexts []ReviewContext) []previousReviewView { +func previousReviewViews(contexts []HistoricalReviewContext) []previousReviewView { views := make([]previousReviewView, 0, len(contexts)) for _, ctx := range contexts { view := previousReviewView{Commit: git.ShortSHA(ctx.SHA)} @@ -382,25 +530,20 @@ func previousReviewViews(contexts []ReviewContext) []previousReviewView { return views } -func renderPreviousReviewsFromContexts(contexts []ReviewContext) (string, error) { - return renderOptionalSectionsFromView(optionalSectionsView{PreviousReviews: previousReviewViews(contexts)}) -} - -func inRangeReviewViews(contexts []ReviewContext) []inRangeReviewView { - views := make([]inRangeReviewView, 0, len(contexts)) +func inRangeReviewViews(contexts []HistoricalReviewContext) []InRangeReviewTemplateContext { + views := make([]InRangeReviewTemplateContext, 0, len(contexts)) for _, ctx := range contexts { if ctx.Review == nil { continue } - verdict := storage.ParseVerdict(ctx.Review.Output) verdictLabel := "unknown" - switch verdict { + switch storage.ParseVerdict(ctx.Review.Output) { case "P": verdictLabel = "passed" case "F": verdictLabel = "failed" } - view := inRangeReviewView{ + view := InRangeReviewTemplateContext{ Commit: git.ShortSHA(ctx.SHA), Agent: ctx.Review.Agent, Verdict: verdictLabel, @@ -417,6 +560,10 @@ func inRangeReviewViews(contexts []ReviewContext) []inRangeReviewView { return views } +func renderPreviousReviewsFromContexts(contexts []HistoricalReviewContext) (string, error) { + return renderOptionalSectionsFromView(optionalSectionsView{PreviousReviews: previousReviewViews(contexts)}) +} + func reviewAttemptViews(reviews []storage.Review) []reviewAttemptView { views := make([]reviewAttemptView, 0, len(reviews)) for i, review := range reviews { diff --git a/internal/prompt/prompt_body_templates_test.go b/internal/prompt/prompt_body_templates_test.go index 8013f30a..95590388 100644 --- a/internal/prompt/prompt_body_templates_test.go +++ b/internal/prompt/prompt_body_templates_test.go @@ -119,7 +119,8 @@ func TestRenderAddressPromptUsesNestedSections(t *testing.T) { Heading: "## Project Guidelines", Body: "Keep it simple.", }, - ToolAttempts: []addressAttemptView{{Responder: "developer", Response: "Tried a narrow fix", When: "2026-04-04 12:00"}}, + ToolAttempts: []addressAttemptView{{Responder: "roborev-fix", Response: "Tried a narrow fix", When: "2026-04-04 12:00"}}, + UserComments: []addressAttemptView{{Responder: "alice", Response: "This is a false positive", When: "2026-04-04 13:00"}}, SeverityFilter: "Only address medium and higher findings.\n\n", ReviewFindings: "- medium: do the thing", OriginalDiff: "diff --git a/a b/a\n+line\n", @@ -131,6 +132,10 @@ func TestRenderAddressPromptUsesNestedSections(t *testing.T) { assert.Contains(t, body, "## Project Guidelines") assert.Contains(t, body, "## Previous Addressing Attempts") + assert.Contains(t, body, "roborev-fix") + assert.Contains(t, body, "## User Comments") + assert.Contains(t, body, "alice") + assert.Contains(t, body, "false positive") assert.Contains(t, body, "## Review Findings to Address (Job 42)") assert.Contains(t, body, "## Original Commit Diff (for context)") } @@ -235,8 +240,106 @@ func TestBuildProjectGuidelinesSectionViewTrimsAndFormats(t *testing.T) { assert.Equal(t, "Prefer composition over inheritance.", section.Body) } -func TestPreviousReviewViewsPreserveChronologicalOrder(t *testing.T) { - views := previousReviewViews([]ReviewContext{ +func TestReviewOptionalContextTrimNextPreservesPriority(t *testing.T) { + ctx := ReviewOptionalContext{ + ProjectGuidelines: &MarkdownSection{Heading: "## Project Guidelines", Body: "Keep it simple."}, + AdditionalContext: "## Pull Request Discussion\n\ncontext\n\n", + PreviousReviews: []PreviousReviewTemplateContext{{Commit: "abc1234", Output: "review", Available: true}}, + InRangeReviews: []InRangeReviewTemplateContext{{Commit: "def5678", Output: "in-range"}}, + PreviousAttempts: []ReviewAttemptTemplateContext{{Label: "Review Attempt 1", Output: "attempt"}}, + } + + require.True(t, ctx.TrimNext()) + assert.Empty(t, ctx.PreviousAttempts) + require.True(t, ctx.TrimNext()) + assert.Empty(t, ctx.InRangeReviews) + require.True(t, ctx.TrimNext()) + assert.Empty(t, ctx.PreviousReviews) + require.True(t, ctx.TrimNext()) + assert.Empty(t, ctx.AdditionalContext) + require.True(t, ctx.TrimNext()) + assert.Nil(t, ctx.ProjectGuidelines) + assert.False(t, ctx.TrimNext()) +} + +// TestTrimOptionalSectionsPropagatesInRangeReviewsClear guards against a +// regression where trimOptionalSections ran TrimNext on a local copy but +// then rebuilt the caller's view field-by-field, omitting InRangeReviews +// so the cleared slice never made it back to the caller. +func TestTrimOptionalSectionsPropagatesInRangeReviewsClear(t *testing.T) { + view := optionalSectionsView{ + PreviousReviews: []PreviousReviewTemplateContext{{Commit: "abc1234", Output: "prev", Available: true}}, + InRangeReviews: []InRangeReviewTemplateContext{{Commit: "def5678", Output: "in-range"}}, + } + + require.True(t, trimOptionalSections(&view)) + assert.Empty(t, view.InRangeReviews, "TrimNext cleared InRangeReviews; the view must reflect the clear") + assert.NotEmpty(t, view.PreviousReviews, "only one section should be trimmed per call") + + require.True(t, trimOptionalSections(&view)) + assert.Empty(t, view.PreviousReviews) + + assert.False(t, trimOptionalSections(&view)) +} + +// TestMeasureOptionalSectionsLossCountsInRangeReviews guards against the +// prior bug where selectRichestRangePromptView treated "kept in-range +// reviews" and "dropped in-range reviews" as equally good, so a richer diff +// fallback could silently discard the per-commit review context. +func TestMeasureOptionalSectionsLossCountsInRangeReviews(t *testing.T) { + original := ReviewOptionalContext{ + InRangeReviews: []InRangeReviewTemplateContext{{Commit: "abc1234", Output: "in-range"}}, + } + kept := original + dropped := ReviewOptionalContext{} + + assert.Zero(t, measureOptionalSectionsLoss(original, kept), + "keeping InRangeReviews must score zero loss") + assert.Equal(t, 1, measureOptionalSectionsLoss(original, dropped), + "dropping InRangeReviews must register as a loss") +} + +func TestTemplateContextCloneIsolatesNestedState(t *testing.T) { + ctx := TemplateContext{ + Review: &ReviewTemplateContext{ + Optional: ReviewOptionalContext{ + ProjectGuidelines: &MarkdownSection{Heading: "## Project Guidelines", Body: "Keep it simple."}, + PreviousAttempts: []ReviewAttemptTemplateContext{{Label: "Review Attempt 1", Output: "attempt"}}, + }, + Subject: SubjectContext{ + Range: &RangeSubjectContext{Count: 2, Entries: []RangeEntryContext{{Commit: "abc1234", Subject: "first"}, {Commit: "def5678", Subject: "second"}}}, + }, + Fallback: FallbackContext{Mode: FallbackModeRange, Range: &RangeFallbackContext{DiffCmd: "git diff"}}, + }, + } + + cloned := ctx.Clone() + require.NotNil(t, cloned.Review) + require.True(t, cloned.Review.Optional.TrimNext()) + require.True(t, cloned.Review.Subject.BlankNextRangeSubject()) + cloned.Review.Fallback.Range.DiffCmd = "git diff --stat" + + require.NotNil(t, ctx.Review.Optional.ProjectGuidelines) + require.Len(t, ctx.Review.Optional.PreviousAttempts, 1) + require.NotNil(t, ctx.Review.Subject.Range) + assert.Equal(t, "second", ctx.Review.Subject.Range.Entries[1].Subject) + assert.Equal(t, "git diff", ctx.Review.Fallback.Range.DiffCmd) +} + +func TestTemplateContextSubjectRangeTrimmingHelpers(t *testing.T) { + ctx := SubjectContext{Range: &RangeSubjectContext{Count: 2, Entries: []RangeEntryContext{{Commit: "abc1234", Subject: "first"}, {Commit: "def5678", Subject: "second"}}}} + + require.True(t, ctx.BlankNextRangeSubject()) + require.NotNil(t, ctx.Range) + assert.Empty(t, ctx.Range.Entries[1].Subject) + assert.Equal(t, "first", ctx.Range.Entries[0].Subject) + require.True(t, ctx.DropLastRangeEntry()) + require.Len(t, ctx.Range.Entries, 1) + assert.Equal(t, "abc1234", ctx.Range.Entries[0].Commit) +} + +func TestHistoricalReviewContextPreviousReviewViewsPreserveChronologicalOrder(t *testing.T) { + views := previousReviewViews([]HistoricalReviewContext{ {SHA: "bbbbbbb", Review: &storage.Review{Output: "second"}}, {SHA: "aaaaaaa", Review: &storage.Review{Output: "first"}}, }) @@ -246,7 +349,7 @@ func TestPreviousReviewViewsPreserveChronologicalOrder(t *testing.T) { } func TestRenderPreviousReviewsFromContexts(t *testing.T) { - body, err := renderPreviousReviewsFromContexts([]ReviewContext{ + body, err := renderPreviousReviewsFromContexts([]HistoricalReviewContext{ { SHA: "abc1234", Review: &storage.Review{Output: "Found a bug"}, diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go index 8b23fd54..9e973fbe 100644 --- a/internal/prompt/prompt_test.go +++ b/internal/prompt/prompt_test.go @@ -70,6 +70,48 @@ func TestBuildPromptWithAdditionalContext(t *testing.T) { assertContains(t, prompt, "Most recent human comment first.", "Prompt should contain additional context body") } +func TestBuildPromptWithAdditionalContextAndPreviousAttemptsPreservesSectionOrder(t *testing.T) { + repoPath, commits := setupTestRepo(t) + db, repoID := setupDBWithCommits(t, repoPath, commits) + + testutil.CreateCompletedReview(t, db, repoID, commits[5], "test", "First review") + + builder := NewBuilder(db) + prompt, err := builder.BuildWithAdditionalContext( + repoPath, + commits[5], + repoID, + 0, + "claude-code", + "", + "", + "## Pull Request Discussion\n\nNewest comment first.\n", + ) + require.NoError(t, err) + + additionalContextPos := strings.Index(prompt, "## Pull Request Discussion") + previousAttemptsPos := strings.Index(prompt, "## Previous Review Attempts") + currentCommitPos := strings.Index(prompt, "## Current Commit") + + require.NotEqual(t, -1, additionalContextPos) + require.NotEqual(t, -1, previousAttemptsPos) + require.NotEqual(t, -1, currentCommitPos) + assert.Less(t, additionalContextPos, previousAttemptsPos) + assert.Less(t, previousAttemptsPos, currentCommitPos) +} + +func TestOrderedPreviousReviewViewsRendersOldestFirst(t *testing.T) { + views := orderedPreviousReviewViews([]HistoricalReviewContext{ + {SHA: "ccccccc", Review: &storage.Review{Output: "newest"}}, + {SHA: "bbbbbbb", Review: &storage.Review{Output: "middle"}}, + {SHA: "aaaaaaa", Review: &storage.Review{Output: "oldest"}}, + }) + require.Len(t, views, 3) + assert.Equal(t, "aaaaaaa", views[0].Commit) + assert.Equal(t, "bbbbbbb", views[1].Commit) + assert.Equal(t, "ccccccc", views[2].Commit) +} + func TestBuildPromptWithPreviousReviews(t *testing.T) { repoPath, commits := setupTestRepo(t) @@ -498,7 +540,8 @@ func TestBuildWithDiffFileNonCodexUsesDiffFile(t *testing.T) { } func TestBuildWithDiffFileSmallDiffInlineIgnoresFile(t *testing.T) { - repoPath, sha := setupSmallDiffRepo(t) + repoPath, commits := setupTestRepo(t) + sha := commits[len(commits)-1] b := NewBuilder(nil) diffFile := filepath.Join(repoPath, ".roborev-review-42.diff") @@ -509,19 +552,70 @@ func TestBuildWithDiffFileSmallDiffInlineIgnoresFile(t *testing.T) { assertNotContains(t, prompt, diffFile, "diff file should not be referenced when diff fits inline") } -func shortestDiffFileFallback(heading string) string { - // Use a representative path for size estimation in cap tests. - variants := diffFileFallbackVariants(heading, "/tmp/roborev-review-0.diff") - return variants[len(variants)-1] +func TestBuildPromptCodexOversizedDiffProvidesGitInspectionInstructions(t *testing.T) { + repoPath, sha := setupLargeDiffRepo(t) + + b := NewBuilder(nil) + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") + require.NoError(t, err, "Build failed: %v", err) + + assertContains(t, prompt, "(Diff too large to include inline)", "expected oversized diff marker") + assertContains(t, prompt, "inspect the commit locally with read-only git commands", "expected Codex git inspection guidance") + assertContains(t, prompt, "git show --stat --summary "+sha, "expected commit stat command") + assertContains(t, prompt, "git show --format=medium --unified=80 "+sha, "expected full commit diff command") + assertContains(t, prompt, "git diff-tree --no-commit-id --name-only -r "+sha, "expected touched files command") + assertNotContains(t, prompt, "View with:", "Codex fallback should not use the weak generic hint") +} + +func TestBuildRangePromptCodexOversizedDiffProvidesGitInspectionInstructions(t *testing.T) { + repoPath, sha := setupLargeDiffRepo(t) + rangeRef := sha + "~1.." + sha + + b := NewBuilder(nil) + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") + require.NoError(t, err, "Build failed: %v", err) + + assertContains(t, prompt, "(Diff too large to include inline)", "expected oversized diff marker") + assertContains(t, prompt, "inspect the commit range locally with read-only git commands", "expected Codex range inspection guidance") + assertContains(t, prompt, "git log --oneline "+rangeRef, "expected range commit list command") + assertContains(t, prompt, "git diff --stat "+rangeRef, "expected range stat command") + assertContains(t, prompt, "git diff --unified=80 "+rangeRef, "expected full range diff command") + assertContains(t, prompt, "git diff --name-only "+rangeRef, "expected touched files command") + assertNotContains(t, prompt, "View with:", "Codex fallback should not use the weak generic hint") +} + +func codexCommitFallback(sha string) string { + return mustRenderPromptTestDiffBlock(codexCommitInspectionFallbackVariants(sha, nil)[0]) +} + +func shortestCodexCommitFallback(sha string) string { + variants := codexCommitInspectionFallbackVariants(sha, nil) + return mustRenderPromptTestDiffBlock(variants[len(variants)-1]) +} + +func shortestCodexRangeFallback(rangeRef string) string { + variants := codexRangeInspectionFallbackVariants(rangeRef, nil) + return mustRenderPromptTestDiffBlock(variants[len(variants)-1]) +} + +func mustRenderPromptTestDiffBlock(view diffSectionView) string { + block, err := renderDiffBlock(view) + if err != nil { + panic(err) + } + return block } func singleCommitPromptPrefixLen(t *testing.T, repoPath, sha string) int { t.Helper() var sb strings.Builder - b := NewBuilder(nil) sb.WriteString(GetSystemPrompt("codex", "review")) sb.WriteString("\n") - b.writeProjectGuidelines(&sb, LoadGuidelines(repoPath)) + guidelines, err := renderOptionalSectionsPrefix(optionalSectionsView{ + ProjectGuidelines: buildProjectGuidelinesSectionView(LoadGuidelines(repoPath)), + }) + require.NoError(t, err) + sb.WriteString(guidelines) info, err := gitpkg.GetCommitInfo(repoPath, sha) require.NoError(t, err, "GetCommitInfo failed: %v", err) @@ -541,10 +635,13 @@ func singleCommitPromptPrefixLen(t *testing.T, repoPath, sha string) int { func rangePromptPrefixLen(t *testing.T, repoPath, rangeRef string) int { t.Helper() var sb strings.Builder - b := NewBuilder(nil) sb.WriteString(GetSystemPrompt("codex", "range")) sb.WriteString("\n") - b.writeProjectGuidelines(&sb, LoadGuidelines(repoPath)) + guidelines, err := renderOptionalSectionsPrefix(optionalSectionsView{ + ProjectGuidelines: buildProjectGuidelinesSectionView(LoadGuidelines(repoPath)), + }) + require.NoError(t, err) + sb.WriteString(guidelines) commits, err := gitpkg.GetRangeCommits(repoPath, rangeRef) require.NoError(t, err, "GetRangeCommits failed: %v", err) @@ -596,52 +693,54 @@ func rangeNearCapRepo(t *testing.T, remainingBudget int) (string, string) { } func TestBuildPromptCodexOversizedDiffStaysWithinMaxPromptSize(t *testing.T) { - setupLargeDiffRepoWithGuidelines(t, 1) - remainingBudget := len(shortestDiffFileFallback("### Diff")) + _, probeSHA := setupLargeDiffRepoWithGuidelines(t, 1) + remainingBudget := len(shortestCodexCommitFallback(probeSHA)) repoPath, sha := singleCommitNearCapRepo(t, remainingBudget) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) - assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final prompt to stay within the prompt cap") - assertContains(t, prompt, "Diff too large", "expected truncation note") + assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final Codex prompt to stay within the prompt cap") + assertContains(t, prompt, "git show", "expected fallback to retain local git inspection guidance") + assert.NotContains(t, prompt, codexCommitFallback(sha), "expected the full fallback to be downgraded when it would overflow") } func TestBuildRangePromptCodexOversizedDiffStaysWithinMaxPromptSize(t *testing.T) { - setupLargeDiffRepoWithGuidelines(t, 1) - remainingBudget := len(shortestDiffFileFallback("### Combined Diff")) + _, probeSHA := setupLargeDiffRepoWithGuidelines(t, 1) + probeRangeRef := probeSHA + "~1.." + probeSHA + remainingBudget := len(shortestCodexRangeFallback(probeRangeRef)) repoPath, sha := rangeNearCapRepo(t, remainingBudget) rangeRef := sha + "~1.." + sha b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, rangeRef, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) - assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final range prompt to stay within the prompt cap") - assertContains(t, prompt, "Diff too large", "expected truncation note") + assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final Codex range prompt to stay within the prompt cap") + assertContains(t, prompt, "git diff", "expected fallback to retain local git inspection guidance") } func TestBuildPromptCodexOversizedDiffTrimsPrefixToFitShortestFallback(t *testing.T) { - setupLargeDiffRepoWithGuidelines(t, 1) - shortestFallback := shortestDiffFileFallback("### Diff") + _, probeSHA := setupLargeDiffRepoWithGuidelines(t, 1) + shortestFallback := shortestCodexCommitFallback(probeSHA) repoPath, sha := singleCommitNearCapRepo(t, len(shortestFallback)-1) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final Codex prompt to stay within the prompt cap") assertContains(t, prompt, "Diff too large", "expected a diff-omitted marker even when the prefix consumed the budget") - assert.Contains(t, prompt, shortestDiffFileFallback("### Diff"), "expected the shortest fallback to be preserved by trimming earlier context") + assert.Contains(t, prompt, shortestCodexCommitFallback(sha), "expected the shortest fallback to be preserved by trimming earlier context") } func TestBuildPromptCodexOversizedDiffKeepsCurrentCommitMetadataWhenTrimming(t *testing.T) { repoPath, sha := singleCommitNearCapRepo(t, 1) - shortestFallback := shortestDiffFileFallback("### Diff") + shortestFallback := shortestCodexCommitFallback(sha) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final Codex prompt to stay within the prompt cap") @@ -654,11 +753,11 @@ func TestBuildPromptCodexOversizedDiffWithLargeCommitBodyStaysWithinMaxPromptSiz repoPath, sha := setupLargeCommitBodyRepo(t, defaultPromptCap) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected large commit metadata to still stay within the prompt cap") - assert.Contains(t, prompt, shortestDiffFileFallback("### Diff"), "expected the shortest fallback to remain present when commit metadata is oversized") + assert.Contains(t, prompt, shortestCodexCommitFallback(sha), "expected the shortest fallback to remain present when commit metadata is oversized") assertContains(t, prompt, "## Current Commit", "expected the current commit section header to remain intact") assertContains(t, prompt, "**Subject:** large change", "expected the current commit subject to remain intact") } @@ -667,11 +766,11 @@ func TestBuildPromptCodexOversizedDiffWithLargeCommitSubjectStaysWithinMaxPrompt repoPath, sha := setupLargeCommitSubjectRepo(t, defaultPromptCap) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected large commit metadata to still stay within the prompt cap") - assert.Contains(t, prompt, shortestDiffFileFallback("### Diff"), "expected the shortest fallback to remain present when commit subject metadata is oversized") + assert.Contains(t, prompt, shortestCodexCommitFallback(sha), "expected the shortest fallback to remain present when commit subject metadata is oversized") assertContains(t, prompt, "## Current Commit", "expected the current commit section header to remain intact") } @@ -679,11 +778,11 @@ func TestBuildPromptCodexOversizedDiffPrioritizesSubjectOverAuthor(t *testing.T) repoPath, sha := setupLargeCommitAuthorRepo(t, defaultPromptCap) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected large commit metadata to still stay within the prompt cap") - assert.Contains(t, prompt, shortestDiffFileFallback("### Diff"), "expected the shortest fallback to remain present when commit author metadata is oversized") + assert.Contains(t, prompt, shortestCodexCommitFallback(sha), "expected the shortest fallback to remain present when commit author metadata is oversized") assertContains(t, prompt, "## Current Commit", "expected the current commit section header to remain intact") assertContains(t, prompt, "**Subject:** large change", "expected the subject line to survive before the author line") } @@ -702,31 +801,30 @@ func TestSetupLargeCommitAuthorRepoIgnoresIdentityEnv(t *testing.T) { } func TestBuildRangePromptCodexOversizedDiffTrimsPrefixToFitShortestFallback(t *testing.T) { - setupLargeDiffRepoWithGuidelines(t, 1) - shortestFallback := shortestDiffFileFallback("### Combined Diff") + _, probeSHA := setupLargeDiffRepoWithGuidelines(t, 1) + probeRangeRef := probeSHA + "~1.." + probeSHA + shortestFallback := shortestCodexRangeFallback(probeRangeRef) repoPath, sha := rangeNearCapRepo(t, len(shortestFallback)-1) rangeRef := sha + "~1.." + sha b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, rangeRef, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final Codex range prompt to stay within the prompt cap") assertContains(t, prompt, "Diff too large", "expected a diff-omitted marker even when the prefix consumed the budget") - assert.Contains(t, prompt, shortestDiffFileFallback("### Combined Diff"), "expected the shortest range fallback to be preserved by trimming earlier context") + assertContains(t, prompt, "git diff", "expected Codex range fallback guidance to remain after trimming earlier context") } func TestBuildRangePromptCodexOversizedDiffKeepsCurrentRangeMetadataWhenTrimming(t *testing.T) { repoPath, sha := rangeNearCapRepo(t, 1) rangeRef := sha + "~1.." + sha - shortestFallback := shortestDiffFileFallback("### Combined Diff") - b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, rangeRef, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected final Codex range prompt to stay within the prompt cap") - assert.Contains(t, prompt, shortestFallback, "expected the shortest range fallback to be preserved after trimming earlier context") + assertContains(t, prompt, "git diff", "expected Codex range fallback guidance to remain after trimming earlier context") assertContains(t, prompt, "## Commit Range", "expected the commit range section header to remain intact") assertContains(t, prompt, "- "+gitpkg.ShortSHA(sha)+" large change", "expected the current range entry to remain intact") } @@ -735,22 +833,112 @@ func TestBuildRangePromptCodexOversizedDiffWithLargeRangeMetadataStaysWithinMaxP repoPath, rangeRef := setupLargeRangeMetadataRepo(t, 80, 4096) b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, rangeRef, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") require.NoError(t, err, "Build failed: %v", err) assert.LessOrEqual(t, len(prompt), defaultPromptCap, "expected large range metadata to still stay within the prompt cap") - assert.Contains(t, prompt, shortestDiffFileFallback("### Combined Diff"), "expected the shortest range fallback to remain present when range metadata is oversized") + assertContains(t, prompt, "git diff", "expected Codex range fallback guidance to remain present when range metadata is oversized") assertContains(t, prompt, "## Commit Range", "expected the commit range section header to remain intact") assertContains(t, prompt, "Reviewing 80 commits:", "expected the range summary to remain intact") } +func TestSelectRichestRangePromptViewPrefersRicherVariantOnEqualMetadataLoss(t *testing.T) { + view := rangePromptView{ + Current: commitRangeSectionView{Count: 3, Entries: []commitRangeEntryView{ + {Commit: "abc1234", Subject: strings.Repeat("s", 200)}, + {Commit: "def5678", Subject: strings.Repeat("s", 200)}, + {Commit: "ghi9012", Subject: strings.Repeat("s", 200)}, + }}, + } + variants := []diffSectionView{ + {Heading: "### Combined Diff", Fallback: strings.Repeat("richer guidance\n", 8)}, + {Heading: "### Combined Diff", Fallback: "short guidance\n"}, + } + trimmedCurrent := commitRangeSectionView{Count: 3, Entries: []commitRangeEntryView{ + {Commit: "abc1234", Subject: strings.Repeat("s", 200)}, + {Commit: "def5678", Subject: strings.Repeat("s", 200)}, + {Commit: "ghi9012"}, + }} + richerBody, err := renderRangePrompt(rangePromptView{Current: trimmedCurrent, Diff: variants[0]}) + require.NoError(t, err) + fullShorterBody, err := renderRangePrompt(rangePromptView{Current: view.Current, Diff: variants[1]}) + require.NoError(t, err) + require.LessOrEqual(t, len(richerBody), len(fullShorterBody), + "test setup must allow the richer variant once both candidates lose the same amount of metadata") + require.Greater(t, len(fullShorterBody), len(richerBody), + "test setup must still require metadata trimming before the richer variant fits") + + selected, err := selectRichestRangePromptView(len(richerBody), templateContextFromRangeView(view), variants) + require.NoError(t, err) + require.NotNil(t, selected.Review) + require.NotNil(t, selected.Review.Subject.Range) + + assert.Equal(t, variants[0].Fallback, selected.Review.Fallback.Rendered()) + assert.Equal(t, []RangeEntryContext{{Commit: "abc1234", Subject: strings.Repeat("s", 200)}, {Commit: "def5678", Subject: strings.Repeat("s", 200)}, {Commit: "ghi9012"}}, selected.Review.Subject.Range.Entries) +} + +func TestSelectRichestRangePromptViewPrefersSummaryWhenRicherNeedsMoreTrimming(t *testing.T) { + view := rangePromptView{ + Current: commitRangeSectionView{Count: 2, Entries: []commitRangeEntryView{ + {Commit: "abc1234", Subject: strings.Repeat("s", 200)}, + {Commit: "def5678", Subject: strings.Repeat("s", 200)}, + }}, + } + variants := []diffSectionView{ + {Heading: "### Combined Diff", Fallback: strings.Repeat("richer guidance\n", 8)}, + {Heading: "### Combined Diff", Fallback: "short guidance\n"}, + } + shorterBody, err := renderRangePrompt(rangePromptView{Current: view.Current, Diff: variants[1]}) + require.NoError(t, err) + trimmedRicherCurrent := commitRangeSectionView{Count: 2, Entries: []commitRangeEntryView{ + {Commit: "abc1234", Subject: strings.Repeat("s", 200)}, + {Commit: "def5678"}, + }} + trimmedRicherBody, err := renderRangePrompt(rangePromptView{Current: trimmedRicherCurrent, Diff: variants[0]}) + require.NoError(t, err) + require.LessOrEqual(t, len(trimmedRicherBody), len(shorterBody), + "test setup must allow the richer variant only after trimming more metadata than the shorter variant needs") + + selected, err := selectRichestRangePromptView(len(shorterBody), templateContextFromRangeView(view), variants) + require.NoError(t, err) + require.NotNil(t, selected.Review) + require.NotNil(t, selected.Review.Subject.Range) + + assert.Equal(t, variants[1].Fallback, selected.Review.Fallback.Rendered()) + assert.Equal(t, []RangeEntryContext{{Commit: "abc1234", Subject: strings.Repeat("s", 200)}, {Commit: "def5678", Subject: strings.Repeat("s", 200)}}, selected.Review.Subject.Range.Entries) +} + +func TestSelectRichestRangePromptViewPrefersOptionalContextWhenRicherNeedsMoreTrimming(t *testing.T) { + view := rangePromptView{ + Optional: optionalSectionsView{AdditionalContext: strings.Repeat("context\n", 24)}, + Current: commitRangeSectionView{Count: 1, Entries: []commitRangeEntryView{{Commit: "abc1234", Subject: "summary"}}}, + } + variants := []diffSectionView{ + {Heading: "### Combined Diff", Fallback: strings.Repeat("richer guidance\n", 8)}, + {Heading: "### Combined Diff", Fallback: "short guidance\n"}, + } + shorterBody, err := renderRangePrompt(rangePromptView{Optional: view.Optional, Current: view.Current, Diff: variants[1]}) + require.NoError(t, err) + trimmedRicherBody, err := renderRangePrompt(rangePromptView{Current: view.Current, Diff: variants[0]}) + require.NoError(t, err) + require.LessOrEqual(t, len(trimmedRicherBody), len(shorterBody), + "test setup must allow the richer variant only after trimming more optional context than the shorter variant needs") + + selected, err := selectRichestRangePromptView(len(shorterBody), templateContextFromRangeView(view), variants) + require.NoError(t, err) + require.NotNil(t, selected.Review) + + assert.Equal(t, variants[1].Fallback, selected.Review.Fallback.Rendered()) + assert.Equal(t, view.Optional.AdditionalContext, selected.Review.Optional.AdditionalContext) +} + func TestBuildPromptNonCodexSmallCapStaysWithinCap(t *testing.T) { repoPath, sha := setupLargeDiffRepoWithGuidelines(t, 5000) cap := 10000 cfg := &config.Config{DefaultMaxPromptSize: cap} b := NewBuilderWithConfig(nil, cfg) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "claude-code", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "claude-code", "", "") require.NoError(t, err) assert.LessOrEqual(t, len(prompt), cap, @@ -766,7 +954,7 @@ func TestBuildRangePromptNonCodexSmallCapStaysWithinCap(t *testing.T) { cfg := &config.Config{DefaultMaxPromptSize: cap} b := NewBuilderWithConfig(nil, cfg) - prompt, err := b.BuildWithDiffFile(repoPath, rangeRef, 0, 0, "claude-code", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "claude-code", "", "") require.NoError(t, err) assert.LessOrEqual(t, len(prompt), cap, @@ -787,6 +975,41 @@ func TestBuildDirtySmallCapStaysWithinCap(t *testing.T) { assert.LessOrEqual(t, len(prompt), cap, "dirty prompt should stay within configured cap") + + repoPath = t.TempDir() + guidelines := strings.Repeat("guideline ", 512) + require.NoError(t, os.WriteFile( + filepath.Join(repoPath, ".roborev.toml"), + []byte("review_guidelines = \""+guidelines+"\"\n"), + 0o644, + )) + + diff = strings.Repeat("+ keep full diff\n", 8) + var diffSection strings.Builder + diffSection.WriteString("### Diff\n\n") + diffSection.WriteString("```diff\n") + diffSection.WriteString(diff) + if !strings.HasSuffix(diff, "\n") { + diffSection.WriteString("\n") + } + diffSection.WriteString("```\n") + + cap = len(GetSystemPrompt("claude-code", "dirty")+"\n") + + len("## Uncommitted Changes\n\nThe following changes have not yet been committed.\n\n") + + diffSection.Len() + 32 + b = NewBuilderWithConfig(nil, &config.Config{DefaultMaxPromptSize: cap}) + + prompt, err = b.BuildDirty(repoPath, diff, 0, 0, "claude-code", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap, + "dirty prompt should stay within configured cap") + assertContains(t, prompt, diff, + "expected the full dirty diff to remain inline after trimming optional context") + assertNotContains(t, prompt, "(Diff too large to include in full)", + "dirty prompt should trim optional context before falling back to a truncated diff") + assertNotContains(t, prompt, guidelines, + "expected oversized optional context to be trimmed") } func TestResolveMaxPromptSizeWithoutConfigUsesConfigDefault(t *testing.T) { @@ -803,14 +1026,13 @@ func TestBuildPromptHonorsDefaultPromptCap(t *testing.T) { legacyCfg := &config.Config{DefaultMaxPromptSize: MaxPromptSize} legacyB := NewBuilderWithConfig(nil, legacyCfg) - dummyDiff := "/tmp/roborev-review-0.diff" - legacyPrompt, err := legacyB.BuildWithDiffFile(repoPath, sha, 0, 0, "claude-code", "", "", dummyDiff) + legacyPrompt, err := legacyB.Build(repoPath, sha, 0, 0, "claude-code", "", "") require.NoError(t, err) require.Greater(t, len(legacyPrompt), config.DefaultMaxPromptSize, "precondition: prompt with legacy cap should exceed the 200KB default") b := NewBuilder(nil) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "claude-code", "", "", dummyDiff) + prompt, err := b.Build(repoPath, sha, 0, 0, "claude-code", "", "") require.NoError(t, err) assert.LessOrEqual(t, len(prompt), config.DefaultMaxPromptSize, @@ -858,6 +1080,165 @@ func TestBuildPromptLargeGuidelinesPrefersDiffOverContext(t *testing.T) { "small diff should be inlined after trimming guidelines") } +func TestBuildDirtyTruncatedFallbackPreservesClosingFenceAtTightCap(t *testing.T) { + repoPath := t.TempDir() + diff := strings.Repeat("+ keep this line in the truncated fallback\n", 128) + + currentSection, err := renderDirtyChangesSection(dirtyChangesSectionView{ + Description: "The following changes have not yet been committed.", + }) + require.NoError(t, err) + fallbackOnly, err := renderDirtyTruncatedDiffFallback("") + require.NoError(t, err) + fallbackBlock, err := renderDiffBlock(diffSectionView{Heading: "### Diff", Fallback: fallbackOnly}) + require.NoError(t, err) + + bodyLimit := len(currentSection) + len(fallbackBlock) + 1005 + cap := len(GetSystemPrompt("claude-code", "dirty")+"\n") + bodyLimit + b := NewBuilderWithConfig(nil, &config.Config{DefaultMaxPromptSize: cap}) + + prompt, err := b.BuildDirty(repoPath, diff, 0, 0, "claude-code", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap) + assert.Contains(t, prompt, "(Diff too large to include in full)") + assert.Contains(t, prompt, "... (truncated)\n```\n", + "dirty truncated fallback should keep the truncation marker and closing fence") +} + +func TestBuildDirtyTruncatedFallbackTrimsOptionalContextBeforeShrinkingSnippet(t *testing.T) { + repoPath := t.TempDir() + guidelines := strings.Repeat("g", 4000) + toml := `review_guidelines = """` + "\n" + guidelines + "\n" + `"""` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(repoPath, ".roborev.toml"), []byte(toml), 0o644)) + + diff := strings.Repeat("+ retained after optional trim\n", 128) + currentSection, err := renderDirtyChangesSection(dirtyChangesSectionView{ + Description: "The following changes have not yet been committed.", + }) + require.NoError(t, err) + fallbackOnly, err := renderDirtyTruncatedDiffFallback("") + require.NoError(t, err) + fallbackBlock, err := renderDiffBlock(diffSectionView{Heading: "### Diff", Fallback: fallbackOnly}) + require.NoError(t, err) + + bodyLimit := len(currentSection) + len(fallbackBlock) + 1200 + cap := len(GetSystemPrompt("claude-code", "dirty")+"\n") + bodyLimit + b := NewBuilderWithConfig(nil, &config.Config{DefaultMaxPromptSize: cap}) + + prompt, err := b.BuildDirty(repoPath, diff, 0, 0, "claude-code", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap) + assert.NotContains(t, prompt, guidelines, + "oversized optional guidelines should be trimmed before collapsing the truncated diff snippet") + assert.Contains(t, prompt, "```diff\n+ retained after optional trim", + "dirty prompt should retain a truncated diff snippet after optional context is trimmed") + assert.Contains(t, prompt, "... (truncated)\n```\n") +} + +func TestBuildDirtyTruncatedFallbackContinuesTrimmingOptionalContextForSnippet(t *testing.T) { + repoPath := t.TempDir() + guidelines := strings.Repeat("g", 1800) + toml := `review_guidelines = """` + "\n" + guidelines + "\n" + `"""` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(repoPath, ".roborev.toml"), []byte(toml), 0o644)) + additionalContext := "## Pull Request Discussion\n\n" + strings.Repeat("discussion\n", 40) + diff := strings.Repeat("+ retain after second trim\n", 128) + + fallbackOnly, err := renderDirtyTruncatedDiffFallback("") + require.NoError(t, err) + snippetBody := strings.Repeat("+ retain after second trim\n", 16) + "... (truncated)\n" + snippetFallback, err := renderDirtyTruncatedDiffFallback(snippetBody) + require.NoError(t, err) + requiredView := dirtyPromptView{ + Current: dirtyChangesSectionView{Description: "The following changes have not yet been committed."}, + Diff: diffSectionView{Heading: "### Diff", Fallback: snippetFallback}, + } + guidelinesView := requiredView + guidelinesView.Optional.ProjectGuidelines = buildProjectGuidelinesSectionView(guidelines) + fullOptionalEmptyView := dirtyPromptView{ + Optional: optionalSectionsView{ + ProjectGuidelines: buildProjectGuidelinesSectionView(guidelines), + AdditionalContext: buildAdditionalContextSection(additionalContext), + }, + Current: dirtyChangesSectionView{Description: "The following changes have not yet been committed."}, + Diff: diffSectionView{Heading: "### Diff", Fallback: fallbackOnly}, + } + guidelinesEmptyView := fullOptionalEmptyView + guidelinesEmptyView.Optional.AdditionalContext = "" + fullOptionalSnippetView := fullOptionalEmptyView + fullOptionalSnippetView.Diff.Fallback = snippetFallback + guidelinesSnippetView := guidelinesView + + requiredSnippetBody, err := renderDirtyPrompt(requiredView) + require.NoError(t, err) + guidelinesSnippetBody, err := renderDirtyPrompt(guidelinesSnippetView) + require.NoError(t, err) + fullOptionalEmptyBody, err := renderDirtyPrompt(fullOptionalEmptyView) + require.NoError(t, err) + guidelinesEmptyBody, err := renderDirtyPrompt(guidelinesEmptyView) + require.NoError(t, err) + fullOptionalSnippetBody, err := renderDirtyPrompt(fullOptionalSnippetView) + require.NoError(t, err) + + lowerBound := max(len(requiredSnippetBody), len(guidelinesEmptyBody)) + upperBound := min(len(guidelinesSnippetBody), len(fullOptionalEmptyBody), len(fullOptionalSnippetBody)) + require.Greater(t, upperBound, lowerBound, + "test setup must require trimming additional context for empty fallback and further optional trimming for the snippet") + + bodyLimit := lowerBound + (upperBound-lowerBound)/2 + cap := len(GetSystemPrompt("claude-code", "dirty")+"\n") + bodyLimit + b := NewBuilderWithConfig(nil, &config.Config{DefaultMaxPromptSize: cap}) + + prompt, err := b.BuildDirty(repoPath, diff, 0, 0, "claude-code", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap) + assert.NotContains(t, prompt, guidelines) + assert.NotContains(t, prompt, "discussion\n") + assert.GreaterOrEqual(t, strings.Count(prompt, "+ retain after second trim\n"), 16, + "dirty truncated fallback should keep sizing the snippet after trimming all removable optional context") +} + +func TestBuildDirtyFallbackOnlyRestoresOptionalContextWhenSnippetCannotFit(t *testing.T) { + repoPath := t.TempDir() + guidelines := "Keep it simple." + toml := `review_guidelines = """` + "\n" + guidelines + "\n" + `"""` + "\n" + require.NoError(t, os.WriteFile(filepath.Join(repoPath, ".roborev.toml"), []byte(toml), 0o644)) + diff := strings.Repeat("+ snippet cannot fit here\n", 128) + + fallbackOnly, err := renderDirtyTruncatedDiffFallback("") + require.NoError(t, err) + singleLineFallback, err := renderDirtyTruncatedDiffFallback("+ snippet cannot fit here\n... (truncated)\n") + require.NoError(t, err) + guidelinesFallbackView := dirtyPromptView{ + Optional: optionalSectionsView{ProjectGuidelines: buildProjectGuidelinesSectionView(guidelines)}, + Current: dirtyChangesSectionView{Description: "The following changes have not yet been committed."}, + Diff: diffSectionView{Heading: "### Diff", Fallback: fallbackOnly}, + } + requiredSingleLineView := dirtyPromptView{ + Current: dirtyChangesSectionView{Description: "The following changes have not yet been committed."}, + Diff: diffSectionView{Heading: "### Diff", Fallback: singleLineFallback}, + } + guidelinesFallbackBody, err := renderDirtyPrompt(guidelinesFallbackView) + require.NoError(t, err) + requiredSingleLineBody, err := renderDirtyPrompt(requiredSingleLineView) + require.NoError(t, err) + require.NotEmpty(t, requiredSingleLineBody) + + cap := len(GetSystemPrompt("claude-code", "dirty")+"\n") + len(guidelinesFallbackBody) + b := NewBuilderWithConfig(nil, &config.Config{DefaultMaxPromptSize: cap}) + + prompt, err := b.BuildDirty(repoPath, diff, 0, 0, "claude-code", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap) + assert.Contains(t, prompt, guidelines, + "fallback-only dirty prompt should restore optional context that still fits once snippet sizing fails") + assert.NotContains(t, prompt, "```diff", + "final prompt should fall back to the marker-only dirty diff when no snippet can fit") +} + func TestBuildDirtySmallCapTruncatesUTF8Safely(t *testing.T) { repoPath, _ := setupLargeDiffRepoWithGuidelines(t, 5000) diff := strings.Repeat("+ ascii line\n", 256) + strings.Repeat("+ 世界\n", 4096) @@ -878,7 +1259,7 @@ func TestBuildPromptCodexTinyCapStillStaysWithinCap(t *testing.T) { cfg := &config.Config{DefaultMaxPromptSize: cap} b := NewBuilderWithConfig(nil, cfg) - prompt, err := b.BuildWithDiffFile(repoPath, sha, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") require.NoError(t, err) assert.LessOrEqual(t, len(prompt), cap) @@ -891,13 +1272,77 @@ func TestBuildRangePromptCodexTinyCapStillStaysWithinCap(t *testing.T) { cfg := &config.Config{DefaultMaxPromptSize: cap} b := NewBuilderWithConfig(nil, cfg) - prompt, err := b.BuildWithDiffFile(repoPath, rangeRef, 0, 0, "codex", "", "", "/tmp/roborev-review-0.diff") + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") require.NoError(t, err) assert.LessOrEqual(t, len(prompt), cap) assert.True(t, utf8.ValidString(prompt), "range prompt should remain valid UTF-8 after hard capping") } +func TestBuildPromptCodexOversizedDiffFallbackCarriesExcludeScope(t *testing.T) { + repoPath, sha := setupLargeExcludePatternRepo(t) + cfg := &config.Config{ + DefaultMaxPromptSize: 4096, + ExcludePatterns: []string{"custom.dat"}, + } + b := NewBuilderWithConfig(nil, cfg) + + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") + require.NoError(t, err) + + assertContains(t, prompt, `:(exclude,glob)**/custom.dat`, + "expected Codex fallback commands to preserve custom exclude patterns") + assertNotContains(t, prompt, `:(exclude,glob)**/go.sum`, + "built-in lockfile excludes should not appear in fallback commands") +} + +func TestBuildPromptCodexShortestFallbackCarriesExcludeScope(t *testing.T) { + repoPath, sha := setupLargeExcludePatternRepo(t) + pathspecArgs := gitpkg.FormatExcludeArgs([]string{"custom.dat"}) + variants := codexCommitInspectionFallbackVariants(sha, pathspecArgs) + shortest := mustRenderPromptTestDiffBlock(variants[len(variants)-1]) + secondShortest := mustRenderPromptTestDiffBlock(variants[len(variants)-2]) + prefixLen := singleCommitPromptPrefixLen(t, repoPath, sha) + cap := prefixLen + len(shortest) + max(1, (len(secondShortest)-len(shortest))/2) + cfg := &config.Config{ + DefaultMaxPromptSize: cap, + ExcludePatterns: []string{"custom.dat"}, + } + b := NewBuilderWithConfig(nil, cfg) + + prompt, err := b.Build(repoPath, sha, 0, 0, "codex", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap) + assert.Contains(t, prompt, shortest, "expected tiny-cap prompt to use the shortest fallback variant") + assertContains(t, prompt, `:(exclude,glob)**/custom.dat`, + "expected shortest Codex fallback command to preserve custom exclude patterns") +} + +func TestBuildRangePromptCodexShortestFallbackCarriesExcludeScope(t *testing.T) { + repoPath, sha := setupLargeExcludePatternRepo(t) + rangeRef := sha + "~1.." + sha + pathspecArgs := gitpkg.FormatExcludeArgs([]string{"custom.dat"}) + variants := codexRangeInspectionFallbackVariants(rangeRef, pathspecArgs) + shortest := mustRenderPromptTestDiffBlock(variants[len(variants)-1]) + secondShortest := mustRenderPromptTestDiffBlock(variants[len(variants)-2]) + prefixLen := rangePromptPrefixLen(t, repoPath, rangeRef) + cap := prefixLen + len(shortest) + max(1, (len(secondShortest)-len(shortest))/2) + cfg := &config.Config{ + DefaultMaxPromptSize: cap, + ExcludePatterns: []string{"custom.dat"}, + } + b := NewBuilderWithConfig(nil, cfg) + + prompt, err := b.Build(repoPath, rangeRef, 0, 0, "codex", "", "") + require.NoError(t, err) + + assert.LessOrEqual(t, len(prompt), cap) + assertContains(t, prompt, "git diff", "expected tiny-cap range prompt to retain Codex diff guidance") + assertContains(t, prompt, `:(exclude,glob)**/custom.dat`, + "expected tiny-cap range fallback command to preserve custom exclude patterns") +} + func TestLoadGuidelines(t *testing.T) { tests := []struct { name string @@ -1172,68 +1617,24 @@ func TestBuildAddressPromptShowsFullDiff(t *testing.T) { assertContains(t, diffSection, "custom.dat", "excluded file should still be in address prompt diff") } -func TestSplitResponses(t *testing.T) { - responses := []storage.Response{ - {Responder: "roborev-fix", Response: "Fix applied"}, - {Responder: "alice", Response: "This is a false positive"}, - {Responder: "roborev-refine", Response: "Created commit abc123"}, - {Responder: "bob", Response: "Please keep this function"}, - } - - tool, user := SplitResponses(responses) - - assert.Len(t, tool, 2) - assert.Equal(t, "roborev-fix", tool[0].Responder) - assert.Equal(t, "roborev-refine", tool[1].Responder) - - assert.Len(t, user, 2) - assert.Equal(t, "alice", user[0].Responder) - assert.Equal(t, "bob", user[1].Responder) -} - -func TestSplitResponsesEmpty(t *testing.T) { - tool, user := SplitResponses(nil) - assert.Nil(t, tool) - assert.Nil(t, user) -} - -func TestFormatUserComments(t *testing.T) { - t.Run("nil returns empty", func(t *testing.T) { - assert.Empty(t, FormatUserComments(nil)) - }) - - t.Run("empty slice returns empty", func(t *testing.T) { - assert.Empty(t, FormatUserComments([]storage.Response{})) - }) +func TestBuildAddressPromptRendersPreviousAttemptsAndOriginalDiff(t *testing.T) { + repoPath, sha := setupExcludePatternRepo(t) + b := NewBuilder(nil) - t.Run("includes comment content", func(t *testing.T) { - comments := []storage.Response{ - {Responder: "alice", Response: "This is a false positive", CreatedAt: time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC)}, - {Responder: "bob", Response: "Please use a different approach", CreatedAt: time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC)}, - } - result := FormatUserComments(comments) - assert.Contains(t, result, "User Comments") - assert.Contains(t, result, "false positive") - assert.Contains(t, result, "different approach") - assert.Contains(t, result, "alice") - assert.Contains(t, result, "bob") - }) -} + review := &storage.Review{ + Agent: "test", + Output: "Found issue: check custom.dat", + Job: &storage.ReviewJob{GitRef: sha}, + } + attempts := []storage.Response{{Responder: "roborev-fix", Response: "Tried a narrow fix"}} -func TestFormatToolAttempts(t *testing.T) { - t.Run("nil returns empty", func(t *testing.T) { - assert.Empty(t, FormatToolAttempts(nil)) - }) + prompt, err := b.BuildAddressPrompt(repoPath, review, attempts, "medium") + require.NoError(t, err) - t.Run("includes attempt content", func(t *testing.T) { - attempts := []storage.Response{ - {Responder: "roborev-fix", Response: "Fix applied (commit: abc123)", CreatedAt: time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)}, - } - result := FormatToolAttempts(attempts) - assert.Contains(t, result, "Previous Addressing Attempts") - assert.Contains(t, result, "roborev-fix") - assert.Contains(t, result, "abc123") - }) + assert.Contains(t, prompt, "## Previous Addressing Attempts") + assert.Contains(t, prompt, "Tried a narrow fix") + assert.Contains(t, prompt, "## Review Findings to Address") + assert.Contains(t, prompt, "## Original Commit Diff") } func TestBuildAddressPromptSplitsResponses(t *testing.T) { @@ -1254,26 +1655,23 @@ func TestBuildAddressPromptSplitsResponses(t *testing.T) { p, err := b.BuildAddressPrompt(r.dir, review, responses, "") require.NoError(t, err) - // Tool attempts should appear under "Previous Addressing Attempts" - assert.Contains(t, p, "Previous Addressing Attempts") + assert.Contains(t, p, "## Previous Addressing Attempts") assert.Contains(t, p, "roborev-fix") + assert.Contains(t, p, "Fix applied") - // User comments should appear under "User Comments" - assert.Contains(t, p, "User Comments") - assert.Contains(t, p, "false positive") + assert.Contains(t, p, "## User Comments") assert.Contains(t, p, "alice") + assert.Contains(t, p, "false positive") } func TestBuildRangePrompt_IncludesInRangeReviews(t *testing.T) { r := newTestRepo(t) - // Create initial commit (will serve as range start / merge base) require.NoError(t, os.WriteFile(filepath.Join(r.dir, "base.txt"), []byte("base\n"), 0o644)) r.git("add", "base.txt") r.git("commit", "-m", "initial") baseSHA := r.git("rev-parse", "HEAD") - // Create two commits on the "branch" require.NoError(t, os.WriteFile(filepath.Join(r.dir, "base.txt"), []byte("change1\n"), 0o644)) r.git("add", "base.txt") r.git("commit", "-m", "first feature commit") @@ -1284,7 +1682,6 @@ func TestBuildRangePrompt_IncludesInRangeReviews(t *testing.T) { r.git("commit", "-m", "second feature commit") commit2 := r.git("rev-parse", "HEAD") - // Set up DB with repo and per-commit reviews db := testutil.OpenTestDB(t) repo, err := db.GetOrCreateRepo(r.dir) require.NoError(t, err) @@ -1294,20 +1691,18 @@ func TestBuildRangePrompt_IncludesInRangeReviews(t *testing.T) { testutil.CreateCompletedReview(t, db, repo.ID, commit2, "test", "No issues found.\n\nVerdict: PASS") - // Build range prompt rangeRef := baseSHA + ".." + commit2 builder := NewBuilder(db) prompt, err := builder.Build(r.dir, rangeRef, repo.ID, 0, "test", "", "") require.NoError(t, err) - // Should contain the in-range reviews section - assert := assert.New(t) - assert.Contains(prompt, "Per-Commit Reviews in This Range") - assert.Contains(prompt, "Do not re-raise issues") - assert.Contains(prompt, "missing null check in handler") - assert.Contains(prompt, "No issues found.") - assert.Contains(prompt, "failed") - assert.Contains(prompt, "passed") + assertContains := assert.New(t) + assertContains.Contains(prompt, "Per-Commit Reviews in This Range") + assertContains.Contains(prompt, "Do not re-raise issues") + assertContains.Contains(prompt, "missing null check in handler") + assertContains.Contains(prompt, "No issues found.") + assertContains.Contains(prompt, "failed") + assertContains.Contains(prompt, "passed") } func TestBuildRangePrompt_NoInRangeReviewsWithoutDB(t *testing.T) { @@ -1323,7 +1718,6 @@ func TestBuildRangePrompt_NoInRangeReviewsWithoutDB(t *testing.T) { r.git("commit", "-m", "feature") headSHA := r.git("rev-parse", "HEAD") - // Build without DB — should not include in-range reviews section builder := NewBuilder(nil) prompt, err := builder.Build(r.dir, baseSHA+".."+headSHA, 0, 0, "test", "", "") require.NoError(t, err) @@ -1331,54 +1725,32 @@ func TestBuildRangePrompt_NoInRangeReviewsWithoutDB(t *testing.T) { assert.NotContains(t, prompt, "Per-Commit Reviews in This Range") } -func TestBuildInjectsSeverityInstruction(t *testing.T) { - assert := assert.New(t) - repoPath, commits := setupTestRepo(t) - targetSHA := commits[len(commits)-1] - - builder := NewBuilder(nil) - - tests := []struct { - name string - reviewType string - minSeverity string - wantContain string - wantAbsent string +// TestRenderShellCommandStripsInlineCodeBreakers locks in that characters +// which would escape or corrupt a surrounding Markdown inline code span +// (backticks and control characters) are dropped before the command string +// is emitted. The rendered commands are only shown to the model inside +// backtick-delimited code spans — sanitization keeps the enclosing prompt +// structure intact if a git ref ever contains hostile bytes. +func TestRenderShellCommandStripsInlineCodeBreakers(t *testing.T) { + cases := []struct { + name string + in []string + want string }{ - {"standard with medium", "", "medium", "Severity filter:", ""}, - {"security with high", "security", "high", "Severity filter:", ""}, - {"design with critical", "design", "critical", "Severity filter:", ""}, - {"empty severity skips injection", "", "", "", "Severity filter:"}, - {"low severity skips injection", "", "low", "", "Severity filter:"}, + {"plain_command", []string{"git", "diff", "abc1234"}, "git diff abc1234"}, + {"backtick_in_arg", []string{"git", "show", "ref`injection"}, "git show 'refinjection'"}, + {"control_char_in_arg", []string{"git", "show", "ref\x00null"}, "git show 'refnull'"}, + {"escape_char_in_arg", []string{"git", "show", "ref\x1bescape"}, "git show 'refescape'"}, + {"delete_char_in_arg", []string{"git", "show", "ref\x7fdel"}, "git show 'refdel'"}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prompt, err := builder.Build(repoPath, targetSHA, 0, 0, "test", tt.reviewType, tt.minSeverity) - require.NoError(t, err) - if tt.wantContain != "" { - assert.Contains(prompt, tt.wantContain) - } - if tt.wantAbsent != "" { - assert.NotContains(prompt, tt.wantAbsent) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := renderShellCommand(tc.in...) + assert.Equal(t, tc.want, got) + assert.NotContains(t, got, "`", "backticks must never survive rendering") + for _, r := range got { + assert.False(t, r < 0x20 || r == 0x7f, "control char 0x%x leaked into rendered command %q", r, got) } }) } } - -func TestBuildDirtyInjectsSeverityInstruction(t *testing.T) { - assert := assert.New(t) - repoPath, _ := setupTestRepo(t) - builder := NewBuilder(nil) - - diff := "diff --git a/foo.go b/foo.go\n--- a/foo.go\n+++ b/foo.go\n@@ -1 +1 @@\n-old\n+new\n" - - prompt, err := builder.BuildDirty(repoPath, diff, 0, 0, "test", "", "high") - require.NoError(t, err) - assert.Contains(prompt, "Severity filter:") - assert.Contains(prompt, "SEVERITY_THRESHOLD_MET") - - // Empty severity: no injection - prompt2, err := builder.BuildDirty(repoPath, diff, 0, 0, "test", "", "") - require.NoError(t, err) - assert.NotContains(prompt2, "Severity filter:") -} diff --git a/internal/prompt/template_context.go b/internal/prompt/template_context.go new file mode 100644 index 00000000..454ff2b5 --- /dev/null +++ b/internal/prompt/template_context.go @@ -0,0 +1,380 @@ +package prompt + +import "slices" + +type TemplateContext struct { + Meta PromptMeta + Review *ReviewTemplateContext + Address *AddressTemplateContext + System *SystemTemplateContext +} + +func (c TemplateContext) Clone() TemplateContext { + cloned := c + if c.Review != nil { + review := c.Review.Clone() + cloned.Review = &review + } + if c.Address != nil { + address := c.Address.Clone() + cloned.Address = &address + } + if c.System != nil { + system := *c.System + cloned.System = &system + } + return cloned +} + +type PromptMeta struct { + AgentName string + PromptType string + ReviewType string +} + +type ReviewTemplateContext struct { + Kind ReviewKind + Optional ReviewOptionalContext + Subject SubjectContext + Diff DiffContext + Fallback FallbackContext +} + +func (c ReviewTemplateContext) Clone() ReviewTemplateContext { + cloned := c + cloned.Optional = c.Optional.Clone() + cloned.Subject = c.Subject.Clone() + cloned.Diff = c.Diff.Clone() + cloned.Fallback = c.Fallback.Clone() + return cloned +} + +type ReviewKind string + +const ( + ReviewKindSingle ReviewKind = "single" + ReviewKindRange ReviewKind = "range" + ReviewKindDirty ReviewKind = "dirty" +) + +type ReviewOptionalContext struct { + ProjectGuidelines *MarkdownSection + AdditionalContext string + PreviousReviews []PreviousReviewTemplateContext + InRangeReviews []InRangeReviewTemplateContext + PreviousAttempts []ReviewAttemptTemplateContext +} + +func (o ReviewOptionalContext) Clone() ReviewOptionalContext { + cloned := o + if o.ProjectGuidelines != nil { + section := *o.ProjectGuidelines + cloned.ProjectGuidelines = §ion + } + cloned.PreviousReviews = slices.Clone(o.PreviousReviews) + for i := range cloned.PreviousReviews { + cloned.PreviousReviews[i].Comments = slices.Clone(cloned.PreviousReviews[i].Comments) + } + cloned.InRangeReviews = slices.Clone(o.InRangeReviews) + for i := range cloned.InRangeReviews { + cloned.InRangeReviews[i].Comments = slices.Clone(cloned.InRangeReviews[i].Comments) + } + cloned.PreviousAttempts = slices.Clone(o.PreviousAttempts) + for i := range cloned.PreviousAttempts { + cloned.PreviousAttempts[i].Comments = slices.Clone(cloned.PreviousAttempts[i].Comments) + } + return cloned +} + +func (o ReviewOptionalContext) IsEmpty() bool { + return o.ProjectGuidelines == nil && + o.AdditionalContext == "" && + len(o.PreviousReviews) == 0 && + len(o.InRangeReviews) == 0 && + len(o.PreviousAttempts) == 0 +} + +func (o ReviewOptionalContext) ProjectGuidelinesBody() string { + if o.ProjectGuidelines == nil { + return "" + } + return o.ProjectGuidelines.Body +} + +func (o *ReviewOptionalContext) TrimNext() bool { + if o == nil { + return false + } + switch { + case len(o.PreviousAttempts) > 0: + o.PreviousAttempts = nil + case len(o.InRangeReviews) > 0: + o.InRangeReviews = nil + case len(o.PreviousReviews) > 0: + o.PreviousReviews = nil + case o.AdditionalContext != "": + o.AdditionalContext = "" + case o.ProjectGuidelines != nil: + o.ProjectGuidelines = nil + default: + return false + } + return true +} + +type SubjectContext struct { + Single *SingleSubjectContext + Range *RangeSubjectContext + Dirty *DirtySubjectContext +} + +func (s SubjectContext) Clone() SubjectContext { + cloned := s + if s.Single != nil { + single := *s.Single + cloned.Single = &single + } + if s.Range != nil { + rangeCtx := *s.Range + rangeCtx.Entries = slices.Clone(s.Range.Entries) + cloned.Range = &rangeCtx + } + if s.Dirty != nil { + dirty := *s.Dirty + cloned.Dirty = &dirty + } + return cloned +} + +func (s *SubjectContext) TrimSingleMessage() bool { + if s == nil || s.Single == nil || s.Single.Message == "" { + return false + } + s.Single.Message = "" + return true +} + +func (s *SubjectContext) TrimSingleAuthor() bool { + if s == nil || s.Single == nil || s.Single.Author == "" { + return false + } + s.Single.Author = "" + return true +} + +func (s *SubjectContext) TrimSingleSubjectTo(maxBytes int) bool { + if s == nil || s.Single == nil || s.Single.Subject == "" { + return false + } + next := truncateUTF8(s.Single.Subject, maxBytes) + if next == s.Single.Subject { + return false + } + s.Single.Subject = next + return true +} + +func (s *SubjectContext) BlankNextRangeSubject() bool { + if s == nil || s.Range == nil { + return false + } + for i := len(s.Range.Entries) - 1; i >= 0; i-- { + if s.Range.Entries[i].Subject == "" { + continue + } + s.Range.Entries[i].Subject = "" + return true + } + return false +} + +func (s *SubjectContext) DropLastRangeEntry() bool { + if s == nil || s.Range == nil || len(s.Range.Entries) == 0 { + return false + } + s.Range.Entries = s.Range.Entries[:len(s.Range.Entries)-1] + return true +} + +type SingleSubjectContext struct { + Commit string + Subject string + Author string + Message string +} + +type RangeSubjectContext struct { + Count int + Entries []RangeEntryContext +} + +type RangeEntryContext struct { + Commit string + Subject string +} + +type DirtySubjectContext struct { + Description string +} + +type DiffContext struct { + Heading string + Body string +} + +func (d DiffContext) Clone() DiffContext { + return d +} + +func (d DiffContext) HasBody() bool { + return d.Body != "" +} + +type FallbackMode string + +const ( + FallbackModeNone FallbackMode = "" + FallbackModeCommit FallbackMode = "commit" + FallbackModeRange FallbackMode = "range" + FallbackModeDirty FallbackMode = "dirty" + FallbackModeGeneric FallbackMode = "generic" +) + +type FallbackContext struct { + Mode FallbackMode + Text string + Commit *CommitFallbackContext + Range *RangeFallbackContext + Dirty *DirtyFallbackContext + Generic *GenericFallbackContext +} + +func (f FallbackContext) Clone() FallbackContext { + cloned := f + if f.Commit != nil { + commit := *f.Commit + cloned.Commit = &commit + } + if f.Range != nil { + rangeCtx := *f.Range + cloned.Range = &rangeCtx + } + if f.Dirty != nil { + dirty := *f.Dirty + cloned.Dirty = &dirty + } + if f.Generic != nil { + generic := *f.Generic + cloned.Generic = &generic + } + return cloned +} + +func (f FallbackContext) IsEmpty() bool { + return f.Mode == FallbackModeNone && f.Text == "" && f.Commit == nil && f.Range == nil && f.Dirty == nil && f.Generic == nil +} + +func (f FallbackContext) HasContent() bool { + return !f.IsEmpty() +} + +func (f FallbackContext) Rendered() string { + switch { + case f.Text != "": + return f.Text + case f.Dirty != nil: + return f.Dirty.Body + default: + return "" + } +} + +type CommitFallbackContext struct { + SHA string + StatCmd string + DiffCmd string + FilesCmd string + ShowPathCmd string +} + +type RangeFallbackContext struct { + RangeRef string + LogCmd string + StatCmd string + DiffCmd string + FilesCmd string + ViewCmd string +} + +type DirtyFallbackContext struct { + Body string +} + +type GenericFallbackContext struct { + ViewCmd string +} + +type AddressTemplateContext struct { + ProjectGuidelines *MarkdownSection + ToolAttempts []AddressAttemptTemplateContext + UserComments []AddressAttemptTemplateContext + SeverityFilter string + ReviewFindings string + OriginalDiff string + JobID int64 +} + +func (c AddressTemplateContext) Clone() AddressTemplateContext { + cloned := c + if c.ProjectGuidelines != nil { + section := *c.ProjectGuidelines + cloned.ProjectGuidelines = §ion + } + cloned.ToolAttempts = slices.Clone(c.ToolAttempts) + cloned.UserComments = slices.Clone(c.UserComments) + return cloned +} + +type SystemTemplateContext struct { + NoSkillsInstruction string + CurrentDate string +} + +type MarkdownSection struct { + Heading string + Body string +} + +type ReviewCommentTemplateContext struct { + Responder string + Response string +} + +type PreviousReviewTemplateContext struct { + Commit string + Output string + Comments []ReviewCommentTemplateContext + Available bool +} + +type InRangeReviewTemplateContext struct { + Commit string + Agent string + Verdict string + Output string + Comments []ReviewCommentTemplateContext +} + +type ReviewAttemptTemplateContext struct { + Label string + Agent string + When string + Output string + Comments []ReviewCommentTemplateContext +} + +type AddressAttemptTemplateContext struct { + Responder string + Response string + When string +} diff --git a/internal/prompt/templates/assembled_address.md.gotmpl b/internal/prompt/templates/assembled_address.md.gotmpl index 61fe7a32..5b8fdae7 100644 --- a/internal/prompt/templates/assembled_address.md.gotmpl +++ b/internal/prompt/templates/assembled_address.md.gotmpl @@ -1 +1 @@ -{{template "project_guidelines" .}}{{template "address_tool_attempts" .}}{{template "address_user_comments" .}}{{template "address_findings" .}} \ No newline at end of file +{{template "project_guidelines" .Address}}{{template "address_tool_attempts" .Address}}{{template "address_user_comments" .Address}}{{template "address_findings" .Address}} \ No newline at end of file diff --git a/internal/prompt/templates/assembled_dirty.md.gotmpl b/internal/prompt/templates/assembled_dirty.md.gotmpl index 051fdc86..6ccfc0e9 100644 --- a/internal/prompt/templates/assembled_dirty.md.gotmpl +++ b/internal/prompt/templates/assembled_dirty.md.gotmpl @@ -1 +1 @@ -{{template "optional_sections" .Optional}}{{template "dirty_changes" .Current}}{{template "diff_block" .Diff}} \ No newline at end of file +{{template "optional_sections" .Review.Optional}}{{template "dirty_changes" .Review.Subject.Dirty}}{{template "diff_block" .Review}} \ No newline at end of file diff --git a/internal/prompt/templates/assembled_range.md.gotmpl b/internal/prompt/templates/assembled_range.md.gotmpl index 7c376a45..6d92834e 100644 --- a/internal/prompt/templates/assembled_range.md.gotmpl +++ b/internal/prompt/templates/assembled_range.md.gotmpl @@ -1 +1 @@ -{{template "optional_sections" .Optional}}{{template "commit_range" .Current}}{{template "diff_block" .Diff}} \ No newline at end of file +{{template "optional_sections" .Review.Optional}}{{template "commit_range" .Review.Subject.Range}}{{template "diff_block" .Review}} \ No newline at end of file diff --git a/internal/prompt/templates/assembled_single.md.gotmpl b/internal/prompt/templates/assembled_single.md.gotmpl index a031de71..8cbb31ad 100644 --- a/internal/prompt/templates/assembled_single.md.gotmpl +++ b/internal/prompt/templates/assembled_single.md.gotmpl @@ -1 +1 @@ -{{template "optional_sections" .Optional}}{{template "current_commit" .Current}}{{template "diff_block" .Diff}} \ No newline at end of file +{{template "optional_sections" .Review.Optional}}{{template "current_commit" .Review.Subject.Single}}{{template "diff_block" .Review}} \ No newline at end of file diff --git a/internal/prompt/templates/claude-code_review.md.gotmpl b/internal/prompt/templates/claude-code_review.md.gotmpl index 438c5ac7..f7984dc0 100644 --- a/internal/prompt/templates/claude-code_review.md.gotmpl +++ b/internal/prompt/templates/claude-code_review.md.gotmpl @@ -24,6 +24,6 @@ Do not include any front matter such as "Reviewing the diff..." or "I'm checking 4. **Regressions**: Changes that might break existing functionality. 5. **Code quality**: Duplication, overly complex logic, unclear naming. -Do not review the commit message. Focus only on the code changes in the diff.{{.NoSkillsInstruction}} +Do not review the commit message. Focus only on the code changes in the diff.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/codex_review.md.gotmpl b/internal/prompt/templates/codex_review.md.gotmpl index c80f59b4..ba96ab6a 100644 --- a/internal/prompt/templates/codex_review.md.gotmpl +++ b/internal/prompt/templates/codex_review.md.gotmpl @@ -25,6 +25,6 @@ Do not include any front matter such as "Reviewing the diff..." or "I'm checking 4. **Regressions**: Changes that might break existing functionality. 5. **Code quality**: Duplication, overly complex logic, unclear naming. -Do not review the commit message. Focus only on the code changes in the diff.{{.NoSkillsInstruction}} +Do not review the commit message. Focus only on the code changes in the diff.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/default_address.md.gotmpl b/internal/prompt/templates/default_address.md.gotmpl index 9e795b74..ea99471e 100644 --- a/internal/prompt/templates/default_address.md.gotmpl +++ b/internal/prompt/templates/default_address.md.gotmpl @@ -24,6 +24,6 @@ Changes: - ... -Keep the summary concise (under 10 bullet points). Put the most important changes first.{{.NoSkillsInstruction}} +Keep the summary concise (under 10 bullet points). Put the most important changes first.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/default_design_review.md.gotmpl b/internal/prompt/templates/default_design_review.md.gotmpl index b7a0e817..fac1b8ed 100644 --- a/internal/prompt/templates/default_design_review.md.gotmpl +++ b/internal/prompt/templates/default_design_review.md.gotmpl @@ -20,6 +20,6 @@ After reviewing, provide: 4. Any missing considerations not covered by the design 5. A verdict: Pass or Fail with brief justification -If you find no issues, state "No issues found." after the summary.{{.NoSkillsInstruction}} +If you find no issues, state "No issues found." after the summary.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/default_dirty.md.gotmpl b/internal/prompt/templates/default_dirty.md.gotmpl index 9cf1607e..bfa2bb63 100644 --- a/internal/prompt/templates/default_dirty.md.gotmpl +++ b/internal/prompt/templates/default_dirty.md.gotmpl @@ -14,6 +14,6 @@ After reviewing, provide: - File and line reference where possible - A brief explanation of the problem and suggested fix -If you find no issues, state "No issues found." after the summary.{{.NoSkillsInstruction}} +If you find no issues, state "No issues found." after the summary.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/default_range.md.gotmpl b/internal/prompt/templates/default_range.md.gotmpl index 3dc42947..e28714c6 100644 --- a/internal/prompt/templates/default_range.md.gotmpl +++ b/internal/prompt/templates/default_range.md.gotmpl @@ -16,6 +16,6 @@ After reviewing, provide: - File and line reference where possible - A brief explanation of the problem and suggested fix -If you find no issues, state "No issues found." after the summary.{{.NoSkillsInstruction}} +If you find no issues, state "No issues found." after the summary.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/default_review.md.gotmpl b/internal/prompt/templates/default_review.md.gotmpl index 008b9faa..3359ce30 100644 --- a/internal/prompt/templates/default_review.md.gotmpl +++ b/internal/prompt/templates/default_review.md.gotmpl @@ -16,6 +16,6 @@ After reviewing, provide: - File and line reference where possible - A brief explanation of the problem and suggested fix -If you find no issues, state "No issues found." after the summary.{{.NoSkillsInstruction}} +If you find no issues, state "No issues found." after the summary.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/default_security.md.gotmpl b/internal/prompt/templates/default_security.md.gotmpl index 71d54e8c..1a78e767 100644 --- a/internal/prompt/templates/default_security.md.gotmpl +++ b/internal/prompt/templates/default_security.md.gotmpl @@ -18,6 +18,6 @@ For each finding, provide: - Suggested remediation If you find no security issues, state "No issues found." after the summary. -Do not report code quality or style issues unless they have security implications.{{.NoSkillsInstruction}} +Do not report code quality or style issues unless they have security implications.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/gemini_review.md.gotmpl b/internal/prompt/templates/gemini_review.md.gotmpl index 53a8a10a..47816269 100644 --- a/internal/prompt/templates/gemini_review.md.gotmpl +++ b/internal/prompt/templates/gemini_review.md.gotmpl @@ -23,6 +23,6 @@ Do NOT build the project, run the test suite, or execute the code while reviewin 4. **Regressions**: Changes that might break existing functionality. 5. **Code quality**: Duplication, overly complex logic, unclear naming. -Do not review the commit message. Focus ONLY on the code changes in the diff.{{.NoSkillsInstruction}} +Do not review the commit message. Focus ONLY on the code changes in the diff.{{.System.NoSkillsInstruction}} -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/gemini_run.md.gotmpl b/internal/prompt/templates/gemini_run.md.gotmpl index 1d27d0b2..9da099bb 100644 --- a/internal/prompt/templates/gemini_run.md.gotmpl +++ b/internal/prompt/templates/gemini_run.md.gotmpl @@ -7,5 +7,5 @@ Focus on: - Providing code examples when helpful - Being practical and actionable -Current date: {{.CurrentDate}} (UTC) +Current date: {{.System.CurrentDate}} (UTC) diff --git a/internal/prompt/templates/prompt_sections.md.gotmpl b/internal/prompt/templates/prompt_sections.md.gotmpl index 16b78dfa..5e024da0 100644 --- a/internal/prompt/templates/prompt_sections.md.gotmpl +++ b/internal/prompt/templates/prompt_sections.md.gotmpl @@ -6,11 +6,10 @@ These guidelines supplement the default review criteria and may add repo-specifi {{end}}{{end}} {{define "additional_context"}}{{- .AdditionalContext -}}{{end}} -{{define "review_comments"}}{{if .}} -Comments on this review: +{{define "review_comments"}}{{if .}}Comments on this review: {{range .}}- {{.Responder}}: {{printf "%q" .Response}} -{{end}}{{end}} {{end}} +{{end}}{{end}} {{define "previous_reviews"}}{{if .PreviousReviews}}## Previous Reviews The following are reviews of recent commits in this repository. Use them as context @@ -24,8 +23,7 @@ or provide context that affects how you should evaluate similar code in the curr {{if .Available}}{{.Output}}{{else}}No review available.{{end}} {{template "review_comments" .Comments}} -{{end}}{{end}} -{{end}} +{{- end}}{{end}}{{end}} {{define "in_range_reviews"}}{{if .InRangeReviews}}## Per-Commit Reviews in This Range The following commits in this range have already been individually reviewed. @@ -38,8 +36,7 @@ Focus on cross-commit interactions and problems not caught by per-commit reviews {{.Output}} {{template "review_comments" .Comments}} -{{end}}{{end}} -{{end}} +{{- end}}{{end}}{{end}} {{define "previous_review_attempts"}}{{if .PreviousAttempts}}## Previous Review Attempts This commit has been reviewed before. The following are previous review results and any @@ -52,8 +49,7 @@ responses from developers. Use this context to: {{.Output}} {{template "review_comments" .Comments}} -{{end}}{{end}} -{{end}} +{{- end}}{{end}}{{end}} {{define "optional_sections"}}{{template "project_guidelines" .}}{{template "additional_context" .}}{{template "previous_reviews" .}}{{template "in_range_reviews" .}}{{template "previous_review_attempts" .}}{{end}} {{define "current_commit_required"}}## Current Commit @@ -82,9 +78,9 @@ Reviewing {{if gt .Count 0}}{{.Count}}{{else}}{{len .Entries}}{{end}} commits: {{if .Description}}{{.Description}}{{else}}The following changes have not yet been committed.{{end}} {{end}} -{{define "diff_block"}}{{if .Heading}}{{.Heading}}{{else}}### Diff{{end}} +{{define "diff_block"}}{{if .Diff.Heading}}{{.Diff.Heading}}{{else}}### Diff{{end}} -{{if .Fallback}}{{.Fallback}}{{else}}{{.Body}}{{end}}{{end}} +{{if .Fallback.HasContent}}{{.Fallback.Rendered}}{{else}}{{.Diff.Body}}{{end}}{{end}} {{define "inline_diff"}}```diff {{.Body}}``` {{end}} @@ -148,6 +144,11 @@ View with: {{.ViewCmd}} {{end}}{{end}} {{define "address_tool_attempts"}}{{if .ToolAttempts}}## Previous Addressing Attempts +The following are previous attempts to address this or related reviews. +Learn from these to avoid repeating approaches that didn't fully resolve the issues. +Be pragmatic - if previous attempts were rejected for being too minor, make more substantive fixes. +If they were rejected for being over-engineered, keep it simpler. + {{range .ToolAttempts}}--- Attempt by {{.Responder}}{{if .When}} at {{.When}}{{end}} --- {{.Response}} @@ -162,7 +163,8 @@ positives, provide additional context, or request specific approaches. {{.Response}} {{end}}{{end}}{{end}} -{{define "address_findings"}}{{if .SeverityFilter}}{{.SeverityFilter}}{{end}}## Review Findings to Address (Job {{.JobID}}) +{{define "address_findings"}}{{if .SeverityFilter}}{{.SeverityFilter}} +{{end}}## Review Findings to Address (Job {{.JobID}}) {{.ReviewFindings}} diff --git a/internal/prompt/test_helpers_test.go b/internal/prompt/test_helpers_test.go index 0e97c7e2..b20faae8 100644 --- a/internal/prompt/test_helpers_test.go +++ b/internal/prompt/test_helpers_test.go @@ -97,7 +97,7 @@ func setupTestRepo(t *testing.T) (string, []string) { return r.dir, commits } -func setupSmallDiffRepo(t *testing.T) (string, string) { +func setupLargeDiffRepo(t *testing.T) (string, string) { t.Helper() r := newTestRepo(t) @@ -108,17 +108,26 @@ func setupSmallDiffRepo(t *testing.T) (string, string) { r.git("add", "base.txt") r.git("commit", "-m", "initial") + var content strings.Builder + for range 20000 { + content.WriteString("line ") + content.WriteString(strings.Repeat("x", 20)) + content.WriteString(" ") + content.WriteString(strings.Repeat("y", 20)) + content.WriteString("\n") + } + require.NoError(t, os.WriteFile( - filepath.Join(r.dir, "small.txt"), - []byte("hello world\n"), 0o644, + filepath.Join(r.dir, "large.txt"), + []byte(content.String()), 0o644, )) - r.git("add", "small.txt") - r.git("commit", "-m", "small change") + r.git("add", "large.txt") + r.git("commit", "-m", "large change") return r.dir, r.git("rev-parse", "HEAD") } -func setupLargeDiffRepo(t *testing.T) (string, string) { +func setupLargeExcludePatternRepo(t *testing.T) (string, string) { t.Helper() r := newTestRepo(t) @@ -142,7 +151,11 @@ func setupLargeDiffRepo(t *testing.T) (string, string) { filepath.Join(r.dir, "large.txt"), []byte(content.String()), 0o644, )) - r.git("add", "large.txt") + require.NoError(t, os.WriteFile( + filepath.Join(r.dir, "custom.dat"), + []byte(content.String()), 0o644, + )) + r.git("add", "large.txt", "custom.dat") r.git("commit", "-m", "large change") return r.dir, r.git("rev-parse", "HEAD") diff --git a/internal/prompt/testdata/golden/address_with_split_responses.golden b/internal/prompt/testdata/golden/address_with_split_responses.golden new file mode 100644 index 00000000..2f597d89 --- /dev/null +++ b/internal/prompt/testdata/golden/address_with_split_responses.golden @@ -0,0 +1,71 @@ +You are a code assistant. Your task is to address the findings from a code review. + +Make the minimal changes necessary to address these findings: +- Be pragmatic and simple - don't over-engineer +- Focus on the specific issues mentioned +- Don't refactor unrelated code +- Don't add unnecessary abstractions or comments +- Don't make cosmetic changes + +After making changes: +1. Run the build command to verify the code compiles +2. Run tests to verify nothing is broken +3. Fix any build errors or test failures before finishing + +For Go projects, use: GOCACHE=/tmp/go-build go build ./... and GOCACHE=/tmp/go-build go test ./... +(The GOCACHE override is needed for sandbox compatibility) + +IMPORTANT: Do NOT commit changes yourself. Just modify the files. The caller will handle committing. + +When finished, provide a brief summary in this format (this will be used in the commit message): + +Changes: +- +- +... + +Keep the summary concise (under 10 bullet points). Put the most important changes first. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Previous Addressing Attempts + +The following are previous attempts to address this or related reviews. +Learn from these to avoid repeating approaches that didn't fully resolve the issues. +Be pragmatic - if previous attempts were rejected for being too minor, make more substantive fixes. +If they were rejected for being over-engineered, keep it simpler. + +--- Attempt by roborev-fix at GOLDEN_TIMESTAMP --- +Added doc comment + +## User Comments + +The following comments were left by the developer on this review. +Take them into account when applying fixes — they may flag false +positives, provide additional context, or request specific approaches. + +**alice** (GOLDEN_TIMESTAMP): +Doc comments are optional here + +Severity filter: Only include Medium, High, and Critical findings. Ignore any findings below medium severity. If ALL findings in the review are below medium severity, output the exact text SEVERITY_THRESHOLD_MET and make no code changes. + +## Review Findings to Address (Job 42) + +- Medium: foo.go:1 missing doc comment + +## Original Commit Diff (for context) + +```diff +diff --git a/foo.go b/foo.go +new file mode 100644 +index 0000000..f52652b +--- /dev/null ++++ b/foo.go +@@ -0,0 +1 @@ ++package foo +``` diff --git a/internal/prompt/testdata/golden/address_without_severity.golden b/internal/prompt/testdata/golden/address_without_severity.golden new file mode 100644 index 00000000..a52ebd35 --- /dev/null +++ b/internal/prompt/testdata/golden/address_without_severity.golden @@ -0,0 +1,60 @@ +You are a code assistant. Your task is to address the findings from a code review. + +Make the minimal changes necessary to address these findings: +- Be pragmatic and simple - don't over-engineer +- Focus on the specific issues mentioned +- Don't refactor unrelated code +- Don't add unnecessary abstractions or comments +- Don't make cosmetic changes + +After making changes: +1. Run the build command to verify the code compiles +2. Run tests to verify nothing is broken +3. Fix any build errors or test failures before finishing + +For Go projects, use: GOCACHE=/tmp/go-build go build ./... and GOCACHE=/tmp/go-build go test ./... +(The GOCACHE override is needed for sandbox compatibility) + +IMPORTANT: Do NOT commit changes yourself. Just modify the files. The caller will handle committing. + +When finished, provide a brief summary in this format (this will be used in the commit message): + +Changes: +- +- +... + +Keep the summary concise (under 10 bullet points). Put the most important changes first. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Previous Addressing Attempts + +The following are previous attempts to address this or related reviews. +Learn from these to avoid repeating approaches that didn't fully resolve the issues. +Be pragmatic - if previous attempts were rejected for being too minor, make more substantive fixes. +If they were rejected for being over-engineered, keep it simpler. + +--- Attempt by roborev-fix at GOLDEN_TIMESTAMP --- +Added doc comment + +## Review Findings to Address (Job 99) + +- Medium: foo.go:1 missing doc comment + +## Original Commit Diff (for context) + +```diff +diff --git a/foo.go b/foo.go +new file mode 100644 +index 0000000..f52652b +--- /dev/null ++++ b/foo.go +@@ -0,0 +1 @@ ++package foo +``` diff --git a/internal/prompt/testdata/golden/design_review.golden b/internal/prompt/testdata/golden/design_review.golden new file mode 100644 index 00000000..c7d8d894 --- /dev/null +++ b/internal/prompt/testdata/golden/design_review.golden @@ -0,0 +1,49 @@ +You are a design reviewer. The changes shown below are expected to contain design artifacts — PRDs, task lists, architectural proposals, or similar planning documents. Review them for: + +1. **Completeness**: Are goals, non-goals, success criteria, and edge cases defined? +2. **Feasibility**: Are technical decisions grounded in the actual codebase? +3. **Task scoping**: Are implementation stages small enough to review incrementally? Are dependencies ordered correctly? +4. **Missing considerations**: Security, performance, backwards compatibility, error handling +5. **Clarity**: Are decisions justified and understandable? + +If the changes do not appear to contain design documents, note this and review whatever design intent is evident from the code changes. + +After reviewing, provide: + +1. A brief summary of what the design proposes +2. PRD findings, listed with: + - Severity (high/medium/low) + - A brief explanation of the issue and suggested improvement +3. Task list findings, listed with: + - Severity (high/medium/low) + - A brief explanation of the issue and suggested improvement +4. Any missing considerations not covered by the design +5. A verdict: Pass or Fail with brief justification + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/dirty_review.golden b/internal/prompt/testdata/golden/dirty_review.golden new file mode 100644 index 00000000..af3ed79a --- /dev/null +++ b/internal/prompt/testdata/golden/dirty_review.golden @@ -0,0 +1,40 @@ +You are a code reviewer. Review the following uncommitted changes for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +After reviewing, provide: + +1. A brief summary of what the changes do +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Uncommitted Changes + +The following changes have not yet been committed. + +### Diff + +```diff +diff --git a/base.txt b/base.txt +index 0000000..1111111 100644 +--- a/base.txt ++++ b/base.txt +@@ -1 +1,2 @@ + base ++added line +``` diff --git a/internal/prompt/testdata/golden/dirty_truncated.golden b/internal/prompt/testdata/golden/dirty_truncated.golden new file mode 100644 index 00000000..c24c0030 --- /dev/null +++ b/internal/prompt/testdata/golden/dirty_truncated.golden @@ -0,0 +1,165 @@ +You are a code reviewer. Review the following uncommitted changes for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +After reviewing, provide: + +1. A brief summary of what the changes do +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Uncommitted Changes + +The following changes have not yet been committed. + +### Diff + +(Diff too large to include in full) +```diff +diff --git a/big.txt b/big.txt +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/big.txt +@@ -0,0 +1,500 @@ ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line of content ++a line +... (truncated) +``` diff --git a/internal/prompt/testdata/golden/previous_reviews_with_comments.golden b/internal/prompt/testdata/golden/previous_reviews_with_comments.golden new file mode 100644 index 00000000..841ae20d --- /dev/null +++ b/internal/prompt/testdata/golden/previous_reviews_with_comments.golden @@ -0,0 +1,68 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Previous Reviews + +The following are reviews of recent commits in this repository. Use them as context +to understand ongoing work and to check if the current commit addresses previous feedback. + +**Important:** Reviews may include responses from developers. Pay attention to these responses - +they may indicate known issues that should be ignored, explain why certain patterns exist, +or provide context that affects how you should evaluate similar code in the current commit. + +--- Review for commit 52f2722 --- +Found unused variable in a.txt + +Verdict: FAIL + +Comments on this review: +- alice: "False positive; we use this field via reflection." +- bob: "Agree with alice." + +--- Review for commit 496ac4b --- +No issues found. + +Verdict: PASS + +## Current Commit + +**Commit:** be8ef2f + +**Subject:** alpha 3 +**Author:** Test User + +### Diff + +```diff +diff --git a/a.txt b/a.txt +index c1827f0..d616f73 100644 +--- a/a.txt ++++ b/a.txt +@@ -1 +1 @@ +-a2 ++a3 +``` diff --git a/internal/prompt/testdata/golden/range_truncated.golden b/internal/prompt/testdata/golden/range_truncated.golden new file mode 100644 index 00000000..b76a91a1 --- /dev/null +++ b/internal/prompt/testdata/golden/range_truncated.golden @@ -0,0 +1,38 @@ +You are a code reviewer. Review the git commit range shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commits do +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Commit Range + +Reviewing 2 commits: + +- ed651f1 first large addition +- 1f1d7cf second large addition + +### Combined Diff + +(Diff too large to include - please review the commits directly) +View with: git diff 391147df1a46b4705d6be3c61c54486f05f1c22e..1f1d7cf82f79ee1200733b19d2fd08609828a296 diff --git a/internal/prompt/testdata/golden/range_truncated_codex_in_range.golden b/internal/prompt/testdata/golden/range_truncated_codex_in_range.golden new file mode 100644 index 00000000..19a9c4b4 --- /dev/null +++ b/internal/prompt/testdata/golden/range_truncated_codex_in_range.golden @@ -0,0 +1,74 @@ +You are a code reviewer. Review the code changes shown below. + +Return only the final review. Do NOT narrate your process, mention files you opened, or describe intermediate checks. +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. +Do NOT build the project, run the test suite, or execute the code while reviewing. Base your review on the diff and static analysis only. +Do NOT search or read files outside the repository checkout, except for an explicitly provided diff file path for this review. Do not use tools like rg, find, or cat on unrelated paths outside the repo. + +## Output Format + +- If you find issues, start immediately with `## Review Findings` and list only the findings: + - **Severity**: High/Medium/Low + - **Location**: File and line number where possible + - **Problem**: Concise description of the issue + - **Fix**: Brief suggested fix +- After the findings, add `## Summary` with a single sentence summarizing the change. +- If you find no issues, start immediately with `No issues found.` and then add `Summary: `. + +Do not include any front matter such as "Reviewing the diff..." or "I'm checking...". + +## Review Criteria + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions. +2. **Security**: Injection vulnerabilities, auth issues, data exposure. +3. **Testing gaps**: Missing unit tests, edge cases, e2e/integration gaps. +4. **Regressions**: Changes that might break existing functionality. +5. **Code quality**: Duplication, overly complex logic, unclear naming. + +Do not review the commit message. Focus only on the code changes in the diff. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Per-Commit Reviews in This Range + +The following commits in this range have already been individually reviewed. +Issues found in earlier commits may have been fixed by later commits in the range. + +Do not re-raise issues identified below unless they persist in the final code. +Focus on cross-commit interactions and problems not caught by per-commit reviews. + +--- Commit ed651f1 (test-worker, failed) --- +Found null-deref in big1.txt + +Verdict: FAIL + +--- Commit 1f1d7cf (test-worker, passed) --- +No issues found. + +Verdict: PASS + +## Commit Range + +Reviewing 2 commits: + +- ed651f1 first large addition +- 1f1d7cf second large addition + +### Combined Diff + +(Diff too large to include inline) + +For Codex in read-only review mode, inspect the commit range locally with read-only git commands before writing findings. Do not claim the diff is inaccessible unless these commands fail. + +Use commands like: +- `git log --oneline 391147df1a46b4705d6be3c61c54486f05f1c22e..1f1d7cf82f79ee1200733b19d2fd08609828a296` +- `git diff --stat 391147df1a46b4705d6be3c61c54486f05f1c22e..1f1d7cf82f79ee1200733b19d2fd08609828a296 --` +- `git diff --unified=80 391147df1a46b4705d6be3c61c54486f05f1c22e..1f1d7cf82f79ee1200733b19d2fd08609828a296 --` +- `git diff --name-only 391147df1a46b4705d6be3c61c54486f05f1c22e..1f1d7cf82f79ee1200733b19d2fd08609828a296 --` + +Review the actual diff before writing findings. diff --git a/internal/prompt/testdata/golden/range_with_in_range_reviews.golden b/internal/prompt/testdata/golden/range_with_in_range_reviews.golden new file mode 100644 index 00000000..87665e44 --- /dev/null +++ b/internal/prompt/testdata/golden/range_with_in_range_reviews.golden @@ -0,0 +1,63 @@ +You are a code reviewer. Review the git commit range shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commits do +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Per-Commit Reviews in This Range + +The following commits in this range have already been individually reviewed. +Issues found in earlier commits may have been fixed by later commits in the range. + +Do not re-raise issues identified below unless they persist in the final code. +Focus on cross-commit interactions and problems not caught by per-commit reviews. + +--- Commit d4c9b80 (test-worker, failed) --- +Found bug: missing null check in handler + +Verdict: FAIL + +--- Commit a5e670f (test-worker, passed) --- +No issues found. + +Verdict: PASS + +## Commit Range + +Reviewing 2 commits: + +- d4c9b80 first feature commit +- a5e670f second feature commit + +### Combined Diff + +```diff +diff --git a/base.txt b/base.txt +index df967b9..39c5733 100644 +--- a/base.txt ++++ b/base.txt +@@ -1 +1 @@ +-base ++change2 +``` diff --git a/internal/prompt/testdata/golden/security_review.golden b/internal/prompt/testdata/golden/security_review.golden new file mode 100644 index 00000000..1731d8f3 --- /dev/null +++ b/internal/prompt/testdata/golden/security_review.golden @@ -0,0 +1,47 @@ +You are a security code reviewer. Analyze the code changes shown below with a security-first mindset. Focus on: + +1. **Injection vulnerabilities**: SQL injection, command injection, XSS, template injection, LDAP injection, header injection +2. **Authentication & authorization**: Missing auth checks, privilege escalation, insecure session handling, broken access control +3. **Credential exposure**: Hardcoded secrets, API keys, passwords, tokens in source code or logs +4. **Path traversal**: Unsanitized file paths, directory traversal via user input, symlink attacks +5. **Unsafe patterns**: Unsafe deserialization, insecure random number generation, missing input validation, buffer overflows +6. **Dependency concerns**: Known vulnerable dependencies, typosquatting risks, pinning issues +7. **CI/CD security**: Workflow injection via pull_request_target, script injection via untrusted inputs, excessive permissions +8. **Data handling**: Sensitive data in logs, missing encryption, insecure data storage, PII exposure +9. **Concurrency issues**: Race conditions leading to security bypasses, TOCTOU vulnerabilities +10. **Error handling**: Information leakage via error messages, missing error checks on security-critical operations + +For each finding, provide: +- Severity (critical/high/medium/low) +- File and line reference +- Description of the vulnerability +- Suggested remediation + +If you find no security issues, state "No issues found." after the summary. +Do not report code quality or style issues unless they have security implications. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_review_claude_code.golden b/internal/prompt/testdata/golden/single_review_claude_code.golden new file mode 100644 index 00000000..99ba166c --- /dev/null +++ b/internal/prompt/testdata/golden/single_review_claude_code.golden @@ -0,0 +1,53 @@ +You are a code reviewer. Review the code changes shown below. + +Return only the final review. Do NOT narrate your process, mention files you opened, or describe intermediate checks. +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. +Do NOT build the project, run the test suite, or execute the code while reviewing. Base your review on the diff and static analysis only. + +## Output Format + +- If you find issues, start immediately with `## Review Findings` and list only the findings: + - **Severity**: High/Medium/Low + - **Location**: File and line number where possible + - **Problem**: Concise description of the issue + - **Fix**: Brief suggested fix +- After the findings, add `## Summary` with a single sentence summarizing the change. +- If you find no issues, start immediately with `No issues found.` and then add `Summary: `. + +Do not include any front matter such as "Reviewing the diff..." or "I'm checking...". + +## Review Criteria + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions. +2. **Security**: Injection vulnerabilities, auth issues, data exposure. +3. **Testing gaps**: Missing unit tests, edge cases, e2e/integration gaps. +4. **Regressions**: Changes that might break existing functionality. +5. **Code quality**: Duplication, overly complex logic, unclear naming. + +Do not review the commit message. Focus only on the code changes in the diff. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_review_codex.golden b/internal/prompt/testdata/golden/single_review_codex.golden new file mode 100644 index 00000000..8d6f0746 --- /dev/null +++ b/internal/prompt/testdata/golden/single_review_codex.golden @@ -0,0 +1,54 @@ +You are a code reviewer. Review the code changes shown below. + +Return only the final review. Do NOT narrate your process, mention files you opened, or describe intermediate checks. +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. +Do NOT build the project, run the test suite, or execute the code while reviewing. Base your review on the diff and static analysis only. +Do NOT search or read files outside the repository checkout, except for an explicitly provided diff file path for this review. Do not use tools like rg, find, or cat on unrelated paths outside the repo. + +## Output Format + +- If you find issues, start immediately with `## Review Findings` and list only the findings: + - **Severity**: High/Medium/Low + - **Location**: File and line number where possible + - **Problem**: Concise description of the issue + - **Fix**: Brief suggested fix +- After the findings, add `## Summary` with a single sentence summarizing the change. +- If you find no issues, start immediately with `No issues found.` and then add `Summary: `. + +Do not include any front matter such as "Reviewing the diff..." or "I'm checking...". + +## Review Criteria + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions. +2. **Security**: Injection vulnerabilities, auth issues, data exposure. +3. **Testing gaps**: Missing unit tests, edge cases, e2e/integration gaps. +4. **Regressions**: Changes that might break existing functionality. +5. **Code quality**: Duplication, overly complex logic, unclear naming. + +Do not review the commit message. Focus only on the code changes in the diff. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_review_default.golden b/internal/prompt/testdata/golden/single_review_default.golden new file mode 100644 index 00000000..9d764946 --- /dev/null +++ b/internal/prompt/testdata/golden/single_review_default.golden @@ -0,0 +1,45 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_review_gemini.golden b/internal/prompt/testdata/golden/single_review_gemini.golden new file mode 100644 index 00000000..4b89ea7f --- /dev/null +++ b/internal/prompt/testdata/golden/single_review_gemini.golden @@ -0,0 +1,52 @@ +You are a code reviewer. Review the code changes shown below. + +Your goal is to be extremely concise and professional. Do NOT explain your process or list the steps you are taking. Just provide the final review results. +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. +Do NOT build the project, run the test suite, or execute the code while reviewing. Base your review on the diff and static analysis only. + +## Output Format + +1. **Summary**: A single-line summary of what the change does to prove you have analyzed the code. +2. **Review Findings**: + - If you find issues, list them by category: + - **Severity**: (High/Medium/Low) + - **Location**: File and line number + - **Problem**: Concise description + - **Fix**: Brief suggested fix + - If no issues are found, state "No issues found." + +## Review Criteria + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions. +2. **Security**: Injection vulnerabilities, auth issues, data exposure. +3. **Testing gaps**: Missing unit tests, edge cases, e2e/integration gaps. +4. **Regressions**: Changes that might break existing functionality. +5. **Code quality**: Duplication, overly complex logic, unclear naming. + +Do not review the commit message. Focus ONLY on the code changes in the diff. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_truncated_diff.golden b/internal/prompt/testdata/golden/single_truncated_diff.golden new file mode 100644 index 00000000..02c6f2c6 --- /dev/null +++ b/internal/prompt/testdata/golden/single_truncated_diff.golden @@ -0,0 +1,38 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** 2be7352 + +**Subject:** huge change +**Author:** Test User + +### Diff + +(Diff too large to include - please review the commit directly) +View with: git show 2be73528b9660e76b0c7fd72ee3255fd6e84257a diff --git a/internal/prompt/testdata/golden/single_truncated_diff_codex.golden b/internal/prompt/testdata/golden/single_truncated_diff_codex.golden new file mode 100644 index 00000000..9bbff471 --- /dev/null +++ b/internal/prompt/testdata/golden/single_truncated_diff_codex.golden @@ -0,0 +1,56 @@ +You are a code reviewer. Review the code changes shown below. + +Return only the final review. Do NOT narrate your process, mention files you opened, or describe intermediate checks. +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. +Do NOT build the project, run the test suite, or execute the code while reviewing. Base your review on the diff and static analysis only. +Do NOT search or read files outside the repository checkout, except for an explicitly provided diff file path for this review. Do not use tools like rg, find, or cat on unrelated paths outside the repo. + +## Output Format + +- If you find issues, start immediately with `## Review Findings` and list only the findings: + - **Severity**: High/Medium/Low + - **Location**: File and line number where possible + - **Problem**: Concise description of the issue + - **Fix**: Brief suggested fix +- After the findings, add `## Summary` with a single sentence summarizing the change. +- If you find no issues, start immediately with `No issues found.` and then add `Summary: `. + +Do not include any front matter such as "Reviewing the diff..." or "I'm checking...". + +## Review Criteria + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions. +2. **Security**: Injection vulnerabilities, auth issues, data exposure. +3. **Testing gaps**: Missing unit tests, edge cases, e2e/integration gaps. +4. **Regressions**: Changes that might break existing functionality. +5. **Code quality**: Duplication, overly complex logic, unclear naming. + +Do not review the commit message. Focus only on the code changes in the diff. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Current Commit + +**Commit:** 2be7352 + +**Subject:** huge change +**Author:** Test User + +### Diff + +(Diff too large to include inline) + +For Codex in read-only review mode, inspect the commit locally with read-only git commands before writing findings. Do not claim the diff is inaccessible unless these commands fail. + +Use commands like: +- `git show --stat --summary 2be73528b9660e76b0c7fd72ee3255fd6e84257a --` +- `git show --format=medium --unified=80 2be73528b9660e76b0c7fd72ee3255fd6e84257a --` +- `git diff-tree --no-commit-id --name-only -r 2be73528b9660e76b0c7fd72ee3255fd6e84257a --` +- `git show 2be73528b9660e76b0c7fd72ee3255fd6e84257a --` + +Review the actual diff before writing findings. diff --git a/internal/prompt/testdata/golden/single_with_additional_context.golden b/internal/prompt/testdata/golden/single_with_additional_context.golden new file mode 100644 index 00000000..5c29f419 --- /dev/null +++ b/internal/prompt/testdata/golden/single_with_additional_context.golden @@ -0,0 +1,49 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Pull Request Discussion + +Reviewer noted the greeting should support i18n in a later PR. + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_with_guidelines.golden b/internal/prompt/testdata/golden/single_with_guidelines.golden new file mode 100644 index 00000000..ad6fbd4e --- /dev/null +++ b/internal/prompt/testdata/golden/single_with_guidelines.golden @@ -0,0 +1,52 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Project Guidelines + +These guidelines supplement the default review criteria and may add repo-specific expectations or constraints. + +- Always prefer table-driven tests for Go. +- No new dependencies without justification. + +## Current Commit + +**Commit:** 6a536dc + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +``` diff --git a/internal/prompt/testdata/golden/single_with_previous_reviews.golden b/internal/prompt/testdata/golden/single_with_previous_reviews.golden new file mode 100644 index 00000000..06fe099e --- /dev/null +++ b/internal/prompt/testdata/golden/single_with_previous_reviews.golden @@ -0,0 +1,64 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +## Previous Reviews + +The following are reviews of recent commits in this repository. Use them as context +to understand ongoing work and to check if the current commit addresses previous feedback. + +**Important:** Reviews may include responses from developers. Pay attention to these responses - +they may indicate known issues that should be ignored, explain why certain patterns exist, +or provide context that affects how you should evaluate similar code in the current commit. + +--- Review for commit 52f2722 --- +No issues found. + +Verdict: PASS + +--- Review for commit 496ac4b --- +Found unused variable in a.txt + +Verdict: FAIL + +## Current Commit + +**Commit:** be8ef2f + +**Subject:** alpha 3 +**Author:** Test User + +### Diff + +```diff +diff --git a/a.txt b/a.txt +index c1827f0..d616f73 100644 +--- a/a.txt ++++ b/a.txt +@@ -1 +1 @@ +-a2 ++a3 +``` diff --git a/internal/prompt/testdata/golden/single_with_severity_filter.golden b/internal/prompt/testdata/golden/single_with_severity_filter.golden new file mode 100644 index 00000000..348bf0f7 --- /dev/null +++ b/internal/prompt/testdata/golden/single_with_severity_filter.golden @@ -0,0 +1,47 @@ +You are a code reviewer. Review the git commit shown below for: + +1. **Bugs**: Logic errors, off-by-one errors, null/undefined issues, race conditions +2. **Security**: Injection vulnerabilities, auth issues, data exposure +3. **Testing gaps**: Missing unit tests, edge cases not covered, e2e/integration test gaps +4. **Regressions**: Changes that might break existing functionality +5. **Code quality**: Duplication that should be refactored, overly complex logic, unclear naming + +Do not review the commit message itself - focus only on the code changes in the diff. + +After reviewing, provide: + +1. A brief summary of what the commit does +2. Any issues found, listed with: + - Severity (high/medium/low) + - File and line reference where possible + - A brief explanation of the problem and suggested fix + +If you find no issues, state "No issues found." after the summary. + +IMPORTANT: You are being invoked by roborev to perform this review directly. Do NOT use any external skills, slash commands, or CLI tools (such as "roborev review") to delegate this task. Perform the review yourself by analyzing the diff provided below. + +Return only the final review content. Do NOT include process narration, progress updates, or front matter such as "Reviewing the diff..." or "I'm checking...". +If you use tools while reviewing, finish all tool use before emitting the final review, and put the final review only after the last tool call. + +Current date: GOLDEN_DATE (UTC) + +Severity filter: Only include Medium, High, and Critical findings. Ignore any findings below medium severity. If ALL findings in the review are below medium severity, output the exact text SEVERITY_THRESHOLD_MET and make no code changes. + +## Current Commit + +**Commit:** fde21d1 + +**Subject:** add greeting +**Author:** Test User + +### Diff + +```diff +diff --git a/hello.txt b/hello.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/hello.txt +@@ -0,0 +1 @@ ++hello world +```