From 0aa806be70c227fe7c970007489fcf351d17d803 Mon Sep 17 00:00:00 2001 From: Greg Gauthier Date: Mon, 2 Mar 2026 20:13:50 +0000 Subject: [PATCH] feat(cmd): add AI documentation generation and command tests - 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 --- .gitea/workflows/ci.yml | 7 ++ .gitignore | 1 + .golangci.yml | 22 +++++ Makefile | 4 +- README.md | 80 +++++++++++++++-- cmd/commit.go | 12 ++- cmd/commit_test.go | 52 +++++++++++ cmd/docs.go | 185 ++++++++++++++++++++++++++++++++++++++++ cmd/docs_test.go | 78 +++++++++++++++++ cmd/history.go | 12 ++- cmd/history_test.go | 53 ++++++++++++ cmd/lint_test.go | 68 +++++++++++++++ cmd/prdescribe.go | 12 ++- cmd/prdescribe_test.go | 52 +++++++++++ cmd/review.go | 12 ++- cmd/review_test.go | 55 ++++++++++++ cmd/root.go | 1 + config/config.go | 1 + 18 files changed, 683 insertions(+), 24 deletions(-) create mode 100644 .golangci.yml create mode 100644 cmd/commit_test.go create mode 100644 cmd/docs.go create mode 100644 cmd/docs_test.go create mode 100644 cmd/history_test.go create mode 100644 cmd/lint_test.go create mode 100644 cmd/prdescribe_test.go create mode 100644 cmd/review_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6ae8593..b50839e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 705bd50..a52a9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/ .junie/ +.claude/ build/ grokkit *.bak diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5a15c5f --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/Makefile b/Makefile index 1d9300e..e89c8b6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index bdaf1fa..f946bc6 100644 --- a/README.md +++ b/README.md @@ -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...]` +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,11 +500,14 @@ 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) -│ ├── queued/ # Pending TODO items -│ └── completed/ # Completed TODO items with history +├── todo/ # TODO tracking: queued/ (pending) and completed/ (historical record) +│ ├── queued/ # Pending TODO items +│ └── completed/ # Completed TODO items with history ├── internal/ │ ├── errors/ # Custom error types │ ├── git/ # Git operations @@ -451,6 +518,7 @@ grokkit/ ├── main.go # Application entrypoint ├── go.mod # Dependencies ├── Makefile # Build automation +├── .golangci.yml # Golangci-lint configuration └── scripts/ # Install scripts ``` diff --git a/cmd/commit.go b/cmd/commit.go index 83aa2c8..3fec2b2 100644 --- a/cmd/commit.go +++ b/cmd/commit.go @@ -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)}, + } +} diff --git a/cmd/commit_test.go b/cmd/commit_test.go new file mode 100644 index 0000000..86309aa --- /dev/null +++ b/cmd/commit_test.go @@ -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) + } + } + }) + } +} diff --git a/cmd/docs.go b/cmd/docs.go new file mode 100644 index 0000000..f9f92f5 --- /dev/null +++ b/cmd/docs.go @@ -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...]", + 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" + } +} diff --git a/cmd/docs_test.go b/cmd/docs_test.go new file mode 100644 index 0000000..3e7a025 --- /dev/null +++ b/cmd/docs_test.go @@ -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) + } + }) + } +} diff --git a/cmd/history.go b/cmd/history.go index 0cb498c..2ea064b 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -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}, + } +} diff --git a/cmd/history_test.go b/cmd/history_test.go new file mode 100644 index 0000000..054b37c --- /dev/null +++ b/cmd/history_test.go @@ -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) + } + }) + } +} diff --git a/cmd/lint_test.go b/cmd/lint_test.go new file mode 100644 index 0000000..b756eec --- /dev/null +++ b/cmd/lint_test.go @@ -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) + } + } + }) + } +} diff --git a/cmd/prdescribe.go b/cmd/prdescribe.go index e237ac7..038ff5f 100644 --- a/cmd/prdescribe.go +++ b/cmd/prdescribe.go @@ -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)}, + } +} diff --git a/cmd/prdescribe_test.go b/cmd/prdescribe_test.go new file mode 100644 index 0000000..b74216b --- /dev/null +++ b/cmd/prdescribe_test.go @@ -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) + } + } + }) + } +} diff --git a/cmd/review.go b/cmd/review.go index 38a2a29..1194c54 100644 --- a/cmd/review.go +++ b/cmd/review.go @@ -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)}, + } +} diff --git a/cmd/review_test.go b/cmd/review_test.go new file mode 100644 index 0000000..776a68f --- /dev/null +++ b/cmd/review_test.go @@ -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) + } + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index fcc9fd7..0e0fac9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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)") diff --git a/config/config.go b/config/config.go index 84a8f58..b9063d2 100644 --- a/config/config.go +++ b/config/config.go @@ -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()