Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b80a1517ed | |||
| 5f7ffab487 | |||
| f8b9d7d282 | |||
| 7a8fc166ec | |||
| 5e351b85f0 | |||
| ab2a6c8aef | |||
| 6b7f3f6924 | |||
| 4c032a3b53 | |||
| 64c9d551ba | |||
| db7b7e66bf | |||
| 0232343126 | |||
| b26514714f | |||
| 028d46942a | |||
| e59c2bc831 | |||
| dc2e1ca5de | |||
| 7de6fdd9ec | |||
| 1e0959b077 | |||
| 67c3db70cb | |||
| a845ce32eb | |||
| 49d6ca77a3 | |||
| 6ebf66aefb | |||
| 004343d05f | |||
| 92b84976cf | |||
| 9f8e9aa8d3 | |||
| 881ce232eb | |||
| 31a28b1dd5 | |||
| e414471a16 | |||
| 41e1d48b54 |
@@ -6,8 +6,8 @@ name: 'AI Code Review'
|
|||||||
description: 'Run AI-powered code review on a pull request using review-bot'
|
description: 'Run AI-powered code review on a pull request using review-bot'
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
gitea-url:
|
vcs-url:
|
||||||
description: 'Gitea instance URL (defaults to server_url)'
|
description: 'VCS server URL (defaults to server_url)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
repo:
|
repo:
|
||||||
@@ -112,10 +112,10 @@ runs:
|
|||||||
id: version
|
id: version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
GITEA_URL="${{ inputs.gitea-url || github.server_url }}"
|
BASE_URL="${{ inputs.vcs-url || github.server_url }}"
|
||||||
REPO="${{ inputs.repo || 'rodin/review-bot' }}"
|
REPO="${{ inputs.repo || 'rodin/review-bot' }}"
|
||||||
if [ "${{ inputs.version }}" = "latest" ]; then
|
if [ "${{ inputs.version }}" = "latest" ]; then
|
||||||
VERSION=$(curl -sSf "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=1" \
|
VERSION=$(curl -sSf "${BASE_URL}/api/v1/repos/${REPO}/releases?limit=1" \
|
||||||
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "$VERSION" ]; then
|
||||||
echo "Failed to determine latest version" >&2
|
echo "Failed to determine latest version" >&2
|
||||||
@@ -124,33 +124,62 @@ runs:
|
|||||||
else
|
else
|
||||||
VERSION="${{ inputs.version }}"
|
VERSION="${{ inputs.version }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Detect OS and architecture for platform-specific binary download
|
||||||
|
OS_RAW=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
case "$OS_RAW" in
|
||||||
|
linux) OS="linux" ;;
|
||||||
|
darwin) OS="darwin" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: unsupported OS: $(uname -s)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RAW_ARCH=$(uname -m)
|
||||||
|
case "$RAW_ARCH" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64 | arm64) ARCH="arm64" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: unsupported architecture: $RAW_ARCH" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "os=${OS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "arch=${ARCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache review-bot binary
|
- name: Cache review-bot binary
|
||||||
id: cache
|
id: cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/review-bot
|
path: ${{ runner.temp }}/review-bot
|
||||||
key: review-bot-linux-amd64-${{ steps.version.outputs.version }}
|
key: review-bot-${{ steps.version.outputs.os }}-${{ steps.version.outputs.arch }}-${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
- name: Install review-bot
|
- name: Install review-bot
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
if: steps.cache.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
GITEA_URL="${{ inputs.gitea-url || github.server_url }}"
|
BASE_URL="${{ inputs.vcs-url || github.server_url }}"
|
||||||
REPO="${{ inputs.repo || 'rodin/review-bot' }}"
|
REPO="${{ inputs.repo || 'rodin/review-bot' }}"
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
BINARY="review-bot-linux-amd64"
|
BINARY="review-bot-${{ steps.version.outputs.os }}-${{ steps.version.outputs.arch }}"
|
||||||
|
|
||||||
curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/${BINARY}" \
|
curl -sSfL "${BASE_URL}/${REPO}/releases/download/${VERSION}/${BINARY}" \
|
||||||
-o "${{ runner.temp }}/review-bot"
|
-o "${{ runner.temp }}/review-bot"
|
||||||
curl -sSfL "${GITEA_URL}/${REPO}/releases/download/${VERSION}/checksums.txt" \
|
curl -sSfL "${BASE_URL}/${REPO}/releases/download/${VERSION}/checksums.txt" \
|
||||||
-o "${{ runner.temp }}/checksums.txt"
|
-o "${{ runner.temp }}/checksums.txt"
|
||||||
|
|
||||||
# Verify SHA-256 checksum
|
# Verify SHA-256 checksum
|
||||||
cd "${{ runner.temp }}"
|
cd "${{ runner.temp }}"
|
||||||
EXPECTED=$(grep "${BINARY}" checksums.txt | awk '{print $1}')
|
EXPECTED=$(grep -E "^[[:xdigit:]]+[[:space:]]+\*?${BINARY}$" checksums.txt | awk '{print $1}')
|
||||||
|
# sha256sum (GNU) is not available on macOS; use shasum -a 256 on darwin.
|
||||||
|
if [ "${{ steps.version.outputs.os }}" = "darwin" ]; then
|
||||||
|
ACTUAL=$(shasum -a 256 review-bot | awk '{print $1}')
|
||||||
|
else
|
||||||
ACTUAL=$(sha256sum review-bot | awk '{print $1}')
|
ACTUAL=$(sha256sum review-bot | awk '{print $1}')
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "$EXPECTED" ]; then
|
if [ -z "$EXPECTED" ]; then
|
||||||
echo "Error: no checksum found for ${BINARY}" >&2
|
echo "Error: no checksum found for ${BINARY}" >&2
|
||||||
@@ -164,12 +193,12 @@ runs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x "${{ runner.temp }}/review-bot"
|
chmod +x "${{ runner.temp }}/review-bot"
|
||||||
echo "Installed review-bot ${VERSION} (checksum verified)"
|
echo "Installed review-bot-${{ steps.version.outputs.os }}-${{ steps.version.outputs.arch }} ${VERSION} (checksum verified)"
|
||||||
|
|
||||||
- name: Run review
|
- name: Run review
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ inputs.gitea-url || github.server_url }}
|
VCS_URL: ${{ inputs.vcs-url || github.server_url }}
|
||||||
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 }}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
- run: go build -o review-bot ./cmd/review-bot
|
- run: go build -o review-bot ./cmd/review-bot
|
||||||
- name: Run ${{ matrix.name }} review
|
- name: Run ${{ matrix.name }} review
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ github.server_url }}
|
VCS_URL: ${{ github.server_url }}
|
||||||
GITEA_REPO: ${{ github.repository }}
|
GITEA_REPO: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
REVIEWER_TOKEN: ${{ secrets[matrix.token_secret] }}
|
REVIEWER_TOKEN: ${{ secrets[matrix.token_secret] }}
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
# Plan: Issue #125 — Rename GITEA_URL → VCS_URL
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The `GITEA_URL` environment variable (and `--gitea-url` flag) implies the binary only works with Gitea.
|
||||||
|
Now that review-bot supports both Gitea and GitHub/GHES, this name is misleading.
|
||||||
|
Renaming to `VCS_URL` makes the binary platform-agnostic in its interface.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Must not break existing users who already use `GITEA_URL` — need a fallback
|
||||||
|
- The CLI flag `--gitea-url` should also be updated to `--vcs-url` for consistency
|
||||||
|
- `INTEGRATION_GITEA_URL` in integration tests is a test-only env var, not the binary's interface; but should be updated for clarity
|
||||||
|
- The action YAML uses `GITEA_URL` as an internal shell variable in bash scripts — distinct from the env var passed to the binary
|
||||||
|
- All changes must compile and pass existing tests
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
|
||||||
|
### Binary / Go source
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `cmd/review-bot/main.go` | Rename `--gitea-url` → `--vcs-url`, add `VCS_URL` as primary, keep `GITEA_URL` fallback |
|
||||||
|
| `cmd/review-bot/integration_test.go` | Rename `INTEGRATION_GITEA_URL` → `INTEGRATION_VCS_URL` (test-only, no external compat concern) |
|
||||||
|
| `integration_test.go` | Same — rename `INTEGRATION_GITEA_URL` → `INTEGRATION_VCS_URL` |
|
||||||
|
|
||||||
|
### Action YAML
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `.gitea/actions/review/action.yml` | Rename input `gitea-url` → `vcs-url`; update env var passed to binary: `VCS_URL` instead of `GITEA_URL`; keep internal bash var as `GITEA_URL` (only used for release download, not passed to binary) |
|
||||||
|
| `.gitea/workflows/ci.yml` | Rename `GITEA_URL` env var to `VCS_URL` in Run review step |
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `README.md` | Update CLI example, env var table entry |
|
||||||
|
|
||||||
|
## Proposed Approach
|
||||||
|
|
||||||
|
### 1. Backward-compatible env var lookup in main.go
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```go
|
||||||
|
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL")
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```go
|
||||||
|
giteaURL := flag.String("vcs-url", envOrDefaultFallback("VCS_URL", "GITEA_URL", ""), "VCS server URL (e.g. https://gitea.example.com)")
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a helper:
|
||||||
|
```go
|
||||||
|
// envOrDefaultFallback reads primary env var; if empty, falls back to deprecated env var.
|
||||||
|
func envOrDefaultFallback(primary, deprecated, defaultVal string) string {
|
||||||
|
if v := os.Getenv(primary); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := os.Getenv(deprecated); v != "" {
|
||||||
|
slog.Warn("deprecated env var in use; rename to " + primary, "old", deprecated, "new", primary)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This must be called AFTER `setupLogger` conceptually, but the flag default is evaluated at flag registration time. Since `setupLogger` runs before `flag.Parse()`, the slog.Warn will print correctly at runtime. We use `log.Printf` as a fallback if this proves problematic.
|
||||||
|
|
||||||
|
Actually — flag defaults are evaluated at registration (line 57), before `setupLogger`. The warning won't go through slog. Two options:
|
||||||
|
- Use `log.Printf` for the deprecation warning (always visible)
|
||||||
|
- Move the fallback lookup to after `flag.Parse()`, checking if the parsed value is still empty
|
||||||
|
|
||||||
|
**Decision:** Move fallback to a post-parse check. This is cleaner:
|
||||||
|
```go
|
||||||
|
vcsURL := flag.String("vcs-url", os.Getenv("VCS_URL"), "VCS server URL")
|
||||||
|
flag.Parse()
|
||||||
|
// Backward compat: fall back to deprecated GITEA_URL
|
||||||
|
if *vcsURL == "" {
|
||||||
|
if v := os.Getenv("GITEA_URL"); v != "" {
|
||||||
|
slog.Warn("GITEA_URL is deprecated; use VCS_URL instead")
|
||||||
|
*vcsURL = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is clean, idiomatic, and the warning goes through slog correctly.
|
||||||
|
|
||||||
|
### 2. Keep `--gitea-url` as deprecated alias
|
||||||
|
|
||||||
|
Add a hidden flag for backward compat:
|
||||||
|
```go
|
||||||
|
giteaURLAlias := flag.String("gitea-url", "", "Deprecated: use --vcs-url")
|
||||||
|
```
|
||||||
|
|
||||||
|
Post-parse:
|
||||||
|
```go
|
||||||
|
if *vcsURL == "" && *giteaURLAlias != "" {
|
||||||
|
slog.Warn("--gitea-url is deprecated; use --vcs-url instead")
|
||||||
|
*vcsURL = *giteaURLAlias
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Internal variable rename
|
||||||
|
|
||||||
|
Rename `giteaURL` local variable → `vcsURL` throughout `main.go` for consistency.
|
||||||
|
|
||||||
|
### 4. Error message update
|
||||||
|
|
||||||
|
```go
|
||||||
|
fmt.Fprintf(os.Stderr, "Required: --vcs-url, --repo, --pr, --reviewer-token, --llm-model\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Action YAML changes
|
||||||
|
|
||||||
|
In `.gitea/actions/review/action.yml`:
|
||||||
|
- Input `gitea-url` → `vcs-url` (with same description, `required: false`, `default: ''`)
|
||||||
|
- Line 172: `GITEA_URL: ${{ inputs.gitea-url || github.server_url }}` → `VCS_URL: ${{ inputs.vcs-url || github.server_url }}`
|
||||||
|
- Lines 115, 140: internal bash vars `GITEA_URL=` are used for downloading binaries — NOT passed to the review-bot binary. Leave them as internal bash vars (they're scope-local in bash). These could be renamed to `SERVER_URL` or `BASE_URL` for local clarity, but renaming them isn't strictly required.
|
||||||
|
|
||||||
|
In `.gitea/workflows/ci.yml`:
|
||||||
|
- Line 52: `GITEA_URL: ${{ github.server_url }}` → `VCS_URL: ${{ github.server_url }}`
|
||||||
|
|
||||||
|
### 6. Integration test updates
|
||||||
|
|
||||||
|
`INTEGRATION_GITEA_URL` → `INTEGRATION_VCS_URL` in both test files.
|
||||||
|
|
||||||
|
### 7. README
|
||||||
|
|
||||||
|
- CLI example: `--gitea-url` → `--vcs-url`
|
||||||
|
- Env var table: `GITEA_URL` → `VCS_URL`, add note about `GITEA_URL` fallback
|
||||||
|
|
||||||
|
## Backward Compatibility Summary
|
||||||
|
|
||||||
|
| Old | New | Fallback? |
|
||||||
|
|-----|-----|-----------|
|
||||||
|
| `GITEA_URL` env var | `VCS_URL` | ✅ with deprecation warning |
|
||||||
|
| `--gitea-url` flag | `--vcs-url` | ✅ with deprecation warning |
|
||||||
|
| `gitea-url` action input | `vcs-url` | ⚠️ No (action version bump handles this) |
|
||||||
|
| `INTEGRATION_GITEA_URL` | `INTEGRATION_VCS_URL` | N/A (test-only) |
|
||||||
|
|
||||||
|
## Error Cases
|
||||||
|
|
||||||
|
- Both `VCS_URL` and `GITEA_URL` set: `VCS_URL` wins (primary takes precedence)
|
||||||
|
- Both `--vcs-url` and `--gitea-url` provided: `--vcs-url` wins
|
||||||
|
- Neither set: existing "missing required flags" error unchanged
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- `os.Getenv` returns "" for unset AND set-to-empty — consistent with existing behavior
|
||||||
|
- The `envOrDefault` helper is unchanged; we add `envOrDefaultFallback` for the one renamed var
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Existing unit tests pass unchanged (they don't test env var parsing directly)
|
||||||
|
- Integration tests updated to use new env var name
|
||||||
|
- Manual: `GITEA_URL=https://example.com ./review-bot --repo x --pr 1 ...` should print deprecation warning and proceed
|
||||||
|
- Manual: `VCS_URL=https://example.com ./review-bot ...` should work silently
|
||||||
|
|
||||||
|
## Completion Checklist
|
||||||
|
|
||||||
|
1. `VCS_URL` is read first; `GITEA_URL` is fallback with deprecation warning
|
||||||
|
2. `--vcs-url` flag is primary; `--gitea-url` is deprecated alias with warning
|
||||||
|
3. Error message references `--vcs-url` not `--gitea-url`
|
||||||
|
4. `action.yml` passes `VCS_URL` (not `GITEA_URL`) to the binary
|
||||||
|
5. `ci.yml` passes `VCS_URL` (not `GITEA_URL`) to the binary
|
||||||
|
6. README updated in CLI example and env var table
|
||||||
|
7. Integration tests use `INTEGRATION_VCS_URL`
|
||||||
|
8. `go test ./...` passes
|
||||||
|
9. `go vet ./...` passes
|
||||||
|
10. `go build ./cmd/review-bot` succeeds
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the CLI flag `--gitea-url` be completely hidden from `--help` or just deprecated with a note? The issue doesn't specify. Decision: keep it visible but add "(deprecated: use --vcs-url)" to the description.
|
||||||
|
- Should action.yml also add `gitea-url` as a deprecated input alias? The issue says "Update the action to pass the new env var name" — no mention of backward compat for the action input. Decision: rename only, no alias (action users pin a version anyway).
|
||||||
|
- The bash-internal `GITEA_URL` variable in action.yml scripts (used for release download, not passed to binary) — rename for clarity? Decision: yes, rename to `BASE_URL` to avoid confusion with the env var.
|
||||||
@@ -299,7 +299,7 @@ All flags have environment variable equivalents:
|
|||||||
|
|
||||||
| Flag | Env Var |
|
| Flag | Env Var |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `--gitea-url` | `GITEA_URL` |
|
| `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) |
|
||||||
| `--repo` | `GITEA_REPO` |
|
| `--repo` | `GITEA_REPO` |
|
||||||
| `--pr` | `PR_NUMBER` |
|
| `--pr` | `PR_NUMBER` |
|
||||||
| `--reviewer-token` | `REVIEWER_TOKEN` |
|
| `--reviewer-token` | `REVIEWER_TOKEN` |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// Integration test requires a running Gitea instance and LLM endpoint.
|
// Integration test requires a running Gitea instance and LLM endpoint.
|
||||||
// Set environment variables:
|
// Set environment variables:
|
||||||
//
|
//
|
||||||
// INTEGRATION_GITEA_URL - Gitea base URL
|
// INTEGRATION_VCS_URL - VCS base URL
|
||||||
// INTEGRATION_GITEA_TOKEN - Gitea API token with repo access
|
// INTEGRATION_GITEA_TOKEN - Gitea API token with repo access
|
||||||
// INTEGRATION_GITEA_REPO - owner/repo with an open PR
|
// INTEGRATION_GITEA_REPO - owner/repo with an open PR
|
||||||
// INTEGRATION_PR_NUMBER - PR number to test against
|
// INTEGRATION_PR_NUMBER - PR number to test against
|
||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
// INTEGRATION_LLM_API_KEY - LLM API key
|
// INTEGRATION_LLM_API_KEY - LLM API key
|
||||||
// INTEGRATION_LLM_MODEL - Model name
|
// INTEGRATION_LLM_MODEL - Model name
|
||||||
func TestIntegration_FullReviewFlow(t *testing.T) {
|
func TestIntegration_FullReviewFlow(t *testing.T) {
|
||||||
giteaURL := os.Getenv("INTEGRATION_GITEA_URL")
|
giteaURL := os.Getenv("INTEGRATION_VCS_URL")
|
||||||
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
|
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
|
||||||
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
|
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
|
||||||
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
|
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
|
||||||
@@ -104,7 +104,7 @@ func TestIntegration_FullReviewFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegration_PostAndCleanup(t *testing.T) {
|
func TestIntegration_PostAndCleanup(t *testing.T) {
|
||||||
giteaURL := os.Getenv("INTEGRATION_GITEA_URL")
|
giteaURL := os.Getenv("INTEGRATION_VCS_URL")
|
||||||
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
|
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
|
||||||
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
|
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
|
||||||
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
|
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
|
||||||
@@ -130,7 +130,7 @@ func TestIntegration_PostAndCleanup(t *testing.T) {
|
|||||||
// Post a test review
|
// Post a test review
|
||||||
sentinel := "<!-- review-bot:integration-test -->"
|
sentinel := "<!-- review-bot:integration-test -->"
|
||||||
testBody := "# Integration Test Review\n\nThis is a test review.\n\n" + sentinel
|
testBody := "# Integration Test Review\n\nThis is a test review.\n\n" + sentinel
|
||||||
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "COMMENT", testBody, nil)
|
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "COMMENT", testBody, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PostReview: %v", err)
|
t.Fatalf("PostReview: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-6
@@ -54,7 +54,8 @@ func main() {
|
|||||||
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
||||||
verbosity := flag.String("verbosity", envOrDefault("LOG_VERBOSITY", "info"), "Log verbosity: debug, info, warn, error")
|
verbosity := flag.String("verbosity", envOrDefault("LOG_VERBOSITY", "info"), "Log verbosity: debug, info, warn, error")
|
||||||
// CLI flags
|
// CLI flags
|
||||||
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL")
|
vcsURL := flag.String("vcs-url", os.Getenv("VCS_URL"), "VCS server URL (e.g. https://gitea.example.com)")
|
||||||
|
giteaURLAlias := flag.String("gitea-url", "", "Deprecated: use --vcs-url")
|
||||||
repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)")
|
repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)")
|
||||||
prNum := flag.String("pr", envOrDefault("PR_NUMBER", ""), "Pull request number")
|
prNum := flag.String("pr", envOrDefault("PR_NUMBER", ""), "Pull request number")
|
||||||
reviewerName := flag.String("reviewer-name", envOrDefault("REVIEWER_NAME", ""), "Reviewer display name")
|
reviewerName := flag.String("reviewer-name", envOrDefault("REVIEWER_NAME", ""), "Reviewer display name")
|
||||||
@@ -91,12 +92,24 @@ func main() {
|
|||||||
|
|
||||||
slog.Info("review-bot starting", "version", version)
|
slog.Info("review-bot starting", "version", version)
|
||||||
|
|
||||||
|
// Backward compatibility: fall back to deprecated env var / flag if VCS_URL / --vcs-url not set.
|
||||||
|
if *vcsURL == "" {
|
||||||
|
if v := os.Getenv("GITEA_URL"); v != "" {
|
||||||
|
slog.Warn("GITEA_URL is deprecated; rename the environment variable to VCS_URL")
|
||||||
|
*vcsURL = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *vcsURL == "" && *giteaURLAlias != "" {
|
||||||
|
slog.Warn("--gitea-url is deprecated; use --vcs-url instead")
|
||||||
|
*vcsURL = *giteaURLAlias
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
// For aicore provider, llm-base-url and llm-api-key are not required
|
// For aicore provider, llm-base-url and llm-api-key are not required
|
||||||
isAICore := llm.Provider(*llmProvider) == llm.ProviderAICore
|
isAICore := llm.Provider(*llmProvider) == llm.ProviderAICore
|
||||||
if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" {
|
if *vcsURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" {
|
||||||
fmt.Fprintf(os.Stderr, "Error: missing required flags or environment variables\n\n")
|
fmt.Fprintf(os.Stderr, "Error: missing required flags or environment variables\n\n")
|
||||||
fmt.Fprintf(os.Stderr, "Required: --gitea-url, --repo, --pr, --reviewer-token, --llm-model\n")
|
fmt.Fprintf(os.Stderr, "Required: --vcs-url, --repo, --pr, --reviewer-token, --llm-model\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if !isAICore && (*llmBaseURL == "" || *llmAPIKey == "") {
|
if !isAICore && (*llmBaseURL == "" || *llmAPIKey == "") {
|
||||||
@@ -139,7 +152,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize clients
|
// Initialize clients
|
||||||
giteaClient := gitea.NewClient(*giteaURL, *reviewerToken)
|
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
|
||||||
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")
|
||||||
@@ -444,7 +457,7 @@ 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 := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, 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)
|
||||||
@@ -453,7 +466,7 @@ func main() {
|
|||||||
|
|
||||||
// Supersede all old reviews with link to the new one
|
// Supersede all old reviews with link to the new one
|
||||||
if len(oldReviews) > 0 {
|
if len(oldReviews) > 0 {
|
||||||
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*giteaURL, "/"), 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 := giteaClient.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
|
cid, err := giteaClient.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+133
-17
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -47,6 +48,12 @@ func IsServerError(err error) bool {
|
|||||||
return errors.As(err, &apiErr) && apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
return errors.As(err, &apiErr) && apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultMaxDiffSize is the default maximum diff size in bytes (10 MB).
|
||||||
|
const DefaultMaxDiffSize = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// ErrDiffTooLarge is returned when a PR diff exceeds the configured MaxDiffSize.
|
||||||
|
var ErrDiffTooLarge = errors.New("diff size exceeds maximum allowed size")
|
||||||
|
|
||||||
// Client interacts with the Gitea API.
|
// Client interacts with the Gitea API.
|
||||||
// A Client is safe for concurrent use by multiple goroutines.
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -61,6 +68,42 @@ type Client struct {
|
|||||||
// This field must be configured before the first request is made.
|
// This field must be configured before the first request is made.
|
||||||
// Modifying it while requests are in flight is not safe.
|
// Modifying it while requests are in flight is not safe.
|
||||||
RetryBackoff []time.Duration
|
RetryBackoff []time.Duration
|
||||||
|
|
||||||
|
// MaxDiffSize is the maximum number of bytes allowed when fetching a PR diff.
|
||||||
|
// If zero, defaults to DefaultMaxDiffSize (10 MB). Set to any negative value
|
||||||
|
// (or math.MaxInt64) to disable the limit.
|
||||||
|
//
|
||||||
|
// This field must be configured before the first request is made.
|
||||||
|
// Modifying it while requests are in flight is not safe.
|
||||||
|
MaxDiffSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
||||||
|
// NOTE: This function is intentionally duplicated in github/client.go (and vice versa)
|
||||||
|
// because the packages are separate. Changes here must be mirrored there.
|
||||||
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
||||||
|
// and cross-host redirects (to prevent following responses from untrusted
|
||||||
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
||||||
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
// Guard for direct invocation in tests and any future callers;
|
||||||
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
||||||
|
if len(via) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prev := via[len(via)-1]
|
||||||
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
||||||
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
||||||
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
// Reject cross-host redirect entirely to avoid consuming responses
|
||||||
|
// from untrusted endpoints.
|
||||||
|
if req.URL.Host != prev.URL.Host {
|
||||||
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Gitea API client.
|
// NewClient creates a new Gitea API client.
|
||||||
@@ -68,13 +111,30 @@ func NewClient(baseURL, token string) *Client {
|
|||||||
return &Client{
|
return &Client{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
http: &http.Client{Timeout: 30 * time.Second},
|
http: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHTTPClient sets the underlying HTTP client used for requests.
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||||
// This is intended for testing to inject mock transports.
|
// This is intended for test setup only to inject mock transports; it must be
|
||||||
|
// called before any goroutines issue requests.
|
||||||
|
//
|
||||||
|
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
||||||
|
// CheckRedirect policy matching NewClient).
|
||||||
|
//
|
||||||
|
// Callers providing a non-nil client are responsible for configuring a safe
|
||||||
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
||||||
|
// redirects and may forward the Authorization header to untrusted hosts.
|
||||||
func (c *Client) SetHTTPClient(hc *http.Client) {
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
}
|
||||||
|
}
|
||||||
c.http = hc
|
c.http = hc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +185,20 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPullRequestDiff fetches the unified diff for a PR.
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
||||||
|
// It enforces MaxDiffSize to prevent unbounded memory allocation.
|
||||||
|
// Returns ErrDiffTooLarge if the diff exceeds the configured limit.
|
||||||
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
|
|
||||||
|
maxSize := c.MaxDiffSize
|
||||||
|
if maxSize == 0 {
|
||||||
|
maxSize = DefaultMaxDiffSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the limit is disabled (negative) or set to math.MaxInt64 (which
|
||||||
|
// would overflow the +1 detection and silently disable enforcement),
|
||||||
|
// use the standard unlimited doGet path.
|
||||||
|
if maxSize < 0 || maxSize == math.MaxInt64 {
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch diff: %w", err)
|
return "", fmt.Errorf("fetch diff: %w", err)
|
||||||
@@ -134,6 +206,13 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, num
|
|||||||
return string(body), nil
|
return string(body), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body, err := c.doGetLimited(ctx, reqURL, maxSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch diff: %w", err)
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPullRequestFiles fetches the list of files changed in a PR.
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
||||||
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
@@ -183,18 +262,22 @@ func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PostReview submits a review to a PR and returns the created review.
|
// PostReview submits a review to a PR and returns the created review.
|
||||||
// event should be "APPROVED" or "REQUEST_CHANGES".
|
// event should be one of "APPROVED", "REQUEST_CHANGES", or "COMMENT".
|
||||||
|
// commitID anchors the review to a specific commit SHA. If empty, Gitea
|
||||||
|
// defaults to the current PR head.
|
||||||
// comments are optional inline comments attached to specific lines.
|
// comments are optional inline comments attached to specific lines.
|
||||||
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) {
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
|
|
||||||
payload := struct {
|
payload := struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
|
CommitID string `json:"commit_id,omitempty"`
|
||||||
Comments []ReviewComment `json:"comments,omitempty"`
|
Comments []ReviewComment `json:"comments,omitempty"`
|
||||||
}{
|
}{
|
||||||
Body: body,
|
Body: body,
|
||||||
Event: event,
|
Event: event,
|
||||||
|
CommitID: commitID,
|
||||||
Comments: comments,
|
Comments: comments,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,9 +375,9 @@ func isRetriableSyscallError(err error) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// redactURL strips query parameters from a URL for safe logging.
|
// redactURL strips query parameters and userinfo credentials from a URL for
|
||||||
// This prevents accidental exposure of sensitive data that future callers
|
// safe logging. This prevents accidental exposure of sensitive data (tokens in
|
||||||
// might pass via query strings.
|
// query strings, or user:pass in the authority) in log output.
|
||||||
func redactURL(rawURL string) string {
|
func redactURL(rawURL string) string {
|
||||||
parsed, err := url.Parse(rawURL)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,6 +385,9 @@ func redactURL(rawURL string) string {
|
|||||||
// potentially logging something sensitive.
|
// potentially logging something sensitive.
|
||||||
return "[invalid URL]"
|
return "[invalid URL]"
|
||||||
}
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
parsed.User = url.User("REDACTED")
|
||||||
|
}
|
||||||
if parsed.RawQuery != "" {
|
if parsed.RawQuery != "" {
|
||||||
parsed.RawQuery = "[redacted]"
|
parsed.RawQuery = "[redacted]"
|
||||||
}
|
}
|
||||||
@@ -322,10 +408,12 @@ func sanitizeErrorForLog(err error) string {
|
|||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// doGet performs an HTTP GET request with retry on 5xx errors and temporary
|
// doGetWithReader performs an HTTP GET request with retry on 5xx errors and
|
||||||
// network errors. Retries up to 3 times with exponential backoff (1s, 2s delays
|
// temporary network errors. Retries up to 3 times with exponential backoff
|
||||||
// by default; configurable via Client.RetryBackoff for testing).
|
// (1s, 2s delays by default; configurable via Client.RetryBackoff for testing).
|
||||||
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
// The readBody function is called with the response body on success (2xx) and
|
||||||
|
// is responsible for reading and closing it.
|
||||||
|
func (c *Client) doGetWithReader(ctx context.Context, reqURL string, readBody func(io.ReadCloser) ([]byte, error)) ([]byte, error) {
|
||||||
const maxAttempts = 3
|
const maxAttempts = 3
|
||||||
// backoff[i] is the delay before attempt i+1 (i.e., after attempt i fails).
|
// backoff[i] is the delay before attempt i+1 (i.e., after attempt i fails).
|
||||||
// First attempt (i=0) has no delay; retries wait 1s then 2s by default.
|
// First attempt (i=0) has no delay; retries wait 1s then 2s by default.
|
||||||
@@ -390,12 +478,7 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
body, err := io.ReadAll(resp.Body)
|
return readBody(resp.Body)
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error path: limit how much we read from potentially malicious server
|
// Error path: limit how much we read from potentially malicious server
|
||||||
@@ -413,6 +496,39 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doGet performs an HTTP GET request with retry, reading the full response body.
|
||||||
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
||||||
|
return c.doGetWithReader(ctx, reqURL, func(body io.ReadCloser) ([]byte, error) {
|
||||||
|
defer body.Close()
|
||||||
|
return io.ReadAll(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// doGetLimited performs an HTTP GET request with retry but enforces a maximum
|
||||||
|
// response body size. Returns ErrDiffTooLarge if the response exceeds maxBytes.
|
||||||
|
// It reads maxBytes+1 (clamped to avoid overflow) to detect truncation without
|
||||||
|
// buffering the entire body.
|
||||||
|
func (c *Client) doGetLimited(ctx context.Context, reqURL string, maxBytes int64) ([]byte, error) {
|
||||||
|
return c.doGetWithReader(ctx, reqURL, func(body io.ReadCloser) ([]byte, error) {
|
||||||
|
defer body.Close()
|
||||||
|
// Read up to maxBytes+1 to detect overflow.
|
||||||
|
// Clamp to prevent integer overflow when maxBytes == math.MaxInt64.
|
||||||
|
limitBytes := maxBytes + 1
|
||||||
|
if limitBytes <= 0 {
|
||||||
|
limitBytes = math.MaxInt64
|
||||||
|
}
|
||||||
|
limited := io.LimitReader(body, limitBytes)
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > maxBytes {
|
||||||
|
return nil, fmt.Errorf("%w: response exceeds %d bytes", ErrDiffTooLarge, maxBytes)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// escapePath escapes each segment of a relative file path for use in URLs.
|
// escapePath escapes each segment of a relative file path for use in URLs.
|
||||||
// Slashes are preserved as path separators; other special characters are escaped.
|
// Slashes are preserved as path separators; other special characters are escaped.
|
||||||
// Input should be a relative path (no leading slash). Already-encoded segments
|
// Input should be a relative path (no leading slash). Already-encoded segments
|
||||||
|
|||||||
+146
-5
@@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -118,6 +119,7 @@ func TestPostReview(t *testing.T) {
|
|||||||
var payload struct {
|
var payload struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
|
CommitID string `json:"commit_id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
t.Fatalf("failed to decode payload: %v", err)
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
@@ -128,14 +130,16 @@ func TestPostReview(t *testing.T) {
|
|||||||
if payload.Event != "APPROVED" {
|
if payload.Event != "APPROVED" {
|
||||||
t.Errorf("expected event %q, got %q", "APPROVED", payload.Event)
|
t.Errorf("expected event %q, got %q", "APPROVED", payload.Event)
|
||||||
}
|
}
|
||||||
|
if payload.CommitID != "abc123def" {
|
||||||
|
t.Errorf("expected commit_id %q, got %q", "abc123def", payload.CommitID)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(`{"id":100,"user":{"login":"review-bot"},"state":"APPROVED","stale":false}`))
|
w.Write([]byte(`{"id":100,"user":{"login":"review-bot"},"state":"APPROVED","stale":false}`))
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", nil)
|
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "abc123def", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -182,12 +186,35 @@ func TestPostReview_Non200(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", nil)
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 403, got nil")
|
t.Fatal("expected error for 403, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := raw["commit_id"]; exists {
|
||||||
|
t.Errorf("expected commit_id to be omitted from payload when empty, but it was present")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":200,"user":{"login":"bot"},"state":"APPROVED","stale":false}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "ok", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetFileContent(t *testing.T) {
|
func TestGetFileContent(t *testing.T) {
|
||||||
expected := "# Conventions\n- Be nice\n"
|
expected := "# Conventions\n- Be nice\n"
|
||||||
|
|
||||||
@@ -944,8 +971,6 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
|
|||||||
t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts)
|
t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// mockTransport is a test helper that returns errors for the first N calls,
|
// mockTransport is a test helper that returns errors for the first N calls,
|
||||||
// then delegates to a real server.
|
// then delegates to a real server.
|
||||||
type mockTransport struct {
|
type mockTransport struct {
|
||||||
@@ -1092,6 +1117,21 @@ func TestRedactURL(t *testing.T) {
|
|||||||
input: "",
|
input: "",
|
||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "with userinfo - redacts credentials",
|
||||||
|
input: "https://admin:secret@gitea.example.com/api/v1/repos",
|
||||||
|
want: "https://REDACTED@gitea.example.com/api/v1/repos",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with userinfo and query params",
|
||||||
|
input: "https://user:pass@example.com/path?token=abc",
|
||||||
|
want: "https://REDACTED@example.com/path?[redacted]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username only - no password",
|
||||||
|
input: "https://user@example.com/path",
|
||||||
|
want: "https://REDACTED@example.com/path",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -1144,3 +1184,104 @@ func TestSanitizeErrorForLog(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewClient_HasCheckRedirect(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
if c.http.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "gitea.example.com", Path: "/foo"},
|
||||||
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on HTTPS->HTTP redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HTTPS to HTTP downgrade") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsCrossHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "cdn.example.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on cross-host redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cross-host") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"token abc"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if auth := req.Header.Get("Authorization"); auth != "token abc" {
|
||||||
|
t.Errorf("expected Authorization to be preserved, got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHostHTTPToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "http", Host: "localhost:3000", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "localhost:3000", Path: "/bar"},
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsTooManyRedirects(t *testing.T) {
|
||||||
|
via := make([]*http.Request, 10)
|
||||||
|
for i := range via {
|
||||||
|
via[i] = &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/"}}
|
||||||
|
}
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/final"}}
|
||||||
|
err := defaultCheckRedirect(req, via)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after 10 redirects")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "10 redirects") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_EmptyViaAllowed(t *testing.T) {
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "gitea.example.com", Path: "/foo"}}
|
||||||
|
err := defaultCheckRedirect(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error with empty via: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||||
|
c := NewClient("https://gitea.example.com", "token")
|
||||||
|
c.SetHTTPClient(nil)
|
||||||
|
if c.http == nil {
|
||||||
|
t.Fatal("expected non-nil http client after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
if c.http.Timeout != 30*time.Second {
|
||||||
|
t.Errorf("expected 30s timeout, got %v", c.http.Timeout)
|
||||||
|
}
|
||||||
|
if c.http.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetPullRequestDiff_SizeLimits(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
diff string
|
||||||
|
maxDiffSize int64
|
||||||
|
wantErr error
|
||||||
|
wantDiff string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exceeds max size",
|
||||||
|
diff: strings.Repeat("+ added line\n", 1000), // ~13 KB
|
||||||
|
maxDiffSize: 100,
|
||||||
|
wantErr: ErrDiffTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "within max size",
|
||||||
|
diff: "diff --git a/f.go b/f.go\n--- a/f.go\n+++ b/f.go\n@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
maxDiffSize: 1024,
|
||||||
|
wantDiff: "diff --git a/f.go b/f.go\n--- a/f.go\n+++ b/f.go\n@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exactly at limit",
|
||||||
|
diff: strings.Repeat("x", 50),
|
||||||
|
maxDiffSize: 50,
|
||||||
|
wantDiff: strings.Repeat("x", 50),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one byte over limit",
|
||||||
|
diff: strings.Repeat("x", 51),
|
||||||
|
maxDiffSize: 50,
|
||||||
|
wantErr: ErrDiffTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled limit",
|
||||||
|
diff: strings.Repeat("x", 10000),
|
||||||
|
maxDiffSize: -1,
|
||||||
|
wantDiff: strings.Repeat("x", 10000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "math.MaxInt64 treated as disabled",
|
||||||
|
diff: strings.Repeat("x", 10000),
|
||||||
|
maxDiffSize: math.MaxInt64,
|
||||||
|
wantDiff: strings.Repeat("x", 10000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default limit",
|
||||||
|
diff: "diff content",
|
||||||
|
maxDiffSize: 0, // zero means use DefaultMaxDiffSize
|
||||||
|
wantDiff: "diff content",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(tt.diff)) //nolint:errcheck // test handler
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
client.MaxDiffSize = tt.maxDiffSize
|
||||||
|
client.RetryBackoff = []time.Duration{}
|
||||||
|
|
||||||
|
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 1)
|
||||||
|
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Errorf("expected %v, got: %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.wantDiff {
|
||||||
|
t.Errorf("diff mismatch: got length %d, want length %d", len(got), len(tt.wantDiff))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestPostReview_WithComments(t *testing.T) {
|
|||||||
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
|
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", comments)
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "", comments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func TestPostReview_NilComments(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewClient(server.URL, "test-token")
|
client := NewClient(server.URL, "test-token")
|
||||||
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", nil)
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// Package github provides a client for the GitHub API.
|
||||||
|
// It supports pull request operations, file content retrieval,
|
||||||
|
// and review submission for both github.com and GitHub Enterprise.
|
||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://api.github.com"
|
||||||
|
|
||||||
|
// maxRetryAttempts is the number of times doRequest will attempt a request.
|
||||||
|
maxRetryAttempts = 3
|
||||||
|
|
||||||
|
// maxRetryAfter caps the maximum delay from a Retry-After header to prevent
|
||||||
|
// a server from stalling the client indefinitely.
|
||||||
|
maxRetryAfter = 60 * time.Second
|
||||||
|
|
||||||
|
// maxErrorBodyBytes limits how much of an error response body we read
|
||||||
|
// to protect against malicious servers sending unbounded data.
|
||||||
|
maxErrorBodyBytes = 64 * 1024 // 64 KB
|
||||||
|
|
||||||
|
// maxResponseBodyBytes limits how much of a successful response body we read
|
||||||
|
// for defense-in-depth against servers returning excessively large payloads.
|
||||||
|
maxResponseBodyBytes = 10 * 1024 * 1024 // 10 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError represents an HTTP error response from the GitHub API.
|
||||||
|
// It carries the status code so callers can distinguish between
|
||||||
|
// different failure modes (e.g. 404 vs 500).
|
||||||
|
//
|
||||||
|
// The Body field stores up to 64 KiB of the raw response for programmatic
|
||||||
|
// inspection. Error() truncates to 200 bytes for safe logging, but callers
|
||||||
|
// should avoid logging or propagating Body directly in production since it may
|
||||||
|
// contain sensitive details from the upstream server.
|
||||||
|
type APIError struct {
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
body := e.Body
|
||||||
|
if len(body) > 200 {
|
||||||
|
body = body[:200] + "...(truncated)"
|
||||||
|
}
|
||||||
|
// Sanitize newlines to prevent log injection from upstream response bodies.
|
||||||
|
body = strings.ReplaceAll(body, "\n", " ")
|
||||||
|
body = strings.ReplaceAll(body, "\r", " ")
|
||||||
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFound reports whether an error is an API 404 response.
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
if apiErr, ok := asAPIError(err); ok {
|
||||||
|
return apiErr.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnauthorized reports whether an error is an API 401 response.
|
||||||
|
func IsUnauthorized(err error) bool {
|
||||||
|
if apiErr, ok := asAPIError(err); ok {
|
||||||
|
return apiErr.StatusCode == http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func asAPIError(err error) (*APIError, bool) {
|
||||||
|
if err == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var target *APIError
|
||||||
|
if errors.As(err, &target) {
|
||||||
|
return target, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client interacts with the GitHub API.
|
||||||
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
|
// SetHTTPClient and SetRetryBackoff are intended for test setup only and must
|
||||||
|
// be called before any goroutines issue requests; they have no synchronization.
|
||||||
|
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
|
||||||
|
token string
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
// allowInsecureHTTP permits requests to HTTP (non-TLS) endpoints.
|
||||||
|
// When false, doRequest rejects URLs with an http:// scheme.
|
||||||
|
allowInsecureHTTP bool
|
||||||
|
|
||||||
|
// retryBackoff defines the delays between retry attempts for 429 responses.
|
||||||
|
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
|
||||||
|
// If nil, defaults to {1s, 2s}.
|
||||||
|
retryBackoff []time.Duration
|
||||||
|
|
||||||
|
// now returns the current time. Defaults to time.Now.
|
||||||
|
// Override in tests to control HTTP-date Retry-After calculations.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultCheckRedirect is the redirect policy used by NewClient.
|
||||||
|
// NOTE: This function is intentionally duplicated in gitea/client.go (and vice versa)
|
||||||
|
// because the packages are separate. Changes here must be mirrored there.
|
||||||
|
// It rejects HTTPS->HTTP protocol downgrades (to prevent plaintext leakage)
|
||||||
|
// and cross-host redirects (to prevent following responses from untrusted
|
||||||
|
// endpoints). Same-host, same-or-upgraded-scheme redirects are allowed.
|
||||||
|
func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
// Guard for direct invocation in tests and any future callers;
|
||||||
|
// net/http guarantees len(via) >= 1 during actual redirects.
|
||||||
|
if len(via) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prev := via[len(via)-1]
|
||||||
|
// Reject protocol downgrade: HTTPS->HTTP leaks request metadata over plaintext.
|
||||||
|
if prev.URL.Scheme == "https" && req.URL.Scheme == "http" {
|
||||||
|
return fmt.Errorf("refusing redirect: HTTPS to HTTP downgrade (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
// Reject cross-host redirect entirely to avoid consuming responses
|
||||||
|
// from untrusted endpoints.
|
||||||
|
if req.URL.Host != prev.URL.Host {
|
||||||
|
return fmt.Errorf("refusing redirect: cross-host (%s -> %s)", prev.URL.Host, req.URL.Host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOption configures optional behavior of a Client.
|
||||||
|
type ClientOption func(*clientConfig)
|
||||||
|
|
||||||
|
type clientConfig struct {
|
||||||
|
allowInsecureHTTP bool
|
||||||
|
insecureIsTestBypass bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowInsecureHTTP permits sending credentials over plaintext HTTP connections.
|
||||||
|
// In production, this option is gated by the REVIEW_BOT_ALLOW_INSECURE=1
|
||||||
|
// environment variable. Without the env var set, the option is ignored
|
||||||
|
// and a warning is logged.
|
||||||
|
//
|
||||||
|
// For tests, use AllowInsecureHTTPForTest (defined in a _test.go file in the same package) which bypasses the env gate.
|
||||||
|
func AllowInsecureHTTP() ClientOption {
|
||||||
|
return func(cfg *clientConfig) {
|
||||||
|
cfg.allowInsecureHTTP = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new GitHub API client.
|
||||||
|
// If baseURL is empty, it defaults to https://api.github.com.
|
||||||
|
// For GitHub Enterprise, pass the API base URL (e.g. https://github.concur.com/api/v3).
|
||||||
|
func NewClient(token, baseURL string, opts ...ClientOption) *Client {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg clientConfig
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.allowInsecureHTTP && !cfg.insecureIsTestBypass {
|
||||||
|
if os.Getenv("REVIEW_BOT_ALLOW_INSECURE") != "1" {
|
||||||
|
slog.Warn("AllowInsecureHTTP ignored: set REVIEW_BOT_ALLOW_INSECURE=1 to enable")
|
||||||
|
cfg.allowInsecureHTTP = false
|
||||||
|
} else {
|
||||||
|
slog.Warn("AllowInsecureHTTP enabled — credentials may be sent over plaintext",
|
||||||
|
"env", "REVIEW_BOT_ALLOW_INSECURE=1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
token: token,
|
||||||
|
allowInsecureHTTP: cfg.allowInsecureHTTP,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
},
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTTPClient sets the underlying HTTP client used for requests.
|
||||||
|
// This is intended for test setup only to inject mock transports; it must be
|
||||||
|
// called before any goroutines issue requests.
|
||||||
|
//
|
||||||
|
// Passing nil restores the default client (30s timeout + redirect-rejecting
|
||||||
|
// CheckRedirect policy matching NewClient).
|
||||||
|
//
|
||||||
|
// Callers providing a non-nil client are responsible for configuring a safe
|
||||||
|
// CheckRedirect policy. Without one, the default net/http behavior will follow
|
||||||
|
// redirects and may forward the Authorization header to untrusted hosts.
|
||||||
|
func (c *Client) SetHTTPClient(hc *http.Client) {
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
CheckRedirect: defaultCheckRedirect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.httpClient = hc
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRetryBackoff sets the delays between retry attempts.
|
||||||
|
// This is intended for testing to speed up retry tests.
|
||||||
|
//
|
||||||
|
// Note: if an empty non-nil slice is provided, Retry-After delays parsed from
|
||||||
|
// server responses will be computed and capped but not applied (because
|
||||||
|
// attempt < len(backoff) is always false). This is acceptable for the
|
||||||
|
// test-only use case but callers should be aware of this edge case.
|
||||||
|
func (c *Client) SetRetryBackoff(backoff []time.Duration) {
|
||||||
|
c.retryBackoff = backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRetryAfter parses a Retry-After header value, supporting both integer
|
||||||
|
// seconds (e.g. "120") and HTTP-date format (e.g. "Thu, 01 Dec 2025 16:00:00 GMT")
|
||||||
|
// as specified in RFC 7231 §7.1.3.
|
||||||
|
//
|
||||||
|
// For integer values, it returns the duration directly.
|
||||||
|
// For HTTP-date values, it computes the delay as the difference between the
|
||||||
|
// parsed time and now. If the date is in the past, it returns 0.
|
||||||
|
//
|
||||||
|
// Returns (0, false) if the value cannot be parsed as either format.
|
||||||
|
func (c *Client) parseRetryAfter(value string) (time.Duration, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// Try integer seconds first (most common from GitHub).
|
||||||
|
// RFC 7231 allows delta-seconds of 0 to indicate immediate retry.
|
||||||
|
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
|
||||||
|
return time.Duration(seconds) * time.Second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try HTTP-date format (RFC 7231 §7.1.3).
|
||||||
|
// http.ParseTime handles RFC 1123, RFC 850, and ASCTIME formats.
|
||||||
|
if retryAt, err := http.ParseTime(value); err == nil {
|
||||||
|
delay := retryAt.Sub(c.now())
|
||||||
|
if delay < 0 {
|
||||||
|
delay = 0
|
||||||
|
}
|
||||||
|
return delay, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactURL redacts sensitive components from a URL for safe inclusion in error
|
||||||
|
// messages and log output. It removes userinfo (e.g., user:pass@) and replaces
|
||||||
|
// query parameters with a placeholder.
|
||||||
|
func redactURL(rawURL string) string {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "<unparseable URL>"
|
||||||
|
}
|
||||||
|
u.User = nil
|
||||||
|
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
u.RawQuery = "<redacted>"
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
||||||
|
// It respects the Retry-After header when present, supporting both integer
|
||||||
|
// seconds and HTTP-date formats (capped at maxRetryAfter).
|
||||||
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
||||||
|
// NOTE: This parses reqURL a second time (http.NewRequestWithContext parses it
|
||||||
|
// again internally). Acceptable cost: URL parsing is cheap and threading the
|
||||||
|
// parsed *url.URL through would complicate the interface for negligible gain.
|
||||||
|
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 backoff []time.Duration
|
||||||
|
if c.retryBackoff != nil {
|
||||||
|
backoff = append([]time.Duration(nil), c.retryBackoff...)
|
||||||
|
} else {
|
||||||
|
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < maxRetryAttempts; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
var delay time.Duration
|
||||||
|
if attempt-1 < len(backoff) {
|
||||||
|
delay = backoff[attempt-1]
|
||||||
|
}
|
||||||
|
if delay > 0 {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
timer.Stop() // no-op after fire; kept for symmetry with the ctx.Done case
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
if accept != "" {
|
||||||
|
req.Header.Set("Accept", accept)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("do request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
lastErr = &APIError{StatusCode: resp.StatusCode, Body: string(errBody)}
|
||||||
|
|
||||||
|
// Retry on 429 rate limit
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
||||||
|
// Check for Retry-After header and override backoff if present.
|
||||||
|
// Supports both integer seconds (common) and HTTP-date format (RFC 7231).
|
||||||
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||||
|
if delay, ok := c.parseRetryAfter(ra); ok {
|
||||||
|
if delay > maxRetryAfter {
|
||||||
|
delay = maxRetryAfter
|
||||||
|
}
|
||||||
|
if attempt < len(backoff) {
|
||||||
|
backoff[attempt] = delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry other errors
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// doGet is a convenience wrapper for GET requests with the default Accept header.
|
||||||
|
func (c *Client) doGet(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
return c.doRequest(ctx, http.MethodGet, url, "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClient_DefaultBaseURL(t *testing.T) {
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
if c.baseURL != defaultBaseURL {
|
||||||
|
t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient_CustomBaseURL(t *testing.T) {
|
||||||
|
c := NewClient("tok", "https://github.concur.com/api/v3/")
|
||||||
|
if c.baseURL != "https://github.concur.com/api/v3" {
|
||||||
|
t.Errorf("baseURL = %q, want trailing slash stripped", c.baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer test-token", got)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("test-token", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"ok":true}` {
|
||||||
|
t.Errorf("body = %q, want %q", body, `{"ok":true}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429_RetryAfter_IntegerSeconds(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", "0")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "success" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "success")
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429_RetryAfter_HTTPDate(t *testing.T) {
|
||||||
|
// Fix "now" to a known time for deterministic testing.
|
||||||
|
fixedNow := time.Date(2025, 12, 1, 15, 59, 59, 0, time.UTC)
|
||||||
|
retryAt := "Mon, 01 Dec 2025 16:00:00 GMT" // 1 second in the future
|
||||||
|
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", retryAt)
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
c.now = func() time.Time { return fixedNow }
|
||||||
|
// Initial backoff is 0; the HTTP-date parser will compute 1s and override.
|
||||||
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "success" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "success")
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429_RetryAfter_HTTPDate_InPast(t *testing.T) {
|
||||||
|
// If the HTTP-date is in the past, delay should be 0 (retry immediately).
|
||||||
|
fixedNow := time.Date(2025, 12, 1, 17, 0, 0, 0, time.UTC)
|
||||||
|
retryAt := "Mon, 01 Dec 2025 16:00:00 GMT" // 1 hour in the past
|
||||||
|
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", retryAt)
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
c.now = func() time.Time { return fixedNow }
|
||||||
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "success" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429_NoRetryAfter_UsesDefaultBackoff(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "success" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "success")
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_429_InvalidRetryAfter_UsesDefaultBackoff(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
w.Header().Set("Retry-After", "not-a-number-or-date")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
c.SetRetryBackoff([]time.Duration{0, 0})
|
||||||
|
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "success" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_404_NoRetry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("not found"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Errorf("expected IsNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("attempts = %d, want 1 (no retry on 404)", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_401_NoRetry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte("unauthorized"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !IsUnauthorized(err) {
|
||||||
|
t.Errorf("expected IsUnauthorized, got %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 1 {
|
||||||
|
t.Errorf("attempts = %d, want 1 (no retry on 401)", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_ContextCanceled(t *testing.T) {
|
||||||
|
// This test exercises the timer-cancel path in the retry select:
|
||||||
|
// select { case <-timer.C; case <-ctx.Done() }
|
||||||
|
// The server returns 429 with a long Retry-After, and we cancel the
|
||||||
|
// context shortly after the first response so that cancellation races
|
||||||
|
// against the timer rather than preventing the initial HTTP round-trip.
|
||||||
|
requestReceived := make(chan struct{}, 1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case requestReceived <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", "10")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
c.SetRetryBackoff([]time.Duration{10 * time.Second, 10 * time.Second})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Cancel the context after the first request completes, while the
|
||||||
|
// client is blocked in the retry timer select.
|
||||||
|
go func() {
|
||||||
|
<-requestReceived
|
||||||
|
// Small delay to ensure we're inside the timer select.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err := c.doGet(ctx, srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_IntegerSeconds(t *testing.T) {
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
delay, ok := c.parseRetryAfter("42")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true")
|
||||||
|
}
|
||||||
|
if delay != 42*time.Second {
|
||||||
|
t.Errorf("delay = %v, want 42s", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_ZeroSeconds(t *testing.T) {
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
delay, ok := c.parseRetryAfter("0")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true for zero seconds (RFC 7231 allows immediate retry)")
|
||||||
|
}
|
||||||
|
if delay != 0 {
|
||||||
|
t.Errorf("delay = %v, want 0", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_NegativeSeconds(t *testing.T) {
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
_, ok := c.parseRetryAfter("-5")
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ok=false for negative seconds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_HTTPDate_Future(t *testing.T) {
|
||||||
|
fixedNow := time.Date(2025, 12, 1, 15, 59, 50, 0, time.UTC)
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
c.now = func() time.Time { return fixedNow }
|
||||||
|
|
||||||
|
delay, ok := c.parseRetryAfter("Mon, 01 Dec 2025 16:00:00 GMT")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true")
|
||||||
|
}
|
||||||
|
// Should be 10 seconds in the future.
|
||||||
|
if delay != 10*time.Second {
|
||||||
|
t.Errorf("delay = %v, want 10s", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_HTTPDate_Past(t *testing.T) {
|
||||||
|
fixedNow := time.Date(2025, 12, 1, 17, 0, 0, 0, time.UTC)
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
c.now = func() time.Time { return fixedNow }
|
||||||
|
|
||||||
|
delay, ok := c.parseRetryAfter("Mon, 01 Dec 2025 16:00:00 GMT")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true")
|
||||||
|
}
|
||||||
|
if delay != 0 {
|
||||||
|
t.Errorf("delay = %v, want 0 (past date)", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_RFC850_Format(t *testing.T) {
|
||||||
|
fixedNow := time.Date(2025, 12, 1, 15, 59, 50, 0, time.UTC)
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
c.now = func() time.Time { return fixedNow }
|
||||||
|
|
||||||
|
// RFC 850 format
|
||||||
|
delay, ok := c.parseRetryAfter("Monday, 01-Dec-25 16:00:00 GMT")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true for RFC 850 format")
|
||||||
|
}
|
||||||
|
if delay != 10*time.Second {
|
||||||
|
t.Errorf("delay = %v, want 10s", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_Invalid(t *testing.T) {
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
_, ok := c.parseRetryAfter("not-valid")
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ok=false for invalid value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_EmptyString(t *testing.T) {
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
_, ok := c.parseRetryAfter("")
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ok=false for empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryAfter_MaxCap(t *testing.T) {
|
||||||
|
// Verify that parseRetryAfter returns the raw value (capping is done by caller).
|
||||||
|
c := NewClient("tok", "")
|
||||||
|
delay, ok := c.parseRetryAfter("3600")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true")
|
||||||
|
}
|
||||||
|
if delay != 3600*time.Second {
|
||||||
|
t.Errorf("delay = %v, want 3600s (caller is responsible for capping)", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_Error_Truncation(t *testing.T) {
|
||||||
|
longBody := make([]byte, 300)
|
||||||
|
for i := range longBody {
|
||||||
|
longBody[i] = 'x'
|
||||||
|
}
|
||||||
|
apiErr := &APIError{StatusCode: 500, Body: string(longBody)}
|
||||||
|
msg := apiErr.Error()
|
||||||
|
if len(msg) > 250 {
|
||||||
|
// "HTTP 500: " (10) + 200 + "...(truncated)" (14) = 224
|
||||||
|
t.Errorf("error message too long: %d chars", len(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIError_Error_NewlineSanitized(t *testing.T) {
|
||||||
|
apiErr := &APIError{StatusCode: 400, Body: "line1\nline2\rline3"}
|
||||||
|
msg := apiErr.Error()
|
||||||
|
for _, c := range msg {
|
||||||
|
if c == '\n' || c == '\r' {
|
||||||
|
t.Errorf("error message contains unsanitized newline: %q", msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient_HasCheckRedirect(t *testing.T) {
|
||||||
|
c := NewClient("secret-token", "https://api.github.com")
|
||||||
|
if c.httpClient.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsHTTPSToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "api.github.com", Path: "/foo"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on HTTPS->HTTP redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HTTPS to HTTP downgrade") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsCrossHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "objects.githubusercontent.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on cross-host redirect")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cross-host") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHost(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/bar"},
|
||||||
|
Header: http.Header{"Authorization": []string{"Bearer token"}},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Auth should be preserved on same-host redirect
|
||||||
|
if auth := req.Header.Get("Authorization"); auth != "Bearer token" {
|
||||||
|
t.Errorf("expected Authorization to be preserved, got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_AllowsSameHostHTTPToHTTP(t *testing.T) {
|
||||||
|
prev := &http.Request{URL: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/foo"}}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/bar"},
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
err := defaultCheckRedirect(req, []*http.Request{prev})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_RejectsTooManyRedirects(t *testing.T) {
|
||||||
|
via := make([]*http.Request, 10)
|
||||||
|
for i := range via {
|
||||||
|
via[i] = &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/"}}
|
||||||
|
}
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/final"}}
|
||||||
|
err := defaultCheckRedirect(req, via)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after 10 redirects")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "10 redirects") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCheckRedirect_EmptyViaAllowed(t *testing.T) {
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.github.com", Path: "/foo"}}
|
||||||
|
err := defaultCheckRedirect(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error with empty via: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
|
||||||
|
c := NewClient("token", "https://api.github.com")
|
||||||
|
c.SetHTTPClient(nil)
|
||||||
|
if c.httpClient == nil {
|
||||||
|
t.Fatal("expected non-nil httpClient after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
if c.httpClient.Timeout != 30*time.Second {
|
||||||
|
t.Errorf("expected 30s timeout, got %v", c.httpClient.Timeout)
|
||||||
|
}
|
||||||
|
if c.httpClient.CheckRedirect == nil {
|
||||||
|
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowInsecureHTTPForTest_PermitsHTTP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTPForTest())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "ok" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoInsecureOption_RejectsHTTP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("request should not have been sent")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL)
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP request without AllowInsecureHTTP")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoInsecureOption_RejectsUppercaseHTTP(t *testing.T) {
|
||||||
|
// Verify case-insensitive scheme check (RFC 3986).
|
||||||
|
c := NewClient("tok", "HTTP://127.0.0.1:1")
|
||||||
|
_, err := c.doGet(context.Background(), "HTTP://127.0.0.1:1/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for uppercase HTTP scheme")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoInsecureOption_RejectsMixedCaseHTTP(t *testing.T) {
|
||||||
|
// Verify mixed case like "Http://" is also rejected.
|
||||||
|
c := NewClient("tok", "Http://127.0.0.1:1")
|
||||||
|
_, err := c.doGet(context.Background(), "Http://127.0.0.1:1/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for mixed-case HTTP scheme")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowInsecureHTTP_WithoutEnvVar_Rejected(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("request should not have been sent")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "")
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error: AllowInsecureHTTP without env var should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowInsecureHTTP_WithEnvVar_Permitted(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("insecure-ok"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "1")
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
|
||||||
|
body, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "insecure-ok" {
|
||||||
|
t.Errorf("body = %q, want %q", body, "insecure-ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowInsecureHTTP_EnvVarNotOne_Rejected(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("request should not have been sent")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// "true" is not "1" — strict check
|
||||||
|
t.Setenv("REVIEW_BOT_ALLOW_INSECURE", "true")
|
||||||
|
|
||||||
|
c := NewClient("tok", srv.URL, AllowInsecureHTTP())
|
||||||
|
_, err := c.doGet(context.Background(), srv.URL+"/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error: env var 'true' is not '1'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing HTTP request") {
|
||||||
|
t.Errorf("unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactURL_WithQuery(t *testing.T) {
|
||||||
|
got := redactURL("http://localhost:1234/path?secret=token&foo=bar")
|
||||||
|
want := "http://localhost:1234/path?<redacted>"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("redactURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactURL_NoQuery(t *testing.T) {
|
||||||
|
got := redactURL("http://localhost:1234/path")
|
||||||
|
want := "http://localhost:1234/path"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("redactURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactURL_Userinfo(t *testing.T) {
|
||||||
|
got := redactURL("http://user:pass@localhost:1234/path")
|
||||||
|
want := "http://localhost:1234/path"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("redactURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactURL_UserinfoWithQuery(t *testing.T) {
|
||||||
|
got := redactURL("http://user:pass@localhost:1234/path?secret=token")
|
||||||
|
want := "http://localhost:1234/path?<redacted>"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("redactURL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
// AllowInsecureHTTPForTest permits sending credentials over plaintext HTTP
|
||||||
|
// without requiring the REVIEW_BOT_ALLOW_INSECURE environment variable.
|
||||||
|
// This is intended exclusively for test code using httptest.Server.
|
||||||
|
//
|
||||||
|
// Defined in a _test.go file so it is only available to test binaries.
|
||||||
|
func AllowInsecureHTTPForTest() ClientOption {
|
||||||
|
return func(cfg *clientConfig) {
|
||||||
|
cfg.allowInsecureHTTP = true
|
||||||
|
cfg.insecureIsTestBypass = true
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-2
@@ -16,7 +16,8 @@ import (
|
|||||||
|
|
||||||
// Integration test requires a running Gitea instance and LLM endpoint.
|
// Integration test requires a running Gitea instance and LLM endpoint.
|
||||||
// Set environment variables:
|
// Set environment variables:
|
||||||
// INTEGRATION_GITEA_URL - Gitea base URL
|
//
|
||||||
|
// INTEGRATION_VCS_URL - VCS base URL
|
||||||
// INTEGRATION_GITEA_TOKEN - Gitea API token with repo access
|
// INTEGRATION_GITEA_TOKEN - Gitea API token with repo access
|
||||||
// INTEGRATION_GITEA_REPO - owner/repo with an open PR
|
// INTEGRATION_GITEA_REPO - owner/repo with an open PR
|
||||||
// INTEGRATION_PR_NUMBER - PR number to test against
|
// INTEGRATION_PR_NUMBER - PR number to test against
|
||||||
@@ -25,7 +26,7 @@ import (
|
|||||||
// INTEGRATION_LLM_MODEL - Model name
|
// INTEGRATION_LLM_MODEL - Model name
|
||||||
|
|
||||||
func TestIntegration_FullReviewFlow(t *testing.T) {
|
func TestIntegration_FullReviewFlow(t *testing.T) {
|
||||||
giteaURL := os.Getenv("INTEGRATION_GITEA_URL")
|
giteaURL := os.Getenv("INTEGRATION_VCS_URL")
|
||||||
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
|
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
|
||||||
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
|
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
|
||||||
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
|
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
|
||||||
|
|||||||
Reference in New Issue
Block a user