Compare commits

..

2 Commits

Author SHA1 Message Date
Rodin 10ef451c20 feat(cmd): add VCS routing for GitHub PR reviews
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 42s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m23s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m38s
Wire up the new GitHub API methods to the review-bot CLI via VCS
type detection. review-bot can now review PRs on both Gitea and
GitHub (including GitHub Enterprise Server).

Changes:
- vcs.go: define vcsClient interface with all PR operations
  - giteaVCSAdapter: wraps gitea.Client, satisfies vcsClient + giteaExtClient
  - githubVCSAdapter: wraps github.Client, satisfies vcsClient
  - giteaExtClient: Gitea-specific extension (supersede, comment resolution)
- main.go: detect VCS type via VCS_TYPE env var (auto-detects github.com URLs)
  - Creates appropriate client (gitea or github) based on vcs_type
  - GitHub API URL derived from server URL (github.com → api.github.com,
    GHES → /api/v3)
  - All main flow uses vcsClient interface
  - Gitea-specific supersede operations gated via giteaExtClient type assertion
  - GitHub: logs info when skipping supersede (not supported)
- Removes old giteaClientAdapter (replaced by giteaVCSAdapter in vcs.go)
- giteaVCSAdapter satisfies review.GiteaClient for persona loading

GitHub limitations handled gracefully:
- Review supersede skipped (GitHub doesn't allow editing submitted reviews)
- DeleteReview returns error for non-pending reviews (documented in adapter)
- Inline comments use absolute line + side='RIGHT' instead of diff position

Closes #130.

Co-authored-by: Rodin <rodin@forgedthought.ai>
2026-05-14 20:43:21 +00:00
Rodin 39f3326674 feat(github): add PR review API methods
Implement the higher-level GitHub API methods that were TODO since
issue #120. The github package now provides:

- GetPullRequest: PR metadata (title, body, head SHA/ref, draft)
- GetPullRequestDiff: unified diff via Accept: application/vnd.github.diff
- GetPullRequestFiles: changed files list (paginated, 100/page)
- GetCommitStatuses: CI statuses (GitHub uses 'state' field, normalized)
- GetFileContent: file content with base64 decode (strips embedded newlines)
- GetFileContentRef: file at a specific ref
- ListContents: directory listing or single-file normalization
- GetAllFilesInPath: recursive file fetching
- PostReview: submit review with event/body/commit/inline comments
- ListReviews: list PR reviews (paginated)
- DeleteReview: delete review (GitHub only allows PENDING deletion)
- GetAuthenticatedUser: returns login of the authed user
- RequestReviewer: add a user as requested reviewer

API types added: PullRequest, CommitStatus, ChangedFile, ReviewComment,
Review, ContentEntry.

Notable edge cases handled:
- GitHub embeds newlines in base64 content; stripped before decode
- GetFileContent returns error for non-file paths (type=dir)
- ListContents normalizes single-file response to a slice
- DeleteReview documents GitHub's PENDING-only constraint

Removes TODO comment from baseURL field (now consumed by all methods).

Closes part of #130.

Co-authored-by: Rodin <rodin@forgedthought.ai>
2026-05-14 20:43:09 +00:00
3 changed files with 102 additions and 217 deletions
+54 -126
View File
@@ -1,151 +1,79 @@
## Dev Loop: review-bot — Continuous Health Monitor
## Dev Loop: review-bot — 2026-05-14 20:10 UTC
### Current Cycle: 2026-05-15 02:10 UTC ✅
**Repository Status:** OPTIMAL
- Main: `9f3f321` (clean, all tests pass)
- Working tree: clean
- Build: ✅ successful
- Vet: ✅ clean
- Test suite: ALL PASS
### Latest: ✅ STABLE STATE — REPO HEALTH COMPLETE
- **Last action:** health check; verified tests pass, repo clean, no action needed
- **Repository:** Clean, all merges complete, no open issues/PRs
- **Main branch:** Up to date with origin/main
- **Test suite:** All passing (cached)
---
## Latest Delivered: Issue #130 ✅
## Repository Status
### GitHub API + VCS Routing Complete
### ✅ Merged to main (recent):
- issue-123 (IP-level SSRF defense) — 6 commits, main at 4440823
- issue-125 (VCS_URL rename + deprecation) — merged
- issue-124 (multi-arch binary support) — merged
- issue-120 (GitHub Actions + VCS abstraction) — merged
- issue-121 (VCS host type detection for binary download) — merged
**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
### 🧹 Cleanup COMPLETE:
- ✅ Removed old worktrees (issue-123, review-bot-issue-125)
- ✅ Test suite passes (all packages)
- ✅ No TODO/FIXME in code except expected GitHub client notes
- ✅ No open issues or pull requests
- ✅ Dependencies up to date
---
## Repository Status Post-Merge
## Current Feature Completeness
### 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
**Core Capabilities:**
- 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)
- Gitea PR integration with structured reviews
- SSRF defense with IP-level validation
- Multi-architecture binary deployment
- VCS abstraction (Gitea/GitHub support)
- Multi-architecture binary support
- GitHub Actions composite action
### ✅ Review Quality
- Structured reviews with code snippets
- LLM-driven analysis
- Persona-based customization
- Context awareness
**Recent Security Work:**
- RFC6598 CGN range detection
- IP fallback dialing for local endpoint rejection
- URL validation for SSRF prevention
### ✅ Security
- RFC6598 CGN detection
- HTTPS enforcement
- Redirect safety
- Credential handling (no logs, no reflection leaks)
- URL validation for VCS API access
**Code Quality:**
- Comprehensive test coverage (all packages tested)
- Consistent error handling with context propagation
- Secure credential handling (unexported fields)
- Concurrency-safe designs
---
## Next Phase: Backlog Priorities
## Next Priority Actions
### 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:** 35 days
**Impact:** Enable automated improvements, fix suggestions with diff context
### Phase 2: Feature Exploration (NEXT SESSION)
- Scan code for potential improvements per REVIEW.md findings
- Assess performance under load
- Review REVIEW.md findings for targeted fixes
- Consider backlog items from design docs
### 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:** 23 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:** 57 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:** 710 days
### Phase 3: Optional Enhancements (BACKLOG)
- Address REVIEW.md context propagation findings (if prioritized)
- Additional LLM provider support
- Enhanced context detection
- Custom report formats
- Webhook management improvements
---
## 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
## Worktrees Status
All old worktrees cleaned up. Ready for new issue 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.
## Dev-Loop Metadata
- **Repo:** /home/ubuntu/review-bot
- **Main branch SHA:** ed3a5dd (last commit)
- **Cron ID:** 5342ac81-4bbc-4e4c-a123-347a7788d50c
- **Scheduled:** Every 4 hours
- **Last health check:** 2026-05-14 20:10 UTC (✅ all healthy)
+48 -55
View File
@@ -376,57 +376,6 @@ func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
return c.doRequest(ctx, http.MethodGet, url, "")
}
// doRequestWithBody performs an HTTP request with an optional body, applying the
// same HTTPS enforcement as doRequest. It is used by write methods (POST, PUT,
// DELETE) that bypass the retry loop in doRequest because write operations are
// not idempotent.
//
// body may be nil for requests that carry no payload (e.g. DELETE).
// When body is non-nil, Content-Type is set to application/json.
func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, body []byte) ([]byte, error) {
if !c.allowInsecureHTTP {
parsed, err := url.Parse(reqURL)
if err != nil {
return nil, fmt.Errorf("parse request URL: %w", err)
}
if strings.EqualFold(parsed.Scheme, "http") {
return nil, fmt.Errorf("refusing HTTP request to %s: use HTTPS or set AllowInsecureHTTP option", redactURL(reqURL))
}
}
var reqBody io.Reader
if body != nil {
reqBody = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return respBody, nil
}
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
}
// --- API types ---
// PullRequest holds relevant PR metadata.
@@ -728,11 +677,29 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
return nil, fmt.Errorf("marshal review payload: %w", err)
}
respBody, err := c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("create review request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("post review: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
if err != nil {
return nil, fmt.Errorf("read review response: %w", err)
}
var review Review
if err := json.Unmarshal(respBody, &review); err != nil {
return nil, fmt.Errorf("parse review response: %w", err)
@@ -773,11 +740,23 @@ func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number in
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
// nil body: the GitHub DELETE endpoint for reviews requires no request body.
_, err := c.doRequestWithBody(ctx, http.MethodDelete, reqURL, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
return fmt.Errorf("create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete review: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
return nil
}
@@ -811,10 +790,24 @@ func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number
return fmt.Errorf("marshal reviewer request: %w", err)
}
_, err = c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create reviewer request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request reviewer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
return nil
}
-36
View File
@@ -1077,42 +1077,6 @@ func TestRequestReviewer_Success(t *testing.T) {
}
}
func TestPostReview_RejectsHTTP(t *testing.T) {
// PostReview must reject http:// base URLs — tokens must not be sent in plaintext.
c := NewClient("tok", "http://127.0.0.1:1")
_, err := c.PostReview(context.Background(), "owner", "repo", 1, "APPROVE", "body", "", nil)
if err == nil {
t.Fatal("expected error for HTTP base URL in PostReview")
}
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestDeleteReview_RejectsHTTP(t *testing.T) {
// DeleteReview must reject http:// base URLs — tokens must not be sent in plaintext.
c := NewClient("tok", "http://127.0.0.1:1")
err := c.DeleteReview(context.Background(), "owner", "repo", 1, 42)
if err == nil {
t.Fatal("expected error for HTTP base URL in DeleteReview")
}
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestRequestReviewer_RejectsHTTP(t *testing.T) {
// RequestReviewer must reject http:// base URLs — tokens must not be sent in plaintext.
c := NewClient("tok", "http://127.0.0.1:1")
err := c.RequestReviewer(context.Background(), "owner", "repo", 1, "reviewer1")
if err == nil {
t.Fatal("expected error for HTTP base URL in RequestReviewer")
}
if !strings.Contains(err.Error(), "refusing HTTP request") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestEscapePath_SpecialChars(t *testing.T) {
tests := []struct {
input string