Compare commits

...

11 Commits

Author SHA1 Message Date
Rodin b02ade4f23 fix: quick wins (#7, #9, #13)
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 59s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m48s
- Add --version flag and log version on startup (closes #9)
- URL-escape ref query parameter in GetFileContentRef (closes #7)
- Add go vet to release workflow (closes #13)

Renamed local url variable to reqURL to avoid shadowing net/url package.
2026-05-01 14:19:37 -07:00
rodin f8e77cf7e3 Merge pull request 'feat: add context.Context + unexport client fields' (#14) from fix/context-and-encapsulation into main
CI / test (push) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
Release / release (push) Successful in 31s
2026-05-01 21:10:37 +00:00
Rodin 69e70466fd fix: address all review findings (context timeout, docs, early exit)
CI / test (pull_request) Successful in 14s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m7s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m40s
- Overall context timeout now derived from LLM timeout + 1 minute
  (no longer hardcoded 3min that could conflict with longer LLM timeouts)
- Clarify concurrency docs: With* methods are setup-only, not concurrent
- Add ctx.Err() checks in fetchFileContext and fetchPatterns loops
  (break early on cancellation instead of making unnecessary requests)
2026-05-01 13:26:19 -07:00
Rodin 0cca44b65a fix: address all remaining review findings on PR #14
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m29s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m36s
- Fix doc comments: WithTimeout and WithTemperature each get their own
- Add TestWithTimeout (verifies short timeout causes request failure)
- Log warning on directory recursion failure in GetAllFilesInPath
- Note: unexported fields is a breaking change, will document in release notes
2026-05-01 13:17:39 -07:00
Rodin 43041a00f5 fix: rewrite action.yml (was corrupted with duplicate keys)
CI / test (pull_request) Successful in 11s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m34s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m33s
Clean single definition of all inputs: temperature, timeout,
patterns-repo, patterns-files. Also added runner requirements
comment at the top.
2026-05-01 13:08:18 -07:00
Rodin 1da61e514d feat: make LLM timeout configurable (default 5min)
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m6s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m14s
New flag: --llm-timeout / LLM_TIMEOUT (seconds, default 300)
New builder: llmClient.WithTimeout(duration)
Composite action: new timeout input

Keeps 5 minutes as the sensible default but allows tuning for
larger repos or slower models.
2026-05-01 13:04:00 -07:00
Rodin 401e94d3e4 fix: increase LLM client timeout to 5 minutes
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m13s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m43s
GPT-5-mini timed out on larger diffs (2min was too short).
LLM calls for code review with full file context can take 2-4min.
2026-05-01 13:00:36 -07:00
Rodin cedb5e7b90 fix: address all review findings on PR #14
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 48s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Failing after 2m12s
- gitea.Client: add concurrency safety doc comment
- gitea.Client: set 30s HTTP client timeout as safety net
- llm.Client: add concurrency safety doc comment
- llm.Client: set 2min HTTP client timeout (LLM calls are slow)
- gitea/client.go: gofmt to fix indentation
- integration_test: update to current BuildSystemPrompt/BuildUserPrompt signatures
- integration_test: use strings.SplitN for owner/repo parsing
2026-05-01 12:56:07 -07:00
Rodin ecebd52371 fix: log warnings instead of swallowing errors
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m29s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m44s
- GetAllFilesInPath: log.Printf when file fetch or dir recursion fails
- integration_test: use strings.SplitN for owner/repo parsing (idiomatic)

Addresses GPT review findings #1, #2.
2026-05-01 12:45:52 -07:00
Rodin 27e0056f29 feat: add context.Context + unexport client fields
CI / test (pull_request) Successful in 13s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 54s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m22s
REVIEW.md findings 1-4, 14:
- All Gitea client methods now accept context.Context as first param
- All LLM client methods now accept context.Context as first param
- Use http.NewRequestWithContext for cancellation/timeout support
- Main uses 3-minute timeout context for all operations
- Unexport Client struct fields (baseURL, token, apiKey, etc.)
- Use bytes.NewReader instead of strings.NewReader(string(...))
2026-05-01 12:31:41 -07:00
aweiker ffca0eb016 Merge pull request 'docs: add comprehensive code review report (vs go-patterns)' (#1) from docs/code-review-report into main
CI / test (push) Successful in 14s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
Reviewed-on: #1
2026-05-01 19:25:16 +00:00
8 changed files with 196 additions and 111 deletions
+8 -2
View File
@@ -1,6 +1,7 @@
# This composite action is designed for Gitea Actions runners.
# Gitea Actions supports GitHub Actions syntax including $GITHUB_OUTPUT,
# actions/cache, and actions/checkout.
# Requirements: python3, sha256sum, curl (all present on ubuntu-* runners).
name: 'AI Code Review'
description: 'Run AI-powered code review on a pull request using review-bot'
@@ -38,17 +39,21 @@ inputs:
required: false
default: ''
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
default: ''
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
default: 'README.md'
temperature:
description: 'LLM temperature (0 = server default)'
required: false
default: '0'
timeout:
description: 'LLM request timeout in seconds (default 300)'
required: false
default: '300'
version:
description: 'review-bot version to install (e.g. v0.1.0, defaults to latest)'
required: false
@@ -134,6 +139,7 @@ runs:
PATTERNS_REPO: ${{ inputs.patterns-repo }}
PATTERNS_FILES: ${{ inputs.patterns-files }}
LLM_TEMPERATURE: ${{ inputs.temperature }}
LLM_TIMEOUT: ${{ inputs.timeout }}
run: |
ARGS=""
if [ "${{ inputs.dry-run }}" = "true" ]; then
+3 -1
View File
@@ -16,7 +16,9 @@ jobs:
go-version: '1.26'
- name: Run tests
run: go test ./...
run: |
go vet ./...
go test ./...
- name: Build binaries
run: |
+48 -13
View File
@@ -1,12 +1,14 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/llm"
@@ -16,6 +18,7 @@ import (
var version = "dev"
func main() {
versionFlag := flag.Bool("version", false, "Print version and exit")
// CLI flags
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL")
repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)")
@@ -30,9 +33,17 @@ func main() {
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")
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)")
flag.Parse()
if *versionFlag {
fmt.Printf("review-bot %s\n", version)
os.Exit(0)
}
log.Printf("review-bot %s", version)
// Validate required fields
if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" ||
*llmBaseURL == "" || *llmAPIKey == "" || *llmModel == "" {
@@ -63,18 +74,26 @@ func main() {
if *llmTemp > 0 {
llmClient.WithTemperature(*llmTemp)
}
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)
// Step 1: Fetch PR metadata
pr, err := giteaClient.GetPullRequest(owner, repoName, prNumber)
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
if err != nil {
log.Fatalf("Failed to fetch PR: %v", err)
}
log.Printf("PR: %s", pr.Title)
// Step 2: Fetch diff
diff, err := giteaClient.GetPullRequestDiff(owner, repoName, prNumber)
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
if err != nil {
log.Fatalf("Failed to fetch diff: %v", err)
}
@@ -82,11 +101,11 @@ func main() {
// Step 3: Fetch full file content for modified files
fileContext := ""
files, err := giteaClient.GetPullRequestFiles(owner, repoName, prNumber)
files, err := giteaClient.GetPullRequestFiles(ctx, owner, repoName, prNumber)
if err != nil {
log.Printf("Warning: could not fetch PR files list: %v", err)
} 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))
}
@@ -94,7 +113,7 @@ func main() {
ciPassed := true
ciDetails := ""
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 {
log.Printf("Warning: could not fetch CI status: %v", err)
} else {
@@ -106,7 +125,7 @@ func main() {
// Step 5: Load conventions file if specified
conventions := ""
if *conventionsFile != "" {
content, err := giteaClient.GetFileContent(owner, repoName, *conventionsFile)
content, err := giteaClient.GetFileContent(ctx, owner, repoName, *conventionsFile)
if err != nil {
log.Printf("Warning: could not load conventions file %q: %v", *conventionsFile, err)
} else {
@@ -118,7 +137,7 @@ func main() {
// Step 6: Load patterns from external repo if specified
patterns := ""
if *patternsRepo != "" {
patterns = fetchPatterns(giteaClient, *patternsRepo, *patternsFiles)
patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles)
log.Printf("Loaded patterns from %s (%d bytes)", *patternsRepo, len(patterns))
}
@@ -133,7 +152,7 @@ func main() {
{Role: "user", Content: userPrompt},
}
response, err := llmClient.Complete(messages)
response, err := llmClient.Complete(ctx, messages)
if err != nil {
log.Fatalf("LLM request failed: %v", err)
}
@@ -158,20 +177,23 @@ func main() {
}
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.Printf("Review posted successfully!")
}
// 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
for _, f := range files {
if ctx.Err() != nil {
break
}
if f.Status == "removed" {
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 {
log.Printf("Warning: could not fetch %s: %v", f.Filename, err)
continue
@@ -188,13 +210,16 @@ func fetchFileContext(client *gitea.Client, owner, repo, ref string, files []git
// patternsRepo is comma-separated list of owner/name repos.
// 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.
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
repos := strings.Split(patternsRepo, ",")
paths := strings.Split(patternsFiles, ",")
for _, repoRef := range repos {
if ctx.Err() != nil {
break
}
repoRef = strings.TrimSpace(repoRef)
if repoRef == "" {
continue
@@ -212,7 +237,7 @@ func fetchPatterns(client *gitea.Client, patternsRepo, patternsFiles string) str
continue
}
files, err := client.GetAllFilesInPath(owner, repo, path)
files, err := client.GetAllFilesInPath(ctx, owner, repo, path)
if err != nil {
log.Printf("Warning: could not fetch %s from %s: %v", path, repoRef, err)
continue
@@ -279,3 +304,13 @@ func envOrDefaultFloat(key string, defaultVal float64) float64 {
}
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
}
+50 -42
View File
@@ -1,26 +1,32 @@
package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
)
// Client interacts with the Gitea API.
// A Client is safe for concurrent use by multiple goroutines.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
baseURL string
token string
http *http.Client
}
// NewClient creates a new Gitea API client.
func NewClient(baseURL, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTP: &http.Client{},
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
http: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -49,9 +55,9 @@ type ChangedFile struct {
}
// GetPullRequest fetches PR metadata.
func (c *Client) GetPullRequest(owner, repo string, number int) (*PullRequest, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.BaseURL, owner, repo, number)
body, err := c.doGet(url)
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)
body, err := c.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("fetch PR: %w", err)
}
@@ -63,9 +69,9 @@ func (c *Client) GetPullRequest(owner, repo string, number int) (*PullRequest, e
}
// GetPullRequestDiff fetches the unified diff for a PR.
func (c *Client) GetPullRequestDiff(owner, repo string, number int) (string, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.BaseURL, owner, repo, number)
body, err := c.doGet(url)
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)
body, err := c.doGet(ctx, url)
if err != nil {
return "", fmt.Errorf("fetch diff: %w", err)
}
@@ -73,9 +79,9 @@ func (c *Client) GetPullRequestDiff(owner, repo string, number int) (string, err
}
// GetPullRequestFiles fetches the list of files changed in a PR.
func (c *Client) GetPullRequestFiles(owner, repo string, number int) ([]ChangedFile, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.BaseURL, owner, repo, number)
body, err := c.doGet(url)
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)
body, err := c.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("fetch PR files: %w", err)
}
@@ -87,9 +93,9 @@ func (c *Client) GetPullRequestFiles(owner, repo string, number int) ([]ChangedF
}
// GetCommitStatuses fetches CI statuses for a commit SHA.
func (c *Client) GetCommitStatuses(owner, repo, sha string) ([]CommitStatus, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.BaseURL, owner, repo, sha)
body, err := c.doGet(url)
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)
body, err := c.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("fetch commit statuses: %w", err)
}
@@ -101,9 +107,9 @@ func (c *Client) GetCommitStatuses(owner, repo, sha string) ([]CommitStatus, err
}
// GetFileContent fetches a file from the default branch of a repo.
func (c *Client) GetFileContent(owner, repo, filepath string) (string, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.BaseURL, owner, repo, filepath)
body, err := c.doGet(url)
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)
body, err := c.doGet(ctx, url)
if err != nil {
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
}
@@ -111,9 +117,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.
func (c *Client) GetFileContentRef(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)
body, err := c.doGet(url)
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, owner, repo, filepath, url.QueryEscape(ref))
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
}
@@ -122,8 +128,8 @@ func (c *Client) GetFileContentRef(owner, repo, filepath, ref string) (string, e
// PostReview submits a review to a PR.
// event should be "APPROVED" or "REQUEST_CHANGES".
func (c *Client) PostReview(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)
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)
payload := struct {
Body string `json:"body"`
@@ -138,14 +144,14 @@ func (c *Client) PostReview(owner, repo string, number int, event, body string)
return fmt.Errorf("marshal review payload: %w", err)
}
req, err := http.NewRequest("POST", url, strings.NewReader(string(data)))
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
if err != nil {
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")
resp, err := c.HTTP.Do(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("post review: %w", err)
}
@@ -158,14 +164,14 @@ func (c *Client) PostReview(owner, repo string, number int, event, body string)
return nil
}
func (c *Client) doGet(url string) ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
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 {
return nil, err
}
@@ -186,9 +192,9 @@ type ContentEntry struct {
}
// ListContents lists files and directories at a given path in a repo.
func (c *Client) ListContents(owner, repo, path string) ([]ContentEntry, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.BaseURL, owner, repo, path)
body, err := c.doGet(url)
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, path)
body, err := c.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("list contents %s: %w", path, err)
}
@@ -202,14 +208,14 @@ func (c *Client) ListContents(owner, repo, path string) ([]ContentEntry, error)
// 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 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)
// Try listing as directory first
entries, err := c.ListContents(owner, repo, path)
entries, err := c.ListContents(ctx, owner, repo, path)
if err != nil {
// 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 {
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err)
}
@@ -220,14 +226,16 @@ func (c *Client) GetAllFilesInPath(owner, repo, path string) (map[string]string,
for _, entry := range entries {
switch entry.Type {
case "file":
content, err := c.GetFileContent(owner, repo, entry.Path)
content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
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
case "dir":
subResults, err := c.GetAllFilesInPath(owner, repo, entry.Path)
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
if err != nil {
log.Printf("Warning: could not recurse into %s: %v", entry.Path, err)
continue
}
for k, v := range subResults {
+13 -12
View File
@@ -1,6 +1,7 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -28,7 +29,7 @@ func TestGetPullRequest(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -55,7 +56,7 @@ func TestGetPullRequestDiff(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -80,7 +81,7 @@ func TestGetCommitStatuses(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -127,7 +128,7 @@ func TestPostReview(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -141,7 +142,7 @@ func TestGetPullRequest_Non200(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetPullRequest("owner", "repo", 999)
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 999)
if err == nil {
t.Fatal("expected error for 404, got nil")
}
@@ -154,7 +155,7 @@ func TestGetPullRequest_BadJSON(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetPullRequest("owner", "repo", 1)
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
if err == nil {
t.Fatal("expected error for bad JSON, got nil")
}
@@ -168,7 +169,7 @@ func TestPostReview_Non200(t *testing.T) {
defer server.Close()
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 {
t.Fatal("expected error for 403, got nil")
}
@@ -186,7 +187,7 @@ func TestGetFileContent(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -206,7 +207,7 @@ func TestGetPullRequestFiles(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -231,7 +232,7 @@ func TestGetFileContentRef(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -251,7 +252,7 @@ func TestListContents(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -282,7 +283,7 @@ func TestGetAllFilesInPath_File(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
+13 -12
View File
@@ -3,8 +3,10 @@
package main
import (
"context"
"os"
"strconv"
"strings"
"testing"
"gitea.weiker.me/rodin/review-bot/gitea"
@@ -42,28 +44,27 @@ func TestIntegration_FullReviewFlow(t *testing.T) {
}
// Parse owner/repo
owner, repoName := "", ""
for i, c := range giteaRepo {
if c == / {
owner = giteaRepo[:i]
repoName = giteaRepo[i+1:]
break
}
parts := strings.SplitN(giteaRepo, "/", 2)
if len(parts) != 2 {
t.Fatalf("Invalid repo format %q", giteaRepo)
}
owner, repoName := parts[0], parts[1]
if owner == "" || repoName == "" {
t.Fatalf("Invalid repo format %q", giteaRepo)
}
ctx := context.Background()
// Step 1: Fetch PR
giteaClient := gitea.NewClient(giteaURL, giteaToken)
pr, err := giteaClient.GetPullRequest(owner, repoName, prNumber)
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
if err != nil {
t.Fatalf("GetPullRequest: %v", err)
}
t.Logf("PR: %s (sha: %s)", pr.Title, pr.Head.Sha)
// Step 2: Fetch diff
diff, err := giteaClient.GetPullRequestDiff(owner, repoName, prNumber)
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
if err != nil {
t.Fatalf("GetPullRequestDiff: %v", err)
}
@@ -73,12 +74,12 @@ func TestIntegration_FullReviewFlow(t *testing.T) {
t.Logf("Diff size: %d bytes", len(diff))
// Step 3: Build prompts
systemPrompt := review.BuildSystemPrompt("")
userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, true, "")
systemPrompt := review.BuildSystemPrompt("", "")
userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, "", true, "")
// Step 4: Call LLM
llmClient := llm.NewClient(llmBaseURL, llmAPIKey, llmModel)
response, err := llmClient.Complete([]llm.Message{
response, err := llmClient.Complete(ctx, []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
+27 -18
View File
@@ -2,35 +2,45 @@ package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client calls an OpenAI-compatible chat completion API.
// A Client is safe for concurrent use by multiple goroutines after construction.
// WithTimeout and WithTemperature must be called during setup, before concurrent use.
type Client struct {
BaseURL string
APIKey string
Model string
Temperature float64
HTTP *http.Client
baseURL string
apiKey string
model string
temperature float64
http *http.Client
}
// NewClient creates a new LLM client.
func NewClient(baseURL, apiKey, model string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Model: model,
HTTP: &http.Client{},
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
model: model,
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).
func (c *Client) WithTemperature(t float64) *Client {
c.Temperature = t
c.temperature = t
return c
}
@@ -57,12 +67,11 @@ type ChatResponse struct {
}
// Complete sends a chat completion request and returns the assistant's response content.
func (c *Client) Complete(messages []Message) (string, error) {
func (c *Client) Complete(ctx context.Context, messages []Message) (string, error) {
reqBody := ChatRequest{
Model: c.Model,
Temperature: c.Temperature,
Model: c.model,
Temperature: c.temperature,
Messages: messages,
}
data, err := json.Marshal(reqBody)
@@ -70,15 +79,15 @@ func (c *Client) Complete(messages []Message) (string, error) {
return "", fmt.Errorf("marshal request: %w", err)
}
url := c.BaseURL + "/chat/completions"
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
url := c.baseURL + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
if err != nil {
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")
resp, err := c.HTTP.Do(req)
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("LLM request: %w", err)
}
+34 -11
View File
@@ -1,10 +1,12 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestComplete_Success(t *testing.T) {
@@ -51,7 +53,7 @@ func TestComplete_Success(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -68,7 +70,7 @@ func TestComplete_APIError(t *testing.T) {
defer server.Close()
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 {
t.Fatal("expected error for 429, got nil")
}
@@ -82,7 +84,7 @@ func TestComplete_NoChoices(t *testing.T) {
defer server.Close()
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 {
t.Fatal("expected error for no choices, got nil")
}
@@ -95,7 +97,7 @@ func TestComplete_BadJSON(t *testing.T) {
defer server.Close()
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 {
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) {
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 {
t.Fatal("expected error for connection refused, got nil")
}
@@ -111,16 +113,16 @@ func TestComplete_ServerDown(t *testing.T) {
func TestWithTemperature(t *testing.T) {
client := NewClient("http://example.com", "key", "model")
if client.Temperature != 0 {
t.Errorf("expected initial temperature 0, got %f", client.Temperature)
if client.temperature != 0 {
t.Errorf("expected initial temperature 0, got %f", client.temperature)
}
result := client.WithTemperature(0.7)
if result != client {
t.Error("WithTemperature should return the same client for chaining")
}
if client.Temperature != 0.7 {
t.Errorf("expected temperature 0.7, got %f", client.Temperature)
if client.temperature != 0.7 {
t.Errorf("expected temperature 0.7, got %f", client.temperature)
}
}
@@ -147,7 +149,7 @@ func TestComplete_TemperatureOmittedWhenZero(t *testing.T) {
defer server.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
@@ -180,8 +182,29 @@ func TestComplete_TemperatureIncludedWhenSet(t *testing.T) {
defer server.Close()
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 {
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")
}
}