feat(cmd): wire --provider and --base-url flags into CLI (Phase 5) #106

Merged
aweiker merged 17 commits from review-bot-issue-82 into feature/github-support 2026-05-13 17:16:28 +00:00
4 changed files with 70 additions and 240 deletions
Showing only changes of commit a89dce1c52 - Show all commits
+14 -2
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
10
@@ -87,6 +88,9 @@ func main() {
// Register --gitea-url as a backward-compatible alias for --vcs-url. // Register --gitea-url as a backward-compatible alias for --vcs-url.
// StringVar shares the *string pointer with vcsURL, so whichever flag is // StringVar shares the *string pointer with vcsURL, so whichever flag is
// set last by flag.Parse wins — both point to the same underlying value. // set last by flag.Parse wins — both point to the same underlying value.
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
// the command line takes effect (standard flag package behavior). This is
// acceptable since --gitea-url is deprecated and both serve the same purpose.
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead") flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
flag.Parse() flag.Parse()
5
@@ -528,14 +532,17 @@ func verdictToEvent(verdict string) vcs.ReviewEvent {
func supersedeOldReviews(ctx context.Context, client vcs.Client, provider, vcsURL, owner, repoName string, prNumber int, oldReviews []vcs.Review, newReviewID int64, sentinel string) error { func supersedeOldReviews(ctx context.Context, client vcs.Client, provider, vcsURL, owner, repoName string, prNumber int, oldReviews []vcs.Review, newReviewID int64, sentinel string) error {
switch provider { switch provider {
case "github": case "github":
// Best-effort dismissal: attempt all reviews, join any errors.
var errs []error
for _, old := range oldReviews { for _, old := range oldReviews {
if err := client.DismissReview(ctx, owner, repoName, prNumber, old.ID, "Superseded by new review"); err != nil { if err := client.DismissReview(ctx, owner, repoName, prNumber, old.ID, "Superseded by new review"); err != nil {
slog.Warn("failed to dismiss review", "id", old.ID, "error", err) slog.Warn("failed to dismiss review", "id", old.ID, "error", err)
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
} else { } else {
slog.Info("dismissed old review", "review_id", old.ID, "new_review_id", newReviewID, "pr", prNumber) slog.Info("dismissed old review", "review_id", old.ID, "new_review_id", newReviewID, "pr", prNumber)
} }
} }
return nil return errors.Join(errs...)
case "gitea": case "gitea":
// Continue to Gitea-specific logic below the switch. // Continue to Gitea-specific logic below the switch.
default: default:
@@ -687,18 +694,20 @@ func isPatternFile(path string) bool {
} }
// evaluateCIStatus checks if all CI statuses indicate success. // evaluateCIStatus checks if all CI statuses indicate success.
// Returns passed=true if no checks have failed (pending checks are not treated as failures).
func evaluateCIStatus(statuses []vcs.CommitStatus) (passed bool, details string) { func evaluateCIStatus(statuses []vcs.CommitStatus) (passed bool, details string) {
if len(statuses) == 0 { if len(statuses) == 0 {
return true, "no CI statuses found" return true, "no CI statuses found"
} }
var failed []string var failed []string
var pending int
for _, s := range statuses { for _, s := range statuses {
switch s.Status { switch s.Status {
case "success": case "success":
// good // good
case "pending": case "pending":
// treat pending as not-failed pending++
case "failure", "error": case "failure", "error":
failed = append(failed, fmt.Sprintf("%s: %s", s.Context, s.Description)) failed = append(failed, fmt.Sprintf("%s: %s", s.Context, s.Description))
} }
@@ -707,6 +716,9 @@ func evaluateCIStatus(statuses []vcs.CommitStatus) (passed bool, details string)
if len(failed) > 0 { if len(failed) > 0 {
return false, strings.Join(failed, "; ") return false, strings.Join(failed, "; ")
} }
if pending > 0 {
return true, fmt.Sprintf("no failures (%d pending)", pending)
}
Review

[NIT] There is a dangling comment fragment: // with collapsed original content and the commit it was evaluated against. — this is the second half of the buildSupersededBody doc comment that was removed when the function moved to gitea/adapter.go. The first line of the doc comment (// buildSupersededBody creates the body for a superseded review: struck-through banner) was deleted but this trailing line was left orphaned. It should be removed.

**[NIT]** There is a dangling comment fragment: `// with collapsed original content and the commit it was evaluated against.` — this is the second half of the `buildSupersededBody` doc comment that was removed when the function moved to `gitea/adapter.go`. The first line of the doc comment (`// buildSupersededBody creates the body for a superseded review: struck-through banner`) was deleted but this trailing line was left orphaned. It should be removed.
return true, "all checks passed" return true, "all checks passed"
} }
+1 -1
View File
2
@@ -547,7 +547,7 @@ func TestEvaluateCIStatus(t *testing.T) {
{Status: "success", Context: "ci/test", Description: "Tests passed"}, {Status: "success", Context: "ci/test", Description: "Tests passed"},
}, },
wantPassed: true, wantPassed: true,
wantSubstr: "all checks passed", wantSubstr: "no failures",
}, },
{ {
name: "multiple failures", name: "multiple failures",
+55
View File
1
@@ -5,6 +5,7 @@ package github
import ( import (
"bytes" "bytes"
"encoding/json"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -383,4 +384,58 @@ func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, r
opts.extraHeaders = map[string]string{"Content-Type": "application/json"} opts.extraHeaders = map[string]string{"Content-Type": "application/json"}
Review

[NIT] Stray blank line between the closing brace of doRequestWithBody and the start of doJSONRequest doc comment — there is also a missing blank line before the closing } of doRequestWithBody (the } is on the line immediately after return). Minor formatting inconsistency that gofmt would not catch but goimports might flag, and it reduces readability.

**[NIT]** Stray blank line between the closing brace of `doRequestWithBody` and the start of `doJSONRequest` doc comment — there is also a missing blank line before the closing `}` of `doRequestWithBody` (the `}` is on the line immediately after `return`). Minor formatting inconsistency that `gofmt` would not catch but goimports might flag, and it reduces readability.
} }
return c.doRequestCore(ctx, method, reqURL, opts) return c.doRequestCore(ctx, method, reqURL, opts)
}
Review

[NIT] doJSONRequest is added but not yet called by any public methods in the diff (the github package has no PostReview/DismissReview/etc. visible in the diff). This is fine for an incremental PR, but it means the new helper is untested at the integration level. The unit tests for doJSONRequest itself are present and cover the retry path.

**[NIT]** doJSONRequest is added but not yet called by any public methods in the diff (the github package has no PostReview/DismissReview/etc. visible in the diff). This is fine for an incremental PR, but it means the new helper is untested at the integration level. The unit tests for doJSONRequest itself are present and cover the retry path.
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
// It handles HTTPS validation, authentication, and response reading.
// This is a general-purpose helper used by any method that needs to send JSON payloads
// (e.g. PostReview, DismissReview).
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
const maxErrorBodyBytes = 4 * 1024
Review

[MINOR] doJSONRequest is added to client.go but is only used internally by review.go methods. Placing transport-layer helpers (doRequestCore, doRequestWithBody) together with a higher-level JSON serialisation helper makes the file slightly inconsistent. This is a minor organisation concern — a comment grouping the helpers or moving doJSONRequest to review.go would improve cohesion, but the current placement is not wrong.

**[MINOR]** `doJSONRequest` is added to `client.go` but is only used internally by `review.go` methods. Placing transport-layer helpers (`doRequestCore`, `doRequestWithBody`) together with a higher-level JSON serialisation helper makes the file slightly inconsistent. This is a minor organisation concern — a comment grouping the helpers or moving `doJSONRequest` to `review.go` would improve cohesion, but the current placement is not wrong.
jsonBody, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
if c.token != "" && !c.allowInsecureHTTP {
parsed, err := url.Parse(reqURL)
if err != nil {
return nil, fmt.Errorf("parse request URL: %w", err)
}
if !strings.EqualFold(parsed.Scheme, "https") {
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use AllowInsecureHTTP option for trusted networks)", reqURL)
}
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/vnd.github+json")
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 {
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxResponseBytes)+1))
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if len(body) > maxResponseBytes {
return nil, fmt.Errorf("response body exceeded %d bytes", maxResponseBytes)
}
return body, nil
}
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxErrorBodyBytes)))
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
} }
-237
View File
@@ -1,237 +0,0 @@
package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// reviewResponse is the GitHub API response for a pull request review.
type reviewResponse struct {
ID int64 `json:"id"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
State string `json:"state"`
CommitID string `json:"commit_id"`
}
// reviewCreateRequest is the GitHub API request body for creating a pull request review.
type reviewCreateRequest struct {
Body string `json:"body"`
Event string `json:"event"`
Comments []reviewCommentCreate `json:"comments,omitempty"`
CommitID string `json:"commit_id,omitempty"`
}
// reviewCommentCreate is a single inline comment in a review creation request.
type reviewCommentCreate struct {
Path string `json:"path"`
Position int `json:"position"`
Body string `json:"body"`
}
// dismissReviewRequest is the GitHub API request body for dismissing a review.
type dismissReviewRequest struct {
Message string `json:"message"`
}
// userResponse is the GitHub API response for the authenticated user.
type userResponse struct {
Login string `json:"login"`
}
// translateReviewEvent converts a vcs.ReviewEvent to the GitHub API event string.
func translateReviewEvent(event vcs.ReviewEvent) string {
switch event {
case vcs.ReviewEventApprove:
return "APPROVE"
case vcs.ReviewEventRequestChanges:
return "REQUEST_CHANGES"
case vcs.ReviewEventComment:
return "COMMENT"
default:
return "COMMENT"
}
}
// PostReview creates a new review on a pull request.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := reviewCreateRequest{
Body: req.Body,
Event: translateReviewEvent(req.Event),
}
for _, comment := range req.Comments {
rc := reviewCommentCreate{
Path: comment.Path,
Position: comment.Position,
Body: comment.Body,
}
payload.Comments = append(payload.Comments, rc)
// Use CommitID from the first comment that has one.
// All comments in a single review are expected to reference the same commit.
if payload.CommitID == "" && comment.CommitID != "" {
payload.CommitID = comment.CommitID
}
}
body, err := c.doJSONRequest(ctx, http.MethodPost, reqURL, payload)
if err != nil {
return nil, fmt.Errorf("post review: %w", err)
}
var resp reviewResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parse review response: %w", err)
}
return &vcs.Review{
ID: resp.ID,
Body: resp.Body,
User: vcs.UserInfo{Login: resp.User.Login},
State: resp.State,
CommitID: resp.CommitID,
}, nil
}
// ListReviews lists all reviews on a pull request.
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
var allReviews []vcs.Review
for page := 1; page <= 100; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=100&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews page %d: %w", page, err)
}
var reviews []reviewResponse
if err := json.Unmarshal(body, &reviews); err != nil {
return nil, fmt.Errorf("parse reviews JSON: %w", err)
}
if len(reviews) == 0 {
break
}
for _, r := range reviews {
allReviews = append(allReviews, vcs.Review{
ID: r.ID,
Body: r.Body,
User: vcs.UserInfo{Login: r.User.Login},
State: r.State,
CommitID: r.CommitID,
})
}
if len(reviews) < 100 {
break
}
}
return allReviews, nil
}
// DeleteReview permanently deletes a review from a pull request.
// Use DismissReview instead when the review should remain visible but marked as dismissed
// (e.g., superseding an outdated review while preserving history).
func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
_, err := c.doRequest(ctx, http.MethodDelete, reqURL, "")
if err != nil {
return fmt.Errorf("delete review: %w", err)
}
return nil
}
// DismissReview dismisses a review on a pull request with a message.
func (c *Client) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d/dismissals",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
payload := dismissReviewRequest{
Message: message,
}
_, err := c.doJSONRequest(ctx, http.MethodPut, reqURL, payload)
if err != nil {
return fmt.Errorf("dismiss review: %w", err)
}
return nil
}
// GetAuthenticatedUser returns the login name of the authenticated user.
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
reqURL := fmt.Sprintf("%s/user", c.baseURL)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("get authenticated user: %w", err)
}
var resp userResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("parse user response: %w", err)
}
return resp.Login, nil
}
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
// It handles HTTPS validation, authentication, and response reading.
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
const maxErrorBodyBytes = 4 * 1024
jsonBody, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
if c.token != "" && !c.allowInsecureHTTP {
parsed, err := url.Parse(reqURL)
if err != nil {
return nil, fmt.Errorf("parse request URL: %w", err)
}
if !strings.EqualFold(parsed.Scheme, "https") {
return nil, fmt.Errorf("refusing to send credentials over non-HTTPS URL %q (use AllowInsecureHTTP option for trusted networks)", reqURL)
}
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/vnd.github+json")
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 {
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxResponseBytes)+1))
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if len(body) > maxResponseBytes {
return nil, fmt.Errorf("response body exceeded %d bytes", maxResponseBytes)
}
return body, nil
}
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxErrorBodyBytes)))
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
}