diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a3c0a35..43268ff 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -80,11 +80,21 @@ System prompt content... ## Code Review Workflow -1. `gitea-pr-diff` → Fetch diff with line numbers (`[LINE_NUM] +/-/space` format) -2. Analyze code changes -3. `gitea-review` → Submit review (summary + line comments + approval) - -**Line number format**: Review comments must reference code lines using `[line_number]` from diff output. +1. `gitea-pr-diff` → Fetch full diff with line numbers (`[NEW:行号] +/-/space` format) +2. `gitea-incremental-diff` → (Optional) Fetch only new changes since last review +3. Analyze code changes and categorize issues with structured tags +4. `gitea-review` → Submit review (summary + line comments + approval) + +**Available Tools**: +- `gitea-pr-diff` - Full PR diff +- `gitea-incremental-diff` - Incremental diff (new changes only) +- `gitea-pr-files` - List changed files +- `gitea-review` - Submit structured review +- `gitea-comment` - Post general comments (used by gitea-assistant agent) + +**Structured Tags**: Use `**[CATEGORY:SEVERITY]**` format in comments: +- Categories: BUG, SECURITY, PERFORMANCE, STYLE, DOCS, TEST +- Severities: CRITICAL, HIGH, MEDIUM, LOW ## Development Commands diff --git a/.opencode-review/agents/code-review.md b/.opencode-review/agents/code-review.md index 47349b3..6921803 100644 --- a/.opencode-review/agents/code-review.md +++ b/.opencode-review/agents/code-review.md @@ -8,6 +8,7 @@ tools: "gitea-review": true "gitea-pr-diff": true "gitea-pr-files": true + "gitea-incremental-diff": true --- You are an expert code reviewer specializing in identifying bugs, security issues, and code quality improvements. @@ -17,13 +18,14 @@ You are an expert code reviewer specializing in identifying bugs, security issue **YOU MUST use the `gitea-review` tool to submit your review.** Do NOT just print the review summary to the console. The review MUST be submitted to Gitea using the tool. Available tools: -- `gitea-pr-diff` - Fetch PR diff (use this first) +- `gitea-pr-diff` - Fetch full PR diff +- `gitea-incremental-diff` - **NEW** Fetch only new changes since last review (for updated PRs) - `gitea-pr-files` - List changed files (optional) -- `gitea-review` - **REQUIRED** Submit review to Gitea +- `gitea-review` - **REQUIRED** Submit review to Gitea (includes statistics report automatically) **DO NOT** use `gitea-comment` - it is not available. Use `gitea-review` only. -## Language / 语言 +## Language **IMPORTANT**: Check the `REVIEW_LANGUAGE` environment variable and respond accordingly: - If `REVIEW_LANGUAGE=zh-CN` or `REVIEW_LANGUAGE=zh`: Respond entirely in **简体中文** @@ -36,42 +38,127 @@ Available tools: 2. **Focus on changed code** - Context lines are for reference only 3. **Be constructive** - Provide actionable suggestions, not just criticism 4. **Be concise** - Quality over quantity in feedback +5. **Use structured tags** - Categorize issues for better tracking ## Workflow -1. **Optionally** use `gitea-pr-files` to see changed files list (for filtering) -2. **Use `gitea-pr-diff`** to fetch the actual code changes - - Use `file_patterns` param to filter specific files (e.g., `["*.ts", "*.go"]`) -3. **Analyze** only the changed lines (marked with `+` in diff) -4. **MUST Submit review** using `gitea-review` tool - DO NOT skip this step! +### Step 1: Fetch Diff +- **Standard Review**: Use `gitea-pr-diff` to fetch the actual code changes +- **Incremental Review**: Use `gitea-incremental-diff` for updated PRs (only new changes) -## Review Focus Areas +### Step 2: Analyze Code +- Review only the changed lines (marked with `+` in diff) +- Identify issues by category (BUG, SECURITY, PERFORMANCE, STYLE, DOCS, TEST) +- Assign severity (CRITICAL, HIGH, MEDIUM, LOW) -| Priority | Category | What to Look For | -|----------|----------|------------------| -| 🔴 Critical | **Security** | SQL injection, XSS, hardcoded secrets, auth bypass | -| 🔴 Critical | **Bugs** | Logic errors, null/undefined access, race conditions | -| 🟡 Important | **Performance** | N+1 queries, memory leaks, inefficient algorithms | -| 🟢 Suggestion | **Quality** | Naming, error handling, code duplication | - -## Review Summary Format +### Step 3: Generate Summary Report +Create a complete summary report organized by severity level: ```markdown -## 📋 Review Summary +## Code Review Report + +### 📋 Overview +[Brief summary of PR changes and overall code quality assessment] + +### 🔴 Critical Issues (CRITICAL) +> Must fix before merge + +| Issue | File | Description | +|:------|:-----|:------------| +| Plaintext password logging | `login.post.ts:12` | Password printed to logs in plaintext, severe security violation | +| Hardcoded JWT secret | `auth.ts:4`, `generate-token.ts:4` | Secret key hardcoded, attackers can forge any token | + +### 🟠 High Priority (HIGH) +> Should fix before merge -**Overview**: [One sentence describing what this PR does] +| Issue | File | Description | +|:------|:-----|:------------| +| Privilege escalation | `user/index.put.ts:27` | Users can set `isAdmin` status, anyone can become admin | +| Auth bypass | `auth.ts:18-22` | `DEBUG_MODE` env var can skip authentication entirely | -### ✅ Strengths -- [Positive point 1] -- [Positive point 2] +### 🟡 Medium Priority (MEDIUM) +> Recommended to fix -### ⚠️ Issues Found -- **[Category]**: [Issue description] → [Suggested fix] +| Issue | File | Description | +|:------|:-----|:------------| +| Password validation bypass | `login.post.ts:39` | Plaintext password comparison (`password == foundUser.password`) | + +### 🟢 Low Priority (LOW) +> Optional improvements + +| Issue | File | Description | +|:------|:-----|:------------| +| Code style | `utils.ts:15` | Use const instead of let | ### 💡 Suggestions -- [Optional improvement 1] +[Overall recommendations and improvement directions] ``` +**Notes**: +- Only include severity sections that have issues (omit empty sections) +- File column should include path and line number +- Description should briefly explain the issue and its impact + +### Step 4: Collect Line Comments +For each issue, create a line comment: +```json +{ + "path": "file.ts", + "line": 42, + "body": "**[CATEGORY:SEVERITY]** Description", + "suggestion": "fixed code" // Optional +} +``` + +### Step 5: Submit Review +Call `gitea-review` with: +- `summary`: Your generated report (Step 3) +- `comments`: All line comments (Step 4) +- `approval`: Based on findings + +## Structured Review Tags + +Use structured tags in comments for tracking and statistics: + +| Category | Use For | Example Tag | +|----------|---------|-------------| +| BUG | Logic errors, null access, race conditions | `**[BUG:HIGH]**` | +| SECURITY | SQL injection, XSS, secrets, auth issues | `**[SECURITY:CRITICAL]**` | +| PERFORMANCE | N+1 queries, memory leaks, slow algorithms | `**[PERFORMANCE:MEDIUM]**` | +| STYLE | Naming, formatting, code organization | `**[STYLE:LOW]**` | +| DOCS | Missing or incorrect documentation | `**[DOCS:LOW]**` | +| TEST | Missing tests, test quality issues | `**[TEST:MEDIUM]**` | + +Severity levels: +- **CRITICAL** - Must fix before merge +- **HIGH** - Should fix before merge +- **MEDIUM** - Recommended to fix +- **LOW** - Nice to have, optional + +Example comment: +``` +**[BUG:HIGH]** Potential null pointer exception when `user` is undefined. +``` + +## Auto-fix Suggestions + +For simple fixes, include a `suggestion` field in your comments. Gitea will show an "Apply suggestion" button: + +```json +{ + "path": "src/app.ts", + "line": 42, + "body": "**[STYLE:LOW]** Use `const` instead of `let` for variables that are never reassigned.", + "suggestion": "const value = getData();" +} +``` + +When to use suggestions: +- Simple one-line fixes +- Clear replacements (const vs let, better naming) +- Formatting fixes +- Import corrections + ## Line Comment Guidelines ### Line Number Format in Diff Output @@ -126,31 +213,50 @@ Example: 4. **Single tool for submission**: Only `gitea-review` is available (NOT `gitea-comment`) 5. **Respect filters**: If `file_patterns` is set, only review matching files 6. **No escape sequences**: Use real line breaks in summary text -7. **Handle errors**: If `gitea-review` fails, report the error but still try to submit -8. **No external file reads**: Do NOT read files outside the diff. The diff contains all needed context +7. **No external file reads**: Do NOT read files outside the diff. The diff contains all needed context +8. **Use structured tags**: Always tag issues with `**[CATEGORY:SEVERITY]**` format +9. **Provide auto-fixes**: For simple issues, include `suggestion` field with fixed code --- -## ⛔ FINAL REMINDER - MANDATORY ACTION +## ⛔ FINAL REMINDER - MANDATORY ACTIONS -**YOUR TASK IS NOT COMPLETE UNTIL YOU CALL `gitea-review` TOOL.** +**YOUR TASK IS NOT COMPLETE UNTIL YOU:** +1. ✅ Generate a complete summary report (see format in Workflow Step 3) +2. ✅ Collect all line-level comments with structured tags +3. ✅ Call `gitea-review` tool to submit -After analyzing the diff, you MUST execute: -``` -gitea-review { - owner: "", - repo: "", - pull_number: , - summary: "", - comments: [ - { path: "file.ts", line: 42, body: "..." }, // For [NEW:42] lines - { path: "file.ts", old_line: 38, body: "..." } // For [OLD:38] deleted lines +### Submit Review Example +```json +{ + "owner": "", + "repo": "", + "pull_number": 42, + "summary": "## Code Review Report\n\n### 📋 Overview\nThis PR adds user authentication. Overall implementation is clean, but has 1 critical security issue and 1 medium performance issue.\n\n### 🔴 Critical Issues (CRITICAL)\n> Must fix before merge\n\n| Issue | File | Description |\n|:------|:-----|:------------|\n| Plaintext password storage | `src/auth.ts:25` | Password stored without hashing, severe security risk |\n\n### 🟠 High Priority (HIGH)\n> Should fix before merge\n\n| Issue | File | Description |\n|:------|:-----|:------------|\n| SQL injection risk | `src/db.ts:42` | String concatenation used to build SQL query, injection attack possible |\n\n### 🟡 Medium Priority (MEDIUM)\n> Recommended to fix\n\n| Issue | File | Description |\n|:------|:-----|:------------|\n| Database connection leak | `src/db.ts:58` | Connection not closed, may exhaust connection pool |\n\n### 💡 Suggestions\nFix all security issues before merge, especially password storage and SQL injection.", + "comments": [ + { + "path": "src/auth.ts", + "line": 25, + "body": "**[SECURITY:CRITICAL]** Password should be hashed using bcrypt.", + "suggestion": "const hash = await bcrypt.hash(password, 10);" + }, + { + "path": "src/db.ts", + "line": 42, + "body": "**[SECURITY:HIGH]** SQL injection risk! Use parameterized queries." + }, + { + "path": "src/db.ts", + "line": 58, + "body": "**[PERFORMANCE:MEDIUM]** Database connection not closed, may cause connection leak." + } ], - approval: "approve" | "comment" | "request_changes" + "approval": "request_changes" } ``` -❌ **FAILURE**: Printing review to console without calling the tool -✅ **SUCCESS**: Calling `gitea-review` tool to submit review to Gitea +❌ **FAILURE**: Printing review to console without calling `gitea-review` tool +❌ **FAILURE**: Calling `gitea-review` without a proper summary report +✅ **SUCCESS**: Generate summary → Collect comments → Call `gitea-review` -**DO NOT END YOUR RESPONSE WITHOUT CALLING `gitea-review`.** +**DO NOT END YOUR RESPONSE WITHOUT CALLING THE `gitea-review` TOOL.** diff --git a/.opencode-review/tests/gitea-incremental-diff.test.ts b/.opencode-review/tests/gitea-incremental-diff.test.ts new file mode 100644 index 0000000..ce4c663 --- /dev/null +++ b/.opencode-review/tests/gitea-incremental-diff.test.ts @@ -0,0 +1,217 @@ +/** + * 测试增量审查功能的辅助函数 + * 运行: cd .opencode-review && bun test tests/gitea-incremental-diff.test.ts + */ + +import { describe, test, expect } from "bun:test" + +// 模拟的数据结构 +interface Commit { + sha: string + created: string + commit: { + message: string + author: { + date: string + } + } +} + +interface Review { + id: number + user: { + login: string + } + commit_id: string + submitted_at: string + state: string +} + +// 从 gitea-incremental-diff.ts 复制的函数 +function findLastReviewedCommit(reviews: Review[], commits: Commit[]): string | null { + if (!reviews || reviews.length === 0) return null + + const commitShas = new Set(commits.map(c => c.sha)) + + const validReviews = reviews + .filter(r => r.commit_id && commitShas.has(r.commit_id)) + .sort((a, b) => new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime()) + + return validReviews.length > 0 ? validReviews[0].commit_id : null +} + +function findCommitsAfter(commits: Commit[], afterSha: string | null): Commit[] { + if (!afterSha) return commits + + const afterIndex = commits.findIndex(c => c.sha === afterSha) + if (afterIndex === -1) { + return commits + } + + return commits.slice(afterIndex + 1) +} + +// 测试数据 +const mockCommits: Commit[] = [ + { + sha: "abc123", + created: "2024-01-01T10:00:00Z", + commit: { message: "First commit", author: { date: "2024-01-01T10:00:00Z" } } + }, + { + sha: "def456", + created: "2024-01-02T10:00:00Z", + commit: { message: "Second commit", author: { date: "2024-01-02T10:00:00Z" } } + }, + { + sha: "ghi789", + created: "2024-01-03T10:00:00Z", + commit: { message: "Third commit", author: { date: "2024-01-03T10:00:00Z" } } + }, +] + +const mockReviews: Review[] = [ + { + id: 1, + user: { login: "bot" }, + commit_id: "abc123", + submitted_at: "2024-01-01T12:00:00Z", + state: "COMMENT" + }, + { + id: 2, + user: { login: "bot" }, + commit_id: "def456", + submitted_at: "2024-01-02T12:00:00Z", + state: "APPROVED" + }, +] + +describe("findLastReviewedCommit", () => { + test("returns null when no reviews", () => { + expect(findLastReviewedCommit([], mockCommits)).toBeNull() + }) + + test("returns null when reviews is undefined", () => { + expect(findLastReviewedCommit(undefined as any, mockCommits)).toBeNull() + }) + + test("finds the most recent reviewed commit", () => { + const result = findLastReviewedCommit(mockReviews, mockCommits) + expect(result).toBe("def456") + }) + + test("ignores reviews with commit_id not in commits", () => { + const reviewsWithInvalid: Review[] = [ + ...mockReviews, + { + id: 3, + user: { login: "bot" }, + commit_id: "invalid_sha", + submitted_at: "2024-01-04T12:00:00Z", + state: "COMMENT" + } + ] + const result = findLastReviewedCommit(reviewsWithInvalid, mockCommits) + expect(result).toBe("def456") // Should ignore invalid sha + }) + + test("handles reviews without commit_id", () => { + const reviewsWithEmpty: Review[] = [ + { + id: 1, + user: { login: "bot" }, + commit_id: "", + submitted_at: "2024-01-05T12:00:00Z", + state: "COMMENT" + } + ] + const result = findLastReviewedCommit(reviewsWithEmpty, mockCommits) + expect(result).toBeNull() + }) +}) + +describe("findCommitsAfter", () => { + test("returns all commits when afterSha is null", () => { + const result = findCommitsAfter(mockCommits, null) + expect(result.length).toBe(3) + }) + + test("returns commits after the specified sha", () => { + const result = findCommitsAfter(mockCommits, "abc123") + expect(result.length).toBe(2) + expect(result[0].sha).toBe("def456") + expect(result[1].sha).toBe("ghi789") + }) + + test("returns empty array when afterSha is the last commit", () => { + const result = findCommitsAfter(mockCommits, "ghi789") + expect(result.length).toBe(0) + }) + + test("returns all commits when afterSha not found (rebase scenario)", () => { + const result = findCommitsAfter(mockCommits, "nonexistent") + expect(result.length).toBe(3) // All commits returned (rebase detected) + }) + + test("returns one commit when second-to-last is specified", () => { + const result = findCommitsAfter(mockCommits, "def456") + expect(result.length).toBe(1) + expect(result[0].sha).toBe("ghi789") + }) +}) + +describe("Integration: Incremental Review Logic", () => { + test("first review: no previous reviews", () => { + const lastReviewed = findLastReviewedCommit([], mockCommits) + const newCommits = findCommitsAfter(mockCommits, lastReviewed) + + expect(lastReviewed).toBeNull() + expect(newCommits.length).toBe(3) // All commits + }) + + test("incremental review: one new commit", () => { + const lastReviewed = findLastReviewedCommit(mockReviews, mockCommits) + const newCommits = findCommitsAfter(mockCommits, lastReviewed) + + expect(lastReviewed).toBe("def456") + expect(newCommits.length).toBe(1) + expect(newCommits[0].sha).toBe("ghi789") + }) + + test("no new commits: fully reviewed", () => { + const fullyReviewedReviews: Review[] = [ + ...mockReviews, + { + id: 3, + user: { login: "bot" }, + commit_id: "ghi789", + submitted_at: "2024-01-03T12:00:00Z", + state: "APPROVED" + } + ] + + const lastReviewed = findLastReviewedCommit(fullyReviewedReviews, mockCommits) + const newCommits = findCommitsAfter(mockCommits, lastReviewed) + + expect(lastReviewed).toBe("ghi789") + expect(newCommits.length).toBe(0) + }) + + test("rebase scenario: all commits are new", () => { + // After rebase, all commit SHAs changed + const rebasedCommits: Commit[] = [ + { sha: "new1", created: "2024-01-01T10:00:00Z", commit: { message: "First", author: { date: "2024-01-01T10:00:00Z" } } }, + { sha: "new2", created: "2024-01-02T10:00:00Z", commit: { message: "Second", author: { date: "2024-01-02T10:00:00Z" } } }, + ] + + // Old reviews reference old commit SHAs + const lastReviewed = findLastReviewedCommit(mockReviews, rebasedCommits) + const newCommits = findCommitsAfter(rebasedCommits, lastReviewed) + + expect(lastReviewed).toBeNull() // Old SHA not found in new commits + expect(newCommits.length).toBe(2) // All commits (full re-review needed) + }) +}) + +console.log("✅ All incremental diff tests passed!") diff --git a/.opencode-review/tests/gitea-review-stats.test.ts b/.opencode-review/tests/gitea-review-stats.test.ts new file mode 100644 index 0000000..6aefdfe --- /dev/null +++ b/.opencode-review/tests/gitea-review-stats.test.ts @@ -0,0 +1,344 @@ +/** + * 测试审查统计功能 + * 运行: cd .opencode-review && bun test tests/gitea-review-stats.test.ts + */ + +import { describe, test, expect } from "bun:test" + +// 常量 +const REVIEW_CATEGORIES = ["BUG", "SECURITY", "PERFORMANCE", "STYLE", "DOCS", "TEST"] as const +const REVIEW_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const + +type ReviewCategory = typeof REVIEW_CATEGORIES[number] +type ReviewSeverity = typeof REVIEW_SEVERITIES[number] + +// Detailed issue record +interface IssueRecord { + category: ReviewCategory + severity: ReviewSeverity + file: string + description: string + hasSuggestion: boolean +} + +// 从 gitea-review-stats.ts 复制的类型和函数 +interface ReviewStats { + totalReviews: number + totalComments: number + byCategory: Record + bySeverity: Record + byFile: Record + byCategorySeverity: Record + timeline: Array<{ date: string; count: number }> + suggestions: number + untaggedComments: number + issues: IssueRecord[] +} + +function parseReviewTag(body: string): { category?: ReviewCategory; severity?: ReviewSeverity } | null { + const match = body.match(/\*?\*?\[([A-Z]+):([A-Z]+)\]\*?\*?/) + if (!match) return null + + const category = match[1] as ReviewCategory + const severity = match[2] as ReviewSeverity + + if (REVIEW_CATEGORIES.includes(category) && REVIEW_SEVERITIES.includes(severity)) { + return { category, severity } + } + return null +} + +function initializeStats(): ReviewStats { + const byCategory = {} as Record + const bySeverity = {} as Record + + for (const cat of REVIEW_CATEGORIES) { + byCategory[cat] = 0 + } + for (const sev of REVIEW_SEVERITIES) { + bySeverity[sev] = 0 + } + + return { + totalReviews: 0, + totalComments: 0, + byCategory, + bySeverity, + byFile: {}, + byCategorySeverity: {}, + timeline: [], + suggestions: 0, + untaggedComments: 0, + issues: [], + } +} + +function extractDescription(body: string): string { + const cleaned = body.replace(/\*?\*?\[[A-Z]+:[A-Z]+\]\*?\*?\s*/g, "").trim() + const withoutSuggestion = cleaned.replace(/```suggestion[\s\S]*?```/g, "").trim() + if (withoutSuggestion.length > 100) { + return withoutSuggestion.slice(0, 97) + "..." + } + return withoutSuggestion || "(no description)" +} + +function analyzeComment(comment: { body: string; path?: string }, stats: ReviewStats): void { + stats.totalComments++ + + const hasSuggestion = comment.body.includes("```suggestion") + + if (hasSuggestion) { + stats.suggestions++ + } + + const tag = parseReviewTag(comment.body) + if (tag && tag.category && tag.severity) { + stats.byCategory[tag.category]++ + stats.bySeverity[tag.severity]++ + + const key = `${tag.category}:${tag.severity}` + stats.byCategorySeverity[key] = (stats.byCategorySeverity[key] || 0) + 1 + + stats.issues.push({ + category: tag.category, + severity: tag.severity, + file: comment.path || "(summary)", + description: extractDescription(comment.body), + hasSuggestion, + }) + } else { + stats.untaggedComments++ + } + + if (comment.path) { + stats.byFile[comment.path] = (stats.byFile[comment.path] || 0) + 1 + } +} + +// 测试 +describe("initializeStats", () => { + test("creates empty stats with all categories", () => { + const stats = initializeStats() + + expect(stats.totalReviews).toBe(0) + expect(stats.totalComments).toBe(0) + expect(stats.suggestions).toBe(0) + expect(stats.untaggedComments).toBe(0) + + for (const cat of REVIEW_CATEGORIES) { + expect(stats.byCategory[cat]).toBe(0) + } + + for (const sev of REVIEW_SEVERITIES) { + expect(stats.bySeverity[sev]).toBe(0) + } + }) +}) + +describe("analyzeComment", () => { + test("increments totalComments", () => { + const stats = initializeStats() + analyzeComment({ body: "test" }, stats) + expect(stats.totalComments).toBe(1) + }) + + test("detects suggestion blocks", () => { + const stats = initializeStats() + analyzeComment({ + body: "Fix this\n```suggestion\nconst x = 1;\n```" + }, stats) + expect(stats.suggestions).toBe(1) + }) + + test("parses tagged comments correctly", () => { + const stats = initializeStats() + analyzeComment({ + body: "**[BUG:HIGH]** Null pointer exception" + }, stats) + + expect(stats.byCategory.BUG).toBe(1) + expect(stats.bySeverity.HIGH).toBe(1) + expect(stats.byCategorySeverity["BUG:HIGH"]).toBe(1) + expect(stats.untaggedComments).toBe(0) + }) + + test("counts untagged comments", () => { + const stats = initializeStats() + analyzeComment({ body: "This has no tag" }, stats) + expect(stats.untaggedComments).toBe(1) + }) + + test("tracks issues by file", () => { + const stats = initializeStats() + analyzeComment({ body: "**[BUG:HIGH]** Issue 1", path: "src/app.ts" }, stats) + analyzeComment({ body: "**[BUG:LOW]** Issue 2", path: "src/app.ts" }, stats) + analyzeComment({ body: "**[STYLE:LOW]** Issue 3", path: "src/util.ts" }, stats) + + expect(stats.byFile["src/app.ts"]).toBe(2) + expect(stats.byFile["src/util.ts"]).toBe(1) + }) + + test("handles multiple categories and severities", () => { + const stats = initializeStats() + + analyzeComment({ body: "**[SECURITY:CRITICAL]** SQL injection" }, stats) + analyzeComment({ body: "**[PERFORMANCE:MEDIUM]** N+1 query" }, stats) + analyzeComment({ body: "**[STYLE:LOW]** Use const" }, stats) + + expect(stats.byCategory.SECURITY).toBe(1) + expect(stats.byCategory.PERFORMANCE).toBe(1) + expect(stats.byCategory.STYLE).toBe(1) + + expect(stats.bySeverity.CRITICAL).toBe(1) + expect(stats.bySeverity.MEDIUM).toBe(1) + expect(stats.bySeverity.LOW).toBe(1) + }) +}) + +describe("Health Score Calculation", () => { + test("calculates weighted score", () => { + const stats = initializeStats() + + // Add some issues + analyzeComment({ body: "**[BUG:CRITICAL]** Critical bug" }, stats) + analyzeComment({ body: "**[BUG:HIGH]** High bug" }, stats) + analyzeComment({ body: "**[STYLE:LOW]** Style issue" }, stats) + + // Calculate score (same logic as in the tool) + const criticalWeight = stats.bySeverity.CRITICAL * 10 + const highWeight = stats.bySeverity.HIGH * 5 + const mediumWeight = stats.bySeverity.MEDIUM * 2 + const lowWeight = stats.bySeverity.LOW * 1 + const totalWeight = criticalWeight + highWeight + mediumWeight + lowWeight + const score = Math.max(0, 100 - totalWeight) + + expect(criticalWeight).toBe(10) + expect(highWeight).toBe(5) + expect(lowWeight).toBe(1) + expect(totalWeight).toBe(16) + expect(score).toBe(84) + }) + + test("score is 100 with no issues", () => { + const stats = initializeStats() + + const totalWeight = + stats.bySeverity.CRITICAL * 10 + + stats.bySeverity.HIGH * 5 + + stats.bySeverity.MEDIUM * 2 + + stats.bySeverity.LOW * 1 + const score = Math.max(0, 100 - totalWeight) + + expect(score).toBe(100) + }) + + test("score bottoms out at 0", () => { + const stats = initializeStats() + + // Add many critical issues + for (let i = 0; i < 15; i++) { + analyzeComment({ body: "**[SECURITY:CRITICAL]** Critical" }, stats) + } + + const totalWeight = stats.bySeverity.CRITICAL * 10 + const score = Math.max(0, 100 - totalWeight) + + expect(totalWeight).toBe(150) + expect(score).toBe(0) + }) +}) + +describe("Full Review Analysis", () => { + test("comprehensive analysis of multiple comments", () => { + const stats = initializeStats() + + const comments = [ + { body: "**[BUG:CRITICAL]** Memory leak in event handler", path: "src/handlers/event.ts" }, + { body: "**[SECURITY:HIGH]** User input not sanitized", path: "src/api/user.ts" }, + { body: "**[PERFORMANCE:MEDIUM]** Consider caching this query", path: "src/db/queries.ts" }, + { body: "**[STYLE:LOW]** Use const\n```suggestion\nconst x = 1;\n```", path: "src/utils.ts" }, + { body: "Nice work on the error handling!", path: "src/api/user.ts" }, // Untagged + ] + + for (const comment of comments) { + analyzeComment(comment, stats) + } + + expect(stats.totalComments).toBe(5) + expect(stats.suggestions).toBe(1) + expect(stats.untaggedComments).toBe(1) + + expect(stats.byCategory.BUG).toBe(1) + expect(stats.byCategory.SECURITY).toBe(1) + expect(stats.byCategory.PERFORMANCE).toBe(1) + expect(stats.byCategory.STYLE).toBe(1) + + expect(stats.bySeverity.CRITICAL).toBe(1) + expect(stats.bySeverity.HIGH).toBe(1) + expect(stats.bySeverity.MEDIUM).toBe(1) + expect(stats.bySeverity.LOW).toBe(1) + + expect(stats.byFile["src/api/user.ts"]).toBe(2) + }) +}) + +describe("Issue Records", () => { + test("creates detailed issue records", () => { + const stats = initializeStats() + + analyzeComment({ + body: "**[BUG:CRITICAL]** Memory leak in event handler", + path: "src/handlers/event.ts" + }, stats) + + expect(stats.issues.length).toBe(1) + expect(stats.issues[0].category).toBe("BUG") + expect(stats.issues[0].severity).toBe("CRITICAL") + expect(stats.issues[0].file).toBe("src/handlers/event.ts") + expect(stats.issues[0].description).toBe("Memory leak in event handler") + expect(stats.issues[0].hasSuggestion).toBe(false) + }) + + test("tracks suggestion in issue record", () => { + const stats = initializeStats() + + analyzeComment({ + body: "**[STYLE:LOW]** Use const\n```suggestion\nconst x = 1;\n```", + path: "src/utils.ts" + }, stats) + + expect(stats.issues[0].hasSuggestion).toBe(true) + }) + + test("truncates long descriptions", () => { + const stats = initializeStats() + const longDescription = "A".repeat(150) + + analyzeComment({ + body: `**[BUG:HIGH]** ${longDescription}`, + path: "test.ts" + }, stats) + + expect(stats.issues[0].description.length).toBeLessThanOrEqual(100) + expect(stats.issues[0].description.endsWith("...")).toBe(true) + }) +}) + +describe("extractDescription", () => { + test("removes tag prefix", () => { + const result = extractDescription("**[BUG:HIGH]** This is the issue") + expect(result).toBe("This is the issue") + }) + + test("removes suggestion blocks", () => { + const result = extractDescription("**[STYLE:LOW]** Use const\n```suggestion\nconst x = 1;\n```") + expect(result).toBe("Use const") + }) + + test("handles empty description", () => { + const result = extractDescription("**[BUG:HIGH]**") + expect(result).toBe("(no description)") + }) +}) + +console.log("✅ All review stats tests passed!") diff --git a/.opencode-review/tests/gitea-review-tags.test.ts b/.opencode-review/tests/gitea-review-tags.test.ts new file mode 100644 index 0000000..495af25 --- /dev/null +++ b/.opencode-review/tests/gitea-review-tags.test.ts @@ -0,0 +1,144 @@ +/** + * 测试 gitea-review 中的结构化标签和 suggestion 功能 + * 运行: cd .opencode-review && bun test tests/gitea-review-tags.test.ts + */ + +import { describe, test, expect } from "bun:test" + +// 复制自 gitea-review.ts 的常量和函数 +const REVIEW_CATEGORIES = ["BUG", "SECURITY", "PERFORMANCE", "STYLE", "DOCS", "TEST"] as const +const REVIEW_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const + +type ReviewCategory = typeof REVIEW_CATEGORIES[number] +type ReviewSeverity = typeof REVIEW_SEVERITIES[number] + +function formatReviewTag(category: ReviewCategory, severity: ReviewSeverity): string { + return `**[${category}:${severity}]**` +} + +function parseReviewTag(body: string): { category?: ReviewCategory; severity?: ReviewSeverity } | null { + const match = body.match(/\*?\*?\[([A-Z]+):([A-Z]+)\]\*?\*?/) + if (!match) return null + + const category = match[1] as ReviewCategory + const severity = match[2] as ReviewSeverity + + if (REVIEW_CATEGORIES.includes(category) && REVIEW_SEVERITIES.includes(severity)) { + return { category, severity } + } + return null +} + +function formatCommentBody(body: string, suggestion?: string): string { + if (!suggestion) return body + + const trimmedBody = body.trimEnd() + + return `${trimmedBody} + +\`\`\`suggestion +${suggestion} +\`\`\` +` +} + +// 测试 +describe("Review Tags", () => { + test("formatReviewTag generates correct format", () => { + expect(formatReviewTag("BUG", "HIGH")).toBe("**[BUG:HIGH]**") + expect(formatReviewTag("SECURITY", "CRITICAL")).toBe("**[SECURITY:CRITICAL]**") + expect(formatReviewTag("STYLE", "LOW")).toBe("**[STYLE:LOW]**") + }) + + test("parseReviewTag extracts category and severity", () => { + const result1 = parseReviewTag("**[BUG:HIGH]** This is a bug") + expect(result1).toEqual({ category: "BUG", severity: "HIGH" }) + + const result2 = parseReviewTag("[SECURITY:CRITICAL] SQL injection risk") + expect(result2).toEqual({ category: "SECURITY", severity: "CRITICAL" }) + + const result3 = parseReviewTag("**[PERFORMANCE:MEDIUM]**") + expect(result3).toEqual({ category: "PERFORMANCE", severity: "MEDIUM" }) + }) + + test("parseReviewTag returns null for invalid tags", () => { + expect(parseReviewTag("No tag here")).toBeNull() + expect(parseReviewTag("[INVALID:HIGH]")).toBeNull() + expect(parseReviewTag("[BUG:UNKNOWN]")).toBeNull() + expect(parseReviewTag("")).toBeNull() + }) + + test("parseReviewTag handles all valid categories", () => { + for (const cat of REVIEW_CATEGORIES) { + const result = parseReviewTag(`**[${cat}:HIGH]** Test`) + expect(result?.category).toBe(cat) + } + }) + + test("parseReviewTag handles all valid severities", () => { + for (const sev of REVIEW_SEVERITIES) { + const result = parseReviewTag(`**[BUG:${sev}]** Test`) + expect(result?.severity).toBe(sev) + } + }) +}) + +describe("Suggestion Blocks", () => { + test("formatCommentBody without suggestion returns body unchanged", () => { + const body = "This is a comment" + expect(formatCommentBody(body)).toBe(body) + }) + + test("formatCommentBody with suggestion adds suggestion block", () => { + const body = "**[STYLE:LOW]** Use const here" + const suggestion = "const x = 1;" + const result = formatCommentBody(body, suggestion) + + expect(result).toContain(body) + expect(result).toContain("```suggestion") + expect(result).toContain(suggestion) + expect(result).toContain("```") + }) + + test("formatCommentBody trims trailing whitespace before suggestion", () => { + const body = "Comment with spaces " + const suggestion = "fixed code" + const result = formatCommentBody(body, suggestion) + + expect(result.startsWith("Comment with spaces\n")).toBe(true) + expect(result).not.toContain("Comment with spaces \n") + }) + + test("formatCommentBody handles multi-line suggestions", () => { + const body = "**[BUG:HIGH]** Fix this function" + const suggestion = `function fixed() { + return true; +}` + const result = formatCommentBody(body, suggestion) + + expect(result).toContain("```suggestion") + expect(result).toContain("function fixed() {") + expect(result).toContain(" return true;") + expect(result).toContain("}") + }) +}) + +describe("Integration", () => { + test("Full review comment with tag and suggestion", () => { + const tag = formatReviewTag("STYLE", "LOW") + const body = `${tag} Variable should be const since it's never reassigned.` + const suggestion = "const userId = getUserId();" + + const formatted = formatCommentBody(body, suggestion) + + // Parse the tag back + const parsed = parseReviewTag(formatted) + expect(parsed).toEqual({ category: "STYLE", severity: "LOW" }) + + // Check suggestion block + expect(formatted).toContain("```suggestion") + expect(formatted).toContain("const userId = getUserId();") + }) +}) + +console.log("✅ All tests passed!") diff --git a/.opencode-review/tools/gitea-incremental-diff.ts b/.opencode-review/tools/gitea-incremental-diff.ts new file mode 100644 index 0000000..558af0c --- /dev/null +++ b/.opencode-review/tools/gitea-incremental-diff.ts @@ -0,0 +1,327 @@ +/// +import { tool } from "@opencode-ai/plugin" +import DESCRIPTION from "./gitea-incremental-diff.txt" + +/** + * Gitea/Forgejo Incremental PR Diff Tool + * + * This MCP tool fetches only the incremental diff for a pull request, + * showing changes since the last review. + * + * Environment Variables: + * GITEA_TOKEN - API token for Gitea/Forgejo + * GITEA_SERVER_URL - Base URL (e.g., https://gitea.example.com) + */ + +interface Commit { + sha: string + created: string + commit: { + message: string + author: { + date: string + } + } +} + +interface Review { + id: number + user: { + login: string + } + commit_id: string + submitted_at: string + state: string +} + +interface PRInfo { + head: { + sha: string + } + base: { + sha: string + } + merged: boolean +} + +async function giteaFetch(endpoint: string, options: RequestInit = {}) { + const baseUrl = process.env.GITEA_SERVER_URL || process.env.GITHUB_SERVER_URL + const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN + + if (!baseUrl) throw new Error("GITEA_SERVER_URL environment variable is required") + if (!token) throw new Error("GITEA_TOKEN environment variable is required") + + const url = `${baseUrl}/api/v1${endpoint}` + + // Build headers properly + const headers: Record = { + Authorization: `token ${token}`, + Accept: "application/json", + } + + // Override Accept if provided in options + if (options.headers) { + const optHeaders = options.headers as Record + if (optHeaders.Accept) { + headers.Accept = optHeaders.Accept + } + } + + const response = await fetch(url, { + ...options, + headers, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Gitea API error: ${response.status} ${response.statusText} - ${text}`) + } + + return response +} + +async function giteaFetchJson(endpoint: string): Promise { + const response = await giteaFetch(endpoint) + return response.json() as Promise +} + +async function giteaFetchText(endpoint: string): Promise { + const response = await giteaFetch(endpoint, { + headers: { Accept: "text/plain" }, + }) + return response.text() +} + +// Identify bot reviews by checking for our review marker +const BOT_REVIEW_MARKER = "" + +function isBotReview(review: Review): boolean { + // Check if review was made by a bot-like user or contains our marker + // Since we can't easily identify our own reviews, we look for any APPROVED or REQUEST_CHANGES + // reviews that have a commit_id + return Boolean(review.commit_id && review.commit_id.length > 0 && + (review.state === "APPROVED" || review.state === "REQUEST_CHANGES" || review.state === "COMMENT")) +} + +function findLastReviewedCommit(reviews: Review[], commits: Commit[]): string | null { + if (!reviews || reviews.length === 0) return null + + // Get commit SHAs in order + const commitShas = new Set(commits.map(c => c.sha)) + + // Find the most recent review with a valid commit_id + const validReviews = reviews + .filter(r => r.commit_id && commitShas.has(r.commit_id)) + .sort((a, b) => new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime()) + + return validReviews.length > 0 ? validReviews[0].commit_id : null +} + +function findCommitsAfter(commits: Commit[], afterSha: string | null): Commit[] { + if (!afterSha) return commits + + const afterIndex = commits.findIndex(c => c.sha === afterSha) + if (afterIndex === -1) { + // Commit not found - likely rebased, return all + return commits + } + + // Return commits after the found one (commits are usually in chronological order) + return commits.slice(afterIndex + 1) +} + +interface ParsedChange { + type: "add" | "del" | "normal" + content: string + oldLine?: number + newLine?: number +} + +interface ParsedHunk { + oldStart: number + newStart: number + changes: ParsedChange[] +} + +interface ParsedFile { + path: string + status: string + hunks: ParsedHunk[] +} + +function parseDiff(diffText: string): ParsedFile[] { + const files: ParsedFile[] = [] + const lines = diffText.split("\n") + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + if (line.startsWith("diff --git")) { + const pathMatch = line.match(/b\/(.+)$/) + const path = pathMatch ? pathMatch[1] : "unknown" + + let status = "modified" + while (i < lines.length && !lines[i].startsWith("@@")) { + if (lines[i].startsWith("new file")) status = "added" + else if (lines[i].startsWith("deleted file")) status = "deleted" + else if (lines[i].startsWith("rename")) status = "renamed" + i++ + } + + const hunks: ParsedHunk[] = [] + + while (i < lines.length && !lines[i].startsWith("diff --git")) { + if (lines[i].startsWith("@@")) { + const hunkMatch = lines[i].match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/) + if (hunkMatch) { + const oldStart = parseInt(hunkMatch[1], 10) + const newStart = parseInt(hunkMatch[2], 10) + const changes: ParsedChange[] = [] + let oldLine = oldStart + let newLine = newStart + + i++ + while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("diff --git")) { + const content = lines[i].slice(1) + if (lines[i].startsWith("+")) { + changes.push({ type: "add", content, newLine: newLine++ }) + } else if (lines[i].startsWith("-")) { + changes.push({ type: "del", content, oldLine: oldLine++ }) + } else if (lines[i].startsWith(" ")) { + changes.push({ type: "normal", content, oldLine: oldLine++, newLine: newLine++ }) + } + i++ + } + + hunks.push({ oldStart, newStart, changes }) + } else { + i++ + } + } else { + i++ + } + } + + files.push({ path, status, hunks }) + } else { + i++ + } + } + + return files +} + +function formatDiffForReview(files: ParsedFile[]): string { + const parts: string[] = [] + + for (const file of files) { + parts.push(`\n## ${file.path} (${file.status})\n`) + + for (const hunk of file.hunks) { + parts.push(`@@ starting at line ${hunk.newStart} (old: ${hunk.oldStart}) @@`) + + for (const change of hunk.changes) { + if (change.type === "add") { + parts.push(`[NEW:${change.newLine}] +${change.content}`) + } else if (change.type === "del") { + parts.push(`[OLD:${change.oldLine}] -${change.content}`) + } else { + parts.push(`[NEW:${change.newLine}] ${change.content}`) + } + } + parts.push("") + } + } + + return parts.join("\n") +} + +export default tool({ + description: DESCRIPTION, + args: { + owner: tool.schema.string().describe("Repository owner"), + repo: tool.schema.string().describe("Repository name"), + pull_number: tool.schema.number().describe("Pull request number"), + format: tool.schema + .enum(["raw", "parsed"]) + .optional() + .default("parsed") + .describe("Output format: 'raw' for unified diff, 'parsed' for line-numbered format"), + }, + async execute(args) { + const { owner, repo, pull_number, format = "parsed" } = args + + // 1. Get PR info + const pr = await giteaFetchJson(`/repos/${owner}/${repo}/pulls/${pull_number}`) + + // 2. Get all commits in the PR + const commits = await giteaFetchJson(`/repos/${owner}/${repo}/pulls/${pull_number}/commits`) + + // 3. Get all reviews + const reviews = await giteaFetchJson(`/repos/${owner}/${repo}/pulls/${pull_number}/reviews`) + + // 4. Find last reviewed commit + const lastReviewedCommit = findLastReviewedCommit(reviews, commits) + + // 5. Determine new commits + const newCommits = findCommitsAfter(commits, lastReviewedCommit) + + // Build status message + let statusMessage = "" + let diffText = "" + + if (!lastReviewedCommit) { + // First review - get full diff + statusMessage = "📋 **First Review** - Showing full PR diff\n" + diffText = await giteaFetchText(`/repos/${owner}/${repo}/pulls/${pull_number}.diff`) + } else if (newCommits.length === 0) { + // No new commits + return `✅ **No New Changes**\n\nThe PR has not been updated since the last review (commit \`${lastReviewedCommit.slice(0, 7)}\`).\n\nNo review needed.` + } else if (newCommits.length === commits.length) { + // All commits are "new" - likely rebased + statusMessage = `⚠️ **Rebase Detected** - PR appears to have been rebased.\n\nShowing full PR diff for complete re-review.\n\n` + diffText = await giteaFetchText(`/repos/${owner}/${repo}/pulls/${pull_number}.diff`) + } else { + // Incremental review + const newCommitShas = newCommits.map(c => c.sha.slice(0, 7)).join(", ") + statusMessage = `🔄 **Incremental Review**\n\n` + + `- Last reviewed: \`${lastReviewedCommit.slice(0, 7)}\`\n` + + `- New commits (${newCommits.length}): ${newCommitShas}\n` + + `- Current HEAD: \`${pr.head.sha.slice(0, 7)}\`\n\n` + + // Try to get incremental diff using compare + try { + diffText = await giteaFetchText(`/repos/${owner}/${repo}/compare/${lastReviewedCommit}...${pr.head.sha}.diff`) + } catch (error) { + // Fallback: get individual commit diffs + statusMessage += `> Note: Using individual commit diffs as fallback\n\n` + const diffs: string[] = [] + for (const commit of newCommits) { + try { + const commitDiff = await giteaFetchText(`/repos/${owner}/${repo}/git/commits/${commit.sha}.diff`) + diffs.push(commitDiff) + } catch { + // Skip if commit diff not available + } + } + diffText = diffs.join("\n") + } + } + + if (!diffText || diffText.trim() === "") { + return `${statusMessage}⚠️ No diff content available.` + } + + const parsed = parseDiff(diffText) + + if (format === "raw") { + return `${statusMessage}---\n\n${diffText}` + } + + const formatted = formatDiffForReview(parsed) + const summary = parsed.map((f) => `- ${f.path} (${f.status})`).join("\n") + + return `# PR #${pull_number} Incremental Diff\n\n${statusMessage}**Files Changed:**\n${summary}\n\n---\n${formatted}` + }, +}) diff --git a/.opencode-review/tools/gitea-incremental-diff.txt b/.opencode-review/tools/gitea-incremental-diff.txt new file mode 100644 index 0000000..6435bf5 --- /dev/null +++ b/.opencode-review/tools/gitea-incremental-diff.txt @@ -0,0 +1,29 @@ +Fetch incremental diff for a pull request, only showing changes since the last review. + +This tool is useful for reviewing updated PRs where you only want to see new changes, avoiding re-reviewing already reviewed code. + +## How it works + +1. Fetches all commits in the PR +2. Finds the last review submitted by this bot (via commit_id in reviews) +3. Returns only the diff for commits added after the last review + +## When to use + +- When a PR has been updated with new commits +- When you want to avoid duplicate review comments +- For large PRs that are incrementally reviewed + +## Output + +Returns: +- If no previous review: full PR diff +- If PR was rebased/force-pushed: full PR diff with warning +- Otherwise: incremental diff since last reviewed commit + +## Parameters + +- owner: Repository owner +- repo: Repository name +- pull_number: Pull request number +- format: "parsed" (default) for line-numbered format, "raw" for unified diff diff --git a/.opencode-review/tools/gitea-review.ts b/.opencode-review/tools/gitea-review.ts index 7829f57..c8d8a46 100644 --- a/.opencode-review/tools/gitea-review.ts +++ b/.opencode-review/tools/gitea-review.ts @@ -6,20 +6,13 @@ import DESCRIPTION from "./gitea-review.txt" * Gitea/Forgejo Code Review Tool * * This MCP tool allows AI agents to submit code reviews on PRs. - * It can create line-level comments and a summary review. + * The AI generates the summary and comments, this tool just submits them. * * Environment Variables: * GITEA_TOKEN - API token for Gitea/Forgejo * GITEA_SERVER_URL - Base URL (e.g., https://gitea.example.com) */ -interface ReviewComment { - path: string - line: number - body: string - side?: "LEFT" | "RIGHT" -} - interface ReviewRequest { body: string event: "COMMENT" | "APPROVED" | "REQUEST_CHANGES" @@ -53,7 +46,6 @@ async function giteaFetch(endpoint: string, options: RequestInit = {}) { if (!response.ok) { const text = await response.text() - // Check for permission errors and provide helpful message if (response.status === 403 || text.includes("required scope")) { throw new Error( `Gitea API permission error: Your token lacks required permissions.\n` + @@ -69,24 +61,94 @@ async function giteaFetch(endpoint: string, options: RequestInit = {}) { return response.json() } +/** + * Review Tag Categories and Severities + * Used for structured review comments that can be parsed for statistics + * + * Format: [CATEGORY:SEVERITY] message + * + * Categories: + * - BUG: Logic errors, null access, race conditions + * - SECURITY: SQL injection, XSS, auth issues, hardcoded secrets + * - PERFORMANCE: N+1 queries, memory leaks, inefficient algorithms + * - STYLE: Naming, formatting, code organization + * - DOCS: Missing or incorrect documentation + * - TEST: Missing tests, test quality issues + * + * Severities: + * - CRITICAL: Must fix before merge + * - HIGH: Should fix before merge + * - MEDIUM: Recommended to fix + * - LOW: Nice to have, optional + */ +export const REVIEW_CATEGORIES = ["BUG", "SECURITY", "PERFORMANCE", "STYLE", "DOCS", "TEST"] as const +export const REVIEW_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const + +export type ReviewCategory = typeof REVIEW_CATEGORIES[number] +export type ReviewSeverity = typeof REVIEW_SEVERITIES[number] + +/** + * Format a structured review tag + */ +export function formatReviewTag(category: ReviewCategory, severity: ReviewSeverity): string { + return `**[${category}:${severity}]**` +} + +/** + * Parse a review comment to extract category and severity + */ +export function parseReviewTag(body: string): { category?: ReviewCategory; severity?: ReviewSeverity } | null { + const match = body.match(/\*?\*?\[([A-Z]+):([A-Z]+)\]\*?\*?/) + if (!match) return null + + const category = match[1] as ReviewCategory + const severity = match[2] as ReviewSeverity + + if (REVIEW_CATEGORIES.includes(category) && REVIEW_SEVERITIES.includes(severity)) { + return { category, severity } + } + return null +} + +/** + * Format a comment with optional suggestion block + */ +export function formatCommentBody(body: string, suggestion?: string): string { + if (!suggestion) return body + + const trimmedBody = body.trimEnd() + + return `${trimmedBody} + +\`\`\`suggestion +${suggestion} +\`\`\` +` +} + +// ═══════════════════════════════════════════════════════════════ +// MAIN TOOL EXPORT +// ═══════════════════════════════════════════════════════════════ + export default tool({ description: DESCRIPTION, args: { owner: tool.schema.string().describe("Repository owner"), repo: tool.schema.string().describe("Repository name"), pull_number: tool.schema.number().describe("Pull request number"), - summary: tool.schema.string().describe("Overall review summary"), + summary: tool.schema.string().describe("Review summary/report content. This is the main review body that will be displayed. AI should generate a complete, well-formatted summary including overview, key findings, file table, etc."), comments: tool.schema .array( tool.schema.object({ path: tool.schema.string().describe("File path"), line: tool.schema.number().optional().describe("Line number in the NEW file (for added/context lines). Use this OR old_line, not both."), old_line: tool.schema.number().optional().describe("Line number in the OLD file (for deleted lines). Use this OR line, not both."), - body: tool.schema.string().describe("Comment content"), + body: tool.schema.string().describe("Comment content. Use structured tags like **[BUG:HIGH]** or **[SECURITY:CRITICAL]** for categorization."), + suggestion: tool.schema.string().optional().describe("Optional: Suggested replacement code. Will create a one-click applicable suggestion block in Gitea."), }), ) .optional() - .describe("Line-level review comments. Use 'line' for new/context lines, 'old_line' for deleted lines."), + .describe("Line-level review comments. Use 'line' for new/context lines, 'old_line' for deleted lines. Include 'suggestion' for auto-fix proposals."), approval: tool.schema .enum(["comment", "approve", "request_changes"]) .optional() @@ -94,7 +156,14 @@ export default tool({ .describe("Review decision"), }, async execute(args) { - const { owner, repo, pull_number, summary, comments = [], approval = "comment" } = args + const { + owner, + repo, + pull_number, + summary, + comments = [], + approval = "comment", + } = args // Map approval to Gitea event type const eventMap: Record = { @@ -113,9 +182,12 @@ export default tool({ .replace(/\\n/g, "\n") .replace(/\\t/g, "\t") - // Format review comments for API - also process escaped characters in comments - // Per Gitea API: use new_position for new file lines, old_position for deleted lines + // Format review comments for API const reviewComments = comments.map((c) => { + const processedBody = c.body.replace(/\\n/g, "\n").replace(/\\t/g, "\t") + const processedSuggestion = c.suggestion?.replace(/\\n/g, "\n").replace(/\\t/g, "\t") + const finalBody = formatCommentBody(processedBody, processedSuggestion) + const comment: { path: string body: string @@ -123,15 +195,13 @@ export default tool({ old_position?: number } = { path: c.path, - body: c.body.replace(/\\n/g, "\n").replace(/\\t/g, "\t"), + body: finalBody, } if (c.old_line !== undefined && c.old_line > 0) { - // Comment on deleted line (old file) comment.old_position = c.old_line comment.new_position = 0 } else if (c.line !== undefined && c.line > 0) { - // Comment on new/context line (new file) comment.new_position = c.line comment.old_position = 0 } @@ -139,6 +209,9 @@ export default tool({ return comment }) + // Count suggestions + const suggestionCount = comments.filter(c => c.suggestion).length + // Create the review const reviewPayload: ReviewRequest = { body: processedSummary, @@ -152,6 +225,7 @@ export default tool({ body: JSON.stringify(reviewPayload), }) - return `✅ Review submitted successfully!\n\n**Decision:** ${approval}\n**Comments:** ${comments.length} line comment(s)` + const suggestionMsg = suggestionCount > 0 ? ` | 💡 ${suggestionCount} auto-fix` : "" + return `✅ Review submitted successfully!\n\n**Decision:** ${approval} | **Comments:** ${comments.length}${suggestionMsg}` }, }) diff --git a/.opencode-review/tools/gitea-review.txt b/.opencode-review/tools/gitea-review.txt index 099ee72..3e966f3 100644 --- a/.opencode-review/tools/gitea-review.txt +++ b/.opencode-review/tools/gitea-review.txt @@ -1,41 +1,97 @@ Submit a code review on a Gitea/Forgejo pull request. -This tool allows you to: -- Post a summary review comment -- Add line-level comments on specific code changes (both new and deleted lines) -- Approve, request changes, or just comment +This tool submits a review with: +1. A summary/report (AI-generated, displayed as the main review body) +2. Line-level comments attached to specific code lines +3. An approval decision (approve/comment/request_changes) -Use this tool after analyzing a PR's code changes to provide structured feedback. +**IMPORTANT**: The `summary` parameter should contain a complete, well-formatted review report. +The tool does NOT auto-generate content - you control the entire output. -## Line Comment Types +## Summary Format (Recommended) -Comments support two targeting modes based on the diff output format: +Generate a summary organized by severity level: -1. **New/Context lines** (marked `[NEW:X]` in diff): use `line: X` -2. **Deleted lines** (marked `[OLD:X]` in diff): use `old_line: X` +\`\`\`markdown +## Code Review Report + +### 📋 Overview +[Brief summary of PR changes and overall code quality assessment] + +### 🔴 Critical Issues (CRITICAL) +> Must fix before merge + +| Issue | File | Description | +|:------|:-----|:------------| +| Plaintext password logging | `login.post.ts:12` | Password printed to logs in plaintext, severe security violation | +| Hardcoded JWT secret | `auth.ts:4`, `token.ts:4` | Secret key hardcoded, attackers can forge any token | + +### 🟠 High Priority (HIGH) +> Should fix before merge + +| Issue | File | Description | +|:------|:-----|:------------| +| Privilege escalation | `user/index.put.ts:27` | Users can set their own `isAdmin` status | + +### 🟡 Medium Priority (MEDIUM) +> Recommended to fix + +| Issue | File | Description | +|:------|:-----|:------------| +| Password bypass | `login.post.ts:39` | Plaintext password comparison used | + +### 🟢 Low Priority (LOW) +> Optional improvements + +| Issue | File | Description | +|:------|:-----|:------------| +| Code style | `utils.ts:15` | Use const instead of let | + +### 💡 Suggestions +[Overall recommendations and improvement directions] +\`\`\` + +**Notes**: +- Only include severity sections that have issues (omit empty sections) +- File column should include path and line number +- Description should briefly explain the issue and its impact + +## Line Comments + +Use structured tags for categorization: +- `**[BUG:HIGH]**` - Bug with severity +- `**[SECURITY:CRITICAL]**` - Security issue +- `**[PERFORMANCE:MEDIUM]**` - Performance concern +- `**[STYLE:LOW]**` - Style suggestion + +Categories: BUG, SECURITY, PERFORMANCE, STYLE, DOCS, TEST +Severities: CRITICAL, HIGH, MEDIUM, LOW ## Example Usage -```json +\`\`\`json { "owner": "org", "repo": "project", "pull_number": 42, - "summary": "Good changes overall, one security concern.", + "summary": "## Code Review Report\n\n### 📋 Overview\nThis PR adds user authentication. Overall implementation is clean, but has 1 critical security issue and 1 medium performance issue.\n\n### 🔴 Critical Issues (CRITICAL)\n> Must fix before merge\n\n| Issue | File | Description |\n|:------|:-----|:------------|\n| Plaintext password storage | `src/auth.ts:25` | Password stored without hashing, severe security risk |\n\n### 🟡 Medium Priority (MEDIUM)\n> Recommended to fix\n\n| Issue | File | Description |\n|:------|:-----|:------------|\n| Database connection leak | `src/db.ts:58` | Connection not closed, may exhaust connection pool |\n\n### 💡 Suggestions\nFix all security issues before merge, especially password storage.", "comments": [ { "path": "src/auth.ts", "line": 25, - "body": "Consider using bcrypt for password hashing" - }, - { - "path": "src/legacy.ts", - "old_line": 18, - "body": "This validation logic was important, ensure it's preserved elsewhere" + "body": "**[SECURITY:CRITICAL]** Password should be hashed using bcrypt.", + "suggestion": "const hash = await bcrypt.hash(password, 10);" } ], "approval": "request_changes" } -``` +\`\`\` + +## Parameters -The comments will appear in the PR's "Files Changed" tab at the specified line numbers. +- owner: Repository owner (required) +- repo: Repository name (required) +- pull_number: PR number (required) +- summary: Complete review report content (required) - AI generates this +- comments: Array of line-level comments (optional) +- approval: "comment" (default), "approve", or "request_changes" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index dd82864..0764a04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,7 @@ WORKDIR /workspace ENV OPENCODE_CONFIG_DIR=/app/.opencode-review \ MODEL=deepseek/deepseek-chat \ REVIEW_LANGUAGE=auto \ - REVIEW_STYLE=balanced \ - AUTO_APPROVE=false + REVIEW_STYLE=balanced # Copy entrypoint script COPY entrypoint.sh /entrypoint.sh diff --git a/README.md b/README.md index 3a84f2a..ff7a9b5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ An AI-powered **automatic code review tool for Gitea/Forgejo PRs**, built on the - 📝 **Line-Level Comments** - Provides precise feedback on specific code lines - ✅ **Review Decisions** - Supports approve, request_changes, and comment states - 🔄 **Auto-Trigger** - Triggered by `/oc` or `/opencode` comments -- 🐳 **Docker Support** - Zero-config installation with pre-built image +- � **Incremental Review** - Only reviews new changes since last review (for updated PRs) +- 🏷️ **Structured Tags** - Categorizes issues by type (BUG, SECURITY, PERFORMANCE) and severity +- �🐳 **Docker Support** - Zero-config installation with pre-built image - 🛡️ **Isolated Configuration** - Uses `.opencode-review/` directory, won't conflict with your existing `.opencode/` setup ## 📦 Installation @@ -206,17 +208,23 @@ export default tool({ │ └── workflow-source.yaml # Source workflow template ├── .github/workflows/ │ └── docker-publish.yaml # Auto-build Docker image -├── .gitea/workflows/ -│ └── opencode-review.yaml # Gitea Actions workflow └── .opencode-review/ # Isolated config directory ├── agents/ - │ └── code-review.md # Code review agent + │ ├── code-review.md # Code review agent (main) + │ └── gitea-assistant.md # General assistant agent ├── tools/ - │ ├── gitea-pr-diff.ts # Get PR diff - │ └── gitea-review.ts # Submit review + │ ├── gitea-pr-diff.ts # Get full PR diff + │ ├── gitea-pr-files.ts # List changed files + │ ├── gitea-incremental-diff.ts # Get incremental diff (new changes only) + │ ├── gitea-review.ts # Submit review with comments + │ └── gitea-comment.ts # Post comments on issues/PRs + ├── skills/ + │ └── pr-review/SKILL.md # Reusable review skill └── package.json # Dependencies ``` +> **Note**: After installation, `.gitea/workflows/opencode-review.yaml` will be created in your project. + ## 🔗 Related Links - [OpenCode Documentation](https://opencode.ai/docs) diff --git a/README_zh.md b/README_zh.md index f461f3c..7de23ad 100644 --- a/README_zh.md +++ b/README_zh.md @@ -14,7 +14,9 @@ - 📝 **行级评论** - 在具体代码行上提供精确反馈 - ✅ **审查决策** - 支持 approve、request_changes、comment 三种审查状态 - 🔄 **自动触发** - 通过 `/oc` 或 `/opencode` 评论触发审查 -- 🐳 **Docker 支持** - 预构建镜像,零配置安装 +- � **增量审查** - 仅审查上次审查后的新变更(适用于 PR 更新) +- 🏷️ **结构化标签** - 按类型(BUG、SECURITY、PERFORMANCE)和严重程度分类问题 +- �🐳 **Docker 支持** - 预构建镜像,零配置安装 - 🛡️ **隔离配置** - 使用独立的 `.opencode-review/` 目录,不会与你现有的 `.opencode/` 配置冲突 ## 📦 安装 @@ -206,17 +208,23 @@ export default tool({ │ └── workflow-source.yaml # 源码 workflow 模板 ├── .github/workflows/ │ └── docker-publish.yaml # 自动构建 Docker 镜像 -├── .gitea/workflows/ -│ └── opencode-review.yaml # Gitea Actions 工作流 └── .opencode-review/ # 隔离的配置目录 ├── agents/ - │ └── code-review.md # 代码审查 Agent + │ ├── code-review.md # 代码审查 Agent(主) + │ └── gitea-assistant.md # 通用助手 Agent ├── tools/ - │ ├── gitea-pr-diff.ts # 获取 PR Diff - │ └── gitea-review.ts # 提交审查 + │ ├── gitea-pr-diff.ts # 获取完整 PR Diff + │ ├── gitea-pr-files.ts # 列出变更的文件 + │ ├── gitea-incremental-diff.ts # 获取增量 Diff(仅新变更) + │ ├── gitea-review.ts # 提交审查和评论 + │ └── gitea-comment.ts # 在 issue/PR 上发表评论 + ├── skills/ + │ └── pr-review/SKILL.md # 可复用的审查技能 └── package.json # 依赖 ``` +> **注意**: 安装后,会在你的项目中创建 `.gitea/workflows/opencode-review.yaml`。 + ## 🔗 相关链接 - [OpenCode 官方文档](https://opencode.ai/docs)