Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc053cfede | |||
| 6f14549062 | |||
| f371c24dc3 | |||
| 3f2d34f4ba | |||
| dcfd360388 | |||
| 4ffa6b681d | |||
| d0b0b0b211 | |||
| 2f085fd6ba | |||
| 00047e9137 | |||
| f28c792bda | |||
| b534247c85 | |||
| 6f02cef662 | |||
| fccfdd2ff7 | |||
| e3fb19fa1b | |||
| a1bbab406d |
@@ -0,0 +1,50 @@
|
|||||||
|
# Dev Loop Health Check — 2026-05-15 03:33 UTC
|
||||||
|
|
||||||
|
## Status: ✅ ACTIVE WORK COMPLETED
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
- All packages: **PASS** ✅ (6/6, fresh -count=1 run)
|
||||||
|
- Build: ✅ successful
|
||||||
|
- Vet: ✅ clean
|
||||||
|
|
||||||
|
### Coverage (current)
|
||||||
|
|
||||||
|
| Package | Coverage |
|
||||||
|
|---------|----------|
|
||||||
|
| budget | 91.8% |
|
||||||
|
| cmd/review-bot | 46.1% |
|
||||||
|
| gitea | 85.2% |
|
||||||
|
| github | 86.3% |
|
||||||
|
| llm | 81.3% |
|
||||||
|
| review | 92.0% |
|
||||||
|
|
||||||
|
### PR #138 Status
|
||||||
|
|
||||||
|
- **Branch:** issue-137
|
||||||
|
- **Feature:** feat(#137): add doc-map input for path-scoped doc injection
|
||||||
|
- **Review status:** ✅ All 3 bots approved (sonnet, gpt, security)
|
||||||
|
- **Review findings addressed:**
|
||||||
|
- Fixed package comment collision in `review/docmap.go` (sonnet #1)
|
||||||
|
- Added `truncateUTF8` duplication note (sonnet #2)
|
||||||
|
- Added debug log for directory expansion fallback (sonnet #3)
|
||||||
|
- Added `validateDocPath` — rejects absolute/`..` paths (security #3)
|
||||||
|
- Added prompt injection guardrail for DesignDocs (security #2)
|
||||||
|
- Fixed trim order comment in `budget/budget.go` (gpt #1)
|
||||||
|
- Fixed `globMatch` comment to say `filepath.Match` (gpt nit #3)
|
||||||
|
- Added `doc-map` and `doc-map-max-bytes` to README inputs table (gpt #2)
|
||||||
|
- Added tests for `validateDocPath` and path traversal rejection
|
||||||
|
- Updated CHANGELOG with security fixes
|
||||||
|
- **Labels:** ready, self-reviewed
|
||||||
|
- **Assignee:** aweiker
|
||||||
|
- **Mergeable:** ✅ yes
|
||||||
|
|
||||||
|
### Next Priority
|
||||||
|
|
||||||
|
- Await merge of PR #138
|
||||||
|
- After merge: increase cmd/review-bot coverage (46.1% → target 60%+)
|
||||||
|
- Issue #132+: PR Submission feature
|
||||||
|
- `github.Client.DismissReview` method referenced but missing — file issue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Dev-loop cycle complete at 03:33 UTC._
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
=============================================================================
|
||||||
|
REVIEW-BOT DEV LOOP STATUS — 2026-05-15 01:48 UTC (post-sync)
|
||||||
|
=============================================================================
|
||||||
|
|
||||||
|
OVERALL STATUS: ✅ OPTIMAL
|
||||||
|
|
||||||
|
Test Results (fresh run post-sync):
|
||||||
|
- All 6 packages: PASS ✅
|
||||||
|
- Build: ✅ clean
|
||||||
|
- Vet: ✅ clean
|
||||||
|
- Fresh run: -count=1 verified
|
||||||
|
|
||||||
|
Recent Major Changes (synced from origin/main):
|
||||||
|
- Significant new GitHub client methods (~360 lines added)
|
||||||
|
- New validateurl package for URL validation
|
||||||
|
- New vcs adapter layer for VCS abstraction
|
||||||
|
- New gitea/ipcheck package for IP validation
|
||||||
|
- Expanded integration tests in cmd/review-bot
|
||||||
|
- All changes verified passing tests
|
||||||
|
|
||||||
|
Coverage (current post-sync):
|
||||||
|
- review: 92.0%
|
||||||
|
- budget: 91.8%
|
||||||
|
- github: 86.3%
|
||||||
|
- gitea: 85.2%
|
||||||
|
- llm: 81.3%
|
||||||
|
- cmd/review-bot: 46.1%
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
- Branch: main (synced with origin — 4ffa6b6)
|
||||||
|
- Working tree: clean
|
||||||
|
- Open issues: 0
|
||||||
|
- Open PRs: 0
|
||||||
|
|
||||||
|
System Health: ✅ GREEN
|
||||||
|
✓ All tests passing (33 commits synced)
|
||||||
|
✓ No warnings
|
||||||
|
✓ Code clean
|
||||||
|
✓ Ready for feature work
|
||||||
|
|
||||||
|
Next Cycle: Ready to pick up feature work
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
@@ -1,151 +1,37 @@
|
|||||||
## Dev Loop: review-bot — Continuous Health Monitor
|
## Dev Loop Status: 2026-05-15 02:28 UTC
|
||||||
|
|
||||||
### Current Cycle: 2026-05-15 02:10 UTC ✅
|
**Repository:** review-bot (rodin/review-bot on Gitea)
|
||||||
|
**Status:** ✅ OPTIMAL
|
||||||
|
|
||||||
**Repository Status:** OPTIMAL
|
### Health Check
|
||||||
- Main: `9f3f321` (clean, all tests pass)
|
|
||||||
- Working tree: clean
|
- **Working tree:** clean
|
||||||
- Build: ✅ successful
|
- **Branch:** main (up to date with origin)
|
||||||
- Vet: ✅ clean
|
- **Build:** ✅ passes (`go build ./cmd/review-bot`)
|
||||||
- Test suite: ALL PASS
|
- **Tests:** ✅ ALL PASS (6/6 packages)
|
||||||
|
- **Vet:** ✅ clean
|
||||||
|
- **Open issues:** 0
|
||||||
|
- **Open PRs:** 0
|
||||||
|
|
||||||
|
### Recent Changes
|
||||||
|
|
||||||
|
Last commit: `dcfd360` (2026-05-15 01:48) — health check post-sync
|
||||||
|
|
||||||
|
### Coverage
|
||||||
|
|
||||||
|
| Package | Coverage |
|
||||||
|
|---------|----------|
|
||||||
|
| cmd/review-bot | 46.1% |
|
||||||
|
| gitea | 85.2% |
|
||||||
|
| github | 86.3% |
|
||||||
|
| review | 92.0% |
|
||||||
|
|
||||||
|
### Next Priority
|
||||||
|
|
||||||
|
- Increase cmd/review-bot coverage (lowest at 46.1%)
|
||||||
|
- Monitor prod logs for edge cases
|
||||||
|
- VCS integration stable; GitHub + Gitea paths clear
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Latest Delivered: Issue #130 ✅
|
_Dev-loop cycle complete at 02:28 UTC._
|
||||||
|
|
||||||
### GitHub API + VCS Routing Complete
|
|
||||||
|
|
||||||
**Phase 1: GitHub API Methods** ✅
|
|
||||||
- 12+ methods implemented in `github/client.go`
|
|
||||||
- GetPullRequest, GetPullRequestDiff, GetPullRequestFiles
|
|
||||||
- GetCommitStatuses, GetFileContent, ListContents, GetAllFilesInPath
|
|
||||||
- PostReview, ListReviews, DeleteReview, GetAuthenticatedUser, RequestReviewer
|
|
||||||
|
|
||||||
**Phase 2: VCS Abstraction** ✅
|
|
||||||
- `vcsClient` interface (GitHub + Gitea)
|
|
||||||
- `giteaExtClient` interface (Gitea-specific ops)
|
|
||||||
- Adapters for both platforms
|
|
||||||
- URL-based auto-detection (github.com → GitHub, else Gitea)
|
|
||||||
- `--vcs-type` flag and `VCS_TYPE` env override
|
|
||||||
|
|
||||||
**Quality Metrics** ✅
|
|
||||||
- 474 lines of GitHub client tests
|
|
||||||
- 82 lines of routing tests
|
|
||||||
- 361 lines of VCS adapter code
|
|
||||||
- Security review: APPROVED (MINOR: URL heuristic note)
|
|
||||||
- All tests passing; go vet clean
|
|
||||||
|
|
||||||
**Known Limitations** (Documented)
|
|
||||||
- GitHub: Can only delete PENDING (draft) reviews, not submitted (handled gracefully)
|
|
||||||
- GitHub pagination: per-page=100 with Link header checking
|
|
||||||
- Check-runs: Uses statuses API; check-runs deferrable to future enhancement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Status Post-Merge
|
|
||||||
|
|
||||||
### Main Branch
|
|
||||||
- Commit: `9f3f321`
|
|
||||||
- Status: ✅ All systems healthy
|
|
||||||
|
|
||||||
### Recent Merged PRs
|
|
||||||
| PR | Issue | Title | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| #131 | #130 | GitHub API methods & VCS routing | ✅ MERGED |
|
|
||||||
| #129 | #123 | IP-level SSRF defense | ✅ MERGED |
|
|
||||||
| #128 | #125 | VCS_URL deprecation & renaming | ✅ MERGED |
|
|
||||||
| #127 | #124 | Multi-arch binary support | ✅ MERGED |
|
|
||||||
| #126 | #120 | GitHub Actions composite action | ✅ MERGED |
|
|
||||||
|
|
||||||
### Closed Issues
|
|
||||||
- #130, #123, #125, #124, #120
|
|
||||||
|
|
||||||
### Open Issues
|
|
||||||
- None blocking; backlog tracked in Gitea project board
|
|
||||||
|
|
||||||
### Worktrees
|
|
||||||
- All cleaned up; no stale branches
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Completeness Summary
|
|
||||||
|
|
||||||
### ✅ Core Functionality
|
|
||||||
- Multi-provider LLM support (OpenAI, Anthropic, SAP AI Core)
|
|
||||||
- Gitea PR review (mature, proven)
|
|
||||||
- **NEW: GitHub PR review (fully implemented)**
|
|
||||||
- VCS abstraction (Gitea/GitHub transparent routing)
|
|
||||||
- SSRF defense with IP-level validation
|
|
||||||
- Multi-architecture binary deployment
|
|
||||||
|
|
||||||
### ✅ Review Quality
|
|
||||||
- Structured reviews with code snippets
|
|
||||||
- LLM-driven analysis
|
|
||||||
- Persona-based customization
|
|
||||||
- Context awareness
|
|
||||||
|
|
||||||
### ✅ Security
|
|
||||||
- RFC6598 CGN detection
|
|
||||||
- HTTPS enforcement
|
|
||||||
- Redirect safety
|
|
||||||
- Credential handling (no logs, no reflection leaks)
|
|
||||||
- URL validation for VCS API access
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Phase: Backlog Priorities
|
|
||||||
|
|
||||||
### Priority 1: PR Submission
|
|
||||||
**Issue:** #132+ (create)
|
|
||||||
**Goal:** Enable review-bot to create PRs (not just post reviews)
|
|
||||||
**Scope:** PR creation flow, commit logic, test coverage
|
|
||||||
**Est. Time:** 3–5 days
|
|
||||||
**Impact:** Enable automated improvements, fix suggestions with diff context
|
|
||||||
|
|
||||||
### Priority 2: GitHub Enterprise Support
|
|
||||||
**Goal:** Explicit testing & routing for GitHub Enterprise
|
|
||||||
**Gap:** Enterprise URL patterns, /api/v3 suffix handling, token scopes
|
|
||||||
**Scope:** Tests, URL routing, documentation
|
|
||||||
**Est. Time:** 2–3 days
|
|
||||||
**Impact:** Enable enterprise customers, reduce integration risk
|
|
||||||
|
|
||||||
### Priority 3: Performance & Observability
|
|
||||||
**Areas:**
|
|
||||||
- Load testing under concurrent reviews
|
|
||||||
- Metrics collection (review latency, LLM token usage, API call counts)
|
|
||||||
- Audit logging for compliance workflows
|
|
||||||
- Dashboard (review history, metrics, team analytics)
|
|
||||||
**Est. Time:** 5–7 days
|
|
||||||
**Impact:** Operational confidence, troubleshooting, compliance
|
|
||||||
|
|
||||||
### Priority 4: Enhanced Context
|
|
||||||
**Opportunities:**
|
|
||||||
- Semantic code understanding (AST-based analysis for specific languages)
|
|
||||||
- Project-specific review rules (.review-bot.yaml in repo root)
|
|
||||||
- Team-level customization
|
|
||||||
**Est. Time:** 7–10 days
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dev Loop Schedule
|
|
||||||
|
|
||||||
- **Interval:** 4 hours
|
|
||||||
- **Next check:** ~6:10 AM UTC (May 15)
|
|
||||||
- **Health:** ✅ Optimal — all systems running
|
|
||||||
- **Status:** Ready for next phase work
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
| Key | Value |
|
|
||||||
|---|---|
|
|
||||||
| Repo | `/home/ubuntu/review-bot` |
|
|
||||||
| Main SHA | `9f3f321` |
|
|
||||||
| Last update | 2026-05-15 02:10 UTC |
|
|
||||||
| Status | All systems optimal |
|
|
||||||
| Next phase | PR submission or GitHub Enterprise support |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Summary:** review-bot now supports both GitHub and Gitea PR reviews with a unified abstraction layer. All tests pass, code is clean, security is approved. Ready to move to PR submission or GitHub Enterprise support in the next cycle.
|
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ func TestFit_PreservesNoteInOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestFit_HugeUserMeta(t *testing.T) {
|
func TestFit_HugeUserMeta(t *testing.T) {
|
||||||
// UserMeta so large that base alone exceeds limit
|
// UserMeta so large that base alone exceeds limit
|
||||||
// Use a unique marker past the truncation point
|
// Use a unique marker past the truncation point
|
||||||
|
|||||||
@@ -901,5 +901,3 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
|
|||||||
}
|
}
|
||||||
return evaluatedSHA != currentSHA
|
return evaluatedSHA != currentSHA
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+278
-2
@@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/review"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateReviewerName(t *testing.T) {
|
func TestValidateReviewerName(t *testing.T) {
|
||||||
@@ -820,8 +823,8 @@ func TestExtractSentinelName_EdgeCases(t *testing.T) {
|
|||||||
{"<!-- review-bot:sonnet --> rest", "sonnet"},
|
{"<!-- review-bot:sonnet --> rest", "sonnet"},
|
||||||
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
|
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
|
||||||
{"no sentinel here", "unknown"},
|
{"no sentinel here", "unknown"},
|
||||||
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
|
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
|
||||||
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
|
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -1107,3 +1110,276 @@ func TestShouldSkipStaleReview(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mock vcsClient for unit tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// mockVCSClient is a minimal mock of vcsClient for testing helper functions.
|
||||||
|
// Only the methods exercised by the test code need implementations; all others
|
||||||
|
// panic with a clear message to catch accidental calls.
|
||||||
|
type mockVCSClient struct {
|
||||||
|
fileContents map[string]string // key: "owner/repo/ref/path"
|
||||||
|
fileContentsErr map[string]error // key same as above → error to return
|
||||||
|
dirContents map[string][]review.ContentEntry
|
||||||
|
dirContentsErr map[string]error
|
||||||
|
allFiles map[string]map[string]string // key: "owner/repo/path"
|
||||||
|
allFilesErr map[string]error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) key(owner, repo, extra string) string {
|
||||||
|
return owner + "/" + repo + "/" + extra
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error) {
|
||||||
|
panic("GetPullRequest not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
||||||
|
panic("GetPullRequestDiff not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error) {
|
||||||
|
panic("GetPullRequestFiles not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error) {
|
||||||
|
panic("GetCommitStatuses not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
||||||
|
panic("GetFileContent not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetFileContentRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
||||||
|
k := m.key(owner, repo, ref+"/"+path)
|
||||||
|
if err, ok := m.fileContentsErr[k]; ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if content, ok := m.fileContents[k]; ok {
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("HTTP 404: not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
|
||||||
|
k := m.key(owner, repo, path)
|
||||||
|
if err, ok := m.dirContentsErr[k]; ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entries, ok := m.dirContents[k]; ok {
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("HTTP 404: not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
|
||||||
|
k := m.key(owner, repo, path)
|
||||||
|
if err, ok := m.allFilesErr[k]; ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if files, ok := m.allFiles[k]; ok {
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("HTTP 404: not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
||||||
|
panic("PostReview not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error) {
|
||||||
|
panic("ListReviews not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
||||||
|
panic("DeleteReview not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
||||||
|
panic("GetAuthenticatedUser not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockVCSClient) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
|
||||||
|
panic("RequestReviewer not implemented in mockVCSClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// fetchFileContext tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestFetchFileContext_NoFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{}
|
||||||
|
got := fetchFileContext(ctx, client, "owner", "repo", "main", nil)
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string for no files, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFileContext_SkipsRemovedFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{}
|
||||||
|
files := []vcsChangedFile{
|
||||||
|
{Filename: "gone.go", Status: "removed"},
|
||||||
|
}
|
||||||
|
got := fetchFileContext(ctx, client, "owner", "repo", "main", files)
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string for removed file, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFileContext_FetchesModifiedFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{
|
||||||
|
fileContents: map[string]string{
|
||||||
|
"owner/repo/main/foo.go": "package main\n\nfunc main() {}\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files := []vcsChangedFile{
|
||||||
|
{Filename: "foo.go", Status: "modified"},
|
||||||
|
}
|
||||||
|
got := fetchFileContext(ctx, client, "owner", "repo", "main", files)
|
||||||
|
if !strings.Contains(got, "--- foo.go ---") {
|
||||||
|
t.Errorf("expected file header in output, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "package main") {
|
||||||
|
t.Errorf("expected file content in output, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFileContext_ContinuesOnError(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{
|
||||||
|
fileContents: map[string]string{
|
||||||
|
"owner/repo/main/good.go": "package good\n",
|
||||||
|
},
|
||||||
|
fileContentsErr: map[string]error{
|
||||||
|
"owner/repo/main/bad.go": fmt.Errorf("network error"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files := []vcsChangedFile{
|
||||||
|
{Filename: "bad.go", Status: "modified"},
|
||||||
|
{Filename: "good.go", Status: "modified"},
|
||||||
|
}
|
||||||
|
got := fetchFileContext(ctx, client, "owner", "repo", "main", files)
|
||||||
|
// bad.go fails, good.go should still be included
|
||||||
|
if strings.Contains(got, "bad.go") {
|
||||||
|
t.Errorf("should not include failed file, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "good.go") {
|
||||||
|
t.Errorf("should include successful file, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFileContext_RespectsContextCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
client := &mockVCSClient{
|
||||||
|
fileContents: map[string]string{
|
||||||
|
"owner/repo/main/foo.go": "package foo\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files := []vcsChangedFile{
|
||||||
|
{Filename: "foo.go", Status: "modified"},
|
||||||
|
}
|
||||||
|
got := fetchFileContext(ctx, client, "owner", "repo", "main", files)
|
||||||
|
// With cancelled context, the loop breaks before fetching
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string with cancelled context, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// fetchPatterns tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestFetchPatterns_EmptyRepo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{}
|
||||||
|
got := fetchPatterns(ctx, client, "", "")
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string for empty patternsRepo, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchPatterns_SingleRepoAllFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{
|
||||||
|
allFiles: map[string]map[string]string{
|
||||||
|
"rodin/patterns/": {
|
||||||
|
"patterns/go.md": "# Go patterns\n\nUse interfaces.",
|
||||||
|
"patterns/binary": "binary data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := fetchPatterns(ctx, client, "rodin/patterns", "")
|
||||||
|
if !strings.Contains(got, "# Go patterns") {
|
||||||
|
t.Errorf("expected markdown content, got: %q", got)
|
||||||
|
}
|
||||||
|
// Binary file should be excluded
|
||||||
|
if strings.Contains(got, "binary data") {
|
||||||
|
t.Errorf("binary file should be excluded, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchPatterns_SpecificFiles(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{
|
||||||
|
allFiles: map[string]map[string]string{
|
||||||
|
"rodin/patterns/go.md": {
|
||||||
|
"go.md": "# Go idioms\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := fetchPatterns(ctx, client, "rodin/patterns", "go.md")
|
||||||
|
if !strings.Contains(got, "# Go idioms") {
|
||||||
|
t.Errorf("expected go idioms content, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchPatterns_SkipsInvalidRepo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{}
|
||||||
|
// "badrepo" has no slash, should be skipped
|
||||||
|
got := fetchPatterns(ctx, client, "badrepo", "")
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string for invalid repo format, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchPatterns_ContinuesOnFetchError(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{
|
||||||
|
allFilesErr: map[string]error{
|
||||||
|
"owner/repo/": fmt.Errorf("server error"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Should not panic; should return empty string
|
||||||
|
got := fetchPatterns(ctx, client, "owner/repo", "")
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string on fetch error, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchPatterns_MultipleRepos(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &mockVCSClient{
|
||||||
|
allFiles: map[string]map[string]string{
|
||||||
|
"org/go-patterns/": {
|
||||||
|
"idioms.md": "# Go idioms\n",
|
||||||
|
},
|
||||||
|
"org/elixir-patterns/": {
|
||||||
|
"pipes.md": "# Elixir pipes\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := fetchPatterns(ctx, client, "org/go-patterns, org/elixir-patterns", "")
|
||||||
|
if !strings.Contains(got, "# Go idioms") {
|
||||||
|
t.Errorf("expected Go idioms content, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "# Elixir pipes") {
|
||||||
|
t.Errorf("expected Elixir pipes content, got: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -971,6 +971,7 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
|
|||||||
t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts)
|
t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockTransport is a test helper that returns errors for the first N calls,
|
// mockTransport is a test helper that returns errors for the first N calls,
|
||||||
// then delegates to a real server.
|
// then delegates to a real server.
|
||||||
type mockTransport struct {
|
type mockTransport struct {
|
||||||
@@ -1419,3 +1420,80 @@ func TestNewSafeHTTPClient_PreservesDefaultTransportSettings(t *testing.T) {
|
|||||||
t.Error("DialContext is nil; expected safeDialContext")
|
t.Error("DialContext is nil; expected safeDialContext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTimelineReviewCommentIDForReview(t *testing.T) {
|
||||||
|
const reviewID = int64(42)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/repo/pulls/5/reviews/42":
|
||||||
|
w.Write([]byte(`{"body": "The review body <!-- review-bot:sonnet -->", "user": {"login": "sonnet-review"}}`))
|
||||||
|
case "/api/v1/repos/owner/repo/issues/5/timeline":
|
||||||
|
w.Write([]byte(`[
|
||||||
|
{"id": 100, "type": "comment", "body": "unrelated", "user": {"login": "sonnet-review"}},
|
||||||
|
{"id": 200, "type": "review", "body": "The review body <!-- review-bot:sonnet -->", "user": {"login": "sonnet-review"}}
|
||||||
|
]`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewTestClient(server.URL, "test-token")
|
||||||
|
id, err := client.GetTimelineReviewCommentIDForReview(context.Background(), "owner", "repo", 5, reviewID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTimelineReviewCommentIDForReview() error = %v", err)
|
||||||
|
}
|
||||||
|
if id != 200 {
|
||||||
|
t.Errorf("got id=%d, want 200", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTimelineReviewCommentIDForReview_ReviewFetchError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewTestClient(server.URL, "test-token")
|
||||||
|
_, err := client.GetTimelineReviewCommentIDForReview(context.Background(), "owner", "repo", 5, 99)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing review, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTimelineReviewCommentIDForReview_EmptyBody(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"body": "", "user": {"login": "bot"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewTestClient(server.URL, "test-token")
|
||||||
|
_, err := client.GetTimelineReviewCommentIDForReview(context.Background(), "owner", "repo", 5, 42)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty body, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "empty body") {
|
||||||
|
t.Errorf("error = %q, want to contain 'empty body'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTimelineReviewCommentIDForReview_NotFoundInTimeline(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/repo/pulls/5/reviews/42":
|
||||||
|
w.Write([]byte(`{"body": "review content <!-- review-bot:sonnet -->", "user": {"login": "bot"}}`))
|
||||||
|
default:
|
||||||
|
// Timeline returns events that don't match (different user)
|
||||||
|
w.Write([]byte(`[{"id": 1, "type": "review", "body": "review content <!-- review-bot:sonnet -->", "user": {"login": "other-user"}}]`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewTestClient(server.URL, "test-token")
|
||||||
|
_, err := client.GetTimelineReviewCommentIDForReview(context.Background(), "owner", "repo", 5, 42)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when review not found in timeline, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ func TestIsBlockedIP(t *testing.T) {
|
|||||||
{"public 8.8.8.8", "8.8.8.8"},
|
{"public 8.8.8.8", "8.8.8.8"},
|
||||||
{"public 1.1.1.1", "1.1.1.1"},
|
{"public 1.1.1.1", "1.1.1.1"},
|
||||||
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
|
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
|
||||||
// not assigned to any real host, but intentionally left unblocked here because
|
// not assigned to any real host, but intentionally left unblocked here because
|
||||||
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
|
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
|
||||||
// blocking it would require tracking every RFC5737 range without meaningful
|
// blocking it would require tracking every RFC5737 range without meaningful
|
||||||
// security benefit (no server should ever listen on a TEST-NET address).
|
// security benefit (no server should ever listen on a TEST-NET address).
|
||||||
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
||||||
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
||||||
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -463,9 +463,9 @@ type ChangedFile struct {
|
|||||||
type ReviewComment struct {
|
type ReviewComment struct {
|
||||||
ID int64 `json:"id,omitempty"`
|
ID int64 `json:"id,omitempty"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Position int64 `json:"position,omitempty"` // GitHub diff hunk position
|
Position int64 `json:"position,omitempty"` // GitHub diff hunk position
|
||||||
Line int64 `json:"line,omitempty"` // GitHub absolute line number (alternative to position)
|
Line int64 `json:"line,omitempty"` // GitHub absolute line number (alternative to position)
|
||||||
Side string `json:"side,omitempty"` // "RIGHT" or "LEFT"
|
Side string `json:"side,omitempty"` // "RIGHT" or "LEFT"
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1130,3 +1130,98 @@ func TestEscapePath_SpecialChars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAllFilesInPath_DirectoryWithFiles(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/repos/owner/repo/contents/patterns":
|
||||||
|
// Directory listing
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`[{"name":"go.md","path":"patterns/go.md","type":"file"}]`))
|
||||||
|
case "/repos/owner/repo/contents/patterns/go.md":
|
||||||
|
// GitHub file response with base64 content
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"name":"go.md","path":"patterns/go.md","type":"file","encoding":"base64","content":"IyBHbyBwYXR0ZXJucwo="}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
files, err := c.GetAllFilesInPath(context.Background(), "owner", "repo", "patterns")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("len(files) = %d, want 1", len(files))
|
||||||
|
}
|
||||||
|
if files["patterns/go.md"] != "# Go patterns\n" {
|
||||||
|
t.Errorf("unexpected content: %q", files["patterns/go.md"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/repos/owner/repo/contents/README.md":
|
||||||
|
// ListContents returns 404 for file paths
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"message":"Not Found"}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
// GetFileContent also goes to /contents/ — this will 404 too.
|
||||||
|
// The function should return the path-not-found error.
|
||||||
|
_, err := c.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when both dir and file 404, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllFilesInPath_DirectoryWithSubdir(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/repos/owner/repo/contents/src":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`[
|
||||||
|
{"name":"main.go","path":"src/main.go","type":"file"},
|
||||||
|
{"name":"sub","path":"src/sub","type":"dir"}
|
||||||
|
]`))
|
||||||
|
case "/repos/owner/repo/contents/src/main.go":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"name":"main.go","path":"src/main.go","type":"file","encoding":"base64","content":"cGFja2FnZSBtYWluCg=="}`))
|
||||||
|
case "/repos/owner/repo/contents/src/sub":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`[{"name":"util.go","path":"src/sub/util.go","type":"file"}]`))
|
||||||
|
case "/repos/owner/repo/contents/src/sub/util.go":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"name":"util.go","path":"src/sub/util.go","type":"file","encoding":"base64","content":"cGFja2FnZSBzdWIK"}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
files, err := c.GetAllFilesInPath(context.Background(), "owner", "repo", "src")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 2 {
|
||||||
|
t.Fatalf("len(files) = %d, want 2: %v", len(files), files)
|
||||||
|
}
|
||||||
|
if files["src/main.go"] != "package main\n" {
|
||||||
|
t.Errorf("src/main.go content unexpected: %q", files["src/main.go"])
|
||||||
|
}
|
||||||
|
if files["src/sub/util.go"] != "package sub\n" {
|
||||||
|
t.Errorf("src/sub/util.go content unexpected: %q", files["src/sub/util.go"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-5
@@ -207,11 +207,11 @@ func (c *Client) completeOpenAI(ctx context.Context, messages []Message) (string
|
|||||||
|
|
||||||
type anthropicRequest struct {
|
type anthropicRequest struct {
|
||||||
AnthropicVersion string `json:"anthropic_version,omitempty"`
|
AnthropicVersion string `json:"anthropic_version,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens"`
|
MaxTokens int `json:"max_tokens"`
|
||||||
System string `json:"system,omitempty"`
|
System string `json:"system,omitempty"`
|
||||||
Messages []anthropicMsg `json:"messages"`
|
Messages []anthropicMsg `json:"messages"`
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type anthropicMsg struct {
|
type anthropicMsg struct {
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ func TestWithTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestComplete_Anthropic_Success(t *testing.T) {
|
func TestComplete_Anthropic_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/messages" {
|
if r.URL.Path != "/messages" {
|
||||||
|
|||||||
+49
-1
@@ -355,7 +355,7 @@ func TestCapitalizeFirst(t *testing.T) {
|
|||||||
{"HELLO", "HELLO"},
|
{"HELLO", "HELLO"},
|
||||||
{"a", "A"},
|
{"a", "A"},
|
||||||
{"", ""},
|
{"", ""},
|
||||||
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
|
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
|
||||||
{"über", "Über"}, // German umlaut
|
{"über", "Über"}, // German umlaut
|
||||||
{"élève", "Élève"}, // French accent
|
{"élève", "Élève"}, // French accent
|
||||||
}
|
}
|
||||||
@@ -957,3 +957,51 @@ func TestYAMLMergeKeyDepthCheck(t *testing.T) {
|
|||||||
t.Errorf("error = %q, want to contain 'depth'", err.Error())
|
t.Errorf("error = %q, want to contain 'depth'", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadPersona_NonexistentFile(t *testing.T) {
|
||||||
|
_, err := LoadPersona("/tmp/nonexistent-persona-file-xyz.yaml")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPersona_NotARegularFile(t *testing.T) {
|
||||||
|
// Use a directory as the path — directories are not regular files.
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := LoadPersona(dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for directory path, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a regular file") {
|
||||||
|
t.Errorf("error = %q, want to contain 'not a regular file'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPersona_OversizedFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "big.yaml")
|
||||||
|
// Write a file larger than MaxPersonaFileSize
|
||||||
|
data := make([]byte, MaxPersonaFileSize+1)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = 'x'
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
_, err := LoadPersona(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for oversized file, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
||||||
|
t.Errorf("error = %q, want to contain 'exceeds maximum size'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapitalizeFirst_RuneError(t *testing.T) {
|
||||||
|
// An invalid UTF-8 byte sequence should return the original string unchanged.
|
||||||
|
invalid := string([]byte{0xFF, 0xFE})
|
||||||
|
got := CapitalizeFirst(invalid)
|
||||||
|
if got != invalid {
|
||||||
|
t.Errorf("CapitalizeFirst(%q) = %q, want original %q", invalid, got, invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ func TestBuildUserPrompt_WithoutFileContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestBuildSystemBase(t *testing.T) {
|
func TestBuildSystemBase(t *testing.T) {
|
||||||
result := BuildSystemBase()
|
result := BuildSystemBase()
|
||||||
if result == "" {
|
if result == "" {
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
|
|
||||||
func TestParsePersonaBytes(t *testing.T) {
|
func TestParsePersonaBytes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data string
|
data string
|
||||||
source string
|
source string
|
||||||
wantName string
|
wantName string
|
||||||
wantErr string
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid yaml",
|
name: "valid yaml",
|
||||||
@@ -38,8 +38,8 @@ focus:
|
|||||||
wantErr: "parse",
|
wantErr: "parse",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "json format by extension",
|
name: "json format by extension",
|
||||||
data: `{"name": "jsontest", "identity": "json identity"}`,
|
data: `{"name": "jsontest", "identity": "json identity"}`,
|
||||||
source: "test.json",
|
source: "test.json",
|
||||||
wantName: "jsontest",
|
wantName: "jsontest",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user