Compare commits

..

6 Commits

Author SHA1 Message Date
Rodin 97bd120537 test(github): add tests for all exported methods in methods.go
Tests cover: GetPullRequest, GetPullRequestDiff, GetPullRequestFiles,
GetCommitStatuses, GetFileContent, GetFileContentRef, ListContents,
ListContents(dot), PostReview (APPROVED→APPROVE normalization), ListReviews,
GetAuthenticatedUser, RequestReviewer, EditComment, ListReviewComments,
DeleteReview, GetAllFilesInPath, ResolveComment (no-op),
GetTimelineReviewCommentIDForReview (pass-through).
2026-05-14 20:24:17 +00:00
Rodin b6ba4e4636 feat(ci): add GitHub Actions workflow for strat/review-bot PRs
Adds .github/workflows/review.yml to run AI code review on PRs using
SAP AI Core. Three reviewers: sonnet, gpt, security (same as Gitea CI).

Uses the .gitea/actions/review composite action which auto-detects
GitHub vs Gitea runners via GITHUB_API_URL and uses GitHub REST API
accordingly.

Prerequisites:
- Set required secrets on strat/review-bot (see comments in workflow)
- Publish at least one release of review-bot on strat/review-bot
  with review-bot-linux-amd64 and checksums.txt assets

Runs on ubuntu-24.04 (matching the runner label used by strat org).
2026-05-14 20:21:16 +00:00
Rodin b531da1355 fix(github): clarify GetTimelineReviewCommentIDForReview supersede limitations
The supersede path calls GetTimelineReviewCommentIDForReview then EditComment.
On GitHub, EditComment targets /pulls/comments/{id} (for inline review comments),
but review bodies are not inline comments. The PATCH will return 404 and be
logged as a warning. The review itself posts correctly regardless.
This is a known limitation pending a future fix via the reviews PATCH endpoint.
2026-05-14 20:21:12 +00:00
Rodin 06b09ce3b8 feat(cmd): wire VCS detection into main — route to github or gitea client
- Replace gitea.NewClient() with newVCSClient(vcsType, vcsURL, token)
- Replace newGiteaClientAdapter() with buildRepoPersonaClient()
- Remove now-redundant giteaClientAdapter struct (moved to vcs.go)
- Update fetchFileContext and fetchPatterns to accept vcsClient interface
- Log detected VCS type at startup

On Gitea runners: behaves identically to before (gitea.NewClient).
On GitHub/GHES runners: uses githubAdapter backed by github.Client,
  all API calls use GitHub REST API v3 with GITHUB_API_URL as base URL.
2026-05-14 20:15:32 +00:00
Rodin 1e1a50581f feat(cmd): add VCS client abstraction for GitHub and Gitea
Adds vcs.go with:
- vcsClient interface that main.go uses for all VCS operations
- githubAdapter that wraps *github.Client and converts types to gitea types
- newVCSClient() factory: detects VCS type via GITHUB_API_URL env var
  (set by GitHub Actions runners; absent on Gitea) and returns the
  appropriate client
- buildRepoPersonaClient() adapter for LoadRepoPersonas
- detectVCSType() and githubAPIBaseURL() helpers

Detection logic mirrors action.yml: GITHUB_API_URL present → github,
absent → gitea. On GitHub/GHES, uses GITHUB_API_URL as the API base URL
(trusted platform value), never user-supplied vcsURL.
2026-05-14 20:15:25 +00:00
Rodin 545ab25bbc feat(github): add high-level PR/review API methods to github client
Adds GetPullRequest, GetPullRequestDiff, GetPullRequestFiles,
GetCommitStatuses, GetFileContent, GetFileContentRef, ListContents,
GetAllFilesInPath, PostReview, ListReviews, DeleteReview,
GetAuthenticatedUser, RequestReviewer, EditComment, ListReviewComments,
ResolveComment, and GetTimelineReviewCommentIDForReview.

These mirror the gitea/ package methods but use GitHub REST API v3 paths
(/repos/{owner}/{repo}/... instead of /api/v1/repos/...) and handle
GitHub-specific behaviors:
- File content is base64-encoded in the JSON response
- Review event is APPROVE (not APPROVED)
- ResolveComment is a no-op (GitHub has no REST API for this)
- GetTimelineReviewCommentIDForReview returns the reviewID as-is
2026-05-14 20:15:17 +00:00
16 changed files with 1636 additions and 1600 deletions
-1
View File
@@ -476,7 +476,6 @@ runs:
shell: bash shell: bash
env: env:
VCS_URL: ${{ steps.version.outputs.server_url }} VCS_URL: ${{ steps.version.outputs.server_url }}
VCS_TYPE: ${{ steps.version.outputs.vcs_type }}
GITEA_REPO: ${{ inputs.repo || github.repository }} GITEA_REPO: ${{ inputs.repo || github.repository }}
PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }} PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
REVIEWER_TOKEN: ${{ inputs.reviewer-token }} REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
+77
View File
@@ -0,0 +1,77 @@
name: AI Code Review
# AI code review for pull requests on github.concur.com/strat/review-bot.
# Uses SAP AI Core as the LLM provider (same as the Gitea CI workflow).
#
# Prerequisites before this workflow can run:
# 1. Set required secrets on strat/review-bot (see list below)
# 2. Publish at least one release of review-bot on strat/review-bot
# (or change action-repo to a repo that already has releases)
#
# Required secrets:
# SONNET_REVIEW_TOKEN — GitHub token for the Sonnet reviewer bot
# GPT_REVIEW_TOKEN — GitHub token for the GPT reviewer bot
# AICORE_CLIENT_ID — SAP AI Core OAuth client ID
# AICORE_CLIENT_SECRET — SAP AI Core OAuth client secret
# AICORE_AUTH_URL — SAP AI Core OAuth token endpoint
# AICORE_API_URL — SAP AI Core inference API URL
# AICORE_RESOURCE_GROUP — SAP AI Core resource group (optional, default: default)
on:
pull_request:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: go test ./...
- run: go vet ./...
- run: go build -o review-bot ./cmd/review-bot
review:
runs-on: ubuntu-24.04
if: github.event_name == 'pull_request'
needs: test
strategy:
fail-fast: false
matrix:
include:
- name: sonnet
token_secret: SONNET_REVIEW_TOKEN
model: anthropic--claude-4.6-sonnet
- name: gpt
token_secret: GPT_REVIEW_TOKEN
model: gpt-5
- name: security
token_secret: GPT_REVIEW_TOKEN
model: gpt-5
system_prompt_file: SECURITY_REVIEW.md
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/review
with:
# On GHES runners, vcs-url is ignored; the composite action uses github.server_url.
# action-repo must be a repo with published review-bot releases.
# Requires strat/review-bot to have at least one release tag with
# review-bot-linux-amd64 and checksums.txt assets.
vcs-url: https://gitea.weiker.me
action-repo: strat/review-bot
reviewer-token: ${{ secrets[matrix.token_secret] }}
reviewer-name: ${{ matrix.name }}
llm-provider: aicore
llm-model: ${{ matrix.model }}
aicore-client-id: ${{ secrets.AICORE_CLIENT_ID }}
aicore-client-secret: ${{ secrets.AICORE_CLIENT_SECRET }}
aicore-auth-url: ${{ secrets.AICORE_AUTH_URL }}
aicore-api-url: ${{ secrets.AICORE_API_URL }}
aicore-resource-group: ${{ secrets.AICORE_RESOURCE_GROUP }}
conventions-file: CONVENTIONS.md
patterns-repo: rodin/go-patterns
patterns-files: README.md,patterns/
timeout: "600"
system-prompt-file: ${{ matrix.system_prompt_file || '' }}
+2 -3
View File
@@ -285,7 +285,7 @@ review-bot \
--vcs-url https://gitea.example.com \ --vcs-url https://gitea.example.com \
--repo owner/name \ --repo owner/name \
--pr 42 \ --pr 42 \
--reviewer-token "$REVIEWER_TOKEN" \ --reviewer-token "$GITEA_TOKEN" \
--reviewer-name "code-review" \ --reviewer-name "code-review" \
--llm-base-url https://api.openai.com/v1 \ --llm-base-url https://api.openai.com/v1 \
--llm-api-key "$OPENAI_API_KEY" \ --llm-api-key "$OPENAI_API_KEY" \
@@ -300,8 +300,7 @@ All flags have environment variable equivalents:
| Flag | Env Var | | Flag | Env Var |
|------|---------| |------|---------|
| `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) | | `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) |
| `--vcs-type` | `VCS_TYPE` (auto-detected from URL if not set; `gitea` or `github`) | | `--repo` | `GITEA_REPO` |
| `--repo` | `GITEA_REPO` (also accepted: set `GITEA_REPO` for Gitea; VCS-agnostic `REPO` coming) |
| `--pr` | `PR_NUMBER` | | `--pr` | `PR_NUMBER` |
| `--reviewer-token` | `REVIEWER_TOKEN` | | `--reviewer-token` | `REVIEWER_TOKEN` |
| `--reviewer-name` | `REVIEWER_NAME` | | `--reviewer-name` | `REVIEWER_NAME` |
-9
View File
@@ -77,12 +77,3 @@ All old worktrees cleaned up. Ready for new issue work.
- **Cron ID:** 5342ac81-4bbc-4e4c-a123-347a7788d50c - **Cron ID:** 5342ac81-4bbc-4e4c-a123-347a7788d50c
- **Scheduled:** Every 4 hours - **Scheduled:** Every 4 hours
- **Last health check:** 2026-05-14 20:10 UTC (✅ all healthy) - **Last health check:** 2026-05-14 20:10 UTC (✅ all healthy)
## Self-Review: review-bot-issue-130-work — 2026-05-14
### Verdict: NEEDS_WORK
- [x] [completeness] `VCS_TYPE` env var is detected in `main.go` but **not passed from `action.yml`'s `Run review` step** to the binary. The binary falls back to a URL heuristic (`github.com` / `github.concur.com` substrings), which will silently misclassify any GitHub Enterprise Server whose hostname does not contain `github`. The action already has `vcs_type` as a step output — it should be passed as `VCS_TYPE: ${{ steps.version.outputs.vcs_type }}` in the `Run review` env block.
- [x] [completeness] `README.md` still references `GITEA_REPO` and `$GITEA_TOKEN` in the CLI example (line ~288) and the env var table (line ~303). Now that the binary supports GitHub too, these should be renamed to VCS-agnostic names (`VCS_REPO`/`REPO`, `VCS_TOKEN`/`REVIEWER_TOKEN`) or at minimum the env var table should note GitHub support.
- [x] [fit] `vcsReviewComment.NewPosition` field comment says "Gitea: absolute line; GitHub: diff hunk position" but in `githubVCSAdapter.PostReview` it is actually mapped to `Line` (absolute line) + `Side: "RIGHT"`, not to `Position` (hunk position). The field name and comment are now slightly misleading — the field means "new line number" for Gitea and is repurposed as "absolute line" for GitHub. Consider renaming to `Line` or `NewLine` with a clearer comment explaining the per-backend semantics.
- [x] [instinct] `validateurl.go` imports `gitea` package for `gitea.IsBlockedIP` — this creates a dependency from the GitHub-generic `validateurl.go` on the Gitea-specific package. The `IsBlockedIP` function is a general networking utility. Consider moving it to a shared `internal/netutil` package (or keeping it in `gitea` and accepting the coupling), but the current import is an unexpected relationship.
+35 -83
View File
@@ -14,7 +14,6 @@ import (
"gitea.weiker.me/rodin/review-bot/budget" "gitea.weiker.me/rodin/review-bot/budget"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/github"
"gitea.weiker.me/rodin/review-bot/llm" "gitea.weiker.me/rodin/review-bot/llm"
"gitea.weiker.me/rodin/review-bot/review" "gitea.weiker.me/rodin/review-bot/review"
) )
@@ -169,40 +168,12 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Detect VCS type and initialize the appropriate client.
vcsType := detectVCSType()
slog.Info("detected VCS type", "vcs", vcsType)
// Initialize clients // Initialize clients
// Detect VCS type: explicit flag > env var > URL heuristic (default: gitea). giteaClient := newVCSClient(vcsType, *vcsURL, *reviewerToken)
vcsType := envOrDefault("VCS_TYPE", "")
if vcsType == "" {
// Heuristic: if the URL looks like github.com or a GitHub Enterprise host,
// default to GitHub. The composite action sets VCS_TYPE explicitly, so this
// is a fallback for manual invocations.
if strings.Contains(*vcsURL, "github.com") || strings.Contains(*vcsURL, "github.concur.com") {
vcsType = "github"
} else {
vcsType = "gitea"
}
}
slog.Info("VCS type detected", "vcs_type", vcsType, "vcs_url", *vcsURL)
var vcs vcsClient
switch vcsType {
case "github":
// GitHub: baseURL is the API URL, derived from server URL.
// github.com → https://api.github.com
// GHES (e.g. https://ghe.example.com) → https://ghe.example.com/api/v3
apiURL := githubAPIURL(*vcsURL)
ghClient := github.NewClient(*reviewerToken, apiURL)
vcs = newGithubVCSAdapter(ghClient)
slog.Info("using GitHub VCS client", "api_url", apiURL)
case "gitea":
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
vcs = newGiteaVCSAdapter(giteaClient)
slog.Info("using Gitea VCS client", "url", *vcsURL)
default:
slog.Error("unsupported VCS type", "vcs_type", vcsType, "valid", "gitea, github")
os.Exit(1)
}
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel) llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
if *llmTemp < 0 || *llmTemp > 2 { if *llmTemp < 0 || *llmTemp > 2 {
slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2") slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2")
@@ -240,7 +211,7 @@ func main() {
var persona *review.Persona var persona *review.Persona
if *personaName != "" { if *personaName != "" {
// Try loading from repo first, then fall back to built-in // Try loading from repo first, then fall back to built-in
repoPersonas, err := review.LoadRepoPersonas(ctx, vcs, owner, repoName) repoPersonas, err := review.LoadRepoPersonas(ctx, buildRepoPersonaClient(giteaClient), owner, repoName)
if err != nil { if err != nil {
slog.Warn("could not load repo personas", "repo", owner+"/"+repoName, "error", err) slog.Warn("could not load repo personas", "repo", owner+"/"+repoName, "error", err)
// Continue with built-in personas only. // Continue with built-in personas only.
@@ -276,7 +247,7 @@ func main() {
slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName)) slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName))
// Step 1: Fetch PR metadata // Step 1: Fetch PR metadata
pr, err := vcs.GetPullRequest(ctx, owner, repoName, prNumber) pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
slog.Error("failed to fetch PR", "pr", prNumber, "error", err) slog.Error("failed to fetch PR", "pr", prNumber, "error", err)
os.Exit(1) os.Exit(1)
@@ -284,7 +255,7 @@ func main() {
slog.Info("fetched PR metadata", "pr", prNumber, "title", pr.Title) slog.Info("fetched PR metadata", "pr", prNumber, "title", pr.Title)
// Step 2: Fetch diff // Step 2: Fetch diff
diff, err := vcs.GetPullRequestDiff(ctx, owner, repoName, prNumber) diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
slog.Error("failed to fetch diff", "pr", prNumber, "error", err) slog.Error("failed to fetch diff", "pr", prNumber, "error", err)
os.Exit(1) os.Exit(1)
@@ -293,11 +264,11 @@ func main() {
// Step 3: Fetch full file content for modified files // Step 3: Fetch full file content for modified files
fileContext := "" fileContext := ""
files, err := vcs.GetPullRequestFiles(ctx, owner, repoName, prNumber) files, err := giteaClient.GetPullRequestFiles(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
slog.Warn("could not fetch PR files list", "pr", prNumber, "error", err) slog.Warn("could not fetch PR files list", "pr", prNumber, "error", err)
} else { } else {
fileContext = fetchFileContext(ctx, vcs, owner, repoName, pr.Head.Ref, files) fileContext = fetchFileContext(ctx, giteaClient, owner, repoName, pr.Head.Ref, files)
slog.Debug("fetched file context", "files", len(files)) slog.Debug("fetched file context", "files", len(files))
} }
@@ -305,7 +276,7 @@ func main() {
ciPassed := true ciPassed := true
ciDetails := "" ciDetails := ""
if pr.Head.Sha != "" { if pr.Head.Sha != "" {
statuses, err := vcs.GetCommitStatuses(ctx, owner, repoName, pr.Head.Sha) statuses, err := giteaClient.GetCommitStatuses(ctx, owner, repoName, pr.Head.Sha)
if err != nil { if err != nil {
slog.Warn("could not fetch CI status", "sha", pr.Head.Sha, "error", err) slog.Warn("could not fetch CI status", "sha", pr.Head.Sha, "error", err)
} else { } else {
@@ -317,7 +288,7 @@ func main() {
// Step 5: Load conventions file if specified // Step 5: Load conventions file if specified
conventions := "" conventions := ""
if *conventionsFile != "" { if *conventionsFile != "" {
content, err := vcs.GetFileContent(ctx, owner, repoName, *conventionsFile) content, err := giteaClient.GetFileContent(ctx, owner, repoName, *conventionsFile)
if err != nil { if err != nil {
slog.Warn("could not load conventions file", "file", *conventionsFile, "error", err) slog.Warn("could not load conventions file", "file", *conventionsFile, "error", err)
} else { } else {
@@ -329,7 +300,7 @@ func main() {
// Step 6: Load patterns from external repo if specified // Step 6: Load patterns from external repo if specified
patterns := "" patterns := ""
if *patternsRepo != "" { if *patternsRepo != "" {
patterns = fetchPatterns(ctx, vcs, *patternsRepo, *patternsFiles) patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles)
slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns)) slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns))
} }
@@ -444,7 +415,7 @@ func main() {
// Stale check: verify HEAD hasn't moved since we started // Stale check: verify HEAD hasn't moved since we started
evaluatedSHA := pr.Head.Sha evaluatedSHA := pr.Head.Sha
var currentSHA string var currentSHA string
currentPR, err := vcs.GetPullRequest(ctx, owner, repoName, prNumber) currentPR, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
slog.Warn("could not re-fetch PR for stale check", "pr", prNumber, "error", err) slog.Warn("could not re-fetch PR for stale check", "pr", prNumber, "error", err)
// currentSHA stays empty — shouldSkipStaleReview will return false // currentSHA stays empty — shouldSkipStaleReview will return false
@@ -461,12 +432,12 @@ func main() {
// Map findings to inline comments for lines present in the diff // Map findings to inline comments for lines present in the diff
diffRanges := gitea.ParseDiffNewLines(diff) diffRanges := gitea.ParseDiffNewLines(diff)
var inlineComments []vcsReviewComment var inlineComments []gitea.ReviewComment
for _, f := range result.Findings { for _, f := range result.Findings {
if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) { if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
inlineComments = append(inlineComments, vcsReviewComment{ inlineComments = append(inlineComments, gitea.ReviewComment{
Path: f.File, Path: f.File,
NewLine: int64(f.Line), NewPosition: int64(f.Line),
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding), Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
}) })
} }
@@ -479,9 +450,9 @@ func main() {
// 1. POST new review first (gets non-stale approval badge on HEAD) // 1. POST new review first (gets non-stale approval badge on HEAD)
// 2. Then supersede old review with link to the new one // 2. Then supersede old review with link to the new one
// Order matters: post first so we have the new review's URL for the supersede message. // Order matters: post first so we have the new review's URL for the supersede message.
var oldReviews []vcsReview var oldReviews []gitea.Review
if *reviewerName != "" { if *reviewerName != "" {
existingReviews, err := vcs.ListReviews(ctx, owner, repoName, prNumber) existingReviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
slog.Warn("could not list existing reviews", "pr", prNumber, "error", err) slog.Warn("could not list existing reviews", "pr", prNumber, "error", err)
} else { } else {
@@ -494,11 +465,11 @@ func main() {
} }
// Self-request as reviewer (ensures we appear in required-reviewer checks) // Self-request as reviewer (ensures we appear in required-reviewer checks)
authUser, err := vcs.GetAuthenticatedUser(ctx) authUser, err := giteaClient.GetAuthenticatedUser(ctx)
if err != nil { if err != nil {
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err) slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
} else if authUser != "" { } else if authUser != "" {
if err := vcs.RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil { if err := giteaClient.RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil {
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err) slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
} else { } else {
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber) slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
@@ -507,34 +478,31 @@ func main() {
// POST new review // POST new review
slog.Info("posting review", "event", event, "pr", prNumber) slog.Info("posting review", "event", event, "pr", prNumber)
posted, err := vcs.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, evaluatedSHA, inlineComments) posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, evaluatedSHA, inlineComments)
if err != nil { if err != nil {
slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err) slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
os.Exit(1) os.Exit(1)
} }
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber) slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
// Supersede all old reviews with link to the new one. // Supersede all old reviews with link to the new one
// This is only supported on Gitea (requires timeline API); GitHub reviews cannot if len(oldReviews) > 0 {
// be edited after submission, so we skip the supersede step there.
extVCS, isGiteaExt := vcs.(giteaExtClient)
if len(oldReviews) > 0 && isGiteaExt {
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*vcsURL, "/"), owner, repoName, prNumber, posted.ID) newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*vcsURL, "/"), owner, repoName, prNumber, posted.ID)
for _, oldReview := range oldReviews { for _, oldReview := range oldReviews {
cid, err := extVCS.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, int64(prNumber), oldReview.ID) cid, err := giteaClient.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
if err != nil { if err != nil {
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err) slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
continue continue
} }
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel) supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
if err := extVCS.EditComment(ctx, owner, repoName, cid, supersededBody); err != nil { if err := giteaClient.EditComment(ctx, owner, repoName, cid, supersededBody); err != nil {
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "comment_id", cid, "error", err) slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "comment_id", cid, "error", err)
continue continue
} }
slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", posted.ID, "pr", prNumber) slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", posted.ID, "pr", prNumber)
// Resolve old review's inline comments // Resolve old review's inline comments
oldComments, err := extVCS.ListReviewComments(ctx, owner, repoName, int64(prNumber), oldReview.ID) oldComments, err := giteaClient.ListReviewComments(ctx, owner, repoName, prNumber, oldReview.ID)
if err != nil { if err != nil {
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err) slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
continue continue
@@ -544,7 +512,7 @@ func main() {
if c.ID == 0 { if c.ID == 0 {
continue continue
} }
if err := extVCS.ResolveComment(ctx, owner, repoName, c.ID); err != nil { if err := giteaClient.ResolveComment(ctx, owner, repoName, c.ID); err != nil {
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err) slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
failed++ failed++
} else { } else {
@@ -558,14 +526,12 @@ func main() {
slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber) slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber)
} }
} }
} else if len(oldReviews) > 0 {
slog.Info("skipping supersede of old reviews (not supported on this VCS)", "old_count", len(oldReviews), "pr", prNumber)
} }
} }
// fetchFileContext fetches the full content of modified files from the PR branch. // fetchFileContext fetches the full content of modified files from the PR branch.
func fetchFileContext(ctx context.Context, client vcsClient, owner, repo, ref string, files []vcsChangedFile) string { func fetchFileContext(ctx context.Context, client vcsClient, owner, repo, ref string, files []gitea.ChangedFile) string {
var sb strings.Builder var sb strings.Builder
for _, f := range files { for _, f := range files {
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -669,7 +635,7 @@ func isPatternFile(path string) bool {
} }
// evaluateCIStatus checks if all CI statuses indicate success. // evaluateCIStatus checks if all CI statuses indicate success.
func evaluateCIStatus(statuses []vcsCommitStatus) (passed bool, details string) { func evaluateCIStatus(statuses []gitea.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"
} }
@@ -692,19 +658,6 @@ func evaluateCIStatus(statuses []vcsCommitStatus) (passed bool, details string)
return true, "all checks passed" return true, "all checks passed"
} }
// githubAPIURL converts a GitHub server URL to its API base URL.
// github.com → https://api.github.com
// GHES (e.g. https://ghe.example.com) → https://ghe.example.com/api/v3
func githubAPIURL(serverURL string) string {
const canonicalGitHub = "https://github.com"
const githubAPIBase = "https://api.github.com"
if serverURL == "" || strings.TrimRight(serverURL, "/") == canonicalGitHub {
return githubAPIBase
}
// GitHub Enterprise Server: /api/v3 suffix
return strings.TrimRight(serverURL, "/") + "/api/v3"
}
func envOrDefault(key, defaultVal string) string { func envOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
@@ -820,7 +773,7 @@ func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string)
// Gitea user. This indicates misconfiguration where two roles share a token // Gitea user. This indicates misconfiguration where two roles share a token
// instead of having separate Gitea accounts. Returns true if shared token // instead of having separate Gitea accounts. Returns true if shared token
// detected (caller should skip update-in-place logic to avoid clobbering). // detected (caller should skip update-in-place logic to avoid clobbering).
func hasSharedToken(reviews []vcsReview, ownSentinel string) bool { func hasSharedToken(reviews []gitea.Review, ownSentinel string) bool {
ownLogin := "" ownLogin := ""
for _, r := range reviews { for _, r := range reviews {
if strings.Contains(r.Body, ownSentinel) { if strings.Contains(r.Body, ownSentinel) {
@@ -858,8 +811,8 @@ func extractSentinelName(body string) string {
} }
// findOwnReview locates the most recent non-superseded review matching the sentinel. // findOwnReview locates the most recent non-superseded review matching the sentinel.
func findOwnReview(reviews []vcsReview, sentinel string) *vcsReview { func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
var best *vcsReview var best *gitea.Review
for i := range reviews { for i := range reviews {
if !strings.Contains(reviews[i].Body, sentinel) { if !strings.Contains(reviews[i].Body, sentinel) {
continue continue
@@ -875,8 +828,8 @@ func findOwnReview(reviews []vcsReview, sentinel string) *vcsReview {
} }
// findAllOwnReviews returns all non-superseded reviews matching the sentinel. // findAllOwnReviews returns all non-superseded reviews matching the sentinel.
func findAllOwnReviews(reviews []vcsReview, sentinel string) []vcsReview { func findAllOwnReviews(reviews []gitea.Review, sentinel string) []gitea.Review {
var result []vcsReview var result []gitea.Review
for i := range reviews { for i := range reviews {
if !strings.Contains(reviews[i].Body, sentinel) { if !strings.Contains(reviews[i].Body, sentinel) {
continue continue
@@ -902,4 +855,3 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
return evaluatedSHA != currentSHA return evaluatedSHA != currentSHA
} }
+32 -30
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/gitea"
) )
func TestValidateReviewerName(t *testing.T) { func TestValidateReviewerName(t *testing.T) {
@@ -153,11 +154,12 @@ func TestValidateWorkspacePath(t *testing.T) {
} }
} }
func makeReview(id int64, login, state string, _ bool, body string) vcsReview { func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
r := vcsReview{ r := gitea.Review{
ID: id, ID: id,
Body: body, Body: body,
State: state, State: state,
Stale: stale,
} }
r.User.Login = login r.User.Login = login
return r return r
@@ -214,7 +216,7 @@ func TestBuildSupersededBodyShortSHA(t *testing.T) {
func TestFindOwnReview(t *testing.T) { func TestFindOwnReview(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
reviews []vcsReview reviews []gitea.Review
sentinel string sentinel string
wantID int64 wantID int64
wantNil bool wantNil bool
@@ -227,7 +229,7 @@ func TestFindOwnReview(t *testing.T) {
}, },
{ {
name: "found by sentinel", name: "found by sentinel",
reviews: []vcsReview{ reviews: []gitea.Review{
makeReview(42, "bot", "APPROVED", false, "review body\n<!-- review-bot:sonnet -->"), makeReview(42, "bot", "APPROVED", false, "review body\n<!-- review-bot:sonnet -->"),
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
@@ -235,7 +237,7 @@ func TestFindOwnReview(t *testing.T) {
}, },
{ {
name: "wrong sentinel", name: "wrong sentinel",
reviews: []vcsReview{ reviews: []gitea.Review{
makeReview(42, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"), makeReview(42, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
@@ -243,7 +245,7 @@ func TestFindOwnReview(t *testing.T) {
}, },
{ {
name: "multiple reviews, returns first match", name: "multiple reviews, returns first match",
reviews: []vcsReview{ reviews: []gitea.Review{
makeReview(10, "bot", "APPROVED", false, "old\n<!-- review-bot:gpt -->"), makeReview(10, "bot", "APPROVED", false, "old\n<!-- review-bot:gpt -->"),
makeReview(20, "bot", "APPROVED", false, "new\n<!-- review-bot:sonnet -->"), makeReview(20, "bot", "APPROVED", false, "new\n<!-- review-bot:sonnet -->"),
}, },
@@ -252,7 +254,7 @@ func TestFindOwnReview(t *testing.T) {
}, },
{ {
name: "skips superseded review", name: "skips superseded review",
reviews: []vcsReview{ reviews: []gitea.Review{
makeReview(10, "bot", "APPROVED", false, "~~Original review~~\n\n**Superseded**\n<!-- review-bot:sonnet -->"), makeReview(10, "bot", "APPROVED", false, "~~Original review~~\n\n**Superseded**\n<!-- review-bot:sonnet -->"),
makeReview(20, "bot", "APPROVED", false, "fresh review\n<!-- review-bot:sonnet -->"), makeReview(20, "bot", "APPROVED", false, "fresh review\n<!-- review-bot:sonnet -->"),
}, },
@@ -261,7 +263,7 @@ func TestFindOwnReview(t *testing.T) {
}, },
{ {
name: "only superseded reviews exist", name: "only superseded reviews exist",
reviews: []vcsReview{ reviews: []gitea.Review{
makeReview(10, "bot", "APPROVED", false, "~~Original review~~\n\n<!-- review-bot:sonnet -->"), makeReview(10, "bot", "APPROVED", false, "~~Original review~~\n\n<!-- review-bot:sonnet -->"),
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
@@ -269,7 +271,7 @@ func TestFindOwnReview(t *testing.T) {
}, },
{ {
name: "picks highest ID among matches", name: "picks highest ID among matches",
reviews: []vcsReview{ reviews: []gitea.Review{
makeReview(50, "bot", "APPROVED", false, "v1\n<!-- review-bot:sonnet -->"), makeReview(50, "bot", "APPROVED", false, "v1\n<!-- review-bot:sonnet -->"),
makeReview(30, "bot", "APPROVED", false, "v0\n<!-- review-bot:sonnet -->"), makeReview(30, "bot", "APPROVED", false, "v0\n<!-- review-bot:sonnet -->"),
}, },
@@ -300,7 +302,7 @@ func TestFindOwnReview(t *testing.T) {
func TestHasSharedToken(t *testing.T) { func TestHasSharedToken(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
reviews []vcsReview reviews []gitea.Review
sentinel string sentinel string
want bool want bool
}{ }{
@@ -312,36 +314,36 @@ func TestHasSharedToken(t *testing.T) {
}, },
{ {
name: "no own review yet - cannot detect", name: "no own review yet - cannot detect",
reviews: []vcsReview{ reviews: []gitea.Review{
{ID: 1, User: struct{ Login string }{Login: "other"}, Body: "<!-- review-bot:gpt --> body"}, {ID: 1, User: struct{ Login string `json:"login"` }{Login: "other"}, Body: "<!-- review-bot:gpt --> body"},
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
{ {
name: "separate users - no shared token", name: "separate users - no shared token",
reviews: []vcsReview{ reviews: []gitea.Review{
{ID: 1, User: struct{ Login string }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"}, {ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
{ID: 2, User: struct{ Login string }{Login: "security-review-bot"}, Body: "<!-- review-bot:security --> body"}, {ID: 2, User: struct{ Login string `json:"login"` }{Login: "security-review-bot"}, Body: "<!-- review-bot:security --> body"},
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
{ {
name: "shared token detected - same user different sentinels", name: "shared token detected - same user different sentinels",
reviews: []vcsReview{ reviews: []gitea.Review{
{ID: 1, User: struct{ Login string }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"}, {ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
{ID: 2, User: struct{ Login string }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:security --> body"}, {ID: 2, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:security --> body"},
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: true, want: true,
}, },
{ {
name: "three roles same user", name: "three roles same user",
reviews: []vcsReview{ reviews: []gitea.Review{
{ID: 1, User: struct{ Login string }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"}, {ID: 1, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"},
{ID: 2, User: struct{ Login string }{Login: "bot"}, Body: "<!-- review-bot:security --> body"}, {ID: 2, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:security --> body"},
{ID: 3, User: struct{ Login string }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"}, {ID: 3, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"},
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: true, want: true,
@@ -551,7 +553,7 @@ func TestBuildPatternPaths(t *testing.T) {
func TestEvaluateCIStatus(t *testing.T) { func TestEvaluateCIStatus(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
statuses []vcsCommitStatus statuses []gitea.CommitStatus
wantPassed bool wantPassed bool
wantSubstr string wantSubstr string
}{ }{
@@ -563,7 +565,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "all success", name: "all success",
statuses: []vcsCommitStatus{ statuses: []gitea.CommitStatus{
{Status: "success", Context: "ci/build", Description: "Build passed"}, {Status: "success", Context: "ci/build", Description: "Build passed"},
{Status: "success", Context: "ci/test", Description: "Tests passed"}, {Status: "success", Context: "ci/test", Description: "Tests passed"},
}, },
@@ -572,7 +574,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "one failure", name: "one failure",
statuses: []vcsCommitStatus{ statuses: []gitea.CommitStatus{
{Status: "success", Context: "ci/build", Description: "Build passed"}, {Status: "success", Context: "ci/build", Description: "Build passed"},
{Status: "failure", Context: "ci/test", Description: "Tests failed"}, {Status: "failure", Context: "ci/test", Description: "Tests failed"},
}, },
@@ -581,7 +583,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "error status", name: "error status",
statuses: []vcsCommitStatus{ statuses: []gitea.CommitStatus{
{Status: "error", Context: "ci/lint", Description: "Lint error"}, {Status: "error", Context: "ci/lint", Description: "Lint error"},
}, },
wantPassed: false, wantPassed: false,
@@ -589,7 +591,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "pending treated as not-failed", name: "pending treated as not-failed",
statuses: []vcsCommitStatus{ statuses: []gitea.CommitStatus{
{Status: "pending", Context: "ci/build", Description: "In progress"}, {Status: "pending", Context: "ci/build", Description: "In progress"},
{Status: "success", Context: "ci/test", Description: "Tests passed"}, {Status: "success", Context: "ci/test", Description: "Tests passed"},
}, },
@@ -598,7 +600,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "multiple failures", name: "multiple failures",
statuses: []vcsCommitStatus{ statuses: []gitea.CommitStatus{
{Status: "failure", Context: "ci/build", Description: "Build failed"}, {Status: "failure", Context: "ci/build", Description: "Build failed"},
{Status: "failure", Context: "ci/test", Description: "Tests failed"}, {Status: "failure", Context: "ci/test", Description: "Tests failed"},
}, },
@@ -607,7 +609,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "mixed with pending and failure", name: "mixed with pending and failure",
statuses: []vcsCommitStatus{ statuses: []gitea.CommitStatus{
{Status: "success", Context: "ci/build", Description: "Build passed"}, {Status: "success", Context: "ci/build", Description: "Build passed"},
{Status: "pending", Context: "ci/deploy", Description: "Deploying"}, {Status: "pending", Context: "ci/deploy", Description: "Deploying"},
{Status: "failure", Context: "ci/test", Description: "Tests failed"}, {Status: "failure", Context: "ci/test", Description: "Tests failed"},
@@ -995,7 +997,7 @@ func cleanEnv() []string {
} }
func TestFindAllOwnReviews(t *testing.T) { func TestFindAllOwnReviews(t *testing.T) {
reviews := []vcsReview{ reviews := []gitea.Review{
{ID: 1, Body: "<!-- review-bot:sonnet -->\nfirst review"}, {ID: 1, Body: "<!-- review-bot:sonnet -->\nfirst review"},
{ID: 2, Body: "<!-- review-bot:gpt -->\nother bot"}, {ID: 2, Body: "<!-- review-bot:gpt -->\nother bot"},
{ID: 3, Body: "<!-- review-bot:sonnet -->\nsecond review"}, {ID: 3, Body: "<!-- review-bot:sonnet -->\nsecond review"},
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.weiker.me/rodin/review-bot/internal/netutil" "gitea.weiker.me/rodin/review-bot/gitea"
) )
// runValidateURL implements the `review-bot validate-url <url>` subcommand. // runValidateURL implements the `review-bot validate-url <url>` subcommand.
@@ -114,7 +114,7 @@ func validateURL(rawURL string) error {
} }
for _, a := range addrs { for _, a := range addrs {
if netutil.IsBlockedIP(a.IP) { if gitea.IsBlockedIP(a.IP) {
return &validateError{ return &validateError{
code: 1, code: 1,
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP), message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
+191 -255
View File
@@ -1,359 +1,295 @@
package main package main
// vcs.go defines the vcsClient interface that both gitea.Client (via giteaVCSAdapter) // vcs.go — VCS client abstraction for supporting both Gitea and GitHub.
// and github.Client (via githubVCSAdapter) satisfy, enabling VCS-type routing in main.go.
// //
// Interface design: // This file defines the vcsClient interface that main.go uses for all VCS
// - Methods cover all PR review operations used by main.go. // operations, and provides a githubAdapter that wraps *github.Client and
// - Gitea-specific operations (supersede, comment resolution) are in the separate // converts between github-package types and the gitea-package types used
// giteaExtClient interface. GitHub implementations return ErrNotSupported for those. // throughout the rest of the binary.
// - Types are defined here as package-local VCS types; each adapter converts from //
// its respective client package's types. // Design rationale: the entire codebase was written against gitea types.
// Rather than introduce a third "shared" type package and update every call
// site, the adapter converts at the boundary. The conversion is cheap — these
// are small structs fetched once per run.
import ( import (
"context" "context"
"errors" "os"
githubpkg "gitea.weiker.me/rodin/review-bot/github"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/github"
"gitea.weiker.me/rodin/review-bot/review" "gitea.weiker.me/rodin/review-bot/review"
) )
// ErrNotSupported is returned by VCS methods that have no implementation for // vcsClient is the interface that main.go uses for all VCS API operations.
// a particular VCS backend (e.g., Gitea-specific timeline APIs on GitHub). // Both *gitea.Client (directly) and *githubAdapter (via this file) satisfy it.
var ErrNotSupported = errors.New("operation not supported on this VCS backend")
// vcsClient is the interface for all PR operations used by main.go.
// It is implemented by both giteaVCSAdapter and githubVCSAdapter.
// Interface defined here (in the consumer package) per Go idiom.
type vcsClient interface { type vcsClient interface {
// PR metadata and content GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error)
GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error)
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]gitea.ChangedFile, error)
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]gitea.CommitStatus, error)
GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error)
GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error)
ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error)
GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]gitea.Review, error)
// Review operations
PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error)
DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error
GetAuthenticatedUser(ctx context.Context) (string, error) GetAuthenticatedUser(ctx context.Context) (string, error)
RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error
} PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []gitea.ReviewComment) (*gitea.Review, error)
GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, number int, reviewID int64) (int64, error)
// giteaExtClient extends vcsClient with Gitea-specific operations that have no EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error
// GitHub equivalent. Code that uses these methods should first do a type assertion. ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]gitea.ReviewComment, error)
type giteaExtClient interface {
vcsClient
GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, prNum, reviewID int64) (int64, error)
EditComment(ctx context.Context, owner, repo string, commentID int64, body string) error
ListReviewComments(ctx context.Context, owner, repo string, prNum, reviewID int64) ([]gitea.ReviewComment, error)
ResolveComment(ctx context.Context, owner, repo string, commentID int64) error ResolveComment(ctx context.Context, owner, repo string, commentID int64) error
ListContents(ctx context.Context, owner, repo, path string) ([]gitea.ContentEntry, error)
} }
// --- shared VCS types --- // vcsClientAdapterForPersona adapts vcsClient to review.GiteaClient.
// Used by LoadRepoPersonas which needs only ListContents + GetFileContent.
// vcsPullRequest is VCS-agnostic PR metadata. type vcsClientAdapterForPersona struct {
type vcsPullRequest struct { client vcsClient
Title string
Body string
Head struct {
Sha string
Ref string
}
} }
// vcsChangedFile is a file changed in a PR. func newVCSClientAdapterForPersona(c vcsClient) *vcsClientAdapterForPersona {
type vcsChangedFile struct { return &vcsClientAdapterForPersona{client: c}
Filename string
Status string
} }
// vcsCommitStatus is a CI status entry. func (a *vcsClientAdapterForPersona) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
type vcsCommitStatus struct { entries, err := a.client.ListContents(ctx, owner, repo, path)
Status string if err != nil {
Context string return nil, err
Description string }
TargetURL string result := make([]review.ContentEntry, len(entries))
for i, e := range entries {
result[i] = review.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
}
}
return result, nil
} }
// vcsReviewComment is an inline review comment. func (a *vcsClientAdapterForPersona) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
type vcsReviewComment struct { return a.client.GetFileContent(ctx, owner, repo, filepath)
Path string
NewLine int64 // absolute line number on the new (right) side of the diff, used by both Gitea and GitHub adapters
Body string
} }
// vcsReview is a submitted PR review. // detectVCSType returns "github" if the environment indicates a GitHub or GHES
type vcsReview struct { // runner, "gitea" otherwise.
ID int64 //
Body string // Detection logic mirrors the action.yml composite action:
CommitID string // - GITHUB_API_URL is set by GitHub Actions runners (github.com and GHES)
User struct { // - On Gitea Actions runners it is empty or absent
Login string func detectVCSType() string {
if os.Getenv("GITHUB_API_URL") != "" {
return "github"
} }
State string return "gitea"
} }
// ============================================================ // githubAPIBaseURL returns the GitHub API base URL from the environment.
// giteaVCSAdapter // On GitHub.com this is https://api.github.com.
// ============================================================ // On GHES this is https://<host>/api/v3.
func githubAPIBaseURL() string {
// giteaVCSAdapter wraps gitea.Client to implement vcsClient + giteaExtClient. if u := os.Getenv("GITHUB_API_URL"); u != "" {
type giteaVCSAdapter struct { return u
c *gitea.Client }
return "https://api.github.com"
} }
func newGiteaVCSAdapter(c *gitea.Client) *giteaVCSAdapter { return &giteaVCSAdapter{c: c} } // githubAdapter wraps *github.Client and translates github-package types to
// gitea-package types so that the rest of main.go can remain unchanged.
type githubAdapter struct {
c *githubpkg.Client
}
func (a *giteaVCSAdapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error) { func newGitHubAdapter(token, apiBaseURL string) *githubAdapter {
return &githubAdapter{c: githubpkg.NewClient(token, apiBaseURL)}
}
func (a *githubAdapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error) {
pr, err := a.c.GetPullRequest(ctx, owner, repo, number) pr, err := a.c.GetPullRequest(ctx, owner, repo, number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r := &vcsPullRequest{Title: pr.Title, Body: pr.Body} return &gitea.PullRequest{
r.Head.Sha = pr.Head.Sha Title: pr.Title,
r.Head.Ref = pr.Head.Ref Body: pr.Body,
return r, nil Head: struct {
Sha string "json:\"sha\""
Ref string "json:\"ref\""
}{Sha: pr.Head.Sha, Ref: pr.Head.Ref},
}, nil
} }
func (a *giteaVCSAdapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) { func (a *githubAdapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
return a.c.GetPullRequestDiff(ctx, owner, repo, number) return a.c.GetPullRequestDiff(ctx, owner, repo, number)
} }
func (a *giteaVCSAdapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error) { func (a *githubAdapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]gitea.ChangedFile, error) {
files, err := a.c.GetPullRequestFiles(ctx, owner, repo, number) files, err := a.c.GetPullRequestFiles(ctx, owner, repo, number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]vcsChangedFile, len(files)) result := make([]gitea.ChangedFile, len(files))
for i, f := range files { for i, f := range files {
out[i] = vcsChangedFile{Filename: f.Filename, Status: f.Status} result[i] = gitea.ChangedFile{
Filename: f.Filename,
Status: f.Status,
} }
return out, nil }
return result, nil
} }
func (a *giteaVCSAdapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error) { func (a *githubAdapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]gitea.CommitStatus, error) {
statuses, err := a.c.GetCommitStatuses(ctx, owner, repo, sha) statuses, err := a.c.GetCommitStatuses(ctx, owner, repo, sha)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]vcsCommitStatus, len(statuses)) result := make([]gitea.CommitStatus, len(statuses))
for i, s := range statuses { for i, s := range statuses {
out[i] = vcsCommitStatus{Status: s.Status, Context: s.Context, Description: s.Description, TargetURL: s.TargetURL} // GitHub uses "state" with values: success, failure, pending, error.
// Gitea uses "status" with values: success, failure, pending, warning, error.
// Map GitHub's "state" to gitea's "status" field for evaluateCIStatus().
result[i] = gitea.CommitStatus{
Status: s.State,
Context: s.Context,
Description: s.Description,
TargetURL: s.TargetURL,
} }
return out, nil }
return result, nil
} }
func (a *giteaVCSAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) { func (a *githubAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return a.c.GetFileContent(ctx, owner, repo, filepath) return a.c.GetFileContent(ctx, owner, repo, filepath)
} }
func (a *giteaVCSAdapter) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) { func (a *githubAdapter) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
return a.c.GetFileContentRef(ctx, owner, repo, filepath, ref) return a.c.GetFileContentRef(ctx, owner, repo, filepath, ref)
} }
func (a *giteaVCSAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) { func (a *githubAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
entries, err := a.c.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
out := make([]review.ContentEntry, len(entries))
for i, e := range entries {
out[i] = review.ContentEntry{Name: e.Name, Path: e.Path, Type: e.Type}
}
return out, nil
}
func (a *giteaVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
return a.c.GetAllFilesInPath(ctx, owner, repo, path) return a.c.GetAllFilesInPath(ctx, owner, repo, path)
} }
func (a *giteaVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) { func (a *githubAdapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]gitea.Review, error) {
gc := make([]gitea.ReviewComment, len(comments))
for i, c := range comments {
gc[i] = gitea.ReviewComment{Path: c.Path, NewPosition: c.NewLine, Body: c.Body}
}
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc)
if err != nil {
return nil, err
}
out := &vcsReview{ID: r.ID, Body: r.Body, CommitID: r.CommitID, State: r.State}
out.User.Login = r.User.Login
return out, nil
}
func (a *giteaVCSAdapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error) {
reviews, err := a.c.ListReviews(ctx, owner, repo, number) reviews, err := a.c.ListReviews(ctx, owner, repo, number)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]vcsReview, len(reviews)) result := make([]gitea.Review, len(reviews))
for i, r := range reviews { for i, r := range reviews {
out[i] = vcsReview{ID: r.ID, Body: r.Body, CommitID: r.CommitID, State: r.State} result[i] = gitea.Review{
out[i].User.Login = r.User.Login ID: r.ID,
Body: r.Body,
User: struct {
Login string "json:\"login\""
}{Login: r.User.Login},
State: r.State,
CommitID: r.CommitID,
} }
return out, nil }
return result, nil
} }
func (a *giteaVCSAdapter) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error { func (a *githubAdapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
return a.c.DeleteReview(ctx, owner, repo, number, reviewID)
}
func (a *giteaVCSAdapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
return a.c.GetAuthenticatedUser(ctx) return a.c.GetAuthenticatedUser(ctx)
} }
func (a *giteaVCSAdapter) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error { func (a *githubAdapter) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
return a.c.RequestReviewer(ctx, owner, repo, number, reviewer) return a.c.RequestReviewer(ctx, owner, repo, number, reviewer)
} }
// Gitea-specific extension methods. func (a *githubAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []gitea.ReviewComment) (*gitea.Review, error) {
// Convert gitea ReviewComments to github ReviewComments.
func (a *giteaVCSAdapter) GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, prNum, reviewID int64) (int64, error) { // NewPosition in Gitea maps to Position in GitHub (diff line position).
return a.c.GetTimelineReviewCommentIDForReview(ctx, owner, repo, int(prNum), reviewID) ghComments := make([]githubpkg.ReviewComment, len(comments))
}
func (a *giteaVCSAdapter) EditComment(ctx context.Context, owner, repo string, commentID int64, body string) error {
return a.c.EditComment(ctx, owner, repo, commentID, body)
}
func (a *giteaVCSAdapter) ListReviewComments(ctx context.Context, owner, repo string, prNum, reviewID int64) ([]gitea.ReviewComment, error) {
return a.c.ListReviewComments(ctx, owner, repo, int(prNum), reviewID)
}
func (a *giteaVCSAdapter) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
return a.c.ResolveComment(ctx, owner, repo, commentID)
}
// ============================================================
// githubVCSAdapter
// ============================================================
// githubVCSAdapter wraps github.Client to implement vcsClient.
// Gitea-specific extension methods (GetTimelineReviewCommentIDForReview, EditComment,
// ListReviewComments, ResolveComment) are not available on GitHub and will not be called
// because main.go gates them with a type assertion to giteaExtClient.
type githubVCSAdapter struct {
c *github.Client
}
func newGithubVCSAdapter(c *github.Client) *githubVCSAdapter { return &githubVCSAdapter{c: c} }
func (a *githubVCSAdapter) GetPullRequest(ctx context.Context, owner, repo string, number int) (*vcsPullRequest, error) {
pr, err := a.c.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return nil, err
}
r := &vcsPullRequest{Title: pr.Title, Body: pr.Body}
r.Head.Sha = pr.Head.Sha
r.Head.Ref = pr.Head.Ref
return r, nil
}
func (a *githubVCSAdapter) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
return a.c.GetPullRequestDiff(ctx, owner, repo, number)
}
func (a *githubVCSAdapter) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]vcsChangedFile, error) {
files, err := a.c.GetPullRequestFiles(ctx, owner, repo, number)
if err != nil {
return nil, err
}
out := make([]vcsChangedFile, len(files))
for i, f := range files {
out[i] = vcsChangedFile{Filename: f.Filename, Status: f.Status}
}
return out, nil
}
func (a *githubVCSAdapter) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]vcsCommitStatus, error) {
statuses, err := a.c.GetCommitStatuses(ctx, owner, repo, sha)
if err != nil {
return nil, err
}
out := make([]vcsCommitStatus, len(statuses))
for i, s := range statuses {
// CommitStatus.Status is tagged as json:"state" — already the normalized "state" value
out[i] = vcsCommitStatus{Status: s.Status, Context: s.Context, Description: s.Description, TargetURL: s.TargetURL}
}
return out, nil
}
func (a *githubVCSAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return a.c.GetFileContent(ctx, owner, repo, filepath)
}
func (a *githubVCSAdapter) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
return a.c.GetFileContentRef(ctx, owner, repo, filepath, ref)
}
func (a *githubVCSAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
entries, err := a.c.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
out := make([]review.ContentEntry, len(entries))
for i, e := range entries {
out[i] = review.ContentEntry{Name: e.Name, Path: e.Path, Type: e.Type}
}
return out, nil
}
func (a *githubVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
return a.c.GetAllFilesInPath(ctx, owner, repo, path)
}
func (a *githubVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
gc := make([]github.ReviewComment, len(comments))
for i, c := range comments { for i, c := range comments {
// GitHub inline comments use Line+Side (absolute line on the RIGHT side). ghComments[i] = githubpkg.ReviewComment{
// NewLine from diff parsing gives absolute new-file line numbers.
// Comments that cannot be mapped will be omitted (GitHub rejects invalid positions).
gc[i] = github.ReviewComment{
Path: c.Path, Path: c.Path,
Line: c.NewLine, Position: c.NewPosition,
Side: "RIGHT",
Body: c.Body, Body: c.Body,
} }
} }
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc) review, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, ghComments)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := &vcsReview{ID: r.ID, Body: r.Body, State: r.State} return &gitea.Review{
out.User.Login = r.User.Login ID: review.ID,
return out, nil Body: review.Body,
User: struct {
Login string "json:\"login\""
}{Login: review.User.Login},
State: review.State,
CommitID: review.CommitID,
}, nil
} }
func (a *githubVCSAdapter) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcsReview, error) { func (a *githubAdapter) GetTimelineReviewCommentIDForReview(ctx context.Context, owner, repo string, number int, reviewID int64) (int64, error) {
reviews, err := a.c.ListReviews(ctx, owner, repo, number) return a.c.GetTimelineReviewCommentIDForReview(ctx, owner, repo, number, reviewID)
}
func (a *githubAdapter) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error {
return a.c.EditComment(ctx, owner, repo, commentID, newBody)
}
func (a *githubAdapter) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]gitea.ReviewComment, error) {
comments, err := a.c.ListReviewComments(ctx, owner, repo, prNumber, reviewID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]vcsReview, len(reviews)) result := make([]gitea.ReviewComment, len(comments))
for i, r := range reviews { for i, c := range comments {
out[i] = vcsReview{ID: r.ID, Body: r.Body, State: r.State} result[i] = gitea.ReviewComment{
out[i].User.Login = r.User.Login ID: c.ID,
Path: c.Path,
NewPosition: c.Position,
Body: c.Body,
} }
return out, nil }
return result, nil
} }
func (a *githubVCSAdapter) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error { func (a *githubAdapter) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
// GitHub only allows deleting PENDING (draft) reviews. review-bot posts submitted return a.c.ResolveComment(ctx, owner, repo, commentID)
// reviews, so this will return an error for any review we actually posted.
// Callers should treat 422 errors here gracefully.
return a.c.DeleteReview(ctx, owner, repo, number, reviewID)
} }
func (a *githubVCSAdapter) GetAuthenticatedUser(ctx context.Context) (string, error) { func (a *githubAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]gitea.ContentEntry, error) {
return a.c.GetAuthenticatedUser(ctx) entries, err := a.c.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
result := make([]gitea.ContentEntry, len(entries))
for i, e := range entries {
result[i] = gitea.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
}
}
return result, nil
} }
func (a *githubVCSAdapter) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error { // newVCSClient creates the appropriate VCS client based on detected VCS type.
return a.c.RequestReviewer(ctx, owner, repo, number, reviewer) // On GitHub/GHES (GITHUB_API_URL set), returns a githubAdapter.
// On Gitea (GITHUB_API_URL absent), returns *gitea.Client directly.
//
// For GitHub: uses GITHUB_API_URL as the API base URL (trusted platform value).
// For Gitea: uses vcsURL (validated before this call).
func newVCSClient(vcsType, vcsURL, reviewerToken string) vcsClient {
switch vcsType {
case "github":
apiURL := githubAPIBaseURL()
return newGitHubAdapter(reviewerToken, apiURL)
default:
return gitea.NewClient(vcsURL, reviewerToken)
} }
}
// buildRepoPersonaClient creates a review.GiteaClient from the active vcsClient.
// This exists because LoadRepoPersonas expects the review.GiteaClient interface
// (which only requires ListContents + GetFileContent).
func buildRepoPersonaClient(c vcsClient) review.GiteaClient {
return newVCSClientAdapterForPersona(c)
}
+83 -14
View File
@@ -1,22 +1,91 @@
// Package gitea provides a client for the Gitea API. // Package gitea provides a client for the Gitea API.
// ipcheck.go re-exports the IsBlockedIP function from internal/netutil for use // ipcheck.go implements IP-level SSRF protection by checking resolved addresses
// by this package's safe dialer (client.go) and for backward compatibility with // against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
// any callers that previously imported it from here.
//
// The implementation has moved to internal/netutil so it can be shared with the
// validate-url subcommand (cmd/review-bot/validateurl.go) without creating a
// dependency from VCS-generic code on the Gitea-specific package.
package gitea package gitea
import ( import (
"fmt"
"net" "net"
"gitea.weiker.me/rodin/review-bot/internal/netutil"
) )
// IsBlockedIP reports whether ip is in a blocked address range. // blockedCIDRStrings is the canonical list of CIDR strings that should never
// It delegates to internal/netutil.IsBlockedIP; see that function for the full // be contacted by review-bot. See IsBlockedIP for the full list of covered
// list of blocked ranges and IPv6-mapped IPv4 normalization behavior. // address families.
func IsBlockedIP(ip net.IP) bool { //
return netutil.IsBlockedIP(ip) // These are hard-coded literals: any parse failure is a programming error.
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
var blockedCIDRStrings = []string{
// IPv4 loopback
"127.0.0.0/8",
// IPv4 unspecified / "this network"
"0.0.0.0/8",
// RFC1918 private ranges
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
"169.254.0.0/16",
// IPv4 shared address space (RFC6598, carrier-grade NAT)
"100.64.0.0/10",
// IPv4 multicast
"224.0.0.0/4",
// IPv4 reserved / broadcast
"240.0.0.0/4",
// IPv6 loopback
"::1/128",
// IPv6 unspecified
"::/128",
// IPv6 link-local
"fe80::/10",
// IPv6 unique local (ULA) — RFC4193
"fc00::/7",
// IPv6 multicast
"ff00::/8",
}
// blockedCIDRs is the parsed form of blockedCIDRStrings.
// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead
// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.
var (
blockedCIDRs []*net.IPNet
blockedCIDRParseErrors []string
)
func init() {
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
for _, r := range blockedCIDRStrings {
_, cidr, err := net.ParseCIDR(r)
if err != nil {
// Record the error rather than panicking; TestBlockedCIDRsValid
// will catch this during tests, and the CI build will fail.
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
continue
}
blockedCIDRs = append(blockedCIDRs, cidr)
}
}
// IsBlockedIP reports whether ip is in a blocked address range.
// It is exported for use by the validate-url subcommand and tests outside
// this package.
//
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
// IPv4 form before checking so that IPv4 CIDRs catch them.
//
// Based on:
// - RFC1918 private ranges
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
// - RFC4291 IPv6 link-local / loopback
func IsBlockedIP(ip net.IP) bool {
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, cidr := range blockedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
} }
+126 -19
View File
@@ -3,35 +3,142 @@ package gitea
import ( import (
"net" "net"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/internal/netutil"
) )
// TestIsBlockedIPForwarding verifies that gitea.IsBlockedIP correctly forwards func TestIsBlockedIP(t *testing.T) {
// to internal/netutil.IsBlockedIP. Full coverage of the blocking logic lives in blocked := []struct {
// internal/netutil/ipcheck_test.go. name string
func TestIsBlockedIPForwarding(t *testing.T) {
cases := []struct {
ip string ip string
blocked bool
}{ }{
{"127.0.0.1", true}, // loopback — must be blocked // IPv4 loopback
{"192.168.1.1", true}, // RFC1918 — must be blocked {"loopback 127.0.0.1", "127.0.0.1"},
{"8.8.8.8", false}, // public — must not be blocked {"loopback 127.0.0.2", "127.0.0.2"},
{"2001:4860:4860::8888", false}, // public IPv6 — must not be blocked {"loopback 127.255.255.255", "127.255.255.255"},
// IPv4 unspecified
{"unspecified 0.0.0.0", "0.0.0.0"},
{"unspecified 0.1.2.3", "0.1.2.3"},
// RFC1918
{"RFC1918 10.0.0.1", "10.0.0.1"},
{"RFC1918 10.255.255.255", "10.255.255.255"},
{"RFC1918 172.16.0.1", "172.16.0.1"},
{"RFC1918 172.31.255.255", "172.31.255.255"},
{"RFC1918 192.168.0.1", "192.168.0.1"},
{"RFC1918 192.168.255.255", "192.168.255.255"},
// Link-local (APIPA / AWS metadata)
{"link-local 169.254.0.1", "169.254.0.1"},
{"link-local 169.254.169.254", "169.254.169.254"},
// Shared address space (carrier-grade NAT)
{"CGN 100.64.0.1", "100.64.0.1"},
{"CGN 100.127.255.255", "100.127.255.255"},
// Multicast
{"multicast 224.0.0.1", "224.0.0.1"},
{"multicast 239.255.255.255", "239.255.255.255"},
// Reserved
{"reserved 240.0.0.1", "240.0.0.1"},
{"broadcast 255.255.255.255", "255.255.255.255"},
// IPv6 loopback
{"IPv6 loopback ::1", "::1"},
// IPv6 unspecified
{"IPv6 unspecified ::", "::"},
// IPv6 link-local
{"IPv6 link-local fe80::1", "fe80::1"},
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
// IPv6 ULA
{"IPv6 ULA fc00::1", "fc00::1"},
{"IPv6 ULA fd00::1", "fd00::1"},
// IPv6 multicast
{"IPv6 multicast ff02::1", "ff02::1"},
} }
for _, tc := range cases {
for _, tc := range blocked {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip) ip := net.ParseIP(tc.ip)
if ip == nil { if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip) t.Fatalf("failed to parse IP %q", tc.ip)
} }
got := IsBlockedIP(ip) if !IsBlockedIP(ip) {
want := netutil.IsBlockedIP(ip) t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
if got != want {
t.Errorf("gitea.IsBlockedIP(%q) = %v, netutil.IsBlockedIP = %v: forwarding mismatch", tc.ip, got, want)
} }
if got != tc.blocked { })
t.Errorf("gitea.IsBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked) }
allowed := []struct {
name string
ip string
}{
{"public 8.8.8.8", "8.8.8.8"},
{"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;
// not assigned to any real host, but intentionally left unblocked here because
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
// blocking it would require tracking every RFC5737 range without meaningful
// security benefit (no server should ever listen on a TEST-NET address).
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
}
for _, tc := range allowed {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
if IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
}
})
}
}
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
// Construct it manually as a 16-byte IP.
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
if !IsBlockedIP(mapped) {
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
}
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
if IsBlockedIP(mappedPublic) {
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
}
}
func TestIsBlockedIPEdgeCases(t *testing.T) {
// The boundary between RFC1918 and public ranges.
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
notPrivate := net.ParseIP("172.15.255.255")
if IsBlockedIP(notPrivate) {
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
}
// 172.32.0.0 is NOT private (just above 172.31.255.255).
notPrivate2 := net.ParseIP("172.32.0.0")
if IsBlockedIP(notPrivate2) {
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
}
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
notCGN := net.ParseIP("100.63.255.255")
if IsBlockedIP(notCGN) {
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
}
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
notCGN2 := net.ParseIP("100.128.0.0")
if IsBlockedIP(notCGN2) {
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
}
}
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
// successfully. This catches programming errors in the CIDR list without
// requiring a startup panic. The init() function records parse failures in
// blockedCIDRParseErrors rather than panicking; this test makes those failures
// visible as test failures during CI.
func TestBlockedCIDRsValid(t *testing.T) {
if len(blockedCIDRParseErrors) > 0 {
for _, msg := range blockedCIDRParseErrors {
t.Errorf("CIDR parse error: %s", msg)
} }
} }
} }
+4 -457
View File
@@ -4,10 +4,7 @@
package github package github
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -95,6 +92,10 @@ func asAPIError(err error) (*APIError, bool) {
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must // SetHTTPClient and SetRetryBackoff are intended for test setup only and must
// be called before any goroutines issue requests; they have no synchronization. // be called before any goroutines issue requests; they have no synchronization.
type Client struct { type Client struct {
// TODO: baseURL is populated by NewClient but not yet consumed by doRequest/doGet.
// Higher-level exported methods (GetPullRequest, etc.) will use it to
// construct request URLs; remove this field if those methods end up
// accepting full URLs instead.
baseURL string baseURL string
token string token string
httpClient *http.Client httpClient *http.Client
@@ -375,457 +376,3 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) { func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
return c.doRequest(ctx, http.MethodGet, url, "") 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.
type PullRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Head struct {
Sha string `json:"sha"`
Ref string `json:"ref"`
} `json:"head"`
Draft bool `json:"draft"`
}
// CommitStatus represents a single CI status entry.
// GitHub returns "state" not "status"; this type uses Status for consistency
// with the gitea package (both are normalized before use).
type CommitStatus struct {
Status string `json:"state"` // GitHub field is "state"
Context string `json:"context"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
}
// ChangedFile represents a file modified in a PR.
type ChangedFile struct {
Filename string `json:"filename"`
Status string `json:"status"`
}
// ReviewComment represents an inline comment to attach to a review.
// GitHub uses "position" (diff hunk position), whereas Gitea uses "new_position" (line number).
// When posting inline comments on GitHub, position is required; line numbers
// from the diff cannot be used directly.
type ReviewComment struct {
ID int64 `json:"id,omitempty"`
Path string `json:"path"`
Position int64 `json:"position,omitempty"` // GitHub diff hunk position
Line int64 `json:"line,omitempty"` // GitHub absolute line number (alternative to position)
Side string `json:"side,omitempty"` // "RIGHT" or "LEFT"
Body string `json:"body"`
}
// Review represents a pull request review from the GitHub API.
type Review struct {
ID int64 `json:"id"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
State string `json:"state"`
}
// contentResponse is the GitHub contents API response for a single file.
type contentResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "dir" or "symlink" or "submodule"
Content string `json:"content"` // Base64-encoded file content (with embedded newlines)
Encoding string `json:"encoding"` // "base64" or ""
}
// ContentEntry represents a file or directory entry from the contents API.
type ContentEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "dir"
}
// --- PR methods ---
// GetPullRequest fetches PR metadata.
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR: %w", err)
}
var pr PullRequest
if err := json.Unmarshal(body, &pr); err != nil {
return nil, fmt.Errorf("parse PR JSON: %w", err)
}
return &pr, nil
}
// GetPullRequestDiff fetches the unified diff for a PR.
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doRequest(ctx, http.MethodGet, reqURL, "application/vnd.github.diff")
if err != nil {
return "", fmt.Errorf("fetch diff: %w", err)
}
return string(body), nil
}
// GetPullRequestFiles fetches the list of files changed in a PR.
// GitHub paginates this endpoint (100 per page max).
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
const perPage = 100
var all []ChangedFile
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=%d&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, perPage, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR files (page %d): %w", page, err)
}
var batch []ChangedFile
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse PR files JSON (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// GetCommitStatuses fetches CI statuses for a commit SHA.
// GitHub has two status systems: legacy "commit statuses" and newer "check runs".
// This method returns commit statuses only; check runs are a separate API.
// Note: GitHub returns "state" in the JSON; CommitStatus.Status is tagged accordingly.
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
const perPage = 100
var all []CommitStatus
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/statuses?per_page=%d&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha), perPage, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch commit statuses (page %d): %w", page, err)
}
var batch []CommitStatus
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse statuses JSON (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// --- File content methods ---
// GetFileContent fetches a file from the default branch of a repo.
// GitHub returns base64-encoded content; this method decodes it.
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return c.getFileContentAtRef(ctx, owner, repo, filepath, "")
}
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha).
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
return c.getFileContentAtRef(ctx, owner, repo, filepath, ref)
}
// getFileContentAtRef fetches a file at the given ref (empty = default branch).
// GitHub's contents API returns base64-encoded file content.
func (c *Client) getFileContentAtRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath))
if ref != "" {
reqURL += "?ref=" + url.QueryEscape(ref)
}
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
}
var resp contentResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("parse file content JSON for %s: %w", filepath, err)
}
if resp.Type != "file" {
return "", fmt.Errorf("path %s is a %s, not a file", filepath, resp.Type)
}
if resp.Encoding == "base64" {
// GitHub embeds newlines in the base64 content for readability.
// Strip them before decoding.
cleaned := strings.ReplaceAll(resp.Content, "\n", "")
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return "", fmt.Errorf("decode base64 content for %s: %w", filepath, err)
}
return string(decoded), nil
}
// Non-base64 encoding (shouldn't happen normally, but handle gracefully).
return resp.Content, nil
}
// ListContents lists files and directories at a given path.
// Pass an empty path to list the repository root.
// GitHub returns a single object (not array) when path is a file — this
// method normalizes both cases to a slice, matching Gitea's behavior.
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
var reqURL string
if path == "" || path == "." {
reqURL = fmt.Sprintf("%s/repos/%s/%s/contents",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo))
} else {
reqURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
}
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list contents %s: %w", path, err)
}
var entries []ContentEntry
if err := json.Unmarshal(body, &entries); err != nil {
// GitHub returns a single object when path is a file (not an array).
var single contentResponse
if err2 := json.Unmarshal(body, &single); err2 != nil {
return nil, fmt.Errorf("parse contents JSON: %w", err)
}
if single.Name == "" && single.Path == "" {
return nil, fmt.Errorf("parse contents JSON: empty response for path %q", path)
}
entries = []ContentEntry{{
Name: single.Name,
Path: single.Path,
Type: single.Type,
}}
}
return entries, nil
}
// 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(ctx context.Context, owner, repo, path string) (map[string]string, error) {
results := make(map[string]string)
entries, err := c.ListContents(ctx, owner, repo, path)
if err != nil {
if !IsNotFound(err) {
return nil, fmt.Errorf("list contents %q: %w", path, err)
}
// 404 means path may be a file — try fetching directly.
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, fileErr)
}
results[path] = content
return results, nil
}
for _, entry := range entries {
switch entry.Type {
case "file":
content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
if err != nil {
slog.Warn("could not fetch file from patterns repo", "file", entry.Path, "error", err)
continue
}
results[entry.Path] = content
case "dir":
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
if err != nil {
slog.Warn("could not recurse into directory", "dir", entry.Path, "error", err)
continue
}
for k, v := range subResults {
results[k] = v
}
}
}
return results, nil
}
// --- Review methods ---
// PostReview submits a review to a PR.
// event should be one of "APPROVE", "REQUEST_CHANGES", or "COMMENT".
// commitID anchors the review to a specific commit SHA. If empty, defaults to current HEAD.
// comments are optional inline comments; GitHub uses diff hunk position (not line numbers).
// Note: unlike Gitea, GitHub does not support deleting submitted reviews.
// Use COMMENT event to supersede old reviews.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"`
}{
Body: body,
Event: event,
CommitID: commitID,
Comments: comments,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal review payload: %w", err)
}
respBody, err := c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
if err != nil {
return nil, fmt.Errorf("post review: %w", err)
}
var review Review
if err := json.Unmarshal(respBody, &review); err != nil {
return nil, fmt.Errorf("parse review response: %w", err)
}
return &review, nil
}
// ListReviews returns all reviews on a pull request.
// GitHub paginates via Link header; this method uses per_page=100.
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error) {
const perPage = 100
var all []Review
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=%d&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, perPage, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews (page %d): %w", page, err)
}
var batch []Review
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse reviews (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// DeleteReview attempts to delete a pull request review.
// GitHub only allows deleting PENDING (draft) reviews. Submitted reviews cannot
// be deleted via the API; this method returns a descriptive error in that case.
// review-bot callers should handle this error gracefully (e.g., by not attempting
// supersede and instead posting a new review alongside the old one).
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)
// nil body: the GitHub DELETE endpoint for reviews requires no request body.
_, err := c.doRequestWithBody(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
return fmt.Errorf("delete review: %w", err)
}
return nil
}
// GetAuthenticatedUser returns the login of the authenticated user.
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
reqURL := c.baseURL + "/user"
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("get authenticated user: %w", err)
}
var result struct {
Login string `json:"login"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse user response: %w", err)
}
return result.Login, nil
}
// RequestReviewer adds a user as a requested reviewer on a pull request.
// This is idempotent — requesting an already-requested reviewer is a no-op.
func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/requested_reviewers",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := struct {
Reviewers []string `json:"reviewers"`
}{Reviewers: []string{reviewer}}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal reviewer request: %w", err)
}
_, err = c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
if err != nil {
return fmt.Errorf("request reviewer: %w", err)
}
return nil
}
// --- helpers ---
// escapePath escapes each segment of a relative file path for use in URLs.
// Slashes are preserved as path separators; other special characters are escaped.
func escapePath(p string) string {
parts := strings.Split(p, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
-474
View File
@@ -2,9 +2,7 @@ package github
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -658,475 +656,3 @@ func TestRedactURL_UserinfoWithQuery(t *testing.T) {
t.Errorf("redactURL = %q, want %q", got, want) t.Errorf("redactURL = %q, want %q", got, want)
} }
} }
// --- Tests for API methods ---
func TestGetPullRequest_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"title":"Test PR","body":"description","head":{"sha":"abc123","ref":"feature"},"draft":false}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pr.Title != "Test PR" {
t.Errorf("Title = %q, want %q", pr.Title, "Test PR")
}
if pr.Head.Sha != "abc123" {
t.Errorf("Head.Sha = %q, want %q", pr.Head.Sha, "abc123")
}
if pr.Head.Ref != "feature" {
t.Errorf("Head.Ref = %q, want %q", pr.Head.Ref, "feature")
}
}
func TestGetPullRequest_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 99)
if err == nil {
t.Fatal("expected error, got nil")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound=true, got false for error: %v", err)
}
}
func TestGetPullRequestDiff_Success(t *testing.T) {
const wantDiff = "diff --git a/foo.go b/foo.go\n--- a/foo.go\n+++ b/foo.go\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/vnd.github.diff" {
t.Errorf("Accept = %q, want application/vnd.github.diff", r.Header.Get("Accept"))
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(wantDiff))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
diff, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff != wantDiff {
t.Errorf("diff = %q, want %q", diff, wantDiff)
}
}
func TestGetPullRequestFiles_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[{"filename":"foo.go","status":"modified"},{"filename":"bar.go","status":"added"}]`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 2 {
t.Fatalf("len(files) = %d, want 2", len(files))
}
if files[0].Filename != "foo.go" || files[0].Status != "modified" {
t.Errorf("files[0] = %+v, want {foo.go modified}", files[0])
}
}
func TestGetPullRequestFiles_Paginated(t *testing.T) {
page := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page++
if page == 1 {
// Return 100 items (page full → expect another request)
items := make([]map[string]string, 100)
for i := range items {
items[i] = map[string]string{"filename": fmt.Sprintf("file%d.go", i), "status": "modified"}
}
data, _ := json.Marshal(items)
w.WriteHeader(http.StatusOK)
w.Write(data)
return
}
// Page 2: return fewer than perPage → stop
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[{"filename":"last.go","status":"added"}]`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 101 {
t.Errorf("len(files) = %d, want 101", len(files))
}
if page != 2 {
t.Errorf("page = %d, want 2", page)
}
}
func TestGetCommitStatuses_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// GitHub uses "state" field
w.Write([]byte(`[{"state":"success","context":"ci/test","description":"Tests pass","target_url":"https://ci.example.com"}]`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "deadbeef")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(statuses) != 1 {
t.Fatalf("len(statuses) = %d, want 1", len(statuses))
}
if statuses[0].Status != "success" {
t.Errorf("Status = %q, want %q", statuses[0].Status, "success")
}
if statuses[0].Context != "ci/test" {
t.Errorf("Context = %q, want %q", statuses[0].Context, "ci/test")
}
}
func TestGetFileContent_Base64(t *testing.T) {
// "hello world\n" base64-encoded with embedded newlines (as GitHub does it)
encoded := "aGVsbG8gd29ybGQK"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/contents/README.md") {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"README.md","path":"README.md","type":"file","content":"` + encoded + `","encoding":"base64"}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
content, err := c.GetFileContent(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "hello world\n" {
t.Errorf("content = %q, want %q", content, "hello world\n")
}
}
func TestGetFileContent_Base64WithNewlines(t *testing.T) {
// GitHub embeds newlines in base64 content for readability (every 60 chars)
// Test that we strip them correctly before decoding
// "hello world\n" = aGVsbG8gd29ybGQK — split it with embedded \n
encoded := "aGVs\nbG8g\nd29y\nbGQK"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// JSON-encode the embedded newlines as \n
body := `{"name":"README.md","path":"README.md","type":"file","content":"aGVs\nbG8g\nd29y\nbGQK","encoding":"base64"}`
_ = encoded // suppress unused warning
w.Write([]byte(body))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
content, err := c.GetFileContent(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "hello world\n" {
t.Errorf("content = %q, want %q", content, "hello world\n")
}
}
func TestGetFileContent_IsDirectory(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"docs","path":"docs","type":"dir","content":"","encoding":""}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "docs")
if err == nil {
t.Fatal("expected error for directory, got nil")
}
}
func TestGetFileContentRef_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != "main" {
t.Errorf("ref = %q, want %q", r.URL.Query().Get("ref"), "main")
}
encoded := "dGVzdA==" // "test"
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"foo.go","path":"foo.go","type":"file","content":"` + encoded + `","encoding":"base64"}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
content, err := c.GetFileContentRef(context.Background(), "owner", "repo", "foo.go", "main")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "test" {
t.Errorf("content = %q, want %q", content, "test")
}
}
func TestListContents_Directory(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[{"name":"foo.go","path":"foo.go","type":"file"},{"name":"bar","path":"bar","type":"dir"}]`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
entries, err := c.ListContents(context.Background(), "owner", "repo", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("len(entries) = %d, want 2", len(entries))
}
if entries[0].Name != "foo.go" || entries[0].Type != "file" {
t.Errorf("entries[0] = %+v, unexpected", entries[0])
}
}
func TestListContents_SingleFile(t *testing.T) {
// GitHub returns a single object when the path is a file
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"README.md","path":"README.md","type":"file","content":"","encoding":""}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
entries, err := c.ListContents(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("len(entries) = %d, want 1", len(entries))
}
if entries[0].Name != "README.md" {
t.Errorf("entries[0].Name = %q, want README.md", entries[0].Name)
}
}
func TestPostReview_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/1/reviews" {
t.Errorf("path = %s, unexpected", r.URL.Path)
}
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode body: %v", err)
}
if payload["event"] != "APPROVE" {
t.Errorf("event = %v, want APPROVE", payload["event"])
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":99,"body":"looks good","user":{"login":"bot"},"state":"APPROVED"}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
review, err := c.PostReview(context.Background(), "owner", "repo", 1, "APPROVE", "looks good", "abc", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.ID != 99 {
t.Errorf("review.ID = %d, want 99", review.ID)
}
if review.User.Login != "bot" {
t.Errorf("review.User.Login = %q, want bot", review.User.Login)
}
}
func TestPostReview_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("bad-tok", srv.URL, AllowInsecureHTTPForTest())
_, err := c.PostReview(context.Background(), "owner", "repo", 1, "APPROVE", "body", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got false for error: %v", err)
}
}
func TestListReviews_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[{"id":1,"body":"review 1","user":{"login":"alice"},"state":"APPROVED"},{"id":2,"body":"review 2","user":{"login":"bob"},"state":"CHANGES_REQUESTED"}]`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 2 {
t.Fatalf("len(reviews) = %d, want 2", len(reviews))
}
if reviews[0].ID != 1 || reviews[0].User.Login != "alice" {
t.Errorf("reviews[0] = %+v, unexpected", reviews[0])
}
}
func TestDeleteReview_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s, want DELETE", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/1/reviews/42" {
t.Errorf("path = %s, unexpected", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
err := c.DeleteReview(context.Background(), "owner", "repo", 1, 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteReview_SubmittedReview(t *testing.T) {
// GitHub returns 422 for trying to delete a non-pending review
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message":"Can only delete a pending review"}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
err := c.DeleteReview(context.Background(), "owner", "repo", 1, 99)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestGetAuthenticatedUser_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user" {
t.Errorf("path = %s, want /user", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"login":"review-bot","id":12345}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
login, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if login != "review-bot" {
t.Errorf("login = %q, want review-bot", login)
}
}
func TestRequestReviewer_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/1/requested_reviewers" {
t.Errorf("path = %s, unexpected", r.URL.Path)
}
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode body: %v", err)
}
reviewers, ok := payload["reviewers"].([]interface{})
if !ok || len(reviewers) != 1 || reviewers[0] != "reviewer1" {
t.Errorf("reviewers = %v, unexpected", payload["reviewers"])
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
err := c.RequestReviewer(context.Background(), "owner", "repo", 1, "reviewer1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
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
want string
}{
{"README.md", "README.md"},
{"docs/guide.md", "docs/guide.md"},
{"path with spaces/file.md", "path%20with%20spaces/file.md"},
{"path/with [brackets]/file.md", "path/with%20%5Bbrackets%5D/file.md"},
}
for _, tt := range tests {
got := escapePath(tt.input)
if got != tt.want {
t.Errorf("escapePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
+552
View File
@@ -0,0 +1,552 @@
// Package github provides a client for the GitHub API.
// This file contains the higher-level PR/review methods built on top of the
// HTTP client in client.go. All methods use GitHub REST API v3 paths.
package github
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
)
// PullRequest holds relevant PR metadata.
type PullRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Head struct {
Sha string `json:"sha"`
Ref string `json:"ref"`
} `json:"head"`
}
// CommitStatus represents a single CI status entry.
// GitHub uses "state" (success/failure/pending/error) unlike Gitea's "status".
type CommitStatus struct {
State string `json:"state"`
Context string `json:"context"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
}
// ChangedFile represents a file modified in a PR.
type ChangedFile struct {
Filename string `json:"filename"`
Status string `json:"status"`
}
// ReviewComment represents an inline comment to attach to a review.
// GitHub uses "path" + "position" or "line" for positioning.
type ReviewComment struct {
ID int64 `json:"id,omitempty"`
Path string `json:"path"`
// Position is the line position in the diff (used when submitting).
// Side+Line is an alternative for GitHub (line in the file), but
// we mirror the Gitea interface using NewPosition mapped to position.
Position int64 `json:"position,omitempty"`
Body string `json:"body"`
}
// ContentEntry represents a file or directory entry from the contents API.
type ContentEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "dir"
}
// Review represents a pull request review.
type Review 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"`
}
// GetPullRequest fetches PR metadata.
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR: %w", err)
}
var pr PullRequest
if err := json.Unmarshal(body, &pr); err != nil {
return nil, fmt.Errorf("parse PR JSON: %w", err)
}
return &pr, nil
}
// GetPullRequestDiff fetches the unified diff for a PR.
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number)
body, err := c.doRequest(ctx, http.MethodGet, reqURL, "application/vnd.github.v3.diff")
if err != nil {
return "", fmt.Errorf("fetch diff: %w", err)
}
return string(body), nil
}
// GetPullRequestFiles fetches the list of files changed in a PR.
// GitHub paginates at 30 files/page (max 3000 files total).
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
const perPage = 100
var all []ChangedFile
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=%d&page=%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number,
perPage,
page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch PR files (page %d): %w", page, err)
}
var batch []ChangedFile
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse PR files JSON (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// GetCommitStatuses fetches CI statuses for a commit SHA.
// GitHub's combined status endpoint returns the most-relevant state per context.
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
const perPage = 100
var all []CommitStatus
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/commits/%s/statuses?per_page=%d&page=%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
url.PathEscape(sha),
perPage,
page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("fetch commit statuses: %w", err)
}
var batch []CommitStatus
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse statuses JSON: %w", err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// GetFileContent fetches a file from the default branch of a repo.
// GitHub's contents API returns base64-encoded content.
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return c.GetFileContentRef(ctx, owner, repo, filepath, "")
}
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
escapePath(filepath))
if ref != "" {
reqURL += "?ref=" + url.QueryEscape(ref)
}
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
}
// GitHub returns JSON with base64-encoded content
var result struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse file content JSON: %w", err)
}
if result.Encoding != "base64" {
return "", fmt.Errorf("unexpected encoding %q for file %s", result.Encoding, filepath)
}
// GitHub wraps base64 content in newlines — strip them before decoding
cleaned := strings.ReplaceAll(result.Content, "\n", "")
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
return "", fmt.Errorf("decode file content: %w", err)
}
return string(decoded), nil
}
// ListContents lists files and directories at a given path in a repo.
// Pass an empty path to list the repository root.
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
if path == "." {
path = ""
}
var reqURL string
if path == "" {
reqURL = fmt.Sprintf("%s/repos/%s/%s/contents",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo))
} else {
reqURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
}
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list contents %s: %w", path, err)
}
var entries []ContentEntry
if err := json.Unmarshal(body, &entries); err != nil {
// GitHub also returns a single object when path is a file
var single ContentEntry
if err2 := json.Unmarshal(body, &single); err2 != nil {
return nil, fmt.Errorf("parse contents JSON: %w", err)
}
if single.Name == "" && single.Path == "" {
return nil, fmt.Errorf("parse contents JSON: empty response for path %q", path)
}
entries = []ContentEntry{single}
}
return entries, nil
}
// GetAllFilesInPath recursively fetches all file contents under a path.
// If the path is a file, returns just that file's content.
func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error) {
results := make(map[string]string)
entries, err := c.ListContents(ctx, owner, repo, path)
if err != nil {
if IsNotFound(err) {
// Try fetching as a file directly
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, fileErr)
}
results[path] = content
return results, nil
}
return nil, fmt.Errorf("list contents %q: %w", path, err)
}
for _, entry := range entries {
switch entry.Type {
case "file":
content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
if err != nil {
slog.Warn("could not fetch file from patterns repo", "file", entry.Path, "error", err)
continue
}
results[entry.Path] = content
case "dir":
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
if err != nil {
slog.Warn("could not recurse into directory", "dir", entry.Path, "error", err)
continue
}
for k, v := range subResults {
results[k] = v
}
}
}
return results, nil
}
// PostReview submits a review to a PR and returns the created review.
// event should be "APPROVE", "REQUEST_CHANGES", or "COMMENT".
// commitID anchors the review to a specific commit SHA.
// comments are optional inline comments.
//
// Note: GitHub uses "APPROVE" (not "APPROVED") for the event name.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number)
// GitHub uses "APPROVE" not "APPROVED", "REQUEST_CHANGES" and "COMMENT" match
ghEvent := event
if event == "APPROVED" {
ghEvent = "APPROVE"
}
payload := struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"`
}{
Body: body,
Event: ghEvent,
CommitID: commitID,
Comments: comments,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal review payload: %w", err)
}
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, 64*1024))
return nil, fmt.Errorf("post review failed (status %d): %s", resp.StatusCode, string(respBody))
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
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)
}
return &review, nil
}
// ListReviews returns all reviews on a pull request.
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error) {
const perPage = 100
var all []Review
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=%d&page=%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number,
perPage,
page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews (page %d): %w", page, err)
}
var batch []Review
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse reviews (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// DeleteReview deletes a review by ID.
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)
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, 256))
return fmt.Errorf("delete review failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}
// GetAuthenticatedUser returns the login of the user authenticated by the token.
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 result struct {
Login string `json:"login"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse user response: %w", err)
}
return result.Login, nil
}
// RequestReviewer adds the given user as a requested reviewer on a pull request.
// This is idempotent on GitHub — requesting an already-requested reviewer succeats.
func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/requested_reviewers",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number)
payload := struct {
Reviewers []string `json:"reviewers"`
}{Reviewers: []string{reviewer}}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal reviewer request: %w", err)
}
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 < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("request reviewer failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}
// EditComment updates the body of a PR review comment.
// GitHub uses PATCH /repos/{owner}/{repo}/pulls/comments/{comment_id}.
func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/comments/%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
commentID)
payload := struct {
Body string `json:"body"`
}{Body: newBody}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal edit payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create edit 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("edit comment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("edit comment failed (status %d): %s", resp.StatusCode, body)
}
return nil
}
// ListReviewComments returns the inline comments attached to a specific review.
func (c *Client) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]ReviewComment, error) {
const perPage = 100
var all []ReviewComment
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d/comments?per_page=%d&page=%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
prNumber,
reviewID,
perPage,
page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list review comments (page %d): %w", page, err)
}
var batch []ReviewComment
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse review comments (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < perPage {
break
}
}
return all, nil
}
// ResolveComment is a no-op on GitHub. GitHub does not support resolving
// individual review comments via the REST API (only via the GraphQL API).
// This method exists to satisfy the VCSClient interface.
func (c *Client) ResolveComment(_ context.Context, _, _ string, _ int64) error {
return nil
}
// GetTimelineReviewCommentIDForReview finds the timeline comment ID for a review.
// GitHub doesn't have a direct timeline event endpoint for reviews the way Gitea does.
// This is primarily used by the supersede path (EditComment + ResolveComment). On GitHub,
// we return the review ID itself. Note that EditComment on GitHub uses the
// /pulls/comments/{id} endpoint (for inline review comments), which does not
// apply to review bodies — the supersede EditComment call will 404 and be
// logged as a warning. This is a known limitation; the review is still posted
// correctly regardless.
func (c *Client) GetTimelineReviewCommentIDForReview(_ context.Context, _, _ string, _ int, reviewID int64) (int64, error) {
return reviewID, nil
}
// escapePath escapes each path segment individually while preserving slashes.
// This avoids double-escaping the forward slash separator in file paths.
// NOTE: Intentionally duplicated from gitea/client.go to keep the packages independent.
func escapePath(p string) string {
parts := strings.Split(p, "/")
escaped := make([]string, len(parts))
for i, part := range parts {
escaped[i] = url.PathEscape(part)
}
return strings.Join(escaped, "/")
}
+518
View File
@@ -0,0 +1,518 @@
package github
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// newTestClient creates a Client pointed at the test server.
func newTestClient(srv *httptest.Server) *Client {
return NewClient("test-token", srv.URL, AllowInsecureHTTPForTest())
}
func TestGetPullRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/repos/owner/repo/pulls/42" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"title":"Fix bug","body":"Body text","head":{"sha":"abc1234","ref":"fix/bug"}}`)
}))
defer srv.Close()
c := newTestClient(srv)
pr, err := c.GetPullRequest(context.Background(), "owner", "repo", 42)
if err != nil {
t.Fatalf("GetPullRequest: %v", err)
}
if pr.Title != "Fix bug" {
t.Errorf("Title = %q, want %q", pr.Title, "Fix bug")
}
if pr.Head.Sha != "abc1234" {
t.Errorf("Head.Sha = %q, want %q", pr.Head.Sha, "abc1234")
}
if pr.Head.Ref != "fix/bug" {
t.Errorf("Head.Ref = %q, want %q", pr.Head.Ref, "fix/bug")
}
}
func TestGetPullRequest_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
}))
defer srv.Close()
c := newTestClient(srv)
_, err := c.GetPullRequest(context.Background(), "owner", "repo", 99)
if err == nil {
t.Fatal("expected error for 404, got nil")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound error, got %v", err)
}
}
func TestGetPullRequestDiff(t *testing.T) {
diffText := "diff --git a/foo.go b/foo.go\n@@ -1,1 +1,2 @@\n+added"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/1" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
if r.Header.Get("Accept") != "application/vnd.github.v3.diff" {
http.Error(w, "wrong accept", http.StatusNotAcceptable)
return
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, diffText)
}))
defer srv.Close()
c := newTestClient(srv)
got, err := c.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("GetPullRequestDiff: %v", err)
}
if got != diffText {
t.Errorf("diff = %q, want %q", got, diffText)
}
}
func TestGetPullRequestFiles(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/5/files" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[{"filename":"foo.go","status":"added"},{"filename":"bar.go","status":"modified"}]`)
}))
defer srv.Close()
c := newTestClient(srv)
files, err := c.GetPullRequestFiles(context.Background(), "owner", "repo", 5)
if err != nil {
t.Fatalf("GetPullRequestFiles: %v", err)
}
if len(files) != 2 {
t.Fatalf("len(files) = %d, want 2", len(files))
}
if files[0].Filename != "foo.go" || files[0].Status != "added" {
t.Errorf("files[0] = %+v", files[0])
}
}
func TestGetCommitStatuses(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/commits/deadbeef/statuses" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[{"state":"success","context":"ci/test","description":"Tests passed","target_url":"https://ci.example.com"}]`)
}))
defer srv.Close()
c := newTestClient(srv)
statuses, err := c.GetCommitStatuses(context.Background(), "owner", "repo", "deadbeef")
if err != nil {
t.Fatalf("GetCommitStatuses: %v", err)
}
if len(statuses) != 1 {
t.Fatalf("len(statuses) = %d, want 1", len(statuses))
}
if statuses[0].State != "success" {
t.Errorf("State = %q, want success", statuses[0].State)
}
if statuses[0].Context != "ci/test" {
t.Errorf("Context = %q, want ci/test", statuses[0].Context)
}
}
func TestGetFileContent(t *testing.T) {
content := "package main\nfunc main() {}\n"
encoded := base64.StdEncoding.EncodeToString([]byte(content))
// GitHub wraps base64 in newlines every 60 chars
var chunked string
for i := 0; i < len(encoded); i += 60 {
end := i + 60
if end > len(encoded) {
end = len(encoded)
}
chunked += encoded[i:end] + "\n"
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/main.go" {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]string{
"content": chunked,
"encoding": "base64",
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}))
defer srv.Close()
c := newTestClient(srv)
got, err := c.GetFileContent(context.Background(), "owner", "repo", "main.go")
if err != nil {
t.Fatalf("GetFileContent: %v", err)
}
if got != content {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestGetFileContentRef(t *testing.T) {
content := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(content))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/README.md" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
if r.URL.Query().Get("ref") != "abc123" {
http.Error(w, "missing ref", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]string{"content": encoded + "\n", "encoding": "base64"}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}))
defer srv.Close()
c := newTestClient(srv)
got, err := c.GetFileContentRef(context.Background(), "owner", "repo", "README.md", "abc123")
if err != nil {
t.Fatalf("GetFileContentRef: %v", err)
}
if got != content {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestListContents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/repos/owner/repo/contents" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[{"name":"README.md","path":"README.md","type":"file"},{"name":"src","path":"src","type":"dir"}]`)
return
}
http.Error(w, "unexpected: "+r.URL.Path, http.StatusNotFound)
}))
defer srv.Close()
c := newTestClient(srv)
entries, err := c.ListContents(context.Background(), "owner", "repo", "")
if err != nil {
t.Fatalf("ListContents: %v", err)
}
if len(entries) != 2 {
t.Fatalf("len(entries) = %d, want 2", len(entries))
}
if entries[0].Name != "README.md" || entries[0].Type != "file" {
t.Errorf("entries[0] = %+v", entries[0])
}
if entries[1].Name != "src" || entries[1].Type != "dir" {
t.Errorf("entries[1] = %+v", entries[1])
}
}
func TestListContents_Dot(t *testing.T) {
// "." should be treated as "" (root)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/repos/owner/repo/contents" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[]`)
return
}
http.Error(w, "unexpected: "+r.URL.Path, http.StatusNotFound)
}))
defer srv.Close()
c := newTestClient(srv)
entries, err := c.ListContents(context.Background(), "owner", "repo", ".")
if err != nil {
t.Fatalf("ListContents: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected empty entries, got %d", len(entries))
}
}
func TestPostReview(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/repos/owner/repo/pulls/10/reviews" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
var payload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
// Verify APPROVED is normalized to APPROVE
if payload.Event != "APPROVE" {
http.Error(w, fmt.Sprintf("expected APPROVE, got %s", payload.Event), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"id":99,"body":%q,"user":{"login":"bot"},"state":"APPROVED","commit_id":%q}`, payload.Body, payload.CommitID)
}))
defer srv.Close()
c := newTestClient(srv)
// Pass "APPROVED" (Gitea-style) — should be normalized to APPROVE
review, err := c.PostReview(context.Background(), "owner", "repo", 10, "APPROVED", "Looks good", "abc123", nil)
if err != nil {
t.Fatalf("PostReview: %v", err)
}
if review.ID != 99 {
t.Errorf("review.ID = %d, want 99", review.ID)
}
if review.User.Login != "bot" {
t.Errorf("review.User.Login = %q, want bot", review.User.Login)
}
}
func TestListReviews(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/7/reviews" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[{"id":1,"body":"LGTM","user":{"login":"alice"},"state":"APPROVED","commit_id":"abc"}]`)
}))
defer srv.Close()
c := newTestClient(srv)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 7)
if err != nil {
t.Fatalf("ListReviews: %v", err)
}
if len(reviews) != 1 {
t.Fatalf("len(reviews) = %d, want 1", len(reviews))
}
if reviews[0].User.Login != "alice" {
t.Errorf("User.Login = %q, want alice", reviews[0].User.Login)
}
}
func TestGetAuthenticatedUser(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"login":"sonnet-review"}`)
}))
defer srv.Close()
c := newTestClient(srv)
login, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
if login != "sonnet-review" {
t.Errorf("login = %q, want sonnet-review", login)
}
}
func TestResolveComment_NoOp(t *testing.T) {
// ResolveComment is a no-op on GitHub — should not make any HTTP call.
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
http.Error(w, "unexpected call", http.StatusInternalServerError)
}))
defer srv.Close()
c := newTestClient(srv)
if err := c.ResolveComment(context.Background(), "owner", "repo", 123); err != nil {
t.Errorf("ResolveComment: %v (expected no-op)", err)
}
if callCount != 0 {
t.Errorf("expected no HTTP calls, got %d", callCount)
}
}
func TestGetTimelineReviewCommentIDForReview(t *testing.T) {
// Should return reviewID unchanged without making HTTP calls.
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer srv.Close()
c := newTestClient(srv)
got, err := c.GetTimelineReviewCommentIDForReview(context.Background(), "owner", "repo", 5, 42)
if err != nil {
t.Fatalf("GetTimelineReviewCommentIDForReview: %v", err)
}
if got != 42 {
t.Errorf("got %d, want 42", got)
}
if callCount != 0 {
t.Errorf("expected no HTTP calls, got %d", callCount)
}
}
func TestRequestReviewer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/repos/owner/repo/pulls/3/requested_reviewers" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
var payload struct {
Reviewers []string `json:"reviewers"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || len(payload.Reviewers) == 0 {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if payload.Reviewers[0] != "bot-user" {
http.Error(w, fmt.Sprintf("unexpected reviewer %q", payload.Reviewers[0]), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, `{}`)
}))
defer srv.Close()
c := newTestClient(srv)
if err := c.RequestReviewer(context.Background(), "owner", "repo", 3, "bot-user"); err != nil {
t.Errorf("RequestReviewer: %v", err)
}
}
func TestEditComment(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.Path != "/repos/owner/repo/pulls/comments/55" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
var payload struct {
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if payload.Body != "updated body" {
http.Error(w, "wrong body", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"id":55,"body":"updated body"}`)
}))
defer srv.Close()
c := newTestClient(srv)
if err := c.EditComment(context.Background(), "owner", "repo", 55, "updated body"); err != nil {
t.Errorf("EditComment: %v", err)
}
}
func TestListReviewComments(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/pulls/9/reviews/20/comments" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[{"id":100,"path":"main.go","position":5,"body":"Needs fix"}]`)
}))
defer srv.Close()
c := newTestClient(srv)
comments, err := c.ListReviewComments(context.Background(), "owner", "repo", 9, 20)
if err != nil {
t.Fatalf("ListReviewComments: %v", err)
}
if len(comments) != 1 {
t.Fatalf("len(comments) = %d, want 1", len(comments))
}
if comments[0].Path != "main.go" {
t.Errorf("Path = %q, want main.go", comments[0].Path)
}
if comments[0].Position != 5 {
t.Errorf("Position = %d, want 5", comments[0].Position)
}
}
func TestDeleteReview(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/repos/owner/repo/pulls/7/reviews/11" {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := newTestClient(srv)
if err := c.DeleteReview(context.Background(), "owner", "repo", 7, 11); err != nil {
t.Errorf("DeleteReview: %v", err)
}
}
func TestGetAllFilesInPath(t *testing.T) {
content := "file content"
encoded := base64.StdEncoding.EncodeToString([]byte(content))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/owner/repo/contents/patterns":
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `[{"name":"patterns.md","path":"patterns/patterns.md","type":"file"}]`)
case "/repos/owner/repo/contents/patterns/patterns.md":
w.Header().Set("Content-Type", "application/json")
resp := map[string]string{"content": encoded + "\n", "encoding": "base64"}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
default:
http.Error(w, "unexpected: "+r.URL.Path, http.StatusNotFound)
}
}))
defer srv.Close()
c := newTestClient(srv)
files, err := c.GetAllFilesInPath(context.Background(), "owner", "repo", "patterns")
if err != nil {
t.Fatalf("GetAllFilesInPath: %v", err)
}
if len(files) != 1 {
t.Fatalf("len(files) = %d, want 1", len(files))
}
if files["patterns/patterns.md"] != content {
t.Errorf("content = %q, want %q", files["patterns/patterns.md"], content)
}
}
-97
View File
@@ -1,97 +0,0 @@
// Package netutil provides shared network utilities for review-bot.
// ipcheck.go implements IP-level SSRF protection by checking resolved addresses
// against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
package netutil
import (
"fmt"
"net"
)
// blockedCIDRStrings is the canonical list of CIDR strings that should never
// be contacted by review-bot. See IsBlockedIP for the full list of covered
// address families.
//
// These are hard-coded literals: any parse failure is a programming error.
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
var blockedCIDRStrings = []string{
// IPv4 loopback
"127.0.0.0/8",
// IPv4 unspecified / "this network"
"0.0.0.0/8",
// RFC1918 private ranges
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
"169.254.0.0/16",
// IPv4 shared address space (RFC6598, carrier-grade NAT)
"100.64.0.0/10",
// IPv4 multicast
"224.0.0.0/4",
// IPv4 reserved / broadcast
"240.0.0.0/4",
// IPv6 loopback
"::1/128",
// IPv6 unspecified
"::/128",
// IPv6 link-local
"fe80::/10",
// IPv6 unique local (ULA) — RFC4193
"fc00::/7",
// IPv6 multicast
"ff00::/8",
}
// blockedCIDRs is the parsed form of blockedCIDRStrings.
// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead
// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.
var (
blockedCIDRs []*net.IPNet
blockedCIDRParseErrors []string
)
func init() {
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
for _, r := range blockedCIDRStrings {
_, cidr, err := net.ParseCIDR(r)
if err != nil {
// Record the error rather than panicking; TestBlockedCIDRsValid
// will catch this during tests, and the CI build will fail.
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
continue
}
blockedCIDRs = append(blockedCIDRs, cidr)
}
}
// BlockedCIDRParseErrors returns any errors encountered parsing the built-in
// CIDR list. In correct code this will always be empty; tests assert it is.
func BlockedCIDRParseErrors() []string {
return blockedCIDRParseErrors
}
// IsBlockedIP reports whether ip is in a blocked address range.
// It is exported for use by the gitea package's safe dialer, the validate-url
// subcommand, and tests outside this package.
//
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
// IPv4 form before checking so that IPv4 CIDRs catch them.
//
// Based on:
// - RFC1918 private ranges
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
// - RFC4291 IPv6 link-local / loopback
func IsBlockedIP(ip net.IP) bool {
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, cidr := range blockedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
}
-142
View File
@@ -1,142 +0,0 @@
package netutil
import (
"net"
"testing"
)
func TestIsBlockedIP(t *testing.T) {
blocked := []struct {
name string
ip string
}{
// IPv4 loopback
{"loopback 127.0.0.1", "127.0.0.1"},
{"loopback 127.0.0.2", "127.0.0.2"},
{"loopback 127.255.255.255", "127.255.255.255"},
// IPv4 unspecified
{"unspecified 0.0.0.0", "0.0.0.0"},
{"unspecified 0.1.2.3", "0.1.2.3"},
// RFC1918
{"RFC1918 10.0.0.1", "10.0.0.1"},
{"RFC1918 10.255.255.255", "10.255.255.255"},
{"RFC1918 172.16.0.1", "172.16.0.1"},
{"RFC1918 172.31.255.255", "172.31.255.255"},
{"RFC1918 192.168.0.1", "192.168.0.1"},
{"RFC1918 192.168.255.255", "192.168.255.255"},
// Link-local (APIPA / AWS metadata)
{"link-local 169.254.0.1", "169.254.0.1"},
{"link-local 169.254.169.254", "169.254.169.254"},
// Shared address space (carrier-grade NAT)
{"CGN 100.64.0.1", "100.64.0.1"},
{"CGN 100.127.255.255", "100.127.255.255"},
// Multicast
{"multicast 224.0.0.1", "224.0.0.1"},
{"multicast 239.255.255.255", "239.255.255.255"},
// Reserved
{"reserved 240.0.0.1", "240.0.0.1"},
{"broadcast 255.255.255.255", "255.255.255.255"},
// IPv6 loopback
{"IPv6 loopback ::1", "::1"},
// IPv6 unspecified
{"IPv6 unspecified ::", "::"},
// IPv6 link-local
{"IPv6 link-local fe80::1", "fe80::1"},
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
// IPv6 ULA
{"IPv6 ULA fc00::1", "fc00::1"},
{"IPv6 ULA fd00::1", "fd00::1"},
// IPv6 multicast
{"IPv6 multicast ff02::1", "ff02::1"},
}
for _, tc := range blocked {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
if !IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
}
})
}
allowed := []struct {
name string
ip string
}{
{"public 8.8.8.8", "8.8.8.8"},
{"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;
// not assigned to any real host, but intentionally left unblocked here because
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
// blocking it would require tracking every RFC5737 range without meaningful
// security benefit (no server should ever listen on a TEST-NET address).
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
}
for _, tc := range allowed {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
if IsBlockedIP(ip) {
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
}
})
}
}
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
// Construct it manually as a 16-byte IP.
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
if !IsBlockedIP(mapped) {
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
}
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
if IsBlockedIP(mappedPublic) {
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
}
}
func TestIsBlockedIPEdgeCases(t *testing.T) {
// The boundary between RFC1918 and public ranges.
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
notPrivate := net.ParseIP("172.15.255.255")
if IsBlockedIP(notPrivate) {
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
}
// 172.32.0.0 is NOT private (just above 172.31.255.255).
notPrivate2 := net.ParseIP("172.32.0.0")
if IsBlockedIP(notPrivate2) {
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
}
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
notCGN := net.ParseIP("100.63.255.255")
if IsBlockedIP(notCGN) {
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
}
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
notCGN2 := net.ParseIP("100.128.0.0")
if IsBlockedIP(notCGN2) {
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
}
}
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
// successfully. This catches programming errors in the CIDR list without
// requiring a startup panic. The init() function records parse failures in
// blockedCIDRParseErrors rather than panicking; this test makes those failures
// visible as test failures during CI.
func TestBlockedCIDRsValid(t *testing.T) {
for _, msg := range BlockedCIDRParseErrors() {
t.Errorf("CIDR parse error: %s", msg)
}
}