Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b65d629
feat: implement json output for review suggestions
juanbzz Jul 26, 2025
0dec6ab
fix: escape newlines in prompt examples and format review output as m…
juanbzz Jul 27, 2025
d940e9e
fix: escape newlines in json body field in prompts
juanbzz Jul 27, 2025
9d22147
fix: unescape newlines in suggestion body before rendering
juanbzz Jul 27, 2025
f501c28
feat: improve llm json parsing by separating code from body
juanbzz Jul 27, 2025
9769515
feat: update prompts to sort suggestions and focus on improvements
juanbzz Jul 27, 2025
43faefa
feat: add --one flag to review command to show single suggestion
juanbzz Jul 27, 2025
a88f3ce
fix: improve llm json parsing robustness
juanbzz Jul 27, 2025
26f1858
fix: update reviewer tests and prompt expectations
juanbzz Jul 27, 2025
4713467
feat: add --one option to diff command
juanbzz Jul 27, 2025
7ec5446
fix: move --one option logic to diff command
juanbzz Jul 27, 2025
75af579
feat: add rich output option to diff command
juanbzz Jul 27, 2025
1e5eefd
fix: remove unused formatter variable in diff command
juanbzz Jul 27, 2025
e5e5c4f
feat: change default diff range to main..head
juanbzz Jul 27, 2025
d6a0c6d
fix: default diff range to main..head when not provided
juanbzz Jul 27, 2025
9193a22
feat: allow diff command to accept an optional file path
juanbzz Jul 27, 2025
c0cca7a
docs: clarify diff command argument order in help text
juanbzz Jul 27, 2025
8a3e2b6
refactor: use flags for range and file in diff command
juanbzz Jul 27, 2025
824c212
fix: convert target file path to relative for accurate comparison
juanbzz Jul 27, 2025
d8a53f1
docs: update changelog with diff command features and fixes
juanbzz Jul 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Add `--one` option to `diff` command to show only the first suggestion per file.
- Add `--output-style rich` option to `diff` command for formatted output.
- Add `--file` option to `diff` command to review a specific file within a range.

### Changed
- Change default git range for `diff` command to `main..HEAD`.
- Refactor `diff` command to use `--range` and `--file` flags instead of positional arguments for clarity.

### Fixed
- Fix `diff` command to correctly handle relative file paths.
- Update internal tests to align with current data structures and prompt formats.

## [0.4.0] - 2025-07-21

### Added
Expand Down
159 changes: 122 additions & 37 deletions cmd/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type ReviewCmd struct {
Message string `short:"m" help:"Message to display while processing" default:"Thinking..."`
DryRun bool `short:"d" help:"Show what would be reviewed without calling LLM"`
OutputStyle string `short:"s" name:"output-style" help:"Output style: plain (default) or rich (formatted with colors and markdown)" enum:"plain,rich" default:"plain"`
One bool `short:"1" name:"one" help:"Show only the first suggestion."`
}

type VersionCmd struct{}
Expand Down Expand Up @@ -154,6 +155,41 @@ func (tp *TestPatternCmd) Run(cli *CLI) error {
return nil
}

func buildSuggestionBody(suggestion agents.Suggestion) string {
var bodyBuilder strings.Builder
bodyBuilder.WriteString(strings.ReplaceAll(suggestion.Body, "\\n", "\n"))

if suggestion.Original != "" || suggestion.Suggestion != "" {
bodyBuilder.WriteString("\n\n")
bodyBuilder.WriteString("```original\n")
bodyBuilder.WriteString(strings.ReplaceAll(suggestion.Original, "\\n", "\n"))
bodyBuilder.WriteString("\n```\n")
bodyBuilder.WriteString("```suggestion\n")
bodyBuilder.WriteString(strings.ReplaceAll(suggestion.Suggestion, "\\n", "\n"))
bodyBuilder.WriteString("\n```")
}
return bodyBuilder.String()
}

func formatSuggestionsToMarkdown(suggestions []agents.Suggestion, filename string) string {
if len(suggestions) == 0 {
return "✅ No issues found."
}

var builder strings.Builder
builder.WriteString(fmt.Sprintf("# 🍲 miso Code review for %s\n\n", filename))

formatter := diff.NewFormatter()
for _, suggestion := range suggestions {
fullBody := buildSuggestionBody(suggestion)
// Format the body to render diffs correctly
formattedBody := formatter.Format(fullBody)
builder.WriteString(fmt.Sprintf("## %s\n%s\n\n", suggestion.Title, formattedBody))
}

return builder.String()
}

func (r *ReviewCmd) Run(cli *CLI) error {
// Load configuration
cfg, err := loadConfig(cli.Config, r.Verbose)
Expand Down Expand Up @@ -218,21 +254,23 @@ func (r *ReviewCmd) Run(cli *CLI) error {
return fmt.Errorf("review failed: %w", err)
}

// Format the output to include diffs
formatter := diff.NewFormatter()
formattedContent := formatter.Format(result.Content)
if r.One && len(result.Suggestions) > 0 {
result.Suggestions = result.Suggestions[:1]
}

markdownReport := formatSuggestionsToMarkdown(result.Suggestions, filename)

// Apply glamour rendering if requested
if r.OutputStyle == "rich" {
rendered, err := renderRichOutput(formattedContent)
if r.OutputStyle == "rich" && len(result.Suggestions) > 0 {
rendered, err := renderRichOutput(markdownReport)
if err != nil {
log.Printf("Failed to initialize rich renderer: %v", err)
fmt.Println(formattedContent)
fmt.Println(markdownReport) // Fallback to plain
} else {
fmt.Print(rendered)
}
} else {
fmt.Println(formattedContent)
fmt.Println(markdownReport)
}

// Display token usage if available
Expand All @@ -256,10 +294,13 @@ func (r *ReviewCmd) Run(cli *CLI) error {
}

type DiffCmd struct {
Range string `arg:"" optional:"" help:"Git range (e.g., main..HEAD, HEAD~1)" default:"HEAD~1"`
Verbose bool `short:"v" help:"Enable verbose output"`
Message string `short:"m" help:"Message to display while processing" default:"Analyzing changes..."`
DryRun bool `short:"d" help:"Show what would be reviewed without calling LLM"`
Range string `short:"r" help:"Git range to review." default:"main..HEAD"`
File string `short:"f" help:"A specific file path to review within the range." type:"existingfile"`
Verbose bool `short:"v" help:"Enable verbose output"`
Message string `short:"m" help:"Message to display while processing" default:"Analyzing changes..."`
DryRun bool `short:"d" help:"Show what would be reviewed without calling LLM"`
One bool `short:"1" name:"one" help:"Show only the first suggestion per file."`
OutputStyle string `short:"s" name:"output-style" help:"Output style: plain (default) or rich (formatted with colors and markdown)" enum:"plain,rich" default:"plain"`
}

type ValidateConfigCmd struct {
Expand Down Expand Up @@ -435,18 +476,19 @@ func (gr *GitHubReviewPRCmd) Run(cli *CLI) error {
continue
}

// Format the output to include diffs
formattedContent := formatter.Format(result.Content)

if formattedContent != "" {
if len(result.Suggestions) > 0 {
reviewOutput.WriteString(fmt.Sprintf("<details>\n"))
reviewOutput.WriteString(
fmt.Sprintf(
"<summary>📝 Review for <strong>%s</strong></summary>\n\n", file,
"<summary>📝 Review for <strong>%s</strong> (%d issues)</summary>\n\n", file, len(result.Suggestions),
),
)
reviewOutput.WriteString(formattedContent)
reviewOutput.WriteString(fmt.Sprintf("\n</details>\n"))
for _, suggestion := range result.Suggestions {
fullBody := buildSuggestionBody(suggestion)
formattedBody := formatter.Format(fullBody)
reviewOutput.WriteString(fmt.Sprintf("### %s\n%s\n\n", suggestion.Title, formattedBody))
}
reviewOutput.WriteString("</details>\n")
}

if result.TokensUsed > 0 {
Expand All @@ -455,7 +497,12 @@ func (gr *GitHubReviewPRCmd) Run(cli *CLI) error {
}

// Post to GitHub
commentBody := fmt.Sprintf("# 🍲 miso Code review\n\n%s", reviewOutput.String())
var commentBody string
if reviewOutput.Len() > 0 {
commentBody = fmt.Sprintf("# 🍲 miso Code review\n\n%s", reviewOutput.String())
} else {
commentBody = "# 🍲 miso Code review\n\n✅ No issues found."
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := ghClient.PostOrUpdateComment(ctx, prNumber, commentBody); err != nil {
Expand Down Expand Up @@ -495,8 +542,11 @@ func (d *DiffCmd) Run(cli *CLI) error {
return fmt.Errorf("failed to initialize git client: %w", err)
}

rangeStr := d.Range
targetFile := d.File

// Parse git range
base, head := git.ParseGitRange(d.Range)
base, head := git.ParseGitRange(rangeStr)

if d.Verbose {
fmt.Printf("Reviewing changes between %s and %s\n", base, head)
Expand All @@ -520,11 +570,42 @@ func (d *DiffCmd) Run(cli *CLI) error {
// Filter files that should be reviewed
res := resolver.NewResolver(cfg)
var reviewableFiles []string
for _, file := range files {
if res.ShouldReview(file) {
reviewableFiles = append(reviewableFiles, file)
} else if d.Verbose {
fmt.Printf("Skipping %s (no matching patterns)\n", file)

if targetFile != "" {
// Convert user-provided file path to a relative path for comparison with git output.
relTargetFile := targetFile
cwd, err := os.Getwd()
if err == nil {
if rel, err := filepath.Rel(cwd, targetFile); err == nil {
relTargetFile = rel
}
}

fileIsChanged := false
for _, f := range files {
if f == relTargetFile {
fileIsChanged = true
break
}
}

if fileIsChanged {
if res.ShouldReview(relTargetFile) {
reviewableFiles = append(reviewableFiles, relTargetFile)
} else if d.Verbose {
fmt.Printf("Skipping %s (no matching patterns)\n", relTargetFile)
}
} else {
fmt.Printf("File '%s' was not changed in the specified range.\n", targetFile)
return nil
}
} else {
for _, file := range files {
if res.ShouldReview(file) {
reviewableFiles = append(reviewableFiles, file)
} else if d.Verbose {
fmt.Printf("Skipping %s (no matching patterns)\n", file)
}
}
}

Expand All @@ -551,9 +632,6 @@ func (d *DiffCmd) Run(cli *CLI) error {
return fmt.Errorf("failed to create reviewer: %w", err)
}

// Initialize diff formatter
formatter := diff.NewFormatter()

// Review each changed file
totalTokens := 0
for _, file := range reviewableFiles {
Expand Down Expand Up @@ -591,16 +669,23 @@ func (d *DiffCmd) Run(cli *CLI) error {
continue
}

// Format the output to include diffs
formattedContent := formatter.Format(result.Content)
if d.One && len(result.Suggestions) > 0 {
result.Suggestions = result.Suggestions[:1]
}

if formattedContent != "" {
fmt.Printf("<details>\n")
fmt.Printf(
"<summary>📝 Review for <strong>%s</strong></summary>\n\n", file,
)
fmt.Println(formattedContent)
fmt.Printf("\n</details>\n")
markdownReport := formatSuggestionsToMarkdown(result.Suggestions, file)

// Apply glamour rendering if requested
if d.OutputStyle == "rich" && len(result.Suggestions) > 0 {
rendered, err := renderRichOutput(markdownReport)
if err != nil {
log.Printf("Failed to initialize rich renderer: %v", err)
fmt.Println(markdownReport) // Fallback to plain
} else {
fmt.Print(rendered)
}
} else {
fmt.Println(markdownReport)
}

if result.TokensUsed > 0 {
Expand Down
34 changes: 32 additions & 2 deletions internal/agents/reviewer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package agents

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"

"github.com/j0lvera/miso/internal/config"
"github.com/j0lvera/miso/internal/git"
Expand Down Expand Up @@ -33,10 +35,19 @@ func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.base.RoundTrip(req2)
}

// Suggestion represents a single review comment from the LLM.
type Suggestion struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Original string `json:"original,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}

// ReviewResult holds the review content and token usage information from an LLM call.
// Provides details about the review content and associated costs.
type ReviewResult struct {
Content string
Suggestions []Suggestion
TokensUsed int
InputTokens int
OutputTokens int
Expand Down Expand Up @@ -143,9 +154,28 @@ func (cr *CodeReviewer) callLLM(prompt string) (*ReviewResult, error) {
content = resp.Choices[0].Content
}

// Find the start of the JSON array to strip any leading text.
startIndex := strings.Index(content, "[")
if startIndex == -1 {
return nil, fmt.Errorf("failed to find start of JSON array in LLM response\nRaw response:\n%s", content)
}

// Find the end of the JSON array
endIndex := strings.LastIndex(content, "]")
if endIndex == -1 {
return nil, fmt.Errorf("failed to find end of JSON array in LLM response\nRaw response:\n%s", content)
}

jsonStr := content[startIndex : endIndex+1]

var suggestions []Suggestion
if err := json.Unmarshal([]byte(jsonStr), &suggestions); err != nil {
return nil, fmt.Errorf("failed to parse LLM JSON response: %w\nRaw response:\n%s", err, content)
}

// Create result with content
result := &ReviewResult{
Content: content,
Suggestions: suggestions,
}

// Check if usage information is available in the response
Expand Down
22 changes: 15 additions & 7 deletions internal/agents/reviewer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ func main() {
if result == nil {
t.Error("Expected non-nil result")
}
if result.Content == "" {
t.Error("Expected non-empty content")
if len(result.Suggestions) == 0 {
t.Error("Expected non-empty suggestions")
}
}
},
Expand Down Expand Up @@ -186,8 +186,8 @@ func TestCodeReviewer_ReviewDiff(t *testing.T) {
if result == nil {
t.Error("Expected non-nil result")
}
if result.Content == "" {
t.Error("Expected non-empty content")
if len(result.Suggestions) == 0 {
t.Error("Expected non-empty suggestions")
}
}
},
Expand Down Expand Up @@ -246,16 +246,24 @@ func TestCodeReviewer_callLLM(t *testing.T) {
// Mock tests for unit testing without API calls
func TestReviewResult_Structure(t *testing.T) {
result := &ReviewResult{
Content: "Test review content",
Suggestions: []Suggestion{
{
Title: "Test review content",
},
},
TokensUsed: 100,
InputTokens: 60,
OutputTokens: 40,
Cost: 0.001,
}

if result.Content != "Test review content" {
if len(result.Suggestions) != 1 {
t.Fatalf("Expected 1 suggestion, got %d", len(result.Suggestions))
}

if result.Suggestions[0].Title != "Test review content" {
t.Errorf(
"Expected content 'Test review content', got %s", result.Content,
"Expected content 'Test review content', got %s", result.Suggestions[0].Title,
)
}

Expand Down
Loading
Loading