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
27 changes: 24 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ permissions:

jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -23,15 +24,35 @@ jobs:
go-version: '1.26'

- name: Build
run: go build ./...
run: go build -v ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./... -v -count=1 -race
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.26'

- name: Test with coverage
run: go test ./... -v -count=1 -race -coverprofile=coverage.out -covermode=atomic

- name: Coverage summary
run: go tool cover -func=coverage.out | tail -1

- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.out

Comment on lines +32 to 53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

πŸ”’ Security & Privacy | 🟑 Minor | ⚑ Quick win

Add persist-credentials: false to checkout steps that don't need to push.

The actions/checkout step here (and similarly in the build/lint jobs) leaves persist-credentials at its default of true, so the GITHUB_TOKEN is written to .git/config for the remainder of the job. This job also uploads a coverage.out artifact β€” if the artifact path ever broadens (e.g. workspace archiving) or a subsequent step is compromised, the persisted token could leak. Since none of these jobs push commits, credentials aren't needed post-checkout.

πŸ”’ Proposed fix
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false

Apply the same change to the checkout steps in the build (line 20) and lint (line 58) jobs.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: Test with coverage
run: go test ./... -v -count=1 -race -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out | tail -1
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.out
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: Test with coverage
run: go test ./... -v -count=1 -race -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out | tail -1
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.out
🧰 Tools
πŸͺ› zizmor (1.26.1)

[warning] 36-36: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 32 - 53, The checkout steps in the
test, build, and lint jobs are persisting Git credentials by default even though
these jobs never push changes. Update each actions/checkout invocation to
disable persisted credentials by setting persist-credentials to false, so the
GITHUB_TOKEN is not written into .git/config after checkout.

Source: Linters/SAST tools

lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/OpticDiff/code-reviewer
go 1.26.1

require (
golang.org/x/term v0.30.0
google.golang.org/genai v1.52.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type Config struct {
CommentMode CommentMode
DryRun bool
OutputJSON bool
NoColor bool // Disable ANSI color output.

// GitLab settings.
GitLabToken string
Expand Down Expand Up @@ -284,6 +285,10 @@ func (c *Config) loadEnv() {
if v := os.Getenv("REVIEW_CUSTOM_PROMPT"); v != "" {
c.CustomPrompt = v
}
// Respect the NO_COLOR standard (https://no-color.org/).
if _, ok := os.LookupEnv("NO_COLOR"); ok {
c.NoColor = true
}
}

func (c *Config) loadFlags() error {
Expand All @@ -303,6 +308,7 @@ func (c *Config) loadFlags() error {
outputJSON := fs.Bool("json", false, "Output results as JSON to stdout")
_ = fs.Bool("version", false, "Print version and exit") // Handled in main() before config.Load().
customPrompt := fs.String("custom-prompt", "", "Path to a custom system prompt file")
noColor := fs.Bool("no-color", false, "Disable ANSI color output")

if err := fs.Parse(os.Args[1:]); err != nil {
return err
Expand Down Expand Up @@ -350,6 +356,9 @@ func (c *Config) loadFlags() error {
if *customPrompt != "" {
c.CustomPrompt = *customPrompt
}
if *noColor {
c.NoColor = true
}

return nil
}
Expand Down
215 changes: 215 additions & 0 deletions internal/reviewer/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package reviewer

import (
"strings"
"testing"

"github.com/OpticDiff/code-reviewer/internal/model"
)

// ---------------------------------------------------------------------------
// ColorTerminalOutput tests (new colored output)
// ---------------------------------------------------------------------------

func TestColorTerminalOutput_NoColor_FallsBackToPlain(t *testing.T) {
result := &model.ReviewResult{
Summary: "test",
Findings: []model.Finding{
{File: "main.go", Line: 1, Severity: "HIGH", Title: "Bug", Body: "desc"},
},
}
// useColor=false should produce output identical to TerminalOutput.
out := ColorTerminalOutput(result, false)
plain := TerminalOutput(result)
if out != plain {
t.Error("ColorTerminalOutput(false) should equal TerminalOutput")
}
}

func TestColorTerminalOutput_WithColor_HasANSI(t *testing.T) {
result := &model.ReviewResult{
Summary: "Found issues.",
Findings: []model.Finding{
{File: "main.go", Line: 42, Severity: "CRITICAL", Title: "SQL Injection", Body: "User input in query.", Suggestion: "use parameterized queries"},
{File: "main.go", Line: 55, Severity: "HIGH", Title: "Resource leak", Body: "Unclosed body."},
{File: "config.go", Line: 10, Severity: "MEDIUM", Title: "Missing validation", Body: "No input check."},
{File: "util.go", Line: 5, Severity: "LOW", Title: "Naming", Body: "Use better name."},
},
}
out := ColorTerminalOutput(result, true)

// Verify ANSI codes are present.
if !strings.Contains(out, "\033[") {
t.Error("expected ANSI escape codes in colored output")
}

// Verify box drawing.
if !strings.Contains(out, "β”Œ") {
t.Error("expected box-drawing top border")
}
if !strings.Contains(out, "β””") {
t.Error("expected box-drawing bottom border")
}

// Verify severity badges.
if !strings.Contains(out, "CRITICAL") {
t.Error("expected CRITICAL severity in output")
}
if !strings.Contains(out, "πŸ”΄") {
t.Error("expected πŸ”΄ in severity counts")
}

// Verify file separators.
if !strings.Contains(out, "── main.go") {
t.Error("expected file separator for main.go")
}

// Verify suggestion block.
if !strings.Contains(out, "Suggestion:") {
t.Error("expected 'Suggestion:' label")
}

// Verify summary counts.
if !strings.Contains(out, "1 critical") {
t.Error("expected '1 critical' in summary")
}
if !strings.Contains(out, "1 high") {
t.Error("expected '1 high' in summary")
}
}

func TestColorTerminalOutput_NoFindings_ShowsClean(t *testing.T) {
result := &model.ReviewResult{
Summary: "Clean code.",
Findings: nil,
}
out := ColorTerminalOutput(result, true)
if !strings.Contains(out, "No issues found") {
t.Error("expected 'No issues found' in colored empty output")
}
if !strings.Contains(out, "β”Œ") {
t.Error("expected box drawing even with no findings")
}
}

// ---------------------------------------------------------------------------
// formatSummaryNote tests (GitLab posting)
// ---------------------------------------------------------------------------

func TestFormatSummaryNote_WithFindings(t *testing.T) {
result := &model.ReviewResult{
Summary: "Mixed issues.",
Findings: []model.Finding{
{File: "a.go", Line: 1, Severity: "CRITICAL", Title: "critical", Body: "c"},
{File: "a.go", Line: 2, Severity: "HIGH", Title: "high", Body: "h"},
{File: "a.go", Line: 3, Severity: "HIGH", Title: "high2", Body: "h2"},
{File: "a.go", Line: 4, Severity: "LOW", Title: "low", Body: "l"},
},
}
out := formatSummaryNote(result)

if !strings.Contains(out, "πŸ“‹ Code Review Summary") {
t.Error("expected summary header")
}
if !strings.Contains(out, "CRITICAL") {
t.Error("expected CRITICAL in table")
}
// HIGH should show count 2.
if !strings.Contains(out, "2") {
t.Error("expected count of 2 for HIGH")
}
}

func TestFormatSummaryNote_NoFindings(t *testing.T) {
result := &model.ReviewResult{
Summary: "All clean.",
Findings: nil,
}
out := formatSummaryNote(result)
if !strings.Contains(out, "No issues found") {
t.Error("expected 'No issues found' in summary note")
}
}

// ---------------------------------------------------------------------------
// Helper function tests
// ---------------------------------------------------------------------------

func TestSeverityEmoji_AllCases(t *testing.T) {
tests := []struct {
severity string
emoji string
}{
{"CRITICAL", "πŸ”΄"},
{"critical", "πŸ”΄"},
{"HIGH", "🟠"},
{"high", "🟠"},
{"MEDIUM", "🟑"},
{"LOW", "πŸ”΅"},
{"UNKNOWN", "βšͺ"},
{"", "βšͺ"},
}
for _, tt := range tests {
t.Run(tt.severity, func(t *testing.T) {
got := severityEmoji(tt.severity)
if got != tt.emoji {
t.Errorf("severityEmoji(%q) = %q, want %q", tt.severity, got, tt.emoji)
}
})
}
}

func TestSeverityColor(t *testing.T) {
tests := []struct {
severity string
color string
}{
{"CRITICAL", ansiRed},
{"HIGH", ansiOrange},
{"MEDIUM", ansiYellow},
{"LOW", ansiBlue},
{"UNKNOWN", ansiWhite},
}
for _, tt := range tests {
t.Run(tt.severity, func(t *testing.T) {
got := severityColor(tt.severity)
if got != tt.color {
t.Errorf("severityColor(%q) = %q, want %q", tt.severity, got, tt.color)
}
})
}
}

func TestFormatInlineComment_WithoutSuggestion(t *testing.T) {
f := model.Finding{
Severity: "HIGH",
Title: "Resource leak",
Body: "File handle not closed.",
}
out := formatInlineComment(f)
if !strings.Contains(out, "🟠") {
t.Error("expected HIGH emoji")
}
if !strings.Contains(out, "Resource leak") {
t.Error("expected title")
}
if strings.Contains(out, "```suggestion") {
t.Error("should not have suggestion block when suggestion is empty")
}
}

func TestFormatInlineComment_WithSuggestion(t *testing.T) {
f := model.Finding{
Severity: "CRITICAL",
Title: "SQL injection",
Body: "Raw concat.",
Suggestion: "db.Query(\"SELECT * FROM t WHERE id = ?\", id)",
}
out := formatInlineComment(f)
if !strings.Contains(out, "```suggestion") {
t.Error("expected suggestion code block")
}
if !strings.Contains(out, "db.Query") {
t.Error("expected suggestion content")
}
}
3 changes: 2 additions & 1 deletion internal/reviewer/reviewer.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ func (r *Reviewer) Run(ctx context.Context) (int, error) {
}
fmt.Println(string(jsonOut))
} else {
fmt.Print(TerminalOutput(result))
useColor := !r.cfg.NoColor && isTTY()
fmt.Print(ColorTerminalOutput(result, useColor))
}
} else {
// Post to GitLab.
Expand Down
Loading
Loading