Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14a0c2a946 | |||
| ef3e6d5e87 | |||
| aade891129 | |||
| 7b42de67ca | |||
| dd2661fe14 | |||
| 98a4772f30 | |||
| fc23b6ebe9 | |||
| b02ade4f23 | |||
| f8e77cf7e3 | |||
| 69e70466fd | |||
| 0cca44b65a | |||
| 43041a00f5 | |||
| 1da61e514d | |||
| 401e94d3e4 | |||
| cedb5e7b90 | |||
| ecebd52371 | |||
| 27e0056f29 | |||
| ffca0eb016 | |||
| 9aec7ff952 | |||
| 582ebf7ff6 |
@@ -1,6 +1,7 @@
|
|||||||
# This composite action is designed for Gitea Actions runners.
|
# This composite action is designed for Gitea Actions runners.
|
||||||
# Gitea Actions supports GitHub Actions syntax including $GITHUB_OUTPUT,
|
# Gitea Actions supports GitHub Actions syntax including $GITHUB_OUTPUT,
|
||||||
# actions/cache, and actions/checkout.
|
# actions/cache, and actions/checkout.
|
||||||
|
# Requirements: python3, sha256sum, curl (all present on ubuntu-* runners).
|
||||||
name: 'AI Code Review'
|
name: 'AI Code Review'
|
||||||
description: 'Run AI-powered code review on a pull request using review-bot'
|
description: 'Run AI-powered code review on a pull request using review-bot'
|
||||||
|
|
||||||
@@ -33,22 +34,30 @@ inputs:
|
|||||||
llm-model:
|
llm-model:
|
||||||
description: 'LLM model name'
|
description: 'LLM model name'
|
||||||
required: true
|
required: true
|
||||||
|
llm-provider:
|
||||||
|
description: 'LLM API provider: openai or anthropic (default openai)'
|
||||||
|
required: false
|
||||||
|
default: 'openai'
|
||||||
conventions-file:
|
conventions-file:
|
||||||
description: 'Path to conventions file in the repo (e.g. CLAUDE.md)'
|
description: 'Path to conventions file in the repo (e.g. CLAUDE.md)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
patterns-repo:
|
patterns-repo:
|
||||||
description: 'Repo with language patterns (e.g. rodin/elixir-patterns)'
|
description: 'Comma-separated repos with language patterns (e.g. rodin/elixir-patterns,rodin/phoenix-conventions)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
patterns-files:
|
patterns-files:
|
||||||
description: 'Comma-separated file paths to fetch from patterns repo'
|
description: 'Comma-separated file paths or directories to fetch from patterns repos'
|
||||||
required: false
|
required: false
|
||||||
default: 'README.md'
|
default: 'README.md'
|
||||||
temperature:
|
temperature:
|
||||||
description: 'LLM temperature (0 = server default)'
|
description: 'LLM temperature (0 = server default)'
|
||||||
required: false
|
required: false
|
||||||
default: '0'
|
default: '0'
|
||||||
|
timeout:
|
||||||
|
description: 'LLM request timeout in seconds (default 300)'
|
||||||
|
required: false
|
||||||
|
default: '300'
|
||||||
version:
|
version:
|
||||||
description: 'review-bot version to install (e.g. v0.1.0, defaults to latest)'
|
description: 'review-bot version to install (e.g. v0.1.0, defaults to latest)'
|
||||||
required: false
|
required: false
|
||||||
@@ -134,6 +143,8 @@ runs:
|
|||||||
PATTERNS_REPO: ${{ inputs.patterns-repo }}
|
PATTERNS_REPO: ${{ inputs.patterns-repo }}
|
||||||
PATTERNS_FILES: ${{ inputs.patterns-files }}
|
PATTERNS_FILES: ${{ inputs.patterns-files }}
|
||||||
LLM_TEMPERATURE: ${{ inputs.temperature }}
|
LLM_TEMPERATURE: ${{ inputs.temperature }}
|
||||||
|
LLM_TIMEOUT: ${{ inputs.timeout }}
|
||||||
|
LLM_PROVIDER: ${{ inputs.llm-provider }}
|
||||||
run: |
|
run: |
|
||||||
ARGS=""
|
ARGS=""
|
||||||
if [ "${{ inputs.dry-run }}" = "true" ]; then
|
if [ "${{ inputs.dry-run }}" = "true" ]; then
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ jobs:
|
|||||||
go-version: '1.26'
|
go-version: '1.26'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./...
|
run: |
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
# review-bot Code Review (vs go-patterns)
|
||||||
|
|
||||||
|
## Overall Assessment
|
||||||
|
|
||||||
|
The review-bot is a well-structured, focused Go application that follows many idiomatic patterns correctly. The package layout is clean (`gitea/`, `llm/`, `review/`, `cmd/`), error handling uses `%w` wrapping consistently, and the test suite covers all major code paths using `httptest`. However, there are several areas where the code diverges from the patterns documented in `go-patterns` — particularly around configuration, context propagation, exported fields, documentation, and testing idioms.
|
||||||
|
|
||||||
|
**Verdict: Solid foundation with targeted improvements needed.**
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Severity | File | Pattern Violated | Finding |
|
||||||
|
|---|----------|------|-----------------|---------|
|
||||||
|
| 1 | MAJOR | `gitea/client.go` | concurrency / api-conventions | No `context.Context` parameter on any method — HTTP calls are uncancellable |
|
||||||
|
| 2 | MAJOR | `llm/client.go` | concurrency / api-conventions | `Complete()` accepts no context — no timeout or cancellation support |
|
||||||
|
| 3 | MAJOR | `gitea/client.go` | structs / encapsulation | `Client` fields (`BaseURL`, `Token`, `HTTP`) are exported but should be unexported |
|
||||||
|
| 4 | MAJOR | `llm/client.go` | structs / encapsulation | `Client` fields (`BaseURL`, `APIKey`, `Model`, `HTTP`) are exported — leaks credentials via reflection/logging |
|
||||||
|
| 5 | MINOR | `cmd/review-bot/main.go` | configuration | No input validation beyond emptiness — e.g., URL format, model name format |
|
||||||
|
| 6 | MINOR | `cmd/review-bot/main.go` | error-handling | Uses `log.Fatalf` for all errors — no cleanup, deferred functions won't run |
|
||||||
|
| 7 | MINOR | `gitea/client.go` | error-handling / style | Error strings in `doGet` are inconsistent — some use `fmt.Errorf`, the raw HTTP error doesn't wrap with `%w` |
|
||||||
|
| 8 | MINOR | `review/prompt.go` | style / api-conventions | `BuildSystemPrompt` uses 20+ `WriteString` calls — could use a raw string literal for readability |
|
||||||
|
| 9 | MINOR | `gitea/client.go` | documentation | No concurrency safety documentation on `Client` type |
|
||||||
|
| 10 | MINOR | `llm/client.go` | documentation | No concurrency safety documentation on `Client` type |
|
||||||
|
| 11 | MINOR | `gitea/client_test.go` | testing-advanced | Tests don't use `t.Run` subtests — individual test functions instead of table-driven with named cases |
|
||||||
|
| 12 | MINOR | `integration_test.go` | style | Uses rune literal `'/'` comparison in a loop instead of `strings.SplitN` (inconsistent with `main.go`) |
|
||||||
|
| 13 | MINOR | `llm/client.go` | configuration | `Temperature: 0.1` is hardcoded — not configurable and the zero-value (0.0) semantic isn't clear |
|
||||||
|
| 14 | NIT | `gitea/client.go` | style | `PostReview` converts `[]byte` to `string` then passes to `strings.NewReader` — use `bytes.NewReader(data)` directly |
|
||||||
|
| 15 | NIT | `review/formatter.go` | documentation | `GiteaEvent` has no doc comment explaining the mapping semantics |
|
||||||
|
| 16 | NIT | `cmd/review-bot/main.go` | package-design | `evaluateCIStatus` is unexported logic in `main` — could live in `review` package for testability |
|
||||||
|
| 17 | NIT | `gitea/client.go` | interfaces | No interface defined for the Gitea client — makes the main function harder to unit test |
|
||||||
|
| 18 | NIT | `llm/client.go` | interfaces | No interface defined for the LLM client — same testability concern |
|
||||||
|
| 19 | NIT | `review/parser.go` | error-handling | `extractJSON` silently handles malformed fences — edge case: `\`\`\`` with only 1 line produces empty string |
|
||||||
|
| 20 | NIT | Various | documentation | No package-level doc comments (`// Package xxx ...`) on any package |
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### 1. No `context.Context` on Gitea client methods (MAJOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
func (c *Client) GetPullRequest(owner, repo string, number int) (*PullRequest, error) {
|
||||||
|
url := fmt.Sprintf(...)
|
||||||
|
body, err := c.doGet(url)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `concurrency.md` §6 (Context Propagation Rules): "Pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx." From `api-conventions.md` §3 (WithContext variant): All I/O-performing functions should accept a context for timeout/cancellation.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
|
||||||
|
...
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `context.Context` as the first parameter to all public methods. Update `doGet` to accept context internally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. No `context.Context` on LLM `Complete()` (MAJOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
func (c *Client) Complete(messages []Message) (string, error) {
|
||||||
|
...
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
Same as finding #1. LLM calls can take 30-60+ seconds. Without context, there's no way to enforce a timeout or cancel a review that's taking too long.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
func (c *Client) Complete(ctx context.Context, messages []Message) (string, error) {
|
||||||
|
...
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The caller in `main.go` should create a context with timeout: `ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3 & 4. Exported struct fields on Client types (MAJOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `structs.md`: use unexported fields for internal state; expose only what callers need to read/modify. From `configuration.md` §9: Document immutability constraints. Exported fields like `Token` and `APIKey` are sensitive credentials that could be accidentally logged, serialized, or mutated after construction.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If tests need to override `HTTP`, expose it via a functional option or a `WithHTTPClient(*http.Client)` setter, or accept it in the constructor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. No input validation beyond emptiness (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
if *giteaURL == "" || *repo == "" || ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `configuration.md` §1 (Zero-Value Config): Document and validate configuration explicitly. A malformed URL (e.g., missing scheme) will produce a confusing error later during HTTP request creation rather than at startup.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
if _, err := url.Parse(*giteaURL); err != nil || !strings.HasPrefix(*giteaURL, "http") {
|
||||||
|
log.Fatalf("Invalid --gitea-url: %s", *giteaURL)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `log.Fatalf` for all errors (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
`log.Fatalf(...)` is used for every error in `main()`.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `api-conventions.md` §9 (Graceful Shutdown): distinguish between fatal and recoverable errors. From `error-handling.md`: error handling should give callers the ability to respond. While `main()` is the top-level caller, `log.Fatalf` calls `os.Exit(1)` which doesn't run deferred functions.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
Use a `run() error` pattern:
|
||||||
|
```go
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows deferred cleanup to run and makes the code testable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Inconsistent error formatting in `doGet` (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
func (c *Client) doGet(url string) ([]byte, error) {
|
||||||
|
...
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The error from the raw HTTP response isn't wrapped with `%w`, but the callers wrap it again: `fmt.Errorf("fetch PR: %w", err)`. The inner error starts with a capital "HTTP".
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `smells/anti-patterns.md` §6 (Error String Formatting): error strings should be lowercase. They compose upward: `fetch PR: HTTP 404: ...` has inconsistent casing.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(body))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Prompt building uses excessive `WriteString` (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
sb.WriteString("You are an expert code reviewer...\n\n")
|
||||||
|
sb.WriteString("Your task:\n")
|
||||||
|
sb.WriteString("1. Review the diff...\n")
|
||||||
|
// ... 20+ more lines
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `style.md`: code should be readable and maintainable. A raw string literal would be far more readable for a multi-line prompt template.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
const systemPromptTemplate = `You are an expert code reviewer. Review the provided pull request diff carefully.
|
||||||
|
|
||||||
|
Your task:
|
||||||
|
1. Review the diff for correctness, idiomatic code, potential bugs, and design issues.
|
||||||
|
...
|
||||||
|
`
|
||||||
|
|
||||||
|
func BuildSystemPrompt(conventions string) string {
|
||||||
|
prompt := systemPromptTemplate
|
||||||
|
if conventions != "" {
|
||||||
|
prompt += fmt.Sprintf("\n\nThe repository has the following coding conventions...\n\n%s\n", conventions)
|
||||||
|
}
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9 & 10. No concurrency safety documentation (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
Neither `gitea.Client` nor `llm.Client` documents whether they're safe for concurrent use.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `documentation.md` §9 (Concurrency Documentation): "Doc comments explicitly state the concurrency safety of a type." Since both types embed `*http.Client` (which IS safe for concurrent use), the wrapping types should document this.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
// Client interacts with the Gitea API.
|
||||||
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
|
type Client struct { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Tests don't use `t.Run` subtests (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
`gitea/client_test.go` defines 8 separate `TestXxx` functions, each creating their own httptest server.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `testing-advanced.md` §1 (Table-Driven Tests with `t.Run`): related tests should use named subtests for filterability and clarity. The Gitea client tests share identical setup patterns — they'd benefit from a shared helper.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
Consider a test helper that creates a server with a handler map, then use `t.Run` for each case. The existing structure is acceptable but could be DRYer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Inconsistent repo parsing in `integration_test.go` (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
for i, c := range giteaRepo {
|
||||||
|
if c == '/' {
|
||||||
|
owner = giteaRepo[:i]
|
||||||
|
repoName = giteaRepo[i+1:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `style.md` §3 (File Organization by Responsibility): related logic should be consistent. `main.go` uses `strings.SplitN(*repo, "/", 2)` for the same operation. The integration test reinvents it with a manual loop.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
Use `strings.SplitN(giteaRepo, "/", 2)` for consistency, or extract a shared helper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Hardcoded `Temperature: 0.1` (MINOR)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
reqBody := ChatRequest{
|
||||||
|
...
|
||||||
|
Temperature: 0.1,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `configuration.md` §1 (Zero-Value Usable Config): "Every field documents its zero-value behavior." The temperature is buried in implementation. It should be configurable (e.g., a field on `Client` with a documented default).
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
Add a `Temperature` field to `Client` with documentation:
|
||||||
|
```go
|
||||||
|
type Client struct {
|
||||||
|
...
|
||||||
|
// Temperature controls LLM randomness. If zero, defaults to 0.1.
|
||||||
|
Temperature float64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. Unnecessary `string()` → `strings.NewReader` conversion (NIT)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
...
|
||||||
|
req, err := http.NewRequest("POST", url, strings.NewReader(string(data)))
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `style.md`: avoid unnecessary allocations. `json.Marshal` returns `[]byte`; use `bytes.NewReader(data)` directly to avoid the `[]byte→string` copy.
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
```go
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. Missing doc comment on `GiteaEvent` (NIT)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
// GiteaEvent converts the verdict to the Gitea API event string.
|
||||||
|
func GiteaEvent(verdict string) string {
|
||||||
|
```
|
||||||
|
|
||||||
|
Actually, this one DOES have a doc comment. On closer inspection the comment exists. Removing this finding — **correction**: the comment is present but minimal. It doesn't document the mapping or the "COMMENT" fallback behavior. This is borderline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. `evaluateCIStatus` in `main` package (NIT)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
The `evaluateCIStatus` function lives in `cmd/review-bot/main.go` and operates on `[]gitea.CommitStatus`.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `package-design.md`: packages should encapsulate related logic. This function interprets CI status semantics — it belongs in the `review` package (or even `gitea`) where it could be unit-tested independently without building the entire binary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17 & 18. No interfaces for testability (NIT)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
`main.go` directly uses `*gitea.Client` and `*llm.Client` concrete types.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `interfaces.md`: "Define interfaces in the package that USES them." From `smells/common-mistakes.md` §10 (Premature Abstraction): don't create interfaces before you need them. However, the consumer (`main.go`) would benefit from small interfaces for testing the orchestration logic independently.
|
||||||
|
|
||||||
|
**How to fix (when needed):**
|
||||||
|
```go
|
||||||
|
// In main or a review orchestrator package:
|
||||||
|
type PRFetcher interface {
|
||||||
|
GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error)
|
||||||
|
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: This is a NIT because the current code doesn't have tests for `main.go` orchestration. If/when that's needed, interfaces become valuable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 19. `extractJSON` edge case (NIT)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
```go
|
||||||
|
if strings.HasPrefix(s, "```") {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
if len(lines) > 2 {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If input is exactly `` ```json\n``` `` (fence with empty body), it produces an empty string that will fail JSON parse with a confusing error message.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `error-handling.md`: errors should carry context. Consider returning an explicit error from `extractJSON` when the extracted content is empty after fence stripping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20. No package doc comments (NIT)
|
||||||
|
|
||||||
|
**What the code does:**
|
||||||
|
None of the packages (`gitea`, `llm`, `review`) have `// Package xxx ...` doc comments.
|
||||||
|
|
||||||
|
**What the pattern says:**
|
||||||
|
From `documentation.md` §1 (Package Documentation): "The first file in a package starts with a `// Package xxx ...` comment that explains the package's purpose."
|
||||||
|
|
||||||
|
**How to fix:**
|
||||||
|
Add to each package's primary file:
|
||||||
|
```go
|
||||||
|
// Package gitea provides a client for the Gitea API, focused on pull request review operations.
|
||||||
|
package gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positive Patterns
|
||||||
|
|
||||||
|
The codebase does several things well:
|
||||||
|
|
||||||
|
1. **Clean package separation** — `gitea/`, `llm/`, `review/`, `cmd/` each have a single responsibility. This matches `package-design.md` perfectly.
|
||||||
|
|
||||||
|
2. **Consistent error wrapping** — Every public function wraps errors with `fmt.Errorf("context: %w", err)`, providing clear error chains. This follows `error-handling.md` closely.
|
||||||
|
|
||||||
|
3. **Return concrete types from constructors** — `NewClient()` returns `*Client`, not an interface. Matches `smells/common-mistakes.md` §7 and `smells/anti-patterns.md` §8.
|
||||||
|
|
||||||
|
4. **httptest-based testing** — Both client packages use `net/http/httptest` for isolated, deterministic tests. No external dependencies needed.
|
||||||
|
|
||||||
|
5. **Good test coverage of error paths** — Tests cover 404s, bad JSON, connection failures, invalid severities, missing fields. This is thorough.
|
||||||
|
|
||||||
|
6. **Zero dependencies** — `go.mod` has no external dependencies. The entire project uses only the standard library. This is excellent for a focused tool.
|
||||||
|
|
||||||
|
7. **Build-tagged integration test** — The `//go:build integration` tag keeps expensive tests separate from unit tests. Good practice.
|
||||||
|
|
||||||
|
8. **`strings.Builder` usage** — Prompt building and formatting use `strings.Builder` correctly for efficient string construction.
|
||||||
|
|
||||||
|
9. **Named return values where useful** — `evaluateCIStatus` uses named returns `(passed bool, details string)` for documentation clarity, matching `style.md` §5.
|
||||||
|
|
||||||
|
10. **No premature abstraction** — The code doesn't define interfaces it doesn't need yet. It's concrete and straightforward, following `smells/common-mistakes.md` §10.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
Priority-ordered list of improvements:
|
||||||
|
|
||||||
|
1. **Add `context.Context` to all client methods** (Critical) — This is the single most impactful change. LLM calls can hang indefinitely without timeout support. Both `gitea.Client` and `llm.Client` should accept context as the first parameter on all public methods. Use `http.NewRequestWithContext`.
|
||||||
|
|
||||||
|
2. **Unexport client struct fields** (High) — `Token`, `APIKey`, `BaseURL` should be unexported to prevent accidental logging/serialization of credentials. Expose only what's needed via methods or constructor options.
|
||||||
|
|
||||||
|
3. **Add package documentation** (Medium) — Each package needs a `// Package xxx ...` comment. This takes 5 minutes and significantly improves discoverability.
|
||||||
|
|
||||||
|
4. **Extract `evaluateCIStatus` to `review` package** (Medium) — Makes it independently testable and keeps `main.go` focused on orchestration.
|
||||||
|
|
||||||
|
5. **Use `run() error` pattern in main** (Medium) — Enables deferred cleanup and makes the orchestration logic more testable.
|
||||||
|
|
||||||
|
6. **Replace `WriteString` chain with raw string literal** (Low) — Pure readability improvement for `BuildSystemPrompt`.
|
||||||
|
|
||||||
|
7. **Make LLM temperature configurable** (Low) — Add as a field on `Client` with documented zero-value default.
|
||||||
|
|
||||||
|
8. **Use `bytes.NewReader` instead of `strings.NewReader(string(...))` in PostReview** (Low) — Eliminates one unnecessary allocation.
|
||||||
|
|
||||||
|
9. **Add concurrency documentation to Client types** (Low) — One-line doc additions.
|
||||||
|
|
||||||
|
10. **Consider consumer-side interfaces when testing `main` orchestration** (Future) — Not needed now, but will become valuable if the `main.go` logic grows or needs unit testing.
|
||||||
+55
-13
@@ -1,12 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
||||||
"gitea.weiker.me/rodin/review-bot/llm"
|
"gitea.weiker.me/rodin/review-bot/llm"
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||||
// CLI flags
|
// CLI flags
|
||||||
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL")
|
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL")
|
||||||
repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)")
|
repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)")
|
||||||
@@ -30,9 +33,18 @@ func main() {
|
|||||||
patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo")
|
patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo")
|
||||||
dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting")
|
dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting")
|
||||||
llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)")
|
llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)")
|
||||||
|
llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)")
|
||||||
|
llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *versionFlag {
|
||||||
|
fmt.Printf("review-bot %s\n", version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("review-bot %s", version)
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" ||
|
if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" ||
|
||||||
*llmBaseURL == "" || *llmAPIKey == "" || *llmModel == "" {
|
*llmBaseURL == "" || *llmAPIKey == "" || *llmModel == "" {
|
||||||
@@ -63,18 +75,32 @@ func main() {
|
|||||||
if *llmTemp > 0 {
|
if *llmTemp > 0 {
|
||||||
llmClient.WithTemperature(*llmTemp)
|
llmClient.WithTemperature(*llmTemp)
|
||||||
}
|
}
|
||||||
|
switch llm.Provider(*llmProvider) {
|
||||||
|
case llm.ProviderOpenAI, llm.ProviderAnthropic:
|
||||||
|
llmClient.WithProvider(llm.Provider(*llmProvider))
|
||||||
|
default:
|
||||||
|
log.Fatalf("Invalid --llm-provider %q, must be openai or anthropic", *llmProvider)
|
||||||
|
}
|
||||||
|
if *llmTimeout > 0 {
|
||||||
|
llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a top-level context. Timeout derived from LLM timeout + 1 min for other ops.
|
||||||
|
overallTimeout := time.Duration(*llmTimeout)*time.Second + time.Minute
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
log.Printf("Reviewing PR #%d on %s/%s", prNumber, owner, repoName)
|
log.Printf("Reviewing PR #%d on %s/%s", prNumber, owner, repoName)
|
||||||
|
|
||||||
// Step 1: Fetch PR metadata
|
// Step 1: Fetch PR metadata
|
||||||
pr, err := giteaClient.GetPullRequest(owner, repoName, prNumber)
|
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to fetch PR: %v", err)
|
log.Fatalf("Failed to fetch PR: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("PR: %s", pr.Title)
|
log.Printf("PR: %s", pr.Title)
|
||||||
|
|
||||||
// Step 2: Fetch diff
|
// Step 2: Fetch diff
|
||||||
diff, err := giteaClient.GetPullRequestDiff(owner, repoName, prNumber)
|
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to fetch diff: %v", err)
|
log.Fatalf("Failed to fetch diff: %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,11 +108,11 @@ func main() {
|
|||||||
|
|
||||||
// Step 3: Fetch full file content for modified files
|
// Step 3: Fetch full file content for modified files
|
||||||
fileContext := ""
|
fileContext := ""
|
||||||
files, err := giteaClient.GetPullRequestFiles(owner, repoName, prNumber)
|
files, err := giteaClient.GetPullRequestFiles(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: could not fetch PR files list: %v", err)
|
log.Printf("Warning: could not fetch PR files list: %v", err)
|
||||||
} else {
|
} else {
|
||||||
fileContext = fetchFileContext(giteaClient, owner, repoName, pr.Head.Ref, files)
|
fileContext = fetchFileContext(ctx, giteaClient, owner, repoName, pr.Head.Ref, files)
|
||||||
log.Printf("Fetched full context for %d files", len(files))
|
log.Printf("Fetched full context for %d files", len(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +120,7 @@ func main() {
|
|||||||
ciPassed := true
|
ciPassed := true
|
||||||
ciDetails := ""
|
ciDetails := ""
|
||||||
if pr.Head.Sha != "" {
|
if pr.Head.Sha != "" {
|
||||||
statuses, err := giteaClient.GetCommitStatuses(owner, repoName, pr.Head.Sha)
|
statuses, err := giteaClient.GetCommitStatuses(ctx, owner, repoName, pr.Head.Sha)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: could not fetch CI status: %v", err)
|
log.Printf("Warning: could not fetch CI status: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -106,7 +132,7 @@ func main() {
|
|||||||
// Step 5: Load conventions file if specified
|
// Step 5: Load conventions file if specified
|
||||||
conventions := ""
|
conventions := ""
|
||||||
if *conventionsFile != "" {
|
if *conventionsFile != "" {
|
||||||
content, err := giteaClient.GetFileContent(owner, repoName, *conventionsFile)
|
content, err := giteaClient.GetFileContent(ctx, owner, repoName, *conventionsFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: could not load conventions file %q: %v", *conventionsFile, err)
|
log.Printf("Warning: could not load conventions file %q: %v", *conventionsFile, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -118,7 +144,7 @@ func main() {
|
|||||||
// Step 6: Load patterns from external repo if specified
|
// Step 6: Load patterns from external repo if specified
|
||||||
patterns := ""
|
patterns := ""
|
||||||
if *patternsRepo != "" {
|
if *patternsRepo != "" {
|
||||||
patterns = fetchPatterns(giteaClient, *patternsRepo, *patternsFiles)
|
patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles)
|
||||||
log.Printf("Loaded patterns from %s (%d bytes)", *patternsRepo, len(patterns))
|
log.Printf("Loaded patterns from %s (%d bytes)", *patternsRepo, len(patterns))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +159,7 @@ func main() {
|
|||||||
{Role: "user", Content: userPrompt},
|
{Role: "user", Content: userPrompt},
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := llmClient.Complete(messages)
|
response, err := llmClient.Complete(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("LLM request failed: %v", err)
|
log.Fatalf("LLM request failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -158,20 +184,23 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Posting review (event=%s)...", event)
|
log.Printf("Posting review (event=%s)...", event)
|
||||||
if err := giteaClient.PostReview(owner, repoName, prNumber, event, reviewBody); err != nil {
|
if err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody); err != nil {
|
||||||
log.Fatalf("Failed to post review: %v", err)
|
log.Fatalf("Failed to post review: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("Review posted successfully!")
|
log.Printf("Review posted successfully!")
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchFileContext fetches the full content of modified files from the PR branch.
|
// fetchFileContext fetches the full content of modified files from the PR branch.
|
||||||
func fetchFileContext(client *gitea.Client, owner, repo, ref string, files []gitea.ChangedFile) string {
|
func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, ref string, files []gitea.ChangedFile) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
if f.Status == "removed" {
|
if f.Status == "removed" {
|
||||||
continue // Skip deleted files
|
continue // Skip deleted files
|
||||||
}
|
}
|
||||||
content, err := client.GetFileContentRef(owner, repo, f.Filename, ref)
|
content, err := client.GetFileContentRef(ctx, owner, repo, f.Filename, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: could not fetch %s: %v", f.Filename, err)
|
log.Printf("Warning: could not fetch %s: %v", f.Filename, err)
|
||||||
continue
|
continue
|
||||||
@@ -188,13 +217,16 @@ func fetchFileContext(client *gitea.Client, owner, repo, ref string, files []git
|
|||||||
// patternsRepo is comma-separated list of owner/name repos.
|
// patternsRepo is comma-separated list of owner/name repos.
|
||||||
// patternsFiles is comma-separated list of file paths or directories.
|
// patternsFiles is comma-separated list of file paths or directories.
|
||||||
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
||||||
func fetchPatterns(client *gitea.Client, patternsRepo, patternsFiles string) string {
|
func fetchPatterns(ctx context.Context, client *gitea.Client, patternsRepo, patternsFiles string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
repos := strings.Split(patternsRepo, ",")
|
repos := strings.Split(patternsRepo, ",")
|
||||||
paths := strings.Split(patternsFiles, ",")
|
paths := strings.Split(patternsFiles, ",")
|
||||||
|
|
||||||
for _, repoRef := range repos {
|
for _, repoRef := range repos {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
repoRef = strings.TrimSpace(repoRef)
|
repoRef = strings.TrimSpace(repoRef)
|
||||||
if repoRef == "" {
|
if repoRef == "" {
|
||||||
continue
|
continue
|
||||||
@@ -212,7 +244,7 @@ func fetchPatterns(client *gitea.Client, patternsRepo, patternsFiles string) str
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := client.GetAllFilesInPath(owner, repo, path)
|
files, err := client.GetAllFilesInPath(ctx, owner, repo, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: could not fetch %s from %s: %v", path, repoRef, err)
|
log.Printf("Warning: could not fetch %s from %s: %v", path, repoRef, err)
|
||||||
continue
|
continue
|
||||||
@@ -279,3 +311,13 @@ func envOrDefaultFloat(key string, defaultVal float64) float64 {
|
|||||||
}
|
}
|
||||||
return defaultVal
|
return defaultVal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envOrDefaultInt(key string, defaultVal int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
i, err := strconv.Atoi(v)
|
||||||
|
if err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|||||||
+71
-42
@@ -1,26 +1,35 @@
|
|||||||
|
// Package gitea provides a client for the Gitea API.
|
||||||
|
// It supports pull request operations, file content retrieval,
|
||||||
|
// and review submission.
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client interacts with the Gitea API.
|
// Client interacts with the Gitea API.
|
||||||
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
baseURL string
|
||||||
Token string
|
token string
|
||||||
HTTP *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Gitea API client.
|
// NewClient creates a new Gitea API client.
|
||||||
func NewClient(baseURL, token string) *Client {
|
func NewClient(baseURL, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
Token: token,
|
token: token,
|
||||||
HTTP: &http.Client{},
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +58,9 @@ type ChangedFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPullRequest fetches PR metadata.
|
// GetPullRequest fetches PR metadata.
|
||||||
func (c *Client) GetPullRequest(owner, repo string, number int) (*PullRequest, error) {
|
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.BaseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, owner, repo, number)
|
||||||
body, err := c.doGet(url)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch PR: %w", err)
|
return nil, fmt.Errorf("fetch PR: %w", err)
|
||||||
}
|
}
|
||||||
@@ -63,9 +72,9 @@ func (c *Client) GetPullRequest(owner, repo string, number int) (*PullRequest, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPullRequestDiff fetches the unified diff for a PR.
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
||||||
func (c *Client) GetPullRequestDiff(owner, repo string, number int) (string, error) {
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.BaseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, repo, number)
|
||||||
body, err := c.doGet(url)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch diff: %w", err)
|
return "", fmt.Errorf("fetch diff: %w", err)
|
||||||
}
|
}
|
||||||
@@ -73,9 +82,9 @@ func (c *Client) GetPullRequestDiff(owner, repo string, number int) (string, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPullRequestFiles fetches the list of files changed in a PR.
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
||||||
func (c *Client) GetPullRequestFiles(owner, repo string, number int) ([]ChangedFile, error) {
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.BaseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, owner, repo, number)
|
||||||
body, err := c.doGet(url)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch PR files: %w", err)
|
return nil, fmt.Errorf("fetch PR files: %w", err)
|
||||||
}
|
}
|
||||||
@@ -87,9 +96,9 @@ func (c *Client) GetPullRequestFiles(owner, repo string, number int) ([]ChangedF
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetCommitStatuses fetches CI statuses for a commit SHA.
|
// GetCommitStatuses fetches CI statuses for a commit SHA.
|
||||||
func (c *Client) GetCommitStatuses(owner, repo, sha string) ([]CommitStatus, error) {
|
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.BaseURL, owner, repo, sha)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, owner, repo, sha)
|
||||||
body, err := c.doGet(url)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch commit statuses: %w", err)
|
return nil, fmt.Errorf("fetch commit statuses: %w", err)
|
||||||
}
|
}
|
||||||
@@ -101,9 +110,9 @@ func (c *Client) GetCommitStatuses(owner, repo, sha string) ([]CommitStatus, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFileContent fetches a file from the default branch of a repo.
|
// GetFileContent fetches a file from the default branch of a repo.
|
||||||
func (c *Client) GetFileContent(owner, repo, filepath string) (string, error) {
|
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.BaseURL, owner, repo, filepath)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, owner, repo, escapePath(filepath))
|
||||||
body, err := c.doGet(url)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
|
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
|
||||||
}
|
}
|
||||||
@@ -111,9 +120,9 @@ func (c *Client) GetFileContent(owner, repo, filepath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
|
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
|
||||||
func (c *Client) GetFileContentRef(owner, repo, filepath, ref string) (string, error) {
|
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.BaseURL, owner, repo, filepath, ref)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, owner, repo, escapePath(filepath), url.QueryEscape(ref))
|
||||||
body, err := c.doGet(url)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
|
return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
|
||||||
}
|
}
|
||||||
@@ -122,8 +131,8 @@ func (c *Client) GetFileContentRef(owner, repo, filepath, ref string) (string, e
|
|||||||
|
|
||||||
// PostReview submits a review to a PR.
|
// PostReview submits a review to a PR.
|
||||||
// event should be "APPROVED" or "REQUEST_CHANGES".
|
// event should be "APPROVED" or "REQUEST_CHANGES".
|
||||||
func (c *Client) PostReview(owner, repo string, number int, event, body string) error {
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string) error {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.BaseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number)
|
||||||
|
|
||||||
payload := struct {
|
payload := struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -138,14 +147,14 @@ func (c *Client) PostReview(owner, repo string, number int, event, body string)
|
|||||||
return fmt.Errorf("marshal review payload: %w", err)
|
return fmt.Errorf("marshal review payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", url, strings.NewReader(string(data)))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create review request: %w", err)
|
return fmt.Errorf("create review request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "token "+c.Token)
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("post review: %w", err)
|
return fmt.Errorf("post review: %w", err)
|
||||||
}
|
}
|
||||||
@@ -158,14 +167,14 @@ func (c *Client) PostReview(owner, repo string, number int, event, body string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) doGet(url string) ([]byte, error) {
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "token "+c.Token)
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -178,6 +187,18 @@ func (c *Client) doGet(url string) ([]byte, error) {
|
|||||||
return io.ReadAll(resp.Body)
|
return io.ReadAll(resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapePath escapes each segment of a relative file path for use in URLs.
|
||||||
|
// Slashes are preserved as path separators; other special characters are escaped.
|
||||||
|
// Input should be a relative path (no leading slash). Already-encoded segments
|
||||||
|
// will be double-encoded, which is the desired behavior for user-provided paths.
|
||||||
|
func escapePath(p string) string {
|
||||||
|
parts := strings.Split(p, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
parts[i] = url.PathEscape(part)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
// ContentEntry represents a file or directory entry from the contents API.
|
// ContentEntry represents a file or directory entry from the contents API.
|
||||||
type ContentEntry struct {
|
type ContentEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -186,9 +207,15 @@ type ContentEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListContents lists files and directories at a given path in a repo.
|
// ListContents lists files and directories at a given path in a repo.
|
||||||
func (c *Client) ListContents(owner, repo, path string) ([]ContentEntry, error) {
|
// Pass an empty path to list the repository root.
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.BaseURL, owner, repo, path)
|
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
||||||
body, err := c.doGet(url)
|
var reqURL string
|
||||||
|
if path == "" {
|
||||||
|
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, owner, repo)
|
||||||
|
} else {
|
||||||
|
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, escapePath(path))
|
||||||
|
}
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list contents %s: %w", path, err)
|
return nil, fmt.Errorf("list contents %s: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -202,14 +229,14 @@ func (c *Client) ListContents(owner, repo, path string) ([]ContentEntry, error)
|
|||||||
// GetAllFilesInPath recursively fetches all file contents under a path.
|
// GetAllFilesInPath recursively fetches all file contents under a path.
|
||||||
// If the path is a file, returns just that file's content.
|
// If the path is a file, returns just that file's content.
|
||||||
// If the path is a directory, recursively fetches all files within it.
|
// If the path is a directory, recursively fetches all files within it.
|
||||||
func (c *Client) GetAllFilesInPath(owner, repo, path string) (map[string]string, error) {
|
func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
|
||||||
results := make(map[string]string)
|
results := make(map[string]string)
|
||||||
|
|
||||||
// Try listing as directory first
|
// Try listing as directory first
|
||||||
entries, err := c.ListContents(owner, repo, path)
|
entries, err := c.ListContents(ctx, owner, repo, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Might be a file, try fetching directly
|
// Might be a file, try fetching directly
|
||||||
content, fileErr := c.GetFileContent(owner, repo, path)
|
content, fileErr := c.GetFileContent(ctx, owner, repo, path)
|
||||||
if fileErr != nil {
|
if fileErr != nil {
|
||||||
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err)
|
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -220,14 +247,16 @@ func (c *Client) GetAllFilesInPath(owner, repo, path string) (map[string]string,
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
switch entry.Type {
|
switch entry.Type {
|
||||||
case "file":
|
case "file":
|
||||||
content, err := c.GetFileContent(owner, repo, entry.Path)
|
content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip files we can't read
|
log.Printf("Warning: could not fetch file %s: %v", entry.Path, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
results[entry.Path] = content
|
results[entry.Path] = content
|
||||||
case "dir":
|
case "dir":
|
||||||
subResults, err := c.GetAllFilesInPath(owner, repo, entry.Path)
|
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("Warning: could not recurse into %s: %v", entry.Path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for k, v := range subResults {
|
for k, v := range subResults {
|
||||||
|
|||||||
+37
-12
@@ -1,6 +1,7 @@
|
|||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -28,7 +29,7 @@ func TestGetPullRequest(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
got, err := client.GetPullRequest("owner", "repo", 1)
|
got, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,7 @@ func TestGetPullRequestDiff(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
got, err := client.GetPullRequestDiff("owner", "repo", 5)
|
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -80,7 +81,7 @@ func TestGetCommitStatuses(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
got, err := client.GetCommitStatuses("owner", "repo", "abc123")
|
got, err := client.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ func TestPostReview(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
err := client.PostReview("owner", "repo", 3, "APPROVED", "LGTM")
|
err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,7 @@ func TestGetPullRequest_Non200(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
_, err := client.GetPullRequest("owner", "repo", 999)
|
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 999)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 404, got nil")
|
t.Fatal("expected error for 404, got nil")
|
||||||
}
|
}
|
||||||
@@ -154,7 +155,7 @@ func TestGetPullRequest_BadJSON(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
_, err := client.GetPullRequest("owner", "repo", 1)
|
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for bad JSON, got nil")
|
t.Fatal("expected error for bad JSON, got nil")
|
||||||
}
|
}
|
||||||
@@ -168,7 +169,7 @@ func TestPostReview_Non200(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
err := client.PostReview("owner", "repo", 1, "APPROVED", "test")
|
err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403, got nil")
|
t.Fatal("expected error for 403, got nil")
|
||||||
}
|
}
|
||||||
@@ -186,7 +187,7 @@ func TestGetFileContent(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
got, err := client.GetFileContent("owner", "repo", "CONVENTIONS.md")
|
got, err := client.GetFileContent(context.Background(), "owner", "repo", "CONVENTIONS.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ func TestGetPullRequestFiles(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
files, err := client.GetPullRequestFiles("owner", "repo", 1)
|
files, err := client.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -231,7 +232,7 @@ func TestGetFileContentRef(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
content, err := client.GetFileContentRef("owner", "repo", "main.go", "feature-branch")
|
content, err := client.GetFileContentRef(context.Background(), "owner", "repo", "main.go", "feature-branch")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -251,7 +252,7 @@ func TestListContents(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
entries, err := client.ListContents("owner", "repo", "docs")
|
entries, err := client.ListContents(context.Background(), "owner", "repo", "docs")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -282,7 +283,7 @@ func TestGetAllFilesInPath_File(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
files, err := client.GetAllFilesInPath("owner", "repo", "README.md")
|
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -293,3 +294,27 @@ func TestGetAllFilesInPath_File(t *testing.T) {
|
|||||||
t.Errorf("unexpected content: %q", files["README.md"])
|
t.Errorf("unexpected content: %q", files["README.md"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEscapePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"simple", "src/main.go", "src/main.go"},
|
||||||
|
{"spaces", "my dir/my file.go", "my%20dir/my%20file.go"},
|
||||||
|
{"special chars", "path/file#1.txt", "path/file%231.txt"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"single segment", "README.md", "README.md"},
|
||||||
|
{"nested deep", "a/b/c/d.md", "a/b/c/d.md"},
|
||||||
|
{"already encoded", "path/file%20name.go", "path/file%2520name.go"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := escapePath(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("escapePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-12
@@ -3,8 +3,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
||||||
@@ -42,28 +44,27 @@ func TestIntegration_FullReviewFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse owner/repo
|
// Parse owner/repo
|
||||||
owner, repoName := "", ""
|
parts := strings.SplitN(giteaRepo, "/", 2)
|
||||||
for i, c := range giteaRepo {
|
if len(parts) != 2 {
|
||||||
if c == / {
|
t.Fatalf("Invalid repo format %q", giteaRepo)
|
||||||
owner = giteaRepo[:i]
|
|
||||||
repoName = giteaRepo[i+1:]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
owner, repoName := parts[0], parts[1]
|
||||||
if owner == "" || repoName == "" {
|
if owner == "" || repoName == "" {
|
||||||
t.Fatalf("Invalid repo format %q", giteaRepo)
|
t.Fatalf("Invalid repo format %q", giteaRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Step 1: Fetch PR
|
// Step 1: Fetch PR
|
||||||
giteaClient := gitea.NewClient(giteaURL, giteaToken)
|
giteaClient := gitea.NewClient(giteaURL, giteaToken)
|
||||||
pr, err := giteaClient.GetPullRequest(owner, repoName, prNumber)
|
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPullRequest: %v", err)
|
t.Fatalf("GetPullRequest: %v", err)
|
||||||
}
|
}
|
||||||
t.Logf("PR: %s (sha: %s)", pr.Title, pr.Head.Sha)
|
t.Logf("PR: %s (sha: %s)", pr.Title, pr.Head.Sha)
|
||||||
|
|
||||||
// Step 2: Fetch diff
|
// Step 2: Fetch diff
|
||||||
diff, err := giteaClient.GetPullRequestDiff(owner, repoName, prNumber)
|
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPullRequestDiff: %v", err)
|
t.Fatalf("GetPullRequestDiff: %v", err)
|
||||||
}
|
}
|
||||||
@@ -73,12 +74,12 @@ func TestIntegration_FullReviewFlow(t *testing.T) {
|
|||||||
t.Logf("Diff size: %d bytes", len(diff))
|
t.Logf("Diff size: %d bytes", len(diff))
|
||||||
|
|
||||||
// Step 3: Build prompts
|
// Step 3: Build prompts
|
||||||
systemPrompt := review.BuildSystemPrompt("")
|
systemPrompt := review.BuildSystemPrompt("", "")
|
||||||
userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, true, "")
|
userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, "", true, "")
|
||||||
|
|
||||||
// Step 4: Call LLM
|
// Step 4: Call LLM
|
||||||
llmClient := llm.NewClient(llmBaseURL, llmAPIKey, llmModel)
|
llmClient := llm.NewClient(llmBaseURL, llmAPIKey, llmModel)
|
||||||
response, err := llmClient.Complete([]llm.Message{
|
response, err := llmClient.Complete(ctx, []llm.Message{
|
||||||
{Role: "system", Content: systemPrompt},
|
{Role: "system", Content: systemPrompt},
|
||||||
{Role: "user", Content: userPrompt},
|
{Role: "user", Content: userPrompt},
|
||||||
})
|
})
|
||||||
|
|||||||
+168
-36
@@ -1,36 +1,68 @@
|
|||||||
|
// Package llm provides clients for LLM chat completion APIs.
|
||||||
|
//
|
||||||
|
// Supports OpenAI-compatible (default) and Anthropic Messages API providers.
|
||||||
package llm
|
package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client calls an OpenAI-compatible chat completion API.
|
// Provider identifies which API format to use.
|
||||||
|
type Provider string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ProviderOpenAI uses the OpenAI-compatible chat/completions endpoint.
|
||||||
|
ProviderOpenAI Provider = "openai"
|
||||||
|
// ProviderAnthropic uses the Anthropic Messages API endpoint.
|
||||||
|
ProviderAnthropic Provider = "anthropic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client calls an LLM chat completion API.
|
||||||
|
// A Client is safe for concurrent use by multiple goroutines after construction.
|
||||||
|
// WithTimeout, WithTemperature, and WithProvider must be called during setup,
|
||||||
|
// before concurrent use.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
baseURL string
|
||||||
APIKey string
|
apiKey string
|
||||||
Model string
|
model string
|
||||||
Temperature float64
|
temperature float64
|
||||||
HTTP *http.Client
|
provider Provider
|
||||||
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new LLM client.
|
// NewClient creates a new LLM client. Default provider is OpenAI-compatible.
|
||||||
func NewClient(baseURL, apiKey, model string) *Client {
|
func NewClient(baseURL, apiKey, model string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
APIKey: apiKey,
|
apiKey: apiKey,
|
||||||
Model: model,
|
model: model,
|
||||||
HTTP: &http.Client{},
|
provider: ProviderOpenAI,
|
||||||
|
http: &http.Client{Timeout: 5 * time.Minute},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTimeout sets the HTTP request timeout for LLM calls (default 5 minutes).
|
||||||
|
func (c *Client) WithTimeout(d time.Duration) *Client {
|
||||||
|
c.http.Timeout = d
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// WithTemperature sets the temperature for LLM requests (0 = omit, uses server default).
|
// WithTemperature sets the temperature for LLM requests (0 = omit, uses server default).
|
||||||
func (c *Client) WithTemperature(t float64) *Client {
|
func (c *Client) WithTemperature(t float64) *Client {
|
||||||
c.Temperature = t
|
c.temperature = t
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProvider sets the API provider format (openai or anthropic).
|
||||||
|
func (c *Client) WithProvider(p Provider) *Client {
|
||||||
|
c.provider = p
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,14 +72,27 @@ type Message struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatRequest is the request payload.
|
// Complete sends a chat completion request and returns the assistant's response content.
|
||||||
|
// The first message with role "system" is treated as the system prompt.
|
||||||
|
func (c *Client) Complete(ctx context.Context, messages []Message) (string, error) {
|
||||||
|
switch c.provider {
|
||||||
|
case ProviderAnthropic:
|
||||||
|
return c.completeAnthropic(ctx, messages)
|
||||||
|
default:
|
||||||
|
return c.completeOpenAI(ctx, messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenAI-compatible implementation ---
|
||||||
|
|
||||||
|
// ChatRequest is the OpenAI request payload.
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatResponse is the response from the API.
|
// ChatResponse is the OpenAI response.
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
@@ -56,13 +101,11 @@ type ChatResponse struct {
|
|||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete sends a chat completion request and returns the assistant's response content.
|
func (c *Client) completeOpenAI(ctx context.Context, messages []Message) (string, error) {
|
||||||
func (c *Client) Complete(messages []Message) (string, error) {
|
|
||||||
reqBody := ChatRequest{
|
reqBody := ChatRequest{
|
||||||
Model: c.Model,
|
Model: c.model,
|
||||||
Temperature: c.Temperature,
|
Temperature: c.temperature,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(reqBody)
|
data, err := json.Marshal(reqBody)
|
||||||
@@ -70,38 +113,127 @@ func (c *Client) Complete(messages []Message) (string, error) {
|
|||||||
return "", fmt.Errorf("marshal request: %w", err)
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := c.BaseURL + "/chat/completions"
|
url := c.baseURL + "/chat/completions"
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("create request: %w", err)
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
return c.doRequest(req, func(body []byte) (string, error) {
|
||||||
|
var resp ChatResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no choices in LLM response")
|
||||||
|
}
|
||||||
|
return resp.Choices[0].Message.Content, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anthropic Messages API implementation ---
|
||||||
|
|
||||||
|
type anthropicRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Messages []anthropicMsg `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicMsg struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicResponse struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) completeAnthropic(ctx context.Context, messages []Message) (string, error) {
|
||||||
|
// Extract system message (first message with role "system")
|
||||||
|
var system string
|
||||||
|
var userMessages []anthropicMsg
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
system = m.Content
|
||||||
|
} else {
|
||||||
|
userMessages = append(userMessages, anthropicMsg{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: m.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := anthropicRequest{
|
||||||
|
Model: c.model,
|
||||||
|
MaxTokens: 8192,
|
||||||
|
System: system,
|
||||||
|
Messages: userMessages,
|
||||||
|
}
|
||||||
|
if c.temperature > 0 {
|
||||||
|
reqBody.Temperature = c.temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.baseURL + "/messages"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("x-api-key", c.apiKey)
|
||||||
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return c.doRequest(req, func(body []byte) (string, error) {
|
||||||
|
var resp anthropicResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Content) == 0 {
|
||||||
|
return "", fmt.Errorf("no content in Anthropic response")
|
||||||
|
}
|
||||||
|
// Concatenate all text blocks
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, block := range resp.Content {
|
||||||
|
if block.Type == "text" {
|
||||||
|
sb.WriteString(block.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := sb.String()
|
||||||
|
if result == "" {
|
||||||
|
return "", fmt.Errorf("no text content in Anthropic response")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared HTTP execution ---
|
||||||
|
|
||||||
|
func (c *Client) doRequest(req *http.Request, parse func([]byte) (string, error)) (string, error) {
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("LLM request: %w", err)
|
return "", fmt.Errorf("LLM request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read response: %w", err)
|
return "", fmt.Errorf("read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var chatResp ChatResponse
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
if err := json.Unmarshal(body, &chatResp); err != nil {
|
return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
return "", fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(chatResp.Choices) == 0 {
|
return parse(body)
|
||||||
return "", fmt.Errorf("no choices in LLM response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return chatResp.Choices[0].Message.Content, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-11
@@ -1,10 +1,12 @@
|
|||||||
package llm
|
package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestComplete_Success(t *testing.T) {
|
func TestComplete_Success(t *testing.T) {
|
||||||
@@ -51,7 +53,7 @@ func TestComplete_Success(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-key", "gpt-4")
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
||||||
got, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
got, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,7 @@ func TestComplete_APIError(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-key", "gpt-4")
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
||||||
_, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 429, got nil")
|
t.Fatal("expected error for 429, got nil")
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,7 @@ func TestComplete_NoChoices(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-key", "gpt-4")
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
||||||
_, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for no choices, got nil")
|
t.Fatal("expected error for no choices, got nil")
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,7 @@ func TestComplete_BadJSON(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-key", "gpt-4")
|
client := NewClient(server.URL, "test-key", "gpt-4")
|
||||||
_, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for bad JSON, got nil")
|
t.Fatal("expected error for bad JSON, got nil")
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,7 @@ func TestComplete_BadJSON(t *testing.T) {
|
|||||||
|
|
||||||
func TestComplete_ServerDown(t *testing.T) {
|
func TestComplete_ServerDown(t *testing.T) {
|
||||||
client := NewClient("http://127.0.0.1:1", "test-key", "gpt-4")
|
client := NewClient("http://127.0.0.1:1", "test-key", "gpt-4")
|
||||||
_, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for connection refused, got nil")
|
t.Fatal("expected error for connection refused, got nil")
|
||||||
}
|
}
|
||||||
@@ -111,16 +113,16 @@ func TestComplete_ServerDown(t *testing.T) {
|
|||||||
|
|
||||||
func TestWithTemperature(t *testing.T) {
|
func TestWithTemperature(t *testing.T) {
|
||||||
client := NewClient("http://example.com", "key", "model")
|
client := NewClient("http://example.com", "key", "model")
|
||||||
if client.Temperature != 0 {
|
if client.temperature != 0 {
|
||||||
t.Errorf("expected initial temperature 0, got %f", client.Temperature)
|
t.Errorf("expected initial temperature 0, got %f", client.temperature)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := client.WithTemperature(0.7)
|
result := client.WithTemperature(0.7)
|
||||||
if result != client {
|
if result != client {
|
||||||
t.Error("WithTemperature should return the same client for chaining")
|
t.Error("WithTemperature should return the same client for chaining")
|
||||||
}
|
}
|
||||||
if client.Temperature != 0.7 {
|
if client.temperature != 0.7 {
|
||||||
t.Errorf("expected temperature 0.7, got %f", client.Temperature)
|
t.Errorf("expected temperature 0.7, got %f", client.temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ func TestComplete_TemperatureOmittedWhenZero(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "key", "model")
|
client := NewClient(server.URL, "key", "model")
|
||||||
_, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -180,8 +182,116 @@ func TestComplete_TemperatureIncludedWhenSet(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "key", "model").WithTemperature(0.7)
|
client := NewClient(server.URL, "key", "model").WithTemperature(0.7)
|
||||||
_, err := client.Complete([]Message{{Role: "user", Content: "Hi"}})
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithTimeout(t *testing.T) {
|
||||||
|
client := NewClient("http://example.com", "key", "model")
|
||||||
|
result := client.WithTimeout(10 * time.Second)
|
||||||
|
if result != client {
|
||||||
|
t.Error("WithTimeout should return the same client for chaining")
|
||||||
|
}
|
||||||
|
// Verify timeout causes failure on slow server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
shortClient := NewClient(server.URL, "key", "model").WithTimeout(50 * time.Millisecond)
|
||||||
|
_, err := shortClient.Complete(context.Background(), []Message{{Role: "user", Content: "hi"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected timeout error with 50ms timeout and 200ms server delay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestComplete_Anthropic_Success(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/messages" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("x-api-key") != "test-key" {
|
||||||
|
t.Errorf("expected x-api-key header, got %q", r.Header.Get("x-api-key"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("anthropic-version") != "2023-06-01" {
|
||||||
|
t.Errorf("expected anthropic-version header, got %q", r.Header.Get("anthropic-version"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var req map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if req["system"] != "You are helpful" {
|
||||||
|
t.Errorf("expected system prompt, got %v", req["system"])
|
||||||
|
}
|
||||||
|
msgs := req["messages"].([]interface{})
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Errorf("expected 1 user message, got %d", len(msgs))
|
||||||
|
}
|
||||||
|
if req["max_tokens"] != float64(8192) {
|
||||||
|
t.Errorf("expected max_tokens 8192, got %v", req["max_tokens"])
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"content":[{"type":"text","text":"Hello from Claude!"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
|
||||||
|
got, err := client.Complete(context.Background(), []Message{
|
||||||
|
{Role: "system", Content: "You are helpful"},
|
||||||
|
{Role: "user", Content: "Hi"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "Hello from Claude!" {
|
||||||
|
t.Errorf("expected %q, got %q", "Hello from Claude!", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComplete_Anthropic_NoContent(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"content":[]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
|
||||||
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty content, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComplete_Anthropic_APIError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":{"message":"invalid request"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic)
|
||||||
|
_, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 400, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithProvider(t *testing.T) {
|
||||||
|
client := NewClient("http://example.com", "key", "model")
|
||||||
|
if client.provider != ProviderOpenAI {
|
||||||
|
t.Errorf("expected default provider openai, got %s", client.provider)
|
||||||
|
}
|
||||||
|
result := client.WithProvider(ProviderAnthropic)
|
||||||
|
if result != client {
|
||||||
|
t.Error("WithProvider should return the same client for chaining")
|
||||||
|
}
|
||||||
|
if client.provider != ProviderAnthropic {
|
||||||
|
t.Errorf("expected provider anthropic, got %s", client.provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Package review builds prompts for AI code review and parses LLM responses
|
||||||
|
// into structured review results.
|
||||||
package review
|
package review
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Reference in New Issue
Block a user