Automated AI Code Review on Golang Pull Requests: Complete Setup
Build an automated code review system with AI for Go PRs. Custom reviewer bot, Copilot integration, review rules based on CLAUDE.md, scoring, and enforcement in GitHub Actions.
Automated Code Review with AI on Pull Requests
Automated code review with AI on pull requests for Golang turns review from a sporadic manual activity into a consistent gate on every PR. In Article 03 we set up the basics of Claude in GitHub Actions. Now we go deeper into a production-ready implementation: an automated code review system that genuinely enforces Go standards, gives actionable feedback, and can be configured to a team’s needs.
This isn’t just “AI reads code and comments” — it’s a system with explicit rules, objective scoring, and enforcement that’s graduated from warning to hard block.
05.1 Review System Architecture
Before writing code, it’s important to understand the overall shape of the system we’ll build. The diagram below maps the three review layers along with the execution flow and the gate rules per layer.
1Components we'll build:
2
3┌─────────────────────────────────────────────────────────────┐
4│ LAYER 1: Fast Pre-filter (< 30 seconds, zero AI cost) │
5│ Secret scan + basic pattern check via grep/regex │
6├─────────────────────────────────────────────────────────────┤
7│ LAYER 2: AI Review Engine (1-2 minutes, low cost) │
8│ Claude haiku + CLAUDE.md context → structured output │
9├─────────────────────────────────────────────────────────────┤
10│ LAYER 3: Result Processor (instant) │
11│ Parse AI output → post PR comment → enforce score gate │
12└─────────────────────────────────────────────────────────────┘
13
14Flow per PR:
15 PR push → Layer 1 (fast filter) → Layer 2 (AI review)
16 → Layer 3 (post comment + enforce)
17
18 If Layer 1 fails: stop, don't waste AI tokens
19 If Layer 2 score < 60: block PR
20 If score 60-80: warning comment
21 If score > 80: approved commentThis layered design has one important cost principle: the free Layer 1 filters out obvious violations before the paid Layer 2 runs. AI tokens are only spent on code that passes the cheap checks.
05.2 Comprehensive Review Rules
The heart of the review system is explicit, measurable rules — without them, the AI only gives subjective opinions. The script below defines the rules that will be injected into the prompt, complete with a point weight per category and a machine-parseable output format.
1# scripts/review-rules.sh
2# Definition of rules that will be injected into the AI prompt
3
4cat << 'RULES'
5=== GO REVIEW RULES FOR SANTEKNO SHOP ===
6
7CRITICAL (each violation = -15 points):
8------------------------------------------
9C1. ERROR WRAPPING
10 WRONG: return err
11 return errors.New("message") in non-domain
12 RIGHT: return fmt.Errorf("packageName.MethodName: %w", err)
13 Exception: domain error types may return errors.New()
14
15C2. REPOSITORY NOT-FOUND PATTERN
16 WRONG: return nil, pgx.ErrNoRows
17 return nil, sql.ErrNoRows
18 RIGHT: return nil, nil (not-found = nil value, nil error)
19 Context: repository layer only — usecase layer checks nil result
20
21C3. MONETARY TYPE
22 WRONG: Price float64
23 Amount float32
24 Total float64
25 RIGHT: PriceIDR int64 (or via money.IDR type)
26 Rule: ALL monetary values must be int64 (cents)
27
28C4. ARCHITECTURE VIOLATION
29 WRONG: Handler imports repository implementation
30 Usecase imports database driver (pgx, redis)
31 Domain imports framework (echo, gin)
32 RIGHT: Handler → usecase interface only
33 Usecase → repository interface only
34 Domain → zero external imports
35
36C5. ERROR IGNORED
37 WRONG: result, _ := someFunc() (in non-test code)
38 go func() { someFunc() }() (goroutine with unhandled error)
39 RIGHT: result, err := someFunc(); if err != nil { ... }
40
41IMPORTANT (each violation = -7 points):
42------------------------------------------
43I1. CONTEXT PROPAGATION
44 WRONG: db.Query("SELECT...") without ctx
45 http.Get(url) without context
46 RIGHT: db.QueryContext(ctx, "SELECT...")
47 req, _ := http.NewRequestWithContext(ctx, ...)
48
49I2. NIL CHECK AFTER REPO CALL
50 WRONG: order, _ := repo.GetByID(ctx, id); order.Status
51 RIGHT: order, err := repo.GetByID(ctx, id)
52 if err != nil { return ... }
53 if order == nil { return ErrNotFound }
54
55I3. INTERFACE SIZE
56 WRONG: Interface with > 7 methods
57 RIGHT: Split into smaller, focused interfaces
58
59SUGGESTION (each violation = -3 points):
60------------------------------------------
61S1. GODOC for exported symbols
62S2. Test coverage for error paths
63S3. Context timeout for external calls
64S4. Confusing named return values
65S5. Magic numbers without a constant
66
67OUTPUT FORMAT (strict, machine-parseable):
68------------------------------------------
69CRITICAL C1: [description] at [file:line]
70 Fix: [exact code fix]
71
72IMPORTANT I1: [description] at [file:line]
73 Fix: [exact code fix]
74
75SUGGESTION S1: [description]
76
77SCORE: [0-100]
78VERDICT: [APPROVED|WARNING|BLOCK]
79 (APPROVED=score≥85, WARNING=score 60-84, BLOCK=score<60)
80RULES'Notice that every rule has a WRONG vs RIGHT example and a clear point weight — this is what makes the score consistent across PRs. The structured output format ensures the AI’s result can be parsed by the next step without ambiguity.
05.3 Review Bot: Go Implementation
The rules above need something to execute them. We build the review bot as a standalone Go binary that receives the diff via stdin, calls Claude with the rules and the CLAUDE.md context, then emits structured JSON and an exit code matching the verdict.
1// cmd/review-bot/main.go
2// Custom review bot that can run standalone or in CI
3
4package main
5
6import (
7 "context"
8 "encoding/json"
9 "fmt"
10 "os"
11 "os/exec"
12 "strings"
13
14 anthropic "github.com/anthropics/anthropic-sdk-go"
15 "github.com/anthropics/anthropic-sdk-go/option"
16)
17
18type ReviewResult struct {
19 Score int `json:"score"`
20 Verdict string `json:"verdict"` // APPROVED | WARNING | BLOCK
21 Critical []Issue `json:"critical"`
22 Important []Issue `json:"important"`
23 Suggestions []Issue `json:"suggestions"`
24}
25
26type Issue struct {
27 Rule string `json:"rule"`
28 Description string `json:"description"`
29 Location string `json:"location"`
30 Fix string `json:"fix"`
31}
32
33func main() {
34 // Get diff from stdin or git
35 var diff string
36 if len(os.Args) > 1 && os.Args[1] == "--from-git" {
37 baseBranch := "main"
38 if len(os.Args) > 2 {
39 baseBranch = os.Args[2]
40 }
41 out, err := exec.Command("git", "diff",
42 "origin/"+baseBranch+"...HEAD", "--", "*.go").Output()
43 if err != nil {
44 fmt.Fprintln(os.Stderr, "git diff failed:", err)
45 os.Exit(1)
46 }
47 diff = string(out)
48 } else {
49 buf := make([]byte, 10*1024*1024) // 10MB max
50 n, _ := os.Stdin.Read(buf)
51 diff = string(buf[:n])
52 }
53
54 if strings.TrimSpace(diff) == "" {
55 fmt.Println("No Go changes detected, skip review")
56 os.Exit(0)
57 }
58
59 // Limit diff size for cost control
60 if len(diff) > 50000 {
61 diff = diff[:50000] + "\n[... diff truncated for cost control ...]"
62 }
63
64 // Load CLAUDE.md context
65 claudeMD := ""
66 if content, err := os.ReadFile("CLAUDE.md"); err == nil {
67 lines := strings.Split(string(content), "\n")
68 if len(lines) > 150 {
69 lines = lines[:150] // first 150 lines
70 }
71 claudeMD = strings.Join(lines, "\n")
72 }
73
74 // Load review rules
75 rules := loadReviewRules()
76
77 // Run AI review
78 result, err := runAIReview(context.Background(), diff, claudeMD, rules)
79 if err != nil {
80 fmt.Fprintln(os.Stderr, "Review failed:", err)
81 // Graceful degradation: don't block the pipeline because the AI failed
82 os.Exit(0)
83 }
84
85 // Output result
86 jsonOut, _ := json.MarshalIndent(result, "", " ")
87 fmt.Println(string(jsonOut))
88
89 // Exit code based on verdict
90 switch result.Verdict {
91 case "BLOCK":
92 os.Exit(2) // block PR
93 case "WARNING":
94 os.Exit(1) // warning, don't block
95 default:
96 os.Exit(0) // approved
97 }
98}
99
100func loadReviewRules() string {
101 // Load from file if it exists, fall back to embedded
102 if content, err := os.ReadFile("scripts/review-rules.md"); err == nil {
103 return string(content)
104 }
105 return defaultReviewRules // embedded default
106}
107
108func runAIReview(ctx context.Context, diff, claudeMD, rules string) (*ReviewResult, error) {
109 client := anthropic.NewClient(
110 option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
111 )
112
113 prompt := fmt.Sprintf(`You are a strict Go code reviewer for a production e-commerce service.
114
115PROJECT CONTEXT (from CLAUDE.md):
116%s
117
118REVIEW RULES:
119%s
120
121CODE DIFF TO REVIEW:
122%s
123
124Apply the review rules above. Output ONLY valid JSON:
125{
126 "score": 85,
127 "verdict": "APPROVED",
128 "critical": [
129 {"rule": "C1", "description": "...", "location": "file.go:45", "fix": "..."}
130 ],
131 "important": [],
132 "suggestions": []
133}
134
135Verdict: APPROVED=score>=85, WARNING=score 60-84, BLOCK=score<60`, claudeMD, rules, diff)
136
137 msg, err := client.Messages.New(ctx, anthropic.MessageNewParams{
138 Model: anthropic.F(anthropic.ModelClaudeHaiku4_5),
139 MaxTokens: anthropic.F(int64(1000)),
140 Messages: anthropic.F([]anthropic.MessageParam{
141 anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
142 }),
143 })
144 if err != nil {
145 return nil, fmt.Errorf("runAIReview: %w", err)
146 }
147
148 // Parse JSON from response
149 text := msg.Content[0].Text
150 // Strip markdown code blocks if present
151 text = strings.TrimPrefix(text, "```json")
152 text = strings.TrimSuffix(text, "```")
153 text = strings.TrimSpace(text)
154
155 var result ReviewResult
156 if err := json.Unmarshal([]byte(text), &result); err != nil {
157 return nil, fmt.Errorf("parse review result: %w (raw: %s)", err, text[:min(len(text), 200)])
158 }
159
160 return &result, nil
161}
162
163const defaultReviewRules = `
164CRITICAL (-15): C1=error wrap, C2=repo nil-nil, C3=monetary int64, C4=arch layer, C5=error ignored
165IMPORTANT (-7): I1=context, I2=nil check, I3=interface size
166SUGGESTION (-3): S1=godoc, S2=test coverage, S3=timeout
167`
168
169func min(a, b int) int {
170 if a < b {
171 return a
172 }
173 return b
174}Two design decisions stand out: graceful degradation (if the Claude API fails, the bot exits 0 instead of blocking the PR) and cost control (the diff is truncated at 50KB, CLAUDE.md capped at 150 lines). AI review is an enhancement, not a gate that should cause a false outage.
05.4 The Complete GitHub Actions Workflow
Once the bot is ready, we wire the three layers into a GitHub Actions workflow. The workflow below runs the free pre-filter, then AI review, then posts a structured comment and enforces the score gate.
1# .github/workflows/code-review.yml
2name: AI Code Review
3
4on:
5 pull_request:
6 types: [opened, synchronize, ready_for_review]
7 paths: ['**.go']
8
9env:
10 GO_VERSION: '1.22'
11 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
12
13jobs:
14 # LAYER 1: Fast pre-filter (no AI cost)
15 pre-filter:
16 name: Pre-filter Check
17 runs-on: ubuntu-latest
18 if: "!github.event.pull_request.draft"
19 outputs:
20 has_changes: ${{ steps.check.outputs.has_changes }}
21 diff_size: ${{ steps.check.outputs.diff_size }}
22 steps:
23 - uses: actions/checkout@v4
24 with:
25 fetch-depth: 0
26
27 - name: Check for Go changes
28 id: check
29 run: |
30 DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- '*.go')
31
32 if [ -z "$DIFF" ]; then
33 echo "has_changes=false" >> $GITHUB_OUTPUT
34 exit 0
35 fi
36
37 # Redact secrets from the diff
38 SAFE_DIFF=$(echo "$DIFF" | \
39 sed 's/sk-ant-[a-zA-Z0-9_-]*/sk-ant-REDACTED/g' | \
40 sed 's/ghp_[a-zA-Z0-9]*/ghp_REDACTED/g')
41
42 echo "$SAFE_DIFF" > /tmp/safe-diff.txt
43 DIFF_SIZE=$(wc -c < /tmp/safe-diff.txt)
44
45 echo "has_changes=true" >> $GITHUB_OUTPUT
46 echo "diff_size=$DIFF_SIZE" >> $GITHUB_OUTPUT
47 echo "Go changes detected: $DIFF_SIZE bytes"
48
49 - name: Quick pattern check (no AI)
50 if: steps.check.outputs.has_changes == 'true'
51 run: |
52 # Fast grep for obvious violations
53 VIOLATIONS=""
54
55 # Check float64 for monetary (quick win)
56 if grep -nE '(Price|Amount|Total|Cost|Fee|Salary)\s+float(32|64)' \
57 <(git diff origin/${{ github.base_ref }}...HEAD -- '*.go') 2>/dev/null; then
58 VIOLATIONS="$VIOLATIONS\n- CRITICAL: float64 detected for monetary field"
59 fi
60
61 # Check for _ ignoring errors in non-test files
62 if git diff origin/${{ github.base_ref }}...HEAD -- '*.go' \
63 | grep '^+' | grep -v '.*_test.go' \
64 | grep -E ',\s*_\s*:?=\s*(dao|repo|db|cache|svc)' > /dev/null 2>&1; then
65 VIOLATIONS="$VIOLATIONS\n- CRITICAL: error ignored with _ in non-test code"
66 fi
67
68 if [ -n "$VIOLATIONS" ]; then
69 echo "Quick scan found violations:"
70 echo -e "$VIOLATIONS"
71 echo "::warning::Quick scan violations found — AI review will detail these"
72 fi
73
74 # LAYER 2: AI Review Engine
75 ai-review:
76 name: AI Review Engine
77 runs-on: ubuntu-latest
78 needs: pre-filter
79 if: needs.pre-filter.outputs.has_changes == 'true'
80 permissions:
81 pull-requests: write
82 contents: read
83 steps:
84 - uses: actions/checkout@v4
85 with:
86 fetch-depth: 0
87 - uses: actions/setup-go@v5
88 with:
89 go-version: '${{ env.GO_VERSION }}'
90 cache: true
91
92 - name: Build review bot
93 run: |
94 go build -o /tmp/review-bot ./cmd/review-bot/
95
96 - name: Run AI Review
97 id: review
98 continue-on-error: true # graceful degradation
99 run: |
100 # Reconstruct safe diff
101 git diff origin/${{ github.base_ref }}...HEAD -- '*.go' \
102 | sed 's/sk-ant-[a-zA-Z0-9_-]*/REDACTED/g' > /tmp/safe-diff.txt
103
104 # Run review bot
105 REVIEW_JSON=$(/tmp/review-bot < /tmp/safe-diff.txt 2>/tmp/review-err.txt)
106 EXIT_CODE=$?
107
108 echo "review_json<<EOF" >> $GITHUB_OUTPUT
109 echo "$REVIEW_JSON" >> $GITHUB_OUTPUT
110 echo "EOF" >> $GITHUB_OUTPUT
111
112 # Extract key fields
113 SCORE=$(echo "$REVIEW_JSON" | jq '.score // 85' 2>/dev/null || echo "85")
114 VERDICT=$(echo "$REVIEW_JSON" | jq -r '.verdict // "APPROVED"' 2>/dev/null || echo "APPROVED")
115
116 echo "score=$SCORE" >> $GITHUB_OUTPUT
117 echo "verdict=$VERDICT" >> $GITHUB_OUTPUT
118 echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
119
120 echo "Review score: $SCORE, verdict: $VERDICT"
121
122 # LAYER 3: Result Processor
123 - name: Format and post review comment
124 uses: actions/github-script@v7
125 with:
126 script: |
127 const reviewJson = `${{ steps.review.outputs.review_json }}`;
128 const score = parseInt('${{ steps.review.outputs.score }}') || 85;
129 const verdict = '${{ steps.review.outputs.verdict }}' || 'APPROVED';
130
131 let review;
132 try {
133 review = JSON.parse(reviewJson);
134 } catch {
135 // AI failed to parse — graceful degradation
136 console.log('Could not parse review JSON, skipping comment');
137 return;
138 }
139
140 // Build comment markdown
141 const scoreEmoji = score >= 85 ? '🟢' : score >= 60 ? '🟡' : '🔴';
142 const verdictEmoji = verdict === 'APPROVED' ? '✅' : verdict === 'WARNING' ? '⚠️' : '❌';
143
144 const lines = [
145 `## 🤖 AI Code Review`,
146 ``,
147 `**Score:** ${scoreEmoji} ${score}/100 | **Verdict:** ${verdictEmoji} ${verdict}`,
148 ``
149 ];
150
151 if (review.critical?.length > 0) {
152 lines.push('### ❌ Critical Issues (must fix before merge)');
153 lines.push('');
154 for (const issue of review.critical) {
155 lines.push(`**${issue.rule}** at \`${issue.location}\``);
156 lines.push(`> ${issue.description}`);
157 lines.push(`> **Fix:** \`${issue.fix}\``);
158 lines.push('');
159 }
160 }
161
162 if (review.important?.length > 0) {
163 lines.push('### ⚠️ Important Issues (strongly recommended)');
164 lines.push('');
165 for (const issue of review.important) {
166 lines.push(`**${issue.rule}** at \`${issue.location}\``);
167 lines.push(`> ${issue.description}`);
168 lines.push('');
169 }
170 }
171
172 if (review.suggestions?.length > 0) {
173 lines.push('<details><summary>💡 Suggestions (optional improvements)</summary>');
174 lines.push('');
175 for (const s of review.suggestions) {
176 lines.push(`- **${s.rule}:** ${s.description}`);
177 }
178 lines.push('</details>');
179 lines.push('');
180 }
181
182 lines.push('---');
183 lines.push(`*AI Review by Claude claude-haiku-4-5 | Score threshold: 60 (block) / 85 (approved)*`);
184
185 const body = lines.join('\n');
186
187 // Update existing comment or create new
188 const { data: comments } = await github.rest.issues.listComments({
189 owner: context.repo.owner,
190 repo: context.repo.repo,
191 issue_number: context.issue.number
192 });
193
194 const existing = comments.find(c => c.body.includes('🤖 AI Code Review'));
195
196 if (existing) {
197 await github.rest.issues.updateComment({
198 owner: context.repo.owner,
199 repo: context.repo.repo,
200 comment_id: existing.id,
201 body
202 });
203 } else {
204 await github.rest.issues.createComment({
205 owner: context.repo.owner,
206 repo: context.repo.repo,
207 issue_number: context.issue.number,
208 body
209 });
210 }
211
212 - name: Enforce score gate
213 run: |
214 SCORE=${{ steps.review.outputs.score }}
215 VERDICT=${{ steps.review.outputs.verdict }}
216
217 echo "Final: Score=$SCORE, Verdict=$VERDICT"
218
219 # Phase 1: warning only (uncomment for Phase 2/3)
220 # if [ "$VERDICT" == "BLOCK" ]; then
221 # echo "::error::PR blocked by AI review (score $SCORE < 60)"
222 # exit 1
223 # fi
224
225 if [ "$VERDICT" == "BLOCK" ]; then
226 echo "::warning::AI review score $SCORE < 60 — consider fixing critical issues"
227 elif [ "$VERDICT" == "WARNING" ]; then
228 echo "::warning::AI review score $SCORE — some issues found"
229 else
230 echo "✅ AI review passed: $SCORE/100"
231 fiNotice that the “Enforce score gate” step is still in warning mode (the blocking line is commented out). This matches the graduated enforcement strategy: start permissive, tighten once the team is used to the output.
05.5 Copilot Review with Custom Instructions
For teams that prefer Copilot over a custom bot, GitHub Copilot can be configured via an instructions file. The file below defines the same review rules — CRITICAL, IMPORTANT, SUGGESTION — in a format Copilot reads natively.
1# .github/copilot-instructions.md
2# Version: 2.0 | Updated: 2026-07
3
4You are reviewing Go code for Santekno Shop — a production B2C e-commerce
5platform running on Go 1.22, Echo v4, pgx/v5, Redis, and Kafka.
6
7## CRITICAL RULES — Flag as CRITICAL if violated
8
9### C1: Error Wrapping
10Every error from external calls MUST be wrapped:
11```go
12// WRONG:
13return err
14return errors.New("database failed")
15
16// RIGHT:
17return fmt.Errorf("orderRepository.GetByID: %w", err)
18```
19
20### C2: Repository Not-Found Pattern
21When a record is not found, repositories MUST return (nil, nil):
22```go
23// WRONG:
24if err == pgx.ErrNoRows {
25 return nil, pgx.ErrNoRows
26}
27
28// RIGHT:
29if errors.Is(err, pgx.ErrNoRows) {
30 return nil, nil // not-found is not an error
31}
32```
33
34### C3: Monetary Types
35ALL monetary values MUST use int64 (representing cents):
36```go
37// WRONG:
38type Order struct {
39 TotalAmount float64
40}
41
42// RIGHT:
43type Order struct {
44 TotalAmountIDR int64 // 50000 = Rp 500.00
45}
46```
47
48### C4: Architecture Boundaries
49Layer import rules:
50- handler: ONLY import usecase interfaces (never impl or repo)
51- usecase: ONLY import domain types + repository interfaces
52- repository: ONLY import domain types + database drivers
53- domain: ZERO external imports (stdlib only)
54
55### C5: Error Ignoring
56NEVER ignore errors with _ in non-test code:
57```go
58// WRONG:
59result, _ := someFunc()
60
61// RIGHT:
62result, err := someFunc()
63if err != nil { return fmt.Errorf("...%w", err) }
64```
65
66## IMPORTANT RULES — Flag as IMPORTANT
67
68- I1: Context (ctx) must be first param and passed to all downstream calls
69- I2: Always nil-check after repository calls before using the result
70- I3: Interfaces should have <= 5 methods (split if larger)
71
72## SUGGESTIONS — Flag as SUGGESTION
73- S1: Exported functions/types/constants need godoc comments
74- S2: Error paths should have corresponding test cases
75- S3: External calls should use context with timeout
76
77## OUTPUT FORMAT
78For each issue: [CRITICAL|IMPORTANT|SUGGESTION] at file:line — brief explanation
79End with: REVIEW_SCORE: X/100The advantage of the Copilot approach is native integration with PR checks without additional infrastructure. The trade-off: you lose the full control over the model and scoring that a custom bot gives you — the choice depends on how deeply the team wants to tune the review.
05.6 Multi-Reviewer Pattern
For high-value PRs, we can run two independent reviewers and combine their scores. The workflow below runs Copilot and the custom Claude bot in parallel, then a third job aggregates the results into a single summary.
1# For high-value PRs: run multiple reviewers
2# and aggregate the results
3
4jobs:
5 # Reviewer 1: Copilot (built-in GitHub)
6 copilot-review:
7 name: Copilot Review
8 runs-on: ubuntu-latest
9 permissions:
10 pull-requests: write
11 contents: read
12 steps:
13 - uses: actions/checkout@v4
14 with: { fetch-depth: 0 }
15 - uses: github/copilot-for-pull-requests@v1
16 with:
17 github-token: ${{ secrets.GITHUB_TOKEN }}
18 review-instructions: |
19 Review per .github/copilot-instructions.md.
20 End with: REVIEW_SCORE: X/100
21
22 # Reviewer 2: Custom Claude Bot
23 claude-review:
24 name: Claude Review
25 runs-on: ubuntu-latest
26 permissions:
27 pull-requests: write
28 contents: read
29 env:
30 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
31 steps:
32 - uses: actions/checkout@v4
33 with: { fetch-depth: 0 }
34 - uses: actions/setup-go@v5
35 with: { go-version: '1.22' }
36 - run: go build -o /tmp/review-bot ./cmd/review-bot/
37 - run: |
38 git diff origin/${{ github.base_ref }}...HEAD -- '*.go' \
39 > /tmp/diff.txt
40 /tmp/review-bot < /tmp/diff.txt > /tmp/review.json || true
41
42 # Aggregator: combine scores and post summary
43 aggregate-reviews:
44 name: Aggregate Review Scores
45 runs-on: ubuntu-latest
46 needs: [copilot-review, claude-review]
47 if: always()
48 permissions:
49 pull-requests: write
50 steps:
51 - uses: actions/github-script@v7
52 with:
53 script: |
54 // Get all PR comments
55 const { data: comments } = await github.rest.issues.listComments({
56 owner: context.repo.owner,
57 repo: context.repo.repo,
58 issue_number: context.issue.number
59 });
60
61 // Extract scores from comments
62 const scorePattern = /REVIEW_SCORE:\s*(\d+)/;
63 const scores = comments
64 .map(c => c.body.match(scorePattern))
65 .filter(Boolean)
66 .map(m => parseInt(m[1]));
67
68 if (scores.length === 0) return;
69
70 const avg = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
71 const min = Math.min(...scores);
72
73 // Post summary comment
74 await github.rest.issues.createComment({
75 owner: context.repo.owner,
76 repo: context.repo.repo,
77 issue_number: context.issue.number,
78 body: [
79 `## 📊 Review Score Summary`,
80 ``,
81 `| Reviewer | Score |`,
82 `|----------|-------|`,
83 ...scores.map((s, i) => `| Reviewer ${i + 1} | ${s}/100 |`),
84 `| **Average** | **${avg}/100** |`,
85 `| **Minimum** | **${min}/100** |`,
86 ``,
87 min < 60 ? `❌ **Blocked:** minimum score ${min} < 60 threshold` :
88 avg < 80 ? `⚠️ **Warning:** average score ${avg}/100` :
89 `✅ **Approved:** all reviewers passed`,
90 ].join('\n')
91 });Two independent reviewers lower the risk of a false negative slipping past a single reviewer. Notice the aggregator uses the minimum for the block decision — one reviewer finding a serious problem is enough to hold the merge.
05.7 Review for Specific Files
Sometimes a per-file review is more useful than reviewing one big diff, especially for precise feedback. The script below iterates over each changed Go file, reviews them one at a time, and computes the average score.
1#!/bin/bash
2# scripts/review-changed-files.sh
3# Review only changed files, per file
4
5set -euo pipefail
6
7BASE_BRANCH="${1:-main}"
8OVERALL_SCORE=0
9FILE_COUNT=0
10
11# Get list of changed Go files (excluding generated)
12CHANGED_FILES=$(git diff origin/${BASE_BRANCH}...HEAD --name-only -- '*.go' \
13 | grep -v '_mock\|\.pb\.\|_gen\.' || true)
14
15if [ -z "$CHANGED_FILES" ]; then
16 echo "No relevant Go files changed"
17 exit 0
18fi
19
20for file in $CHANGED_FILES; do
21 if [ ! -f "$file" ]; then continue; fi
22
23 echo "Reviewing: $file..."
24
25 # Get file diff
26 FILE_DIFF=$(git diff origin/${BASE_BRANCH}...HEAD -- "$file")
27
28 # Get full file content for context
29 FILE_CONTENT=$(cat "$file" | head -100)
30
31 PROMPT="Review this Go file change for production code issues.
32
33FILE: $file
34CONTEXT (first 100 lines):
35$FILE_CONTENT
36
37DIFF:
38$FILE_DIFF
39
40Check: error wrapping, monetary types (int64 not float64),
41nil checks after repo calls, context propagation, architecture violations.
42
43Output JSON: {\"score\": 85, \"issues\": [{\"severity\": \"critical|important|suggestion\",
44\"line\": 45, \"description\": \"...\", \"fix\": \"...\"}]}"
45
46 RESULT=$(./scripts/claude-call.sh "$PROMPT" 500) || {
47 echo " Review failed for $file, skip"
48 continue
49 }
50
51 SCORE=$(echo "$RESULT" | jq '.score // 85' 2>/dev/null || echo "85")
52 ISSUES=$(echo "$RESULT" | jq '.issues | length' 2>/dev/null || echo "0")
53
54 echo " Score: $SCORE, Issues: $ISSUES"
55
56 OVERALL_SCORE=$((OVERALL_SCORE + SCORE))
57 FILE_COUNT=$((FILE_COUNT + 1))
58done
59
60if [ "$FILE_COUNT" -gt 0 ]; then
61 AVG_SCORE=$((OVERALL_SCORE / FILE_COUNT))
62 echo ""
63 echo "Average score: $AVG_SCORE/100 across $FILE_COUNT files"
64fiNotice the grep -v filter that excludes mock and generated files — reviewing machine-generated code just wastes tokens. The per-file approach is useful when you want inline comments attached to specific lines, rather than one big summary.
05.8 Handling Large PRs
A PR that touches dozens of files risks exceeding the context window and is expensive to review fully. The step below detects a large PR, skips the AI review, and replaces it with a manual checklist.
1# Strategy for very large PRs (> 50 files)
2
3- name: Check PR size
4 id: size_check
5 run: |
6 CHANGED=$(git diff origin/${{ github.base_ref }}...HEAD --name-only -- '*.go' | wc -l)
7 echo "changed_files=$CHANGED" >> $GITHUB_OUTPUT
8
9 if [ "$CHANGED" -gt 50 ]; then
10 echo "too_large=true" >> $GITHUB_OUTPUT
11 else
12 echo "too_large=false" >> $GITHUB_OUTPUT
13 fi
14
15- name: Large PR notice
16 if: steps.size_check.outputs.too_large == 'true'
17 uses: actions/github-script@v7
18 with:
19 script: |
20 await github.rest.issues.createComment({
21 owner: context.repo.owner,
22 repo: context.repo.repo,
23 issue_number: context.issue.number,
24 body: [
25 `## ⚠️ Large PR Detected`,
26 ``,
27 `This PR touches ${steps.size_check.outputs.changed_files} Go files.`,
28 `Full AI review skipped for cost efficiency.`,
29 ``,
30 `**Manual review checklist:**`,
31 `- [ ] Error handling: all errors wrapped with fmt.Errorf`,
32 `- [ ] Monetary types: all float64 → int64`,
33 `- [ ] Architecture: no cross-layer imports`,
34 `- [ ] Tests: coverage for error paths`,
35 ``,
36 `Consider splitting this PR into smaller, focused PRs.`
37 ].join('\n')
38 });
39
40- name: AI Review (skip if too large)
41 if: steps.size_check.outputs.too_large != 'true'
42 run: |
43 # Only review if the PR isn't too large
44 /tmp/review-bot --from-git ${{ github.base_ref }}The cost control here isn’t a limitation but a feature: a skipped large PR gets a nudge to be split into small PRs that are easier to review — both by AI and by humans.
05.9 Review History and Trending
To see whether code quality improves over time, every review result is saved as an artifact. The step below stores the score per commit with a 90-day retention.
1# Track review scores over time to see improvement
2
3- name: Save review score to artifact
4 if: always()
5 run: |
6 mkdir -p /tmp/review-history
7 cat > /tmp/review-history/score-${{ github.sha }}.json << EOF
8 {
9 "pr": ${{ github.event.number }},
10 "sha": "${{ github.sha }}",
11 "author": "${{ github.actor }}",
12 "score": ${{ steps.review.outputs.score }},
13 "verdict": "${{ steps.review.outputs.verdict }}",
14 "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
15 "base_branch": "${{ github.base_ref }}"
16 }
17 EOF
18
19- uses: actions/upload-artifact@v4
20 if: always()
21 with:
22 name: review-score-${{ github.sha }}
23 path: /tmp/review-history/
24 retention-days: 90
25
26# Scheduled job to aggregate monthly stats:
27# - Average score per developer
28# - Most common violation types
29# - Trending improvement/degradationThis historical data becomes objective material for a monthly retro: instead of debating “code quality is declining” subjectively, the team has concrete numbers per developer and per violation type.
05.10 Review Rules Versioning
Review rules will evolve as the team learns. Versioning the rules file lets us track changes and exempt older PRs from new rules. The example below shows a version header and changelog in the rules file.
1# scripts/review-rules.md
2# Version: 1.2 | Updated: 2026-07
3
4## Changelog
5# v1.2: Added I3 (interface size limit)
6# v1.1: Relaxed S2 (godoc) from IMPORTANT to SUGGESTION
7# v1.0: Initial rules
8
9## Critical Rules
10[... rules content ...]
11
12# Use the version in the prompt:
13PROMPT="Using review rules version $(grep 'Version:' scripts/review-rules.md | head -1)..."This version can also be verified in CI so no stale rules get used silently. The step below prints the currently active rules version.
1# Verify rules version in CI
2- name: Check review rules version
3 run: |
4 VERSION=$(grep 'Version:' scripts/review-rules.md | head -1 | grep -oP '\d+\.\d+')
5 echo "Using review rules v$VERSION"
6 echo "::notice::Review rules version: $VERSION"With an explicit version announced to the team, rule changes don’t surprise anyone — a PR opened before the update can be given a one-sprint grace period before the new rules fully apply.
05.11 Integration with PR Status Checks
For the review to genuinely be a gate, it must be registered as a required status check in branch protection. The configuration below shows how to match the job name with the required check and handle PRs from forks.
1# Add AI review as a required status check:
2
3# 1. In GitHub: Settings → Branches → Branch protection rules
4# → Require status checks → add "AI Review Engine"
5
6# 2. The required check name must match the job name:
7jobs:
8 ai-review:
9 name: "AI Review Engine" # ← this is what gets required
10
11# 3. For PRs from forks (no secrets):
12jobs:
13 ai-review:
14 if: github.event.pull_request.head.repo.full_name == github.repository
15 # Skip review for fork PRs that don't have ANTHROPIC_API_KEYTwo details are easy to miss: the job name must exactly match the required check name, and fork PRs don’t have access to secrets so they must be skipped to avoid false failures. Handling both from the start avoids a stuck pipeline.
05.12 Debugging Review Output
Before relying on the bot, we need to verify it actually catches the violations it should. The script below creates a file with deliberate violations, runs the bot, and compares the output to expectations.
1# Debug the review bot locally
2
3# 1. Test with a sample diff containing violations
4cat > /tmp/test-violation.go << 'GOTEST'
5package usecase
6
7import "errors"
8
9type Order struct {
10 Price float64 // VIOLATION: should be int64
11}
12
13func GetOrder() (*Order, error) {
14 order, err := repository.Get()
15 return order, err // VIOLATION: error not wrapped
16}
17GOTEST
18
19# Create fake diff
20git diff HEAD /dev/null -- /tmp/test-violation.go > /tmp/test.diff
21
22# Run review bot
23export ANTHROPIC_API_KEY="sk-ant-xxx"
24go run cmd/review-bot/main.go < /tmp/test.diff
25
26# Expected output:
27# {
28# "score": 60,
29# "verdict": "WARNING",
30# "critical": [
31# {"rule": "C3", "description": "Price field uses float64", ...},
32# {"rule": "C1", "description": "error not wrapped", ...}
33# ],
34# ...
35# }
36
37# 2. Test false positive: code that should pass
38git diff HEAD~1 HEAD -- internal/usecase/order/cancel_order.go > /tmp/good.diff
39go run cmd/review-bot/main.go < /tmp/good.diff
40# Expected: score >= 85, verdict APPROVEDThis two-way test matters: one ensures the bot catches violations (true positive), the other ensures the bot doesn’t flag clean code (false positive). Run both every time the rules change.
05.13 Review for Test Files
Test files have different rules from production code — something acceptable in a test can be a violation in production. The prompt below gives context so the AI applies the appropriate rules per file type.
1# In the review prompt, add context:
2PROMPT="Review Go code.
3NOTE: Test files (*_test.go) have different rules:
4- OK to use _ for ignoring errors in test setup
5- OK to use t.Fatal() instead of fmt.Errorf
6- OK to have direct struct initialization (no constructor)
7- NOT OK: float64 for monetary even in tests
8- NOT OK: wrong error types in test assertions
9
10Diff includes test files — apply appropriate rules per file type."Without this context, the bot will flood test files with false positives — for instance flagging _ ignoring an error in test setup, which is a reasonable practice. Differentiating rules per file type keeps the review relevant.
05.14 Performance: Parallel Review for Batches
For a monorepo with many services, reviewing one at a time is too slow. The Go function below runs reviews in parallel with a semaphore to limit the number of concurrent AI calls for cost control.
1// For a monorepo with many services: parallel review
2
3func reviewAllServices(ctx context.Context, services []string) []ReviewResult {
4 results := make([]ReviewResult, len(services))
5
6 var wg sync.WaitGroup
7 // Semaphore to limit concurrent AI calls (cost control)
8 sem := make(chan struct{}, 3) // max 3 parallel reviews
9
10 for i, service := range services {
11 wg.Add(1)
12 go func(idx int, svc string) {
13 defer wg.Done()
14 sem <- struct{}{}
15 defer func() { <-sem }()
16
17 diff := getServiceDiff(svc)
18 result, err := runAIReview(ctx, diff, "", defaultReviewRules)
19 if err != nil {
20 results[idx] = ReviewResult{Score: 85, Verdict: "APPROVED"} // graceful
21 return
22 }
23 results[idx] = *result
24 }(i, service)
25 }
26
27 wg.Wait()
28 return results
29}The semaphore sem := make(chan struct{}, 3) is the key: it caps the maximum at three reviews running simultaneously, balancing speed against cost and API rate limits. This pattern is idiomatic in Go for limiting concurrency without an external library.
05.15 Review Metrics Dashboard
The stored score artifacts need to be processed into insight. The Go tool below reads the review artifacts, groups by author, and prints average statistics along with the overall trend.
1// cmd/review-metrics/main.go
2// Aggregate review scores from GitHub Actions artifacts
3
4package main
5
6import (
7 "encoding/json"
8 "fmt"
9 "os"
10 "path/filepath"
11 "sort"
12)
13
14type ScoreRecord struct {
15 PR int `json:"pr"`
16 Author string `json:"author"`
17 Score int `json:"score"`
18 Verdict string `json:"verdict"`
19 Timestamp string `json:"timestamp"`
20}
21
22func main() {
23 artifactDir := os.Args[1] // path to downloaded artifacts
24
25 var records []ScoreRecord
26
27 filepath.Walk(artifactDir, func(path string, info os.FileInfo, err error) error {
28 if !info.IsDir() && filepath.Ext(path) == ".json" {
29 data, _ := os.ReadFile(path)
30 var r ScoreRecord
31 if json.Unmarshal(data, &r) == nil {
32 records = append(records, r)
33 }
34 }
35 return nil
36 })
37
38 // Group by author
39 byAuthor := make(map[string][]int)
40 for _, r := range records {
41 byAuthor[r.Author] = append(byAuthor[r.Author], r.Score)
42 }
43
44 fmt.Println("=== Review Score Metrics ===")
45 fmt.Println()
46
47 type AuthorStats struct {
48 Name string
49 Average float64
50 Min int
51 PRCount int
52 }
53
54 var stats []AuthorStats
55 for author, scores := range byAuthor {
56 sum := 0
57 minScore := 100
58 for _, s := range scores {
59 sum += s
60 if s < minScore {
61 minScore = s
62 }
63 }
64 stats = append(stats, AuthorStats{
65 Name: author,
66 Average: float64(sum) / float64(len(scores)),
67 Min: minScore,
68 PRCount: len(scores),
69 })
70 }
71
72 sort.Slice(stats, func(i, j int) bool {
73 return stats[i].Average > stats[j].Average
74 })
75
76 for _, s := range stats {
77 fmt.Printf("%-20s avg: %.1f/100 min: %d PRs: %d\n",
78 s.Name, s.Average, s.Min, s.PRCount)
79 }
80
81 // Overall trend
82 if len(records) >= 2 {
83 first := records[0].Score
84 last := records[len(records)-1].Score
85 trend := last - first
86 symbol := "📈"
87 if trend < 0 {
88 symbol = "📉"
89 }
90 fmt.Printf("\nOverall trend: %s %+d points (%d → %d)\n",
91 symbol, trend, first, last)
92 }
93}This simple dashboard turns raw data into a useful conversation: who consistently scores high, who might need support, and whether the team’s trend is improving. Importantly, this is material for constructive discussion, not a tool for judgment.
05.16 Review for Database Query Changes
Changes that touch SQL carry special risks that deserve a separate review. The prompt below focuses the analysis on five categories of query problems: injection, N+1, missing index, unbounded query, and transaction.
1# Special review for changes that touch SQL:
2
3PROMPT="Review Go code that contains database query changes.
4
5Special focus for query changes:
61. SQL INJECTION: is there string concatenation in the query?
7 WRONG: fmt.Sprintf('SELECT * FROM orders WHERE id = ' + userInput)
8 RIGHT: pool.QueryRow(ctx, 'SELECT ... WHERE id = \$1', userInput)
9
102. N+1 QUERY: is there a query inside a loop?
11 WRONG: for _, order := range orders { db.Get(order.UserID) }
12 RIGHT: batch query or JOIN
13
143. MISSING INDEX: is there a filter on a column that might not have an index?
15 Warning: large table filter by non-PK column
16
174. UNBOUNDED QUERY: SELECT without LIMIT for a potentially large table?
18 WRONG: 'SELECT * FROM orders WHERE user_id = \$1'
19 RIGHT: 'SELECT * FROM orders WHERE user_id = \$1 LIMIT \$2 OFFSET \$3'
20
215. TRANSACTION USAGE: are atomic operations already in a transaction?
22 Multi-table update that must be atomic: transaction required
23
24Diff:
25\${DIFF}"This special query review catches a class of bugs that often slips past generic review — especially N+1 and unbounded queries that don’t look dangerous in a small PR but blow up when production data grows.
05.17 Feedback Loop: From Review to CLAUDE.md Update
A good review system keeps learning from the patterns that emerge. The flow diagram below describes the continuous improvement cycle: from sprint retro, to monthly analytics, to rule versioning.
1Workflow for continuously improving review rules:
2
3Sprint End Review (15 minutes):
4 Questions:
5 1. What was most frequently flagged by AI review this month?
6 2. Are there annoying false positives?
7 3. Are there new patterns that should be added to the rules?
8
9 Action:
10 → Update .github/copilot-instructions.md
11 → Update cmd/review-bot/ rules
12 → Update CLAUDE.md if relevant
13
14Monthly Analytics (from the metrics dashboard):
15 → Average score per developer
16 → Most common violation types
17 → Trending: improving or degrading?
18 → PRs consistently scoring low → need mentoring?
19
20Version control rules:
21 → Every update to review rules = a semver bump
22 → CHANGELOG in scripts/review-rules.md
23 → Announce to the team via SlackThis loop makes the review rules a living document, not a static set of rules that quickly goes stale. Frequently flagged patterns become candidates for CLAUDE.md; annoying false positives become candidates for an explicit exclusion.
05.18 Tips & Gotchas
💡 Tip 1: Graceful degradation. If AI review fails (API down, timeout), don’t block the PR. continue-on-error: true and log to the step summary, but don’t fail the job.
💡 Tip 2: Update the comment, don’t add. Each new push to the PR updates the same review comment — no stacking of confusing comments.
💡 Tip 3: A clear score vs verbose. One line of score at the top of the comment is more useful than 50 lines of explanation. A developer needs to know pass/fail in 3 seconds.
💡 Tip 4: Collapsible suggestions. Use the <details> HTML tag for suggestions — so the PR doesn’t look cluttered but the information is still there.
⚠️ Gotcha 1: Don’t review generated files. Mock files, protobuf-generated code, and the vendor folder must be excluded from the diff before it’s sent to the AI.
⚠️ Gotcha 2: Token limit. A very large diff can exceed the context window. Implement chunking or truncation with an explicit [truncated] marker.
⚠️ Gotcha 3: A review that’s too lenient. If every PR gets a score of 90+, the rules need to be tightened. Effective review rules produce meaningful variance.
05.19 Article 05 Summary
We’ve built a production-ready automated code review system:
Custom review bot (cmd/review-bot/) with explicit rules, JSON output, and objective scoring.
GitHub Actions workflow with 3 layers: pre-filter (zero cost) → AI review → result processor.
Copilot integration as an alternative via a comprehensive .github/copilot-instructions.md.
Graduated enforcement: from continue-on-error: true to hard block.
The key to success is starting permissive and tightening gradually — teams that apply hard enforcement immediately tend to look for ways to bypass it, while teams that prove the value first achieve genuine adoption.
05.20 Automated Review as a Team Standard
Consistent automated review is more valuable than sporadic manual review. With the system built in this article, every PR gets the same review — not dependent on reviewer availability, not dependent on working hours or energy levels.
A realistic 30-day adoption path:
- Week 1: Set up Copilot instructions, enable them on draft PRs first, warning-only mode.
- Week 2: Enable for all non-draft PRs, tune the false positive rate.
- Week 3-4: Enforce CRITICAL issues, update review rules based on real patterns.
- Month 2: Measure the improvement in PR cycle time, present the results to the team.
With good review rules and a comprehensive context file, an AI review score consistently above 85 is a realistic target within a month. In the next article, we add a deeper security layer: automated security audit that scans dependencies and code for vulnerabilities.