mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
* feat: add tool_calls field to JSON output for tool usage statistics Track per-tool invocation counts in llmloop.Runner and expose them through the ResultProvider interface so both review and scan modes report tool call statistics in --format json output. * fix: correct tool_calls counting and ensure stable JSON schema Move recordToolCall after lookupTool nil check so only actually-executed tool calls are counted. Always emit tool_calls field in JSON output for a consistent schema, initializing by_tool to empty map when nil. * fix: remove omitempty from tool_calls to ensure stable JSON schema Drop omitempty from the tool_calls struct tag and initialize ToolCalls in outputJSONNoFiles so the field is always present in JSON output regardless of execution path.
343 lines
8.8 KiB
Go
343 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/open-code-review/open-code-review/internal/agent"
|
|
"github.com/open-code-review/open-code-review/internal/model"
|
|
"github.com/open-code-review/open-code-review/internal/suggestdiff"
|
|
)
|
|
|
|
func outputText(comments []model.LlmComment) {
|
|
if len(comments) == 0 {
|
|
fmt.Println("No comments generated. Looks good to me.")
|
|
return
|
|
}
|
|
for _, c := range comments {
|
|
renderComment(c)
|
|
}
|
|
}
|
|
|
|
func hasSubtaskErrors(warnings []agent.AgentWarning) bool {
|
|
for _, w := range warnings {
|
|
if w.Type == "subtask_error" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning) {
|
|
if len(comments) == 0 {
|
|
if hasSubtaskErrors(warnings) {
|
|
fmt.Println("Some files could not be reviewed due to errors (see warnings below).")
|
|
} else {
|
|
fmt.Println("No comments generated. Looks good to me.")
|
|
}
|
|
} else {
|
|
for _, c := range comments {
|
|
renderComment(c)
|
|
}
|
|
}
|
|
for _, w := range warnings {
|
|
if w.Type == "subtask_error" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ocr] WARNING [%s] %s: %s\n", w.Type, sanitizeTerminal(w.File), sanitizeTerminal(w.Message))
|
|
}
|
|
}
|
|
|
|
func renderComment(comment model.LlmComment) {
|
|
lines := buildDiffLines(comment)
|
|
if len(lines) == 0 && comment.Content == "" {
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
|
|
|
|
if comment.Content != "" {
|
|
for _, ln := range wrapByRunes(sanitizeTerminal(comment.Content), 100) {
|
|
fmt.Printf("%s\n", ln)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
if len(lines) > 0 {
|
|
for _, dl := range lines {
|
|
switch dl.Type {
|
|
case suggestdiff.DiffAdded:
|
|
printDiffLine("+", sanitizeTerminal(dl.Content), "\033[92m", "\033[48;2;0;60;0m")
|
|
case suggestdiff.DiffDeleted:
|
|
printDiffLine("-", sanitizeTerminal(dl.Content), "\033[91m", "\033[48;2;70;0;0m")
|
|
case suggestdiff.DiffContext:
|
|
printDiffLine(" ", sanitizeTerminal(dl.Content), "\033[2m", "\033[48;2;38;38;38m")
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// printDiffLine renders a single diff line with colored prefix and background on content.
|
|
func printDiffLine(prefix, content, fgColor, bgColor string) {
|
|
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
|
|
}
|
|
|
|
// wrapByRunes splits text into lines that fit within maxWidth **rune** columns.
|
|
// Respects existing newlines and wraps at word boundaries.
|
|
func wrapByRunes(text string, maxW int) []string {
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
var result []string
|
|
for _, para := range strings.Split(text, "\n") {
|
|
result = append(result, wrapSingleRuneLine(para, maxW)...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// wrapSingleRuneLine breaks one paragraph (no newlines) into rune-width-constrained lines.
|
|
func wrapSingleRuneLine(line string, maxW int) []string {
|
|
runes := []rune(line)
|
|
if visibleRunesLen(runes) <= maxW {
|
|
return []string{line}
|
|
}
|
|
var result []string
|
|
for len(runes) > 0 {
|
|
cut := runeWrapCut(runes, maxW)
|
|
result = append(result, string(runes[:cut]))
|
|
runes = runes[cut:]
|
|
// trim leading spaces of next segment
|
|
for len(runes) > 0 && runes[0] == ' ' {
|
|
runes = runes[1:]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// runeWrapCut returns a rune index suitable for breaking the line at ~maxW display width.
|
|
func runeWrapCut(runes []rune, maxW int) int {
|
|
if visibleRunesLen(runes) <= maxW {
|
|
return len(runes)
|
|
}
|
|
best := maxW
|
|
if best >= len(runes) {
|
|
return len(runes)
|
|
}
|
|
for i := best; i > 0; i-- {
|
|
if runes[i] == ' ' || runes[i] == '\t' {
|
|
return i
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func visibleRunesLen(runes []rune) int {
|
|
n := 0
|
|
for _, r := range runes {
|
|
if r >= 32 && r != 127 {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func sanitizeTerminal(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
if r == '\t' || r == '\n' || !unicode.IsControl(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func splitToLines(s string) []string {
|
|
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func buildDiffLines(comment model.LlmComment) []suggestdiff.DiffLine {
|
|
if comment.SuggestionCode == "" || comment.ExistingCode == "" {
|
|
return nil
|
|
}
|
|
oldLines := splitToLines(comment.ExistingCode)
|
|
newLines := splitToLines(comment.SuggestionCode)
|
|
return suggestdiff.ComputeLineDiff(oldLines, newLines)
|
|
}
|
|
|
|
type jsonSummary struct {
|
|
FilesReviewed int64 `json:"files_reviewed"`
|
|
Comments int64 `json:"comments"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
|
|
Elapsed string `json:"elapsed"`
|
|
}
|
|
|
|
type jsonToolCalls struct {
|
|
Total int64 `json:"total"`
|
|
ByTool map[string]int64 `json:"by_tool"`
|
|
}
|
|
|
|
type jsonOutput struct {
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
Summary *jsonSummary `json:"summary,omitempty"`
|
|
ToolCalls *jsonToolCalls `json:"tool_calls"`
|
|
Comments []model.LlmComment `json:"comments"`
|
|
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
|
|
ProjectSummary string `json:"project_summary,omitempty"`
|
|
}
|
|
|
|
func outputJSON(comments []model.LlmComment) error {
|
|
out := jsonOutput{
|
|
Status: "success",
|
|
Comments: comments,
|
|
}
|
|
if len(comments) == 0 {
|
|
out.Message = "No comments generated. Looks good to me."
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
|
|
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
|
|
duration time.Duration, projectSummary string, toolCalls map[string]int64) error {
|
|
out := jsonOutput{
|
|
Status: "success",
|
|
Comments: comments,
|
|
Summary: &jsonSummary{
|
|
FilesReviewed: filesReviewed,
|
|
Comments: int64(len(comments)),
|
|
TotalTokens: totalTokens,
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
CacheReadTokens: cacheReadTokens,
|
|
CacheWriteTokens: cacheWriteTokens,
|
|
Elapsed: duration.Round(time.Second).String(),
|
|
},
|
|
ProjectSummary: projectSummary,
|
|
}
|
|
var total int64
|
|
for _, v := range toolCalls {
|
|
total += v
|
|
}
|
|
byTool := toolCalls
|
|
if byTool == nil {
|
|
byTool = make(map[string]int64)
|
|
}
|
|
out.ToolCalls = &jsonToolCalls{
|
|
Total: total,
|
|
ByTool: byTool,
|
|
}
|
|
if len(comments) == 0 {
|
|
if hasSubtaskErrors(warnings) {
|
|
out.Message = "Some files could not be reviewed due to errors."
|
|
} else {
|
|
out.Message = "No comments generated. Looks good to me."
|
|
}
|
|
}
|
|
if len(warnings) > 0 {
|
|
out.Warnings = warnings
|
|
if hasSubtaskErrors(warnings) {
|
|
out.Status = "completed_with_errors"
|
|
} else {
|
|
out.Status = "completed_with_warnings"
|
|
}
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
func outputJSONNoFiles() error {
|
|
out := jsonOutput{
|
|
Status: "skipped",
|
|
Message: "No supported files changed.",
|
|
Comments: []model.LlmComment{},
|
|
ToolCalls: &jsonToolCalls{
|
|
ByTool: map[string]int64{},
|
|
},
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
func outputPreviewText(p *agent.DiffPreview) {
|
|
if p.TotalFiles == 0 {
|
|
fmt.Println("No files changed.")
|
|
return
|
|
}
|
|
|
|
maxPathLen := 0
|
|
for _, e := range p.Entries {
|
|
if n := len(sanitizeTerminal(e.Path)); n > maxPathLen {
|
|
maxPathLen = n
|
|
}
|
|
}
|
|
if maxPathLen < 20 {
|
|
maxPathLen = 20
|
|
}
|
|
pathFmt := fmt.Sprintf("%%-%ds", maxPathLen)
|
|
|
|
fmt.Printf("\nPreview: %d file(s) changed | \033[32m+%d\033[0m \033[31m-%d\033[0m\n",
|
|
p.TotalFiles, p.TotalInsertions, p.TotalDeletions)
|
|
|
|
if p.ReviewableCount > 0 {
|
|
fmt.Printf("\n\033[1mWill review (%d):\033[0m\n", p.ReviewableCount)
|
|
for _, e := range p.Entries {
|
|
if !e.WillReview {
|
|
continue
|
|
}
|
|
fmt.Printf(" %s "+pathFmt+" \033[32m+%-4d\033[0m \033[31m-%-4d\033[0m\n",
|
|
statusBadge(e.Status), sanitizeTerminal(e.Path), e.Insertions, e.Deletions)
|
|
}
|
|
}
|
|
|
|
if p.ExcludedCount > 0 {
|
|
fmt.Printf("\n\033[1mExcluded from review (%d):\033[0m\n", p.ExcludedCount)
|
|
for _, e := range p.Entries {
|
|
if e.WillReview {
|
|
continue
|
|
}
|
|
fmt.Printf(" %s "+pathFmt+" \033[2m(%s)\033[0m\n",
|
|
statusBadge(e.Status), sanitizeTerminal(e.Path), sanitizeTerminal(string(e.ExcludeReason)))
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
func statusBadge(status string) string {
|
|
switch status {
|
|
case "added":
|
|
return "\033[32m[A]\033[0m"
|
|
case "modified":
|
|
return "\033[33m[M]\033[0m"
|
|
case "deleted":
|
|
return "\033[31m[D]\033[0m"
|
|
case "renamed":
|
|
return "\033[36m[R]\033[0m"
|
|
case "binary":
|
|
return "\033[35m[B]\033[0m"
|
|
case "scan":
|
|
return "\033[34m[S]\033[0m"
|
|
default:
|
|
return "[?]"
|
|
}
|
|
}
|