Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions README.ja-JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ ocr review \

`--format json`フラグは、CIスクリプトでのパースに適した機械可読な結果を出力します。

各指摘には2つの構造化フィールドが付与され、CI統合はコメント本文を再パースせずに並べ替え・グループ化・フィルタリング・ビルドのゲート判定を行えます:

| フィールド | 許可される値 | 説明 |
|-----------|-------------|------|
| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 指摘が属するカテゴリ。 |
| `severity` | `critical`、`high`、`medium`、`low` | 指摘の重要度。 |

JSON出力ではこの2つのフィールドは`content`や`start_line`などと同じ階層に並びます。ターミナルでは、コメントの前にインラインの`[category · severity]`バッジとして表示され、重要度に応じて色分けされます。

統合例は[`examples/`](./examples/)ディレクトリを参照してください:

- [`github_actions/`](./examples/github_actions/) — GitHub Actions統合の例
Expand Down
9 changes: 9 additions & 0 deletions README.ko-KR.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ ocr review \

`--format json` flag는 CI script에서 파싱하기 좋은 machine-readable 결과를 출력합니다.

각 finding에는 두 개의 구조화된 field가 포함되어, CI 통합에서 comment 텍스트를 다시 파싱하지 않고도 정렬·그룹화·필터링하거나 build를 gate할 수 있습니다:

| Field | 허용 값 | 설명 |
|-------|--------|------|
| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | 이슈가 속한 카테고리. |
| `severity` | `critical`, `high`, `medium`, `low` | 이슈의 중요도. |

JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 sibling으로 나타납니다. 터미널에서는 comment 앞에 인라인 `[category · severity]` badge로 표시되며 severity에 따라 색상이 지정됩니다.

통합 예시는 [`examples/`](./examples/) 디렉터리를 참고하세요.

- [`github_actions/`](./examples/github_actions/): GitHub Actions 통합 예시
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,15 @@ The `--from` flag accepts a branch ref (e.g., `origin/main`) or commit SHA as th

The `--format json` flag outputs machine-readable results suitable for parsing in CI scripts.

Each finding carries two structured fields so CI integrations can sort, group, filter, or gate builds without re-parsing comment text:

| Field | Allowed values | Notes |
|-------|----------------|-------|
| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | The category the issue belongs to. |
| `severity` | `critical`, `high`, `medium`, `low` | The importance of the issue. |

In JSON output the two fields appear as siblings alongside `content`, `start_line`, etc. In the terminal, they render as an inline `[category · severity]` badge before the comment, colored by severity.

See the [`examples/`](./examples/) directory for integration examples:

- [`github_actions/`](./examples/github_actions/) — GitHub Actions integration example
Expand Down
9 changes: 9 additions & 0 deletions README.ru-RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,15 @@ ocr review \

Флаг `--format json` выводит машиночитаемый результат, удобный для разбора в CI-скриптах.

Каждое замечание содержит два структурированных поля, чтобы CI-интеграции могли сортировать, группировать, фильтровать замечания или блокировать сборку без повторного разбора текста комментария:

| Поле | Допустимые значения | Примечание |
|------|---------------------|------------|
| `category` | `bug`, `security`, `performance`, `maintainability`, `test`, `style`, `documentation`, `other` | Категория, к которой относится замечание. |
| `severity` | `critical`, `high`, `medium`, `low` | Важность замечания. |

В JSON-выводе эти два поля располагаются рядом с `content`, `start_line` и др. В терминале они отображаются перед комментарием как встроенный бейдж `[category · severity]`, цвет которого определяется важностью.

Примеры интеграции — в каталоге [`examples/`](./examples/):

- [`github_actions/`](./examples/github_actions/) — пример интеграции с GitHub Actions
Expand Down
9 changes: 9 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ ocr review \

`--format json` 参数输出适合 CI 脚本解析的机器可读结果。

每条评审结果都带有两个结构化字段,便于 CI 集成在无需解析评论文本的情况下排序、分组、过滤或卡点构建:

| 字段 | 允许的取值 | 说明 |
|------|-----------|------|
| `category` | `bug`、`security`、`performance`、`maintainability`、`test`、`style`、`documentation`、`other` | 问题所属的类别。 |
| `severity` | `critical`、`high`、`medium`、`low` | 问题的严重程度。 |

在 JSON 输出中,这两个字段与 `content`、`start_line` 等平级;在终端中,它们会以内联的 `[category · severity]` 徽章形式显示在评论前,并按严重程度着色。

集成示例请参见 [`examples/`](./examples/) 目录:

- [`github_actions/`](./examples/github_actions/) — GitHub Actions 集成示例
Expand Down
49 changes: 48 additions & 1 deletion cmd/opencodereview/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,19 @@ func renderComment(comment model.LlmComment) {
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) {
badge := buildBadge(comment)
content := sanitizeTerminal(comment.Content)
if badge != "" {
// Prepend the plain badge text to the content so it wraps inline with
// the first line, then colorize just the badge prefix after wrapping.
content = badge + " " + content
}
lines := wrapByRunes(content, 100)
for i, ln := range lines {
if i == 0 && badge != "" && strings.HasPrefix(ln, badge) {
color := severityColor(comment.Severity)
ln = color + badge + "\033[0m" + ln[len(badge):]
}
fmt.Printf("%s\n", ln)
}
Comment on lines +71 to 78
fmt.Println()
Expand All @@ -83,6 +95,41 @@ func renderComment(comment model.LlmComment) {
fmt.Println()
}

// buildBadge renders a compact "[category · severity]" tag for a finding. It returns
// an empty string when neither structured field is present, so text output for findings
// without metadata is unchanged.
func buildBadge(comment model.LlmComment) string {
category := sanitizeTerminal(comment.Category)
severity := sanitizeTerminal(comment.Severity)
switch {
case category != "" && severity != "":
return fmt.Sprintf("[%s · %s]", category, severity)
case category != "":
return fmt.Sprintf("[%s]", category)
case severity != "":
return fmt.Sprintf("[%s]", severity)
default:
return ""
}
}

// severityColor maps a finding severity to an ANSI color used for its badge.
// Unknown or empty severities fall back to dim.
func severityColor(severity string) string {
switch severity {
Comment on lines +118 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The severityColor function performs case-sensitive matching against lowercase severity values ("critical", "high", etc.), but the severity value comes directly from LLM output without any normalization (see internal/tool/code_comment.go). If the LLM returns "Critical", "HIGH", or "Medium", the color will silently fall back to dim (\033[2m) while the badge still displays the original casing.

Consider normalizing the severity to lowercase before matching, e.g.:

switch strings.ToLower(severity) {

This makes the color assignment resilient to LLM casing variations.

Suggestion:

Suggested change
func severityColor(severity string) string {
switch severity {
func severityColor(severity string) string {
switch strings.ToLower(severity) {

case "critical":
return "\033[1;91m" // bold bright red
case "high":
return "\033[91m" // bright red
case "medium":
return "\033[93m" // bright yellow
case "low":
return "\033[94m" // bright blue
default:
return "\033[2m" // dim
}
}

// 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")
Expand Down
74 changes: 73 additions & 1 deletion cmd/opencodereview/output_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,78 @@
package main

import "testing"
import (
"strings"
"testing"

"github.com/open-code-review/open-code-review/internal/model"
)

func TestBuildBadge(t *testing.T) {
tests := []struct {
name string
comment model.LlmComment
want string
}{
{"both fields", model.LlmComment{Category: "security", Severity: "high"}, "[security · high]"},
{"category only", model.LlmComment{Category: "bug"}, "[bug]"},
{"severity only", model.LlmComment{Severity: "low"}, "[low]"},
{"neither", model.LlmComment{}, ""},
{"strips control chars", model.LlmComment{Category: "bug\x1b[0m", Severity: "high"}, "[bug[0m · high]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildBadge(tt.comment); got != tt.want {
t.Errorf("buildBadge() = %q, want %q", got, tt.want)
}
})
}
}

func TestSeverityColor(t *testing.T) {
// Each known severity must map to a distinct color; unknown falls back to dim.
seen := map[string]string{}
for _, sev := range []string{"critical", "high", "medium", "low"} {
c := severityColor(sev)
if c == "" {
t.Errorf("severityColor(%q) is empty", sev)
}
if prev, ok := seen[c]; ok {
t.Errorf("severityColor(%q) shares color with %q", sev, prev)
}
seen[c] = sev
}
if got := severityColor("bogus"); got != "\033[2m" {
t.Errorf("severityColor(unknown) = %q, want dim", got)
}
if got := severityColor(""); got != "\033[2m" {
t.Errorf("severityColor(empty) = %q, want dim", got)
}
}

// TestRenderComment_BadgeInline verifies the badge is colorized and rendered inline
// with the first line of the comment content.
func TestRenderComment_BadgeInline(t *testing.T) {
out := captureStdout(t, func() {
renderComment(model.LlmComment{
Path: "internal/mcp/client.go",
StartLine: 27,
EndLine: 27,
Content: "Potential environment variable leak.",
Category: "security",
Severity: "high",
})
})
if !strings.Contains(out, "[security · high]") {
t.Errorf("expected badge in output, got:\n%s", out)
}
// severity high → bright red; the badge must be wrapped in the color + reset.
if !strings.Contains(out, "\033[91m[security · high]\033[0m") {
t.Errorf("expected colorized badge, got:\n%q", out)
}
if !strings.Contains(out, "Potential environment variable leak.") {
t.Errorf("expected content in output, got:\n%s", out)
}
}

func TestSanitizeTerminal(t *testing.T) {
tests := []struct {
Expand Down
28 changes: 27 additions & 1 deletion internal/config/toolsconfig/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,37 @@
"suggestion_code": {
"type": "string",
"description": "Corresponding suggested code snippet, maintaining consistent code style."
},
"category": {
"type": "string",
"enum": [
"bug",
"security",
"performance",
"maintainability",
"test",
"style",
"documentation",
"other"
],
"description": "The category the issue belongs to."
},
"severity": {
"type": "string",
"enum": [
"critical",
"high",
"medium",
"low"
],
"description": "The severity of the issue."
}
},
"required": [
"content",
"existing_code"
"existing_code",
"category",
"severity"
]
Comment on lines 80 to 85
}
}
Expand Down
6 changes: 6 additions & 0 deletions internal/model/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ type LlmComment struct {
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Thinking string `json:"thinking,omitempty"`
// Category classifies the finding. One of:
// bug, security, performance, maintainability, test, style, documentation, other.
Category string `json:"category,omitempty"`
// Severity indicates the importance of the finding. One of:
// critical, high, medium, low.
Severity string `json:"severity,omitempty"`
}

// CodeReviewResult holds raw LLM-generated review suggestion for a code segment.
Expand Down
6 changes: 6 additions & 0 deletions internal/tool/code_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ func ParseComments(args map[string]any) ([]model.LlmComment, string) {
if thinking, ok := obj["thinking"].(string); ok {
cm.Thinking = thinking
}
if category, ok := obj["category"].(string); ok {
cm.Category = category
}
if severity, ok := obj["severity"].(string); ok {
cm.Severity = severity
}
Comment on lines +71 to +76
if path, ok := args["path"].(string); ok {
cm.Path = path
}
Expand Down
Loading
Loading