feat(cmd): add AI documentation generation and command tests
Some checks failed
CI / Test (push) Failing after 25s
CI / Lint (push) Has been skipped
CI / Build (push) Has been skipped

- Implemented `grokkit docs` command for generating language-specific documentation comments (godoc, PEP 257, Doxygen, etc.) with previews, backups, and auto-apply option
- Extracted message builder functions for commit, history, pr-describe, and review commands
- Added comprehensive unit tests for all command message builders (commit_test.go, docs_test.go, history_test.go, lint_test.go, prdescribe_test.go, review_test.go)
- Enforced 70% test coverage threshold in CI workflow
- Added .golangci.yml configuration with linters like govet, errcheck, staticcheck
- Updated Makefile to include -race in tests and add help target
- Updated README.md with new docs command details, workflows, and quality features
- Added .claude/ to .gitignore
- Configured default model for docs command in config.go
This commit is contained in:
Greg Gauthier 2026-03-02 20:13:50 +00:00
parent 6e3a52728e
commit 0aa806be70
18 changed files with 683 additions and 24 deletions

View File

@ -40,9 +40,15 @@ jobs:
run: |
go tool cover -func=coverage.out | tail -1
- name: Enforce coverage threshold
run: |
PCT=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
awk "BEGIN { exit ($PCT < 70) }" || (echo "Coverage ${PCT}% is below 70%" && exit 1)
lint:
name: Lint
runs-on: ubuntu-gitea
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -60,6 +66,7 @@ jobs:
build:
name: Build
runs-on: ubuntu-gitea
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.idea/
.junie/
.claude/
build/
grokkit
*.bak

22
.golangci.yml Normal file
View File

@ -0,0 +1,22 @@
version: "2"
linters:
default: none
enable:
- govet
- errcheck
- staticcheck
- ineffassign
- unused
- gosimple
- typecheck
- misspell
- gofmt
settings:
errcheck:
check-type-assertions: true
check-blank: false
run:
timeout: 5m

View File

@ -1,11 +1,11 @@
.PHONY: test test-cover test-agent lint build install clean
.PHONY: test test-cover test-agent lint build install clean help
VERSION ?= dev-$(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)
test:
go test ./... -v
go test ./... -v -race
test-cover:
@mkdir -p build

View File

@ -37,6 +37,15 @@ grokkit version
## 📋 Table of Contents
- [Commands](#commands)
- [chat](#-grokkit-chat)
- [edit](#-grokkit-edit-file-instruction)
- [commit / commitmsg](#-grokkit-commitmsg)
- [review](#-grokkit-review)
- [pr-describe](#-grokkit-pr-describe)
- [history](#-grokkit-history)
- [lint](#-grokkit-lint-file)
- [docs](#-grokkit-docs-file)
- [agent](#-grokkit-agent)
- [Configuration](#configuration)
- [Workflows](#workflows)
- [Shell Completions](#shell-completions)
@ -148,6 +157,41 @@ Summarize recent git commits.
grokkit history # Last 10 commits
```
### 📖 `grokkit docs <file> [file...]`
Generate language-appropriate documentation comments using Grok AI.
```bash
# Preview and confirm
grokkit docs main.go
# Auto-apply without confirmation
grokkit docs handlers.go models.go --auto-apply
# Document multiple files at once
grokkit docs cmd/*.go --auto-apply
# Use specific model
grokkit docs app.py -m grok-4
```
**Supported doc styles by language:**
| Language | Style |
|----------|-------|
| Go | godoc (`// FuncName does...`) |
| Python | PEP 257 docstrings (`"""Summary\n\nArgs:..."""`) |
| C / C++ | Doxygen (`/** @brief ... @param ... @return ... */`) |
| JavaScript / TypeScript | JSDoc (`/** @param {type} name ... */`) |
| Rust | rustdoc (`/// Summary\n/// # Arguments`) |
| Ruby | YARD (`# @param [Type] name`) |
| Java | Javadoc (`/** @param ... @return ... */`) |
| Shell | Shell comments (`# function: desc, # Args: ...`) |
**Safety features:**
- Creates `.bak` backup before any changes
- Shows first 50 lines of documented code as preview
- Requires confirmation (unless `--auto-apply`)
### 🤖 `grokkit agent`
Multi-file agent for complex refactoring (experimental).
@ -315,6 +359,23 @@ grokkit review
grokkit commit
```
### Documentation Generation Workflow
```bash
# 1. Preview docs for a single file
grokkit docs internal/api/handler.go
# 2. Batch-document a package
grokkit docs cmd/*.go --auto-apply
# 3. Document across languages in one pass
grokkit docs lib/utils.py src/helpers.ts --auto-apply
# 4. Review and commit
grokkit review
grokkit commit
```
## Shell Completions
Generate shell completions for faster command entry:
@ -369,10 +430,13 @@ grokkit review -v
- ✅ **Safe file editing** - Automatic backups, preview, confirmation
- ✅ **Git workflow integration** - Commit messages, reviews, PR descriptions
- ✅ **Multi-language linting** - 9 languages supported with AI-powered fixes
- ✅ **AI documentation generation** - 8 doc styles (godoc, PEP 257, Doxygen, JSDoc, rustdoc, YARD, Javadoc, shell)
### Quality & Testing
- ✅ **Test coverage 72%** - Comprehensive unit tests
- ✅ **CI/CD with Gitea Actions** - Automated testing and builds
- ✅ **Test coverage 72%+** - Comprehensive unit tests including all command message builders
- ✅ **Coverage gate in CI** - Builds fail if coverage drops below 70%
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
- ✅ **Interface-based design** - Testable and maintainable
- ✅ **Zero external dependencies** - Only stdlib + well-known libs
@ -413,7 +477,7 @@ make test-agent
make test-cover
open build/coverage.html
# Linting (matches CI)
# Linting (matches CI, uses .golangci.yml config)
make lint
# Install golangci-lint if needed:
# go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
@ -436,6 +500,9 @@ make clean
```
grokkit/
├── cmd/ # CLI commands (Cobra)
│ ├── docs.go # grokkit docs — AI documentation generation
│ ├── lint.go # grokkit lint — AI-powered linting
│ └── ... # chat, edit, commit, review, history, pr-describe, agent
├── config/ # Viper configuration
├── docs/ # Documentation
├── todo/ # TODO tracking: queued/ (pending) and completed/ (historical record)
@ -451,6 +518,7 @@ grokkit/
├── main.go # Application entrypoint
├── go.mod # Dependencies
├── Makefile # Build automation
├── .golangci.yml # Golangci-lint configuration
└── scripts/ # Install scripts
```

View File

@ -28,10 +28,7 @@ var commitCmd = &cobra.Command{
model := config.GetModel("commit", modelFlag)
client := grok.NewClient()
messages := []map[string]string{
{"role": "system", "content": "Return ONLY a conventional commit message (type(scope): subject\n\nbody)."},
{"role": "user", "content": fmt.Sprintf("Staged changes:\n%s", diff)},
}
messages := buildCommitMessages(diff)
color.Yellow("Generating commit message...")
msg := client.Stream(messages, model)
@ -54,3 +51,10 @@ var commitCmd = &cobra.Command{
}
},
}
func buildCommitMessages(diff string) []map[string]string {
return []map[string]string{
{"role": "system", "content": "Return ONLY a conventional commit message (type(scope): subject\n\nbody)."},
{"role": "user", "content": fmt.Sprintf("Staged changes:\n%s", diff)},
}
}

52
cmd/commit_test.go Normal file
View File

@ -0,0 +1,52 @@
package cmd
import (
"strings"
"testing"
)
func TestBuildCommitMessages(t *testing.T) {
tests := []struct {
name string
diff string
sysCheck string
usrCheck []string
}{
{
name: "normal diff",
diff: "diff --git a/foo.go b/foo.go\n+func bar() {}",
sysCheck: "conventional commit",
usrCheck: []string{"diff --git a/foo.go", "Staged changes:"},
},
{
name: "empty diff",
diff: "",
sysCheck: "conventional commit",
usrCheck: []string{"Staged changes:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgs := buildCommitMessages(tt.diff)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0]["role"] != "system" {
t.Errorf("first message role = %q, want %q", msgs[0]["role"], "system")
}
if msgs[1]["role"] != "user" {
t.Errorf("second message role = %q, want %q", msgs[1]["role"], "user")
}
if !strings.Contains(strings.ToLower(msgs[0]["content"]), strings.ToLower(tt.sysCheck)) {
t.Errorf("system prompt missing %q; got: %s", tt.sysCheck, msgs[0]["content"])
}
for _, check := range tt.usrCheck {
if !strings.Contains(msgs[1]["content"], check) {
t.Errorf("user prompt missing %q", check)
}
}
})
}
}

185
cmd/docs.go Normal file
View File

@ -0,0 +1,185 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"gmgauthier.com/grokkit/config"
"gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/linter"
"gmgauthier.com/grokkit/internal/logger"
)
var autoApply bool
var docsCmd = &cobra.Command{
Use: "docs <file> [file...]",
Short: "Generate documentation comments for source files",
Long: `Detects the programming language of each file and uses Grok AI to generate
language-appropriate documentation comments.
Supported doc styles:
Go godoc (// FuncName does...)
Python PEP 257 docstrings
C/C++ Doxygen (/** @brief ... */)
JS/TS JSDoc (/** @param ... */)
Rust rustdoc (/// Summary)
Ruby YARD (# @param ...)
Java Javadoc (/** @param ... */)
Shell Shell comments (# function: ...)
Safety features:
- Creates .bak backup before modifying files
- Shows preview of changes before applying
- Requires confirmation unless --auto-apply is used`,
Args: cobra.MinimumNArgs(1),
Run: runDocs,
}
func init() {
docsCmd.Flags().BoolVar(&autoApply, "auto-apply", false, "Apply documentation without confirmation")
}
func runDocs(cmd *cobra.Command, args []string) {
modelFlag, _ := cmd.Flags().GetString("model")
model := config.GetModel("docs", modelFlag)
client := grok.NewClient()
for _, filePath := range args {
processDocsFile(client, model, filePath)
}
}
func processDocsFile(client *grok.Client, model, filePath string) {
logger.Info("starting docs operation", "file", filePath)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
logger.Error("file not found", "file", filePath)
color.Red("❌ File not found: %s", filePath)
return
}
lang, err := linter.DetectLanguage(filePath)
if err != nil {
logger.Warn("unsupported language", "file", filePath, "error", err)
color.Yellow("⚠️ Skipping %s: %v", filePath, err)
return
}
originalContent, err := os.ReadFile(filePath)
if err != nil {
logger.Error("failed to read file", "file", filePath, "error", err)
color.Red("❌ Failed to read file: %v", err)
return
}
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
messages := buildDocsMessages(lang.Name, string(originalContent))
response := client.StreamSilent(messages, model)
if response == "" {
logger.Error("AI returned empty response", "file", filePath)
color.Red("❌ Failed to get AI response")
return
}
documented := grok.CleanCodeResponse(response)
logger.Info("received AI documentation", "file", filePath, "size", len(documented))
// Show preview
fmt.Println()
color.Cyan("📋 Preview of documented code:")
fmt.Println(strings.Repeat("-", 80))
lines := strings.Split(documented, "\n")
previewLines := 50
if len(lines) < previewLines {
previewLines = len(lines)
}
for i := 0; i < previewLines; i++ {
fmt.Println(lines[i])
}
if len(lines) > previewLines {
fmt.Printf("... (%d more lines)\n", len(lines)-previewLines)
}
fmt.Println(strings.Repeat("-", 80))
fmt.Println()
if !autoApply {
color.Yellow("Apply documentation to %s? (y/N): ", filePath)
var confirm string
if _, err := fmt.Scanln(&confirm); err != nil {
color.Yellow("❌ Cancelled. No changes made to: %s", filePath)
return
}
confirm = strings.ToLower(strings.TrimSpace(confirm))
if confirm != "y" && confirm != "yes" {
logger.Info("user cancelled docs application", "file", filePath)
color.Yellow("❌ Cancelled. No changes made to: %s", filePath)
return
}
}
// Create backup
backupPath := filePath + ".bak"
if err := os.WriteFile(backupPath, originalContent, 0644); err != nil {
logger.Error("failed to create backup", "file", filePath, "error", err)
color.Red("❌ Failed to create backup: %v", err)
return
}
logger.Info("backup created", "backup", backupPath)
if err := os.WriteFile(filePath, []byte(documented), 0644); err != nil {
logger.Error("failed to write documented file", "file", filePath, "error", err)
color.Red("❌ Failed to write file: %v", err)
return
}
logger.Info("documentation applied successfully", "file", filePath)
color.Green("✅ Documentation applied: %s", filePath)
color.Cyan("💾 Original saved to: %s", backupPath)
}
func buildDocsMessages(language, code string) []map[string]string {
style := docStyle(language)
systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+
"Return ONLY the documented code with no explanations, markdown, or extra text. "+
"Do NOT include markdown code fences. Document all public functions, methods, types, and constants.",
style,
)
userPrompt := fmt.Sprintf("Add documentation comments to the following %s code:\n\n%s", language, code)
return []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
}
}
func docStyle(language string) string {
switch strings.ToLower(language) {
case "go":
return "godoc"
case "python":
return "PEP 257 docstring"
case "c", "c++":
return "Doxygen"
case "javascript", "typescript":
return "JSDoc"
case "rust":
return "rustdoc"
case "ruby":
return "YARD"
case "java":
return "Javadoc"
case "shell", "bash":
return "shell comment"
default:
return "standard documentation comment"
}
}

78
cmd/docs_test.go Normal file
View File

@ -0,0 +1,78 @@
package cmd
import (
"strings"
"testing"
)
func TestBuildDocsMessages(t *testing.T) {
tests := []struct {
language string
code string
styleCheck string
}{
{language: "Go", code: "package main\nfunc Foo() {}", styleCheck: "godoc"},
{language: "Python", code: "def foo():\n pass", styleCheck: "PEP 257"},
{language: "C", code: "int foo(void) { return 0; }", styleCheck: "Doxygen"},
{language: "C++", code: "int foo() { return 0; }", styleCheck: "Doxygen"},
{language: "JavaScript", code: "function foo() {}", styleCheck: "JSDoc"},
{language: "TypeScript", code: "function foo(): void {}", styleCheck: "JSDoc"},
{language: "Rust", code: "pub fn foo() {}", styleCheck: "rustdoc"},
{language: "Ruby", code: "def foo; end", styleCheck: "YARD"},
{language: "Java", code: "public void foo() {}", styleCheck: "Javadoc"},
{language: "Shell", code: "foo() { echo hello; }", styleCheck: "shell comment"},
}
for _, tt := range tests {
t.Run(tt.language, func(t *testing.T) {
msgs := buildDocsMessages(tt.language, tt.code)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0]["role"] != "system" {
t.Errorf("first message role = %q, want %q", msgs[0]["role"], "system")
}
if msgs[1]["role"] != "user" {
t.Errorf("second message role = %q, want %q", msgs[1]["role"], "user")
}
if !strings.Contains(msgs[0]["content"], tt.styleCheck) {
t.Errorf("system prompt missing %q for language %s; got: %s",
tt.styleCheck, tt.language, msgs[0]["content"])
}
if !strings.Contains(msgs[1]["content"], tt.code) {
t.Errorf("user prompt missing code for language %s", tt.language)
}
})
}
}
func TestDocStyle(t *testing.T) {
tests := []struct {
language string
want string
}{
{"go", "godoc"},
{"Go", "godoc"},
{"python", "PEP 257 docstring"},
{"c", "Doxygen"},
{"c++", "Doxygen"},
{"javascript", "JSDoc"},
{"typescript", "JSDoc"},
{"rust", "rustdoc"},
{"ruby", "YARD"},
{"java", "Javadoc"},
{"shell", "shell comment"},
{"bash", "shell comment"},
{"unknown", "standard documentation comment"},
}
for _, tt := range tests {
t.Run(tt.language, func(t *testing.T) {
got := docStyle(tt.language)
if got != tt.want {
t.Errorf("docStyle(%q) = %q, want %q", tt.language, got, tt.want)
}
})
}
}

View File

@ -26,11 +26,15 @@ var historyCmd = &cobra.Command{
model := config.GetModel("history", modelFlag)
client := grok.NewClient()
messages := []map[string]string{
{"role": "system", "content": "Summarize the recent git history in 3-5 bullet points."},
{"role": "user", "content": log},
}
messages := buildHistoryMessages(log)
color.Yellow("Summarizing recent commits...")
client.Stream(messages, model)
},
}
func buildHistoryMessages(log string) []map[string]string {
return []map[string]string{
{"role": "system", "content": "Summarize the recent git history in 3-5 bullet points."},
{"role": "user", "content": log},
}
}

53
cmd/history_test.go Normal file
View File

@ -0,0 +1,53 @@
package cmd
import (
"strings"
"testing"
)
func TestBuildHistoryMessages(t *testing.T) {
tests := []struct {
name string
log string
sysCheck string
usrCheck string
}{
{
name: "with recent commits",
log: "abc1234 feat: add new feature\ndef5678 fix: resolve bug",
sysCheck: "git history",
usrCheck: "abc1234 feat: add new feature",
},
{
name: "empty log",
log: "",
sysCheck: "git history",
usrCheck: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgs := buildHistoryMessages(tt.log)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0]["role"] != "system" {
t.Errorf("first message role = %q, want %q", msgs[0]["role"], "system")
}
if msgs[1]["role"] != "user" {
t.Errorf("second message role = %q, want %q", msgs[1]["role"], "user")
}
if !strings.Contains(strings.ToLower(msgs[0]["content"]), strings.ToLower(tt.sysCheck)) {
t.Errorf("system prompt missing %q; got: %s", tt.sysCheck, msgs[0]["content"])
}
if tt.usrCheck != "" && !strings.Contains(msgs[1]["content"], tt.usrCheck) {
t.Errorf("user content missing %q", tt.usrCheck)
}
if msgs[1]["content"] != tt.log {
t.Errorf("user content = %q, want %q", msgs[1]["content"], tt.log)
}
})
}
}

68
cmd/lint_test.go Normal file
View File

@ -0,0 +1,68 @@
package cmd
import (
"strings"
"testing"
"gmgauthier.com/grokkit/internal/linter"
)
func TestBuildLintFixMessages(t *testing.T) {
tests := []struct {
name string
result *linter.LintResult
code string
wantLen int
sysCheck string
usrCheck []string
}{
{
name: "go file with issues",
result: &linter.LintResult{
Language: "Go",
LinterUsed: "golangci-lint",
Output: "line 5: unused variable x",
},
code: "package main\nfunc main() { x := 1 }",
wantLen: 2,
sysCheck: "code quality",
usrCheck: []string{"Go", "golangci-lint", "unused variable x", "package main"},
},
{
name: "python file with issues",
result: &linter.LintResult{
Language: "Python",
LinterUsed: "flake8",
Output: "E501 line too long",
},
code: "def foo():\n pass",
wantLen: 2,
sysCheck: "code quality",
usrCheck: []string{"Python", "flake8", "E501 line too long", "def foo"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgs := buildLintFixMessages(tt.result, tt.code)
if len(msgs) != tt.wantLen {
t.Fatalf("expected %d messages, got %d", tt.wantLen, len(msgs))
}
if msgs[0]["role"] != "system" {
t.Errorf("first message role = %q, want %q", msgs[0]["role"], "system")
}
if msgs[1]["role"] != "user" {
t.Errorf("second message role = %q, want %q", msgs[1]["role"], "user")
}
if !strings.Contains(strings.ToLower(msgs[0]["content"]), strings.ToLower(tt.sysCheck)) {
t.Errorf("system prompt missing %q; got: %s", tt.sysCheck, msgs[0]["content"])
}
for _, check := range tt.usrCheck {
if !strings.Contains(msgs[1]["content"], check) {
t.Errorf("user prompt missing %q", check)
}
}
})
}
}

View File

@ -30,11 +30,15 @@ var prDescribeCmd = &cobra.Command{
model := config.GetModel("prdescribe", modelFlag)
client := grok.NewClient()
messages := []map[string]string{
{"role": "system", "content": "Write a professional GitHub PR title + detailed body (changes, motivation, testing notes)."},
{"role": "user", "content": fmt.Sprintf("Diff:\n%s", diff)},
}
messages := buildPRDescribeMessages(diff)
color.Yellow("Writing PR description...")
client.Stream(messages, model)
},
}
func buildPRDescribeMessages(diff string) []map[string]string {
return []map[string]string{
{"role": "system", "content": "Write a professional GitHub PR title + detailed body (changes, motivation, testing notes)."},
{"role": "user", "content": fmt.Sprintf("Diff:\n%s", diff)},
}
}

52
cmd/prdescribe_test.go Normal file
View File

@ -0,0 +1,52 @@
package cmd
import (
"strings"
"testing"
)
func TestBuildPRDescribeMessages(t *testing.T) {
tests := []struct {
name string
diff string
sysCheck string
usrCheck []string
}{
{
name: "branch with changes",
diff: "diff --git a/cmd/new.go b/cmd/new.go\n+package cmd",
sysCheck: "PR",
usrCheck: []string{"Diff:", "diff --git a/cmd/new.go"},
},
{
name: "empty diff",
diff: "",
sysCheck: "PR",
usrCheck: []string{"Diff:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgs := buildPRDescribeMessages(tt.diff)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0]["role"] != "system" {
t.Errorf("first message role = %q, want %q", msgs[0]["role"], "system")
}
if msgs[1]["role"] != "user" {
t.Errorf("second message role = %q, want %q", msgs[1]["role"], "user")
}
if !strings.Contains(msgs[0]["content"], tt.sysCheck) {
t.Errorf("system prompt missing %q; got: %s", tt.sysCheck, msgs[0]["content"])
}
for _, check := range tt.usrCheck {
if !strings.Contains(msgs[1]["content"], check) {
t.Errorf("user prompt missing %q", check)
}
}
})
}
}

View File

@ -29,11 +29,15 @@ var reviewCmd = &cobra.Command{
return
}
messages := []map[string]string{
{"role": "system", "content": "You are an expert code reviewer. Give a concise summary + 3-5 actionable improvements."},
{"role": "user", "content": fmt.Sprintf("Git status:\n%s\n\nGit diff:\n%s", status, diff)},
}
messages := buildReviewMessages(status, diff)
color.Yellow("Grok is reviewing the repo...")
client.Stream(messages, model)
},
}
func buildReviewMessages(status, diff string) []map[string]string {
return []map[string]string{
{"role": "system", "content": "You are an expert code reviewer. Give a concise summary + 3-5 actionable improvements."},
{"role": "user", "content": fmt.Sprintf("Git status:\n%s\n\nGit diff:\n%s", status, diff)},
}
}

55
cmd/review_test.go Normal file
View File

@ -0,0 +1,55 @@
package cmd
import (
"strings"
"testing"
)
func TestBuildReviewMessages(t *testing.T) {
tests := []struct {
name string
status string
diff string
sysCheck string
usrCheck []string
}{
{
name: "with status and diff",
status: "M main.go",
diff: "diff --git a/main.go b/main.go\n+func foo() {}",
sysCheck: "code reviewer",
usrCheck: []string{"M main.go", "diff --git a/main.go"},
},
{
name: "empty diff",
status: "",
diff: "",
sysCheck: "code reviewer",
usrCheck: []string{"Git status:", "Git diff:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgs := buildReviewMessages(tt.status, tt.diff)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0]["role"] != "system" {
t.Errorf("first message role = %q, want %q", msgs[0]["role"], "system")
}
if msgs[1]["role"] != "user" {
t.Errorf("second message role = %q, want %q", msgs[1]["role"], "user")
}
if !strings.Contains(strings.ToLower(msgs[0]["content"]), strings.ToLower(tt.sysCheck)) {
t.Errorf("system prompt missing %q; got: %s", tt.sysCheck, msgs[0]["content"])
}
for _, check := range tt.usrCheck {
if !strings.Contains(msgs[1]["content"], check) {
t.Errorf("user prompt missing %q", check)
}
}
})
}
}

View File

@ -56,6 +56,7 @@ func init() {
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(completionCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(docsCmd)
// Add model flag to all commands
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")

View File

@ -35,6 +35,7 @@ func Load() {
viper.SetDefault("commands.lint.model", "grok-4-1-fast-non-reasoning")
viper.SetDefault("commands.prdescribe.model", "grok-4")
viper.SetDefault("commands.review.model", "grok-4")
viper.SetDefault("commands.docs.model", "grok-4")
// Config file is optional, so we ignore read errors
_ = viper.ReadInConfig()