Compare commits
7 Commits
issue-148
...
d946db830c
| Author | SHA1 | Date | |
|---|---|---|---|
| d946db830c | |||
| f7008ab86b | |||
| 1e50a22caa | |||
| 3387456b93 | |||
| 3e33e3d3a0 | |||
| 3433446c19 | |||
| 30fe48d265 |
@@ -487,6 +487,7 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
VCS_URL: ${{ steps.version.outputs.server_url }}
|
VCS_URL: ${{ steps.version.outputs.server_url }}
|
||||||
|
VCS_TYPE: ${{ steps.version.outputs.vcs_type }}
|
||||||
GITEA_REPO: ${{ inputs.repo || github.repository }}
|
GITEA_REPO: ${{ inputs.repo || github.repository }}
|
||||||
PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
|
PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
|
||||||
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
|
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
|
||||||
|
|||||||
+92
-38
@@ -1,50 +1,104 @@
|
|||||||
# Dev Loop Health Check — 2026-05-15 03:33 UTC
|
# Dev Loop Health Check — 2026-05-15 09:00 UTC
|
||||||
|
|
||||||
## Status: ✅ ACTIVE WORK COMPLETED
|
## Status: ✅ FIXES COMPLETED & PUSHED
|
||||||
|
|
||||||
### Test Results
|
### Summary
|
||||||
- All packages: **PASS** ✅ (6/6, fresh -count=1 run)
|
- **Main branch:** current (30fe48d)
|
||||||
|
- **Recent work:** issue-130 self-review findings fixed and pushed
|
||||||
|
- **Active worktrees:**
|
||||||
|
- issue-130 (review-bot-issue-130-work): Fixes completed, awaiting manual next steps
|
||||||
|
|
||||||
|
### Test Results (issue-130 worktree)
|
||||||
|
- All packages: **PASS** ✅ (7/7 packages)
|
||||||
- Build: ✅ successful
|
- Build: ✅ successful
|
||||||
- Vet: ✅ clean
|
- Vet: ✅ clean (not run in this cycle)
|
||||||
|
|
||||||
### Coverage (current)
|
### Coverage (issue-130 worktree post-fix)
|
||||||
|
|
||||||
| Package | Coverage |
|
| Package | Coverage |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| budget | 91.8% |
|
| budget | 91.8% |
|
||||||
| cmd/review-bot | 46.1% |
|
| cmd/review-bot | 36.8% |
|
||||||
| gitea | 85.2% |
|
| gitea | 79.9% |
|
||||||
| github | 86.3% |
|
| github | 79.9% |
|
||||||
|
| internal/netutil | 85.7% |
|
||||||
| llm | 81.3% |
|
| llm | 81.3% |
|
||||||
| review | 92.0% |
|
| review | 91.5% |
|
||||||
|
| **Total** | **70.4%** |
|
||||||
### PR #138 Status
|
|
||||||
|
|
||||||
- **Branch:** issue-137
|
|
||||||
- **Feature:** feat(#137): add doc-map input for path-scoped doc injection
|
|
||||||
- **Review status:** ✅ All 3 bots approved (sonnet, gpt, security)
|
|
||||||
- **Review findings addressed:**
|
|
||||||
- Fixed package comment collision in `review/docmap.go` (sonnet #1)
|
|
||||||
- Added `truncateUTF8` duplication note (sonnet #2)
|
|
||||||
- Added debug log for directory expansion fallback (sonnet #3)
|
|
||||||
- Added `validateDocPath` — rejects absolute/`..` paths (security #3)
|
|
||||||
- Added prompt injection guardrail for DesignDocs (security #2)
|
|
||||||
- Fixed trim order comment in `budget/budget.go` (gpt #1)
|
|
||||||
- Fixed `globMatch` comment to say `filepath.Match` (gpt nit #3)
|
|
||||||
- Added `doc-map` and `doc-map-max-bytes` to README inputs table (gpt #2)
|
|
||||||
- Added tests for `validateDocPath` and path traversal rejection
|
|
||||||
- Updated CHANGELOG with security fixes
|
|
||||||
- **Labels:** ready, self-reviewed
|
|
||||||
- **Assignee:** aweiker
|
|
||||||
- **Mergeable:** ✅ yes
|
|
||||||
|
|
||||||
### Next Priority
|
|
||||||
|
|
||||||
- Await merge of PR #138
|
|
||||||
- After merge: increase cmd/review-bot coverage (46.1% → target 60%+)
|
|
||||||
- Issue #132+: PR Submission feature
|
|
||||||
- `github.Client.DismissReview` method referenced but missing — file issue
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Dev-loop cycle complete at 03:33 UTC._
|
## Completed in This Cycle
|
||||||
|
|
||||||
|
### Issue #130: Self-Review Fixes ✅
|
||||||
|
|
||||||
|
**Branch:** review-bot-issue-130-work
|
||||||
|
**Status:** ✅ ALL FINDINGS ADDRESSED & PUSHED
|
||||||
|
|
||||||
|
**Fixes Applied:**
|
||||||
|
1. ✅ Added VCS_TYPE env var export to action.yml Run step
|
||||||
|
2. ✅ Fixed README CLI example and env var table (VCS-agnostic format)
|
||||||
|
3. ✅ Renamed vcsReviewComment.NewPosition → NewLine with clearer semantics
|
||||||
|
4. ✅ Moved IsBlockedIP to internal/netutil (removed gitea import from validateurl.go)
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
- 5e20dba fix(#130): pass VCS_TYPE env var from action.yml Run review step
|
||||||
|
- 9a1410c docs(#130): fix README CLI example and env var table for VCS-agnostic usage
|
||||||
|
- c5261b9 refactor(#130): rename vcsReviewComment.NewPosition to NewLine with clearer semantics
|
||||||
|
- f0ba8fe refactor(#130): move IsBlockedIP to internal/netutil to remove gitea import in validateurl.go
|
||||||
|
- 24d4dcb chore(#130): mark self-review findings as addressed in TODO.md
|
||||||
|
|
||||||
|
**Pushed to:** origin/review-bot-issue-130-work ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blockers & Manual Steps Required
|
||||||
|
|
||||||
|
### Rebase Conflict on origin/main
|
||||||
|
|
||||||
|
**Issue:** The original `review-bot-issue-130` branch was created before issue-141 merged. When rebasing review-bot-issue-130-work onto main, conflicts arise in:
|
||||||
|
- github/client.go (GitHub PR review features added in commits 39f3326, 10ef451)
|
||||||
|
- github/client_test.go
|
||||||
|
|
||||||
|
**Why:** Issue-130 work includes new GitHub PR review API implementation (3 commits: 39f3326, 10ef451, d545abe). These sit between the old branch point and main, creating merge conflicts.
|
||||||
|
|
||||||
|
**Resolution:** Manual decision needed:
|
||||||
|
- Option A: Rebase with conflict resolution (merge the GitHub features carefully)
|
||||||
|
- Option B: Abandon branch-based approach, fold work into new issue if still needed
|
||||||
|
- Option C: Verify if issue-130 work is still desired or superseded by other issues (#143, #148)
|
||||||
|
|
||||||
|
**Current:** review-bot-issue-130-work is pushed and ready, but NOT rebased on main yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worktrees Summary
|
||||||
|
|
||||||
|
| Issue | Branch | Status | Notes |
|
||||||
|
|-------|--------|--------|-------|
|
||||||
|
| #130 | review-bot-issue-130-work | ✅ FIXES PUSHED | Awaiting manual rebase/merge decision |
|
||||||
|
| #137 | (merged) | ✅ MERGED | Cleanup ready after #130 complete |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Actions for Human/Next Cycle
|
||||||
|
|
||||||
|
1. Decide on issue-130 path forward (rebase, abandon, or consolidate)
|
||||||
|
2. If rebasing: resolve conflicts in github/client.go and github/client_test.go
|
||||||
|
3. Once rebased: run self-review, address findings, mark ready
|
||||||
|
4. Clean up merged worktrees (#137)
|
||||||
|
5. Triage new issues (#143, #146, #150) for next cycle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Metadata
|
||||||
|
|
||||||
|
- **Repo:** gitea.weiker.me/rodin/review-bot
|
||||||
|
- **Main branch SHA:** 30fe48d
|
||||||
|
- **Cron ID:** 5342ac81-4bbc-4e4c-a123-347a7788d50c
|
||||||
|
- **Scheduled:** Every 4 hours
|
||||||
|
- **Last cycle:** 2026-05-15 03:33 UTC (issue-137 merged)
|
||||||
|
- **This cycle:** 2026-05-15 09:00 UTC (issue-130 fixes completed, rebase conflict detected)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Dev-loop cycle complete. Awaiting human decision on issue-130 rebase/merge strategy._
|
||||||
|
|||||||
+20
-31
@@ -1,36 +1,25 @@
|
|||||||
=============================================================================
|
Last updated: 2026-05-15 (dev-loop run)
|
||||||
REVIEW-BOT DEV LOOP STATUS — 2026-05-15 04:08 UTC
|
Coverage (origin/main): 54.1% cmd/review-bot
|
||||||
=============================================================================
|
|
||||||
|
|
||||||
OVERALL STATUS: ✅ PR OPEN
|
## Open Issues
|
||||||
|
- #143: bug: doc-map config loaded from PR branch (untrusted) → IN PR #153
|
||||||
|
- #150: fix: validateDocmapPath — add EvalSymlinks → IN PR #152
|
||||||
|
- #154: refactor: extract shared base-args helper in main_test.go (LOW PRIORITY, deferred NIT)
|
||||||
|
|
||||||
Active Work:
|
## Closed This Run
|
||||||
- PR #140: test(#139): improve cmd/review-bot coverage 44.6% → 49.3%
|
- #144: bug: dev-loop merged PR autonomously → closed (fixed by #148 pure shell dispatch)
|
||||||
State: open, labeled: ready, self-reviewed
|
- #145: bug: merged despite REQUEST_CHANGES → closed (fixed by #148 pure shell dispatch)
|
||||||
Branch: issue-139
|
- #146: missing subprocess tests → closed (fixed by PR #151 + comments)
|
||||||
|
- #147: coverage <50% → closed (54.1% on origin/main)
|
||||||
|
|
||||||
Test Results (last full run, worktree):
|
## Open PRs (waiting for review/merge by Aaron)
|
||||||
- All 6 packages: PASS ✅
|
- #151: test(#146): add InvalidDocMapPath/File tests (base: main) — labels: ai-review
|
||||||
- Build: ✅ clean
|
- #152: fix(#150): EvalSymlinks dir-symlink bypass (base: main) — labels: needs-review
|
||||||
- Vet: ✅ clean
|
- #153: feat(#143): doc-map-trusted-ref (base: main, rebased on issue-146) — labels: needs-review
|
||||||
|
|
||||||
Coverage (post-change):
|
## Merge Order
|
||||||
- cmd/review-bot: 49.3% (was 44.6%)
|
Recommended: #152 first (no deps), then #151, then #153 (rebased on issue-146, no conflict)
|
||||||
- review: 91.9%
|
|
||||||
- budget: 92.0%
|
|
||||||
- github: 86.3%
|
|
||||||
- gitea: 85.2%
|
|
||||||
- llm: 81.3%
|
|
||||||
|
|
||||||
Repository (main):
|
## Notes
|
||||||
- Branch: main (up to date with origin — 1e3d86b)
|
- PR #153 is rebased on issue-146 (which is the base for PR #151). Merge #151 before #153.
|
||||||
- Working tree: clean
|
- PR #154 (refactor) is low priority — deferred NIT from PR #151 review.
|
||||||
- Open issues: 1 (#139, addressed by PR #140)
|
|
||||||
- Open PRs: 1 (#140, ready for review)
|
|
||||||
|
|
||||||
System Health: ✅ GREEN
|
|
||||||
✓ All tests passing
|
|
||||||
✓ No warnings
|
|
||||||
✓ PR ready for merge
|
|
||||||
|
|
||||||
=============================================================================
|
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ review-bot \
|
|||||||
--vcs-url https://gitea.example.com \
|
--vcs-url https://gitea.example.com \
|
||||||
--repo owner/name \
|
--repo owner/name \
|
||||||
--pr 42 \
|
--pr 42 \
|
||||||
--reviewer-token "$GITEA_TOKEN" \
|
--reviewer-token "$REVIEWER_TOKEN" \
|
||||||
--reviewer-name "code-review" \
|
--reviewer-name "code-review" \
|
||||||
--llm-base-url https://api.openai.com/v1 \
|
--llm-base-url https://api.openai.com/v1 \
|
||||||
--llm-api-key "$OPENAI_API_KEY" \
|
--llm-api-key "$OPENAI_API_KEY" \
|
||||||
@@ -337,7 +337,8 @@ All flags have environment variable equivalents:
|
|||||||
| Flag | Env Var |
|
| Flag | Env Var |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) |
|
| `--vcs-url` | `VCS_URL` (fallback: `GITEA_URL`) |
|
||||||
| `--repo` | `GITEA_REPO` |
|
| `--vcs-type` | `VCS_TYPE` (auto-detected from URL if not set; `gitea` or `github`) |
|
||||||
|
| `--repo` | `GITEA_REPO` (also accepted: set `GITEA_REPO` for Gitea; VCS-agnostic `REPO` coming) |
|
||||||
| `--pr` | `PR_NUMBER` |
|
| `--pr` | `PR_NUMBER` |
|
||||||
| `--reviewer-token` | `REVIEWER_TOKEN` |
|
| `--reviewer-token` | `REVIEWER_TOKEN` |
|
||||||
| `--reviewer-name` | `REVIEWER_NAME` |
|
| `--reviewer-name` | `REVIEWER_NAME` |
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Dev-Loop Skill: review-bot
|
||||||
|
|
||||||
|
This file documents the dev-loop architecture for the `review-bot` project.
|
||||||
|
It lives in the repo so changes are version-controlled alongside the code.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Dispatch is a **pure shell script** — no model reasoning.
|
||||||
|
|
||||||
|
```
|
||||||
|
Cron (agentTurn, toolsAllow: [exec, sessions_spawn, read])
|
||||||
|
→ runs dispatch script
|
||||||
|
→ reads output for SPAWN or HANDOFF lines
|
||||||
|
→ spawns worker if instructed
|
||||||
|
|
||||||
|
Dispatch script (~/.openclaw/workspace/scripts/dev-loop-dispatch.sh)
|
||||||
|
→ pure bash, all decisions are curl API calls + branches
|
||||||
|
→ exits after emitting one SPAWN line (at most one worker per run)
|
||||||
|
→ emits HANDOFF for each qualifying PR (does not exit after HANDOFF)
|
||||||
|
|
||||||
|
Workers (Opus, spawned by cron model)
|
||||||
|
→ receive precise task description
|
||||||
|
→ do one job: self-review, fix CI, address feedback, or implement
|
||||||
|
→ remove wip label when done, reply NO_REPLY
|
||||||
|
```
|
||||||
|
|
||||||
|
The cron model's **only** job: run script, read output, spawn worker if told to.
|
||||||
|
The model **never** assesses project state or makes dispatch decisions.
|
||||||
|
|
||||||
|
## Safety Invariants
|
||||||
|
|
||||||
|
1. **NEVER MERGE** — no merge API call exists anywhere in the script or worker templates
|
||||||
|
2. **REQUEST_CHANGES always blocks** — checked first, before CI, before self-review, before handoff
|
||||||
|
3. **WIP mutex** — one active worker per repo; WIP label gates new issue pickup
|
||||||
|
4. **One SPAWN per run** — script emits at most one SPAWN line per execution
|
||||||
|
5. **set -euo pipefail** — any curl failure aborts immediately, no partial actions
|
||||||
|
6. **Workers reply NO_REPLY** — no dispatch-level side effects (workers may push changes and manage labels as part of their task)
|
||||||
|
|
||||||
|
## Dispatch Rules (in order)
|
||||||
|
|
||||||
|
| Rule | Condition | Action |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| 0 | WIP label > 1hr old | Remove stale WIP, continue |
|
||||||
|
| 0b | WIP label ≤ 1hr old | Mark ACTIVE_WIP=1, continue (only gates Rule 10) |
|
||||||
|
| _(1)_ | _(reserved — intentionally unused)_ | — |
|
||||||
|
| 2 | Any reviewer has REQUEST_CHANGES | SPAWN:findings |
|
||||||
|
| 3 | PR not mergeable | SPAWN:rebase |
|
||||||
|
| 4 | CI failure, no fix plan | SPAWN:ci-fix |
|
||||||
|
| 4b | CI failure, fix plan exists | Skip (worker in progress) |
|
||||||
|
| 5 | Bot review missing | Wait |
|
||||||
|
| 6 | CI pending/unknown | Wait |
|
||||||
|
| 7 | No clean self-review, no fix plan | SPAWN:self-review |
|
||||||
|
| 7b | Self-review needs attention, no fix plan | SPAWN:sr-fix |
|
||||||
|
| 8 | Unacknowledged bot review findings | SPAWN:address-feedback |
|
||||||
|
| 9 | Unresolved inline diff comments | SPAWN:address-feedback |
|
||||||
|
| 10 | All checks pass | HANDOFF |
|
||||||
|
| 11 | No open PRs + no ACTIVE_WIP | SPAWN:impl (next issue) |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `~/.openclaw/workspace/scripts/dev-loop-dispatch.sh` | Dispatch script — pure bash |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/self-review.md` | Self-review worker template |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/sr-fix.md` | Fix findings from self-review |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/ci-fix.md` | CI fix worker template |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/address-feedback.md` | Address feedback worker template |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/findings.md` | Address REQUEST_CHANGES findings |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/rebase.md` | Rebase worker template |
|
||||||
|
| `~/.openclaw/workspace/scripts/worker-tasks/impl.md` | Issue implementation worker template |
|
||||||
|
| `~/.openclaw/workspace/scripts/test/dispatch.bats` | Unit tests (bats) |
|
||||||
|
| `~/.openclaw/workspace/scripts/test/check-invariants.sh` | Static invariant checks |
|
||||||
|
| `~/.openclaw/workspace/memory/projects/review-bot.yaml` | Project config |
|
||||||
|
|
||||||
|
## Project Config
|
||||||
|
|
||||||
|
Config is at `~/.openclaw/workspace/memory/projects/review-bot.yaml`.
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
- `repo`: `rodin/review-bot`
|
||||||
|
- `api_base`: `https://gitea.weiker.me/api/v1`
|
||||||
|
- `user`: `rodin` (bot Gitea username)
|
||||||
|
- `labels.wip`: WIP label ID
|
||||||
|
- `labels.ready`: ready label ID
|
||||||
|
- `review_bots`: list of bot sentinel names
|
||||||
|
|
||||||
|
## Cron Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- label: review-bot-dev-loop
|
||||||
|
schedule: "*/15 * * * *"
|
||||||
|
prompt: |
|
||||||
|
Run: bash ~/.openclaw/workspace/scripts/dev-loop-dispatch.sh review-bot
|
||||||
|
|
||||||
|
Read the output. If it contains a SPAWN line, load the matching template from
|
||||||
|
~/.openclaw/workspace/scripts/worker-tasks/<type>.md, substitute {{PROJECT}},
|
||||||
|
{{PR_NUM}}, and {{HEAD_SHA}}, then spawn with sessions_spawn(mode: "run",
|
||||||
|
model: "hai-anthropic/anthropic--claude-4.6-opus", thinking: "high").
|
||||||
|
|
||||||
|
If no SPAWN line in output, reply NO_REPLY.
|
||||||
|
|
||||||
|
See ~/.openclaw/workspace/skills/dev-loop/SKILL.md for full instructions.
|
||||||
|
(This repo's SKILL.md is deployed to that workspace path.)
|
||||||
|
model: hai-anthropic/anthropic--claude-4.5-haiku
|
||||||
|
toolsAllow: [exec, sessions_spawn, read]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests (no real API calls):
|
||||||
|
bats ~/.openclaw/workspace/scripts/test/dispatch.bats
|
||||||
|
|
||||||
|
# Invariant checks (static analysis):
|
||||||
|
bash ~/.openclaw/workspace/scripts/test/check-invariants.sh
|
||||||
|
|
||||||
|
# Dry-run against real API:
|
||||||
|
DRY_RUN=1 bash ~/.openclaw/workspace/scripts/dev-loop-dispatch.sh review-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- **#144** — autonomous merge: eliminated by removing all merge API calls from dispatch
|
||||||
|
- **#145** — merged despite REQUEST_CHANGES: eliminated by checking REQUEST_CHANGES first, unconditionally
|
||||||
|
- **#148** — this redesign
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
Full design spec: `docs/dev-loop-spec.md`
|
||||||
@@ -510,9 +510,9 @@ func main() {
|
|||||||
for _, f := range result.Findings {
|
for _, f := range result.Findings {
|
||||||
if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
|
if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
|
||||||
inlineComments = append(inlineComments, vcsReviewComment{
|
inlineComments = append(inlineComments, vcsReviewComment{
|
||||||
Path: f.File,
|
Path: f.File,
|
||||||
NewPosition: int64(f.Line),
|
NewLine: int64(f.Line),
|
||||||
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
|
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
"gitea.weiker.me/rodin/review-bot/internal/netutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runValidateURL implements the `review-bot validate-url <url>` subcommand.
|
// runValidateURL implements the `review-bot validate-url <url>` subcommand.
|
||||||
@@ -114,7 +114,7 @@ func validateURL(rawURL string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, a := range addrs {
|
for _, a := range addrs {
|
||||||
if gitea.IsBlockedIP(a.IP) {
|
if netutil.IsBlockedIP(a.IP) {
|
||||||
return &validateError{
|
return &validateError{
|
||||||
code: 1,
|
code: 1,
|
||||||
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
|
message: fmt.Sprintf("blocked: %q resolves to private/reserved IP %s", host, a.IP),
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ type vcsCommitStatus struct {
|
|||||||
|
|
||||||
// vcsReviewComment is an inline review comment.
|
// vcsReviewComment is an inline review comment.
|
||||||
type vcsReviewComment struct {
|
type vcsReviewComment struct {
|
||||||
Path string
|
Path string
|
||||||
NewPosition int64 // Gitea: absolute line; GitHub: diff hunk position
|
NewLine int64 // absolute line number on the new (right) side of the diff, used by both Gitea and GitHub adapters
|
||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// vcsReview is a submitted PR review.
|
// vcsReview is a submitted PR review.
|
||||||
@@ -176,7 +176,7 @@ func (a *giteaVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, pa
|
|||||||
func (a *giteaVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
func (a *giteaVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
||||||
gc := make([]gitea.ReviewComment, len(comments))
|
gc := make([]gitea.ReviewComment, len(comments))
|
||||||
for i, c := range comments {
|
for i, c := range comments {
|
||||||
gc[i] = gitea.ReviewComment{Path: c.Path, NewPosition: c.NewPosition, Body: c.Body}
|
gc[i] = gitea.ReviewComment{Path: c.Path, NewPosition: c.NewLine, Body: c.Body}
|
||||||
}
|
}
|
||||||
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc)
|
r, err := a.c.PostReview(ctx, owner, repo, number, event, body, commitID, gc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -311,14 +311,12 @@ func (a *githubVCSAdapter) GetAllFilesInPath(ctx context.Context, owner, repo, p
|
|||||||
func (a *githubVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
func (a *githubVCSAdapter) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []vcsReviewComment) (*vcsReview, error) {
|
||||||
gc := make([]github.ReviewComment, len(comments))
|
gc := make([]github.ReviewComment, len(comments))
|
||||||
for i, c := range comments {
|
for i, c := range comments {
|
||||||
// GitHub inline comments use diff hunk "position", not absolute line numbers.
|
// GitHub inline comments use Line+Side (absolute line on the RIGHT side).
|
||||||
// NewPosition from gitea diff parsing gives absolute line numbers, which
|
// NewLine from diff parsing gives absolute new-file line numbers.
|
||||||
// will not match GitHub's position values. For initial GitHub support, we
|
|
||||||
// attach comments with Line+Side (absolute line on the RIGHT side) instead.
|
|
||||||
// Comments that cannot be mapped will be omitted (GitHub rejects invalid positions).
|
// Comments that cannot be mapped will be omitted (GitHub rejects invalid positions).
|
||||||
gc[i] = github.ReviewComment{
|
gc[i] = github.ReviewComment{
|
||||||
Path: c.Path,
|
Path: c.Path,
|
||||||
Line: c.NewPosition,
|
Line: c.NewLine,
|
||||||
Side: "RIGHT",
|
Side: "RIGHT",
|
||||||
Body: c.Body,
|
Body: c.Body,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Dev-Loop Dispatch Spec
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Status:** Implemented
|
||||||
|
**Implements:** Issue #148
|
||||||
|
|
||||||
|
This document is the authoritative spec for the review-bot dev-loop dispatch architecture.
|
||||||
|
The dispatch script (`~/.openclaw/workspace/scripts/dev-loop-dispatch.sh`) and its tests
|
||||||
|
are validated against the rules and invariants in this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The dev-loop is a 15-minute cron that advances the state of open pull requests and picks up
|
||||||
|
new issues when there is nothing in review. It is designed for **zero human intervention**
|
||||||
|
in the normal flow and **hard stops at key safety boundaries**.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Cron (15-min cadence)
|
||||||
|
→ exec: bash dev-loop-dispatch.sh <project>
|
||||||
|
→ read stdout for SPAWN/HANDOFF lines
|
||||||
|
→ if SPAWN: load worker template, spawn subagent
|
||||||
|
→ if HANDOFF: log, do nothing else
|
||||||
|
→ if neither: NO_REPLY
|
||||||
|
```
|
||||||
|
|
||||||
|
The cron model has **no ambient knowledge** of the project state. All state is derived
|
||||||
|
from the dispatch script's output, which in turn comes from live API calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Inputs
|
||||||
|
|
||||||
|
### Project Config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# memory/projects/<project>.yaml
|
||||||
|
repo: rodin/review-bot # <owner>/<repo>
|
||||||
|
api_base: https://gitea.../v1 # API base URL
|
||||||
|
token_path: ~/.openclaw/... # path to bearer token
|
||||||
|
user: rodin # bot Gitea username
|
||||||
|
labels:
|
||||||
|
wip: <id>
|
||||||
|
ready: <id>
|
||||||
|
review_bots: # sentinel names in review bodies
|
||||||
|
- sonnet
|
||||||
|
- gpt
|
||||||
|
- security
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Arguments
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash dev-loop-dispatch.sh <project> # normal run
|
||||||
|
DRY_RUN=1 bash dev-loop-dispatch.sh <project> # dry-run (no mutations)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State
|
||||||
|
|
||||||
|
The dispatch script is **stateless per run**. All state lives in the Gitea API:
|
||||||
|
|
||||||
|
| State | API location |
|
||||||
|
|-------|-------------|
|
||||||
|
| Open PRs | `GET /repos/:repo/pulls?state=open` |
|
||||||
|
| PR labels | `GET /repos/:repo/issues/:n/labels` |
|
||||||
|
| PR reviews | `GET /repos/:repo/pulls/:n/reviews` |
|
||||||
|
| CI status | `GET /repos/:repo/commits/:sha/status` |
|
||||||
|
| Issue comments | `GET /repos/:repo/issues/:n/comments` |
|
||||||
|
| Inline diff comments | `GET /repos/:repo/pulls/:n/comments` |
|
||||||
|
| Issue timeline | `GET /repos/:repo/issues/:n/timeline` |
|
||||||
|
|
||||||
|
No file-based state. No cron-to-cron carry-over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Output Protocol
|
||||||
|
|
||||||
|
The script emits structured lines to stdout. Stderr is diagnostic logging.
|
||||||
|
|
||||||
|
### `SPAWN:<type>:<number>:<sha>`
|
||||||
|
|
||||||
|
A worker is needed. The cron model reads this and spawns a subagent using the
|
||||||
|
template at `worker-tasks/<type>.md`.
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `type` | Worker type: `self-review`, `ci-fix`, `address-feedback`, `findings`, `rebase`, `impl` |
|
||||||
|
| `number` | PR number (or issue number for `impl`) |
|
||||||
|
| `sha` | HEAD SHA of the PR (empty for `impl`) |
|
||||||
|
|
||||||
|
At most **one SPAWN** is emitted per script run.
|
||||||
|
|
||||||
|
### `HANDOFF:<pr_num>`
|
||||||
|
|
||||||
|
All checks passed for `pr_num`. The script applied the `ready` label and assigned
|
||||||
|
to the human reviewer. The cron model logs this and takes no further action.
|
||||||
|
|
||||||
|
Multiple HANDOFFs may be emitted in one run (one per qualifying PR).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dispatch Rules
|
||||||
|
|
||||||
|
Rules are evaluated **in order** for each open PR. The first matching condition wins.
|
||||||
|
Only one SPAWN is emitted per full pass.
|
||||||
|
|
||||||
|
### Rule 0: WIP Cleanup
|
||||||
|
|
||||||
|
For each open PR with a `wip` label:
|
||||||
|
|
||||||
|
1. Find the timestamp when the label was most recently applied (via timeline events)
|
||||||
|
2. If age > 1hr: **remove the label** (stale lock — worker likely crashed)
|
||||||
|
3. If age ≤ 1hr: **set ACTIVE_WIP=1** (do not exit, only gates Rule 10)
|
||||||
|
|
||||||
|
### Rule 2: REQUEST_CHANGES Blocks
|
||||||
|
|
||||||
|
**ALWAYS evaluated before any other per-PR rule.**
|
||||||
|
|
||||||
|
For each reviewer, take their **latest** review state. If any reviewer's latest
|
||||||
|
state is `REQUEST_CHANGES`:
|
||||||
|
|
||||||
|
→ Acquire WIP label on this PR
|
||||||
|
→ Emit `SPAWN:findings:<pr_num>:<head_sha>`
|
||||||
|
→ Continue to next PR (but only one SPAWN total)
|
||||||
|
|
||||||
|
This rule cannot be bypassed by any condition. There is no waiver mechanism.
|
||||||
|
|
||||||
|
### Rule 3: Merge Conflicts
|
||||||
|
|
||||||
|
If `mergeable == false`:
|
||||||
|
|
||||||
|
→ Acquire WIP
|
||||||
|
→ Emit `SPAWN:rebase:<pr_num>:<head_sha>`
|
||||||
|
|
||||||
|
### Rule 4: CI Failure
|
||||||
|
|
||||||
|
If CI state is `failure` or `error`:
|
||||||
|
|
||||||
|
- If a fix plan comment exists for this HEAD SHA: **skip** (worker in progress)
|
||||||
|
- Otherwise:
|
||||||
|
|
||||||
|
→ Acquire WIP
|
||||||
|
→ Emit `SPAWN:ci-fix:<pr_num>:<head_sha>`
|
||||||
|
|
||||||
|
### Rule 5: Bot Reviews Missing
|
||||||
|
|
||||||
|
For each configured `review_bot`, check whether a review body contains the
|
||||||
|
sentinel `<!-- review-bot:<name> -->`.
|
||||||
|
|
||||||
|
If any sentinel is missing: **wait** (continue to next PR, no SPAWN).
|
||||||
|
|
||||||
|
### Rule 6: CI Pending/Unknown
|
||||||
|
|
||||||
|
If CI state is `pending` or `unknown`: **wait**.
|
||||||
|
|
||||||
|
### Rule 7: Self-Review
|
||||||
|
|
||||||
|
Check for a self-review comment from the bot user against the current HEAD SHA:
|
||||||
|
- Comment contains `Self-review against <head_sha>`
|
||||||
|
|
||||||
|
Sub-cases:
|
||||||
|
- **Missing**: No self-review comment →
|
||||||
|
→ Acquire WIP, emit `SPAWN:self-review:<pr_num>:<head_sha>`
|
||||||
|
- **Needs attention** (`Assessment: ⚠️`): Found, but has findings:
|
||||||
|
- Fix plan exists for HEAD SHA: skip
|
||||||
|
- No fix plan: → Acquire WIP, emit `SPAWN:sr-fix:<pr_num>:<head_sha>`
|
||||||
|
- **Clean** (`Assessment: ✅ Clean`): Continue to Rule 8
|
||||||
|
|
||||||
|
### Rule 8: Unacknowledged Bot Review Findings
|
||||||
|
|
||||||
|
For each **current** (contains `Evaluated against <head_short>`) APPROVED bot review
|
||||||
|
that has a findings table:
|
||||||
|
|
||||||
|
A finding is **unacknowledged** if it does not appear as `Finding #N` in a fix plan
|
||||||
|
comment from the bot user for this HEAD SHA.
|
||||||
|
|
||||||
|
If any unacknowledged findings exist:
|
||||||
|
- Fix plan exists: skip
|
||||||
|
- No fix plan: → Acquire WIP, emit `SPAWN:address-feedback:<pr_num>:<head_sha>`
|
||||||
|
|
||||||
|
### Rule 9: Unresolved Inline Diff Comments
|
||||||
|
|
||||||
|
An inline diff comment is **unresolved** if:
|
||||||
|
1. `in_reply_to_id` is null (top-level comment)
|
||||||
|
2. `resolver` is null (not formally resolved)
|
||||||
|
3. No other comment has `in_reply_to_id` pointing to this comment (no reply)
|
||||||
|
|
||||||
|
If unresolved comments exist:
|
||||||
|
- Fix plan exists: skip
|
||||||
|
- No fix plan: → Acquire WIP, emit `SPAWN:address-feedback:<pr_num>:<head_sha>`
|
||||||
|
|
||||||
|
### Rule 10: Handoff
|
||||||
|
|
||||||
|
All rules above passed. Verify all bot reviews are current (contain `Evaluated against <head_short>`).
|
||||||
|
|
||||||
|
If all current:
|
||||||
|
- Apply `ready` label
|
||||||
|
- Assign to `aweiker`
|
||||||
|
- Emit `HANDOFF:<pr_num>`
|
||||||
|
- Continue evaluating remaining PRs (do NOT exit)
|
||||||
|
|
||||||
|
If already assigned to `aweiker`: skip (assume handoff was already performed; continue to next PR without emitting another HANDOFF).
|
||||||
|
|
||||||
|
### Rule 11: New Issue Pickup
|
||||||
|
|
||||||
|
Only runs if: no open PRs exist AND `ACTIVE_WIP == 0`.
|
||||||
|
|
||||||
|
Fetch open, unassigned issues. Priority: bugs first, then by number ascending.
|
||||||
|
|
||||||
|
Claim the issue (assign to bot user to prevent double-pick), then:
|
||||||
|
→ Emit `SPAWN:impl:<issue_num>:`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Safety Invariants
|
||||||
|
|
||||||
|
These are statically checked by `~/.openclaw/workspace/scripts/test/check-invariants.sh` and enforced in all changes:
|
||||||
|
|
||||||
|
| ID | Invariant |
|
||||||
|
|----|-----------|
|
||||||
|
| S1 | Zero merge API calls in dispatch script (`/merge` does not appear) |
|
||||||
|
| S2 | REQUEST_CHANGES check (Rule 2) appears before CI check (Rule 4) |
|
||||||
|
| S3 | REQUEST_CHANGES check (Rule 2) appears before ready label application (Rule 10) |
|
||||||
|
| S4 | No model/AI API references in dispatch script |
|
||||||
|
| S5 | `set -euo pipefail` present |
|
||||||
|
| S6 | Active WIP does not cause early exit (only sets ACTIVE_WIP flag) |
|
||||||
|
| S7 | SPAWN:impl guarded by `ACTIVE_WIP == 0` check |
|
||||||
|
| S8 | No merge calls in any worker template |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Error Handling
|
||||||
|
|
||||||
|
| Error | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| `curl` returns error | `set -euo pipefail` aborts script — no partial actions |
|
||||||
|
| `jq` parse error | Script aborts |
|
||||||
|
| Worker crashes | WIP label left on PR; stale WIP cleanup (Rule 0) removes it after 1hr |
|
||||||
|
| Race: two crons fire | WIP mutex prevents double-dispatch for same PR |
|
||||||
|
| `sessions_spawn` fails | Worker not spawned; WIP label orphaned → cleaned in 1hr |
|
||||||
|
| Config file missing | Exit code 2 with error message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Worker Templates
|
||||||
|
|
||||||
|
Each worker receives a precise task description with substituted values:
|
||||||
|
|
||||||
|
| Template | Trigger | Key job |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `self-review.md` | No clean self-review | Post self-review comment, remove WIP |
|
||||||
|
| `sr-fix.md` | Self-review needs attention | Address self-review findings, push, remove WIP |
|
||||||
|
| `ci-fix.md` | CI failing | Diagnose, fix, push, remove WIP |
|
||||||
|
| `address-feedback.md` | Unacknowledged findings or inline comments | Address feedback, push, remove WIP |
|
||||||
|
| `findings.md` | REQUEST_CHANGES present | Address REQUEST_CHANGES, push, remove WIP |
|
||||||
|
| `rebase.md` | Merge conflicts | Rebase on main, push, remove WIP |
|
||||||
|
| `impl.md` | New issue | Implement feature/fix, open PR |
|
||||||
|
|
||||||
|
Workers **always** remove the WIP label on completion and reply `NO_REPLY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fixes for Issues #144 and #145
|
||||||
|
|
||||||
|
**Issue #144** (autonomous merge):
|
||||||
|
The dispatch script contains no merge API calls anywhere. The `~/.openclaw/workspace/scripts/test/check-invariants.sh`
|
||||||
|
invariant `S1` verifies this. Workers do not receive merge instructions.
|
||||||
|
|
||||||
|
**Issue #145** (merged despite REQUEST_CHANGES):
|
||||||
|
Rule 2 is the **first** rule evaluated per PR. It cannot be skipped, reasoned past,
|
||||||
|
or bypassed. It is checked before CI, before self-review, before handoff. The check
|
||||||
|
uses latest-per-reviewer state, so a reviewer who re-approved after REQUEST_CHANGES
|
||||||
|
is correctly handled.
|
||||||
+12
-81
@@ -1,91 +1,22 @@
|
|||||||
// Package gitea provides a client for the Gitea API.
|
// Package gitea provides a client for the Gitea API.
|
||||||
// ipcheck.go implements IP-level SSRF protection by checking resolved addresses
|
// ipcheck.go re-exports the IsBlockedIP function from internal/netutil for use
|
||||||
// against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
|
// by this package's safe dialer (client.go) and for backward compatibility with
|
||||||
|
// any callers that previously imported it from here.
|
||||||
|
//
|
||||||
|
// The implementation has moved to internal/netutil so it can be shared with the
|
||||||
|
// validate-url subcommand (cmd/review-bot/validateurl.go) without creating a
|
||||||
|
// dependency from VCS-generic code on the Gitea-specific package.
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/internal/netutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// blockedCIDRStrings is the canonical list of CIDR strings that should never
|
|
||||||
// be contacted by review-bot. See IsBlockedIP for the full list of covered
|
|
||||||
// address families.
|
|
||||||
//
|
|
||||||
// These are hard-coded literals: any parse failure is a programming error.
|
|
||||||
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
|
|
||||||
var blockedCIDRStrings = []string{
|
|
||||||
// IPv4 loopback
|
|
||||||
"127.0.0.0/8",
|
|
||||||
// IPv4 unspecified / "this network"
|
|
||||||
"0.0.0.0/8",
|
|
||||||
// RFC1918 private ranges
|
|
||||||
"10.0.0.0/8",
|
|
||||||
"172.16.0.0/12",
|
|
||||||
"192.168.0.0/16",
|
|
||||||
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
|
|
||||||
"169.254.0.0/16",
|
|
||||||
// IPv4 shared address space (RFC6598, carrier-grade NAT)
|
|
||||||
"100.64.0.0/10",
|
|
||||||
// IPv4 multicast
|
|
||||||
"224.0.0.0/4",
|
|
||||||
// IPv4 reserved / broadcast
|
|
||||||
"240.0.0.0/4",
|
|
||||||
// IPv6 loopback
|
|
||||||
"::1/128",
|
|
||||||
// IPv6 unspecified
|
|
||||||
"::/128",
|
|
||||||
// IPv6 link-local
|
|
||||||
"fe80::/10",
|
|
||||||
// IPv6 unique local (ULA) — RFC4193
|
|
||||||
"fc00::/7",
|
|
||||||
// IPv6 multicast
|
|
||||||
"ff00::/8",
|
|
||||||
}
|
|
||||||
|
|
||||||
// blockedCIDRs is the parsed form of blockedCIDRStrings.
|
|
||||||
// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead
|
|
||||||
// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.
|
|
||||||
var (
|
|
||||||
blockedCIDRs []*net.IPNet
|
|
||||||
blockedCIDRParseErrors []string
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
|
|
||||||
for _, r := range blockedCIDRStrings {
|
|
||||||
_, cidr, err := net.ParseCIDR(r)
|
|
||||||
if err != nil {
|
|
||||||
// Record the error rather than panicking; TestBlockedCIDRsValid
|
|
||||||
// will catch this during tests, and the CI build will fail.
|
|
||||||
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
|
|
||||||
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blockedCIDRs = append(blockedCIDRs, cidr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsBlockedIP reports whether ip is in a blocked address range.
|
// IsBlockedIP reports whether ip is in a blocked address range.
|
||||||
// It is exported for use by the validate-url subcommand and tests outside
|
// It delegates to internal/netutil.IsBlockedIP; see that function for the full
|
||||||
// this package.
|
// list of blocked ranges and IPv6-mapped IPv4 normalization behavior.
|
||||||
//
|
|
||||||
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
|
|
||||||
// IPv4 form before checking so that IPv4 CIDRs catch them.
|
|
||||||
//
|
|
||||||
// Based on:
|
|
||||||
// - RFC1918 private ranges
|
|
||||||
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
|
|
||||||
// - RFC4291 IPv6 link-local / loopback
|
|
||||||
func IsBlockedIP(ip net.IP) bool {
|
func IsBlockedIP(ip net.IP) bool {
|
||||||
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
|
return netutil.IsBlockedIP(ip)
|
||||||
if v4 := ip.To4(); v4 != nil {
|
|
||||||
ip = v4
|
|
||||||
}
|
|
||||||
for _, cidr := range blockedCIDRs {
|
|
||||||
if cidr.Contains(ip) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-132
@@ -3,142 +3,35 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/internal/netutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIsBlockedIP(t *testing.T) {
|
// TestIsBlockedIPForwarding verifies that gitea.IsBlockedIP correctly forwards
|
||||||
blocked := []struct {
|
// to internal/netutil.IsBlockedIP. Full coverage of the blocking logic lives in
|
||||||
name string
|
// internal/netutil/ipcheck_test.go.
|
||||||
ip string
|
func TestIsBlockedIPForwarding(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ip string
|
||||||
|
blocked bool
|
||||||
}{
|
}{
|
||||||
// IPv4 loopback
|
{"127.0.0.1", true}, // loopback — must be blocked
|
||||||
{"loopback 127.0.0.1", "127.0.0.1"},
|
{"192.168.1.1", true}, // RFC1918 — must be blocked
|
||||||
{"loopback 127.0.0.2", "127.0.0.2"},
|
{"8.8.8.8", false}, // public — must not be blocked
|
||||||
{"loopback 127.255.255.255", "127.255.255.255"},
|
{"2001:4860:4860::8888", false}, // public IPv6 — must not be blocked
|
||||||
// IPv4 unspecified
|
|
||||||
{"unspecified 0.0.0.0", "0.0.0.0"},
|
|
||||||
{"unspecified 0.1.2.3", "0.1.2.3"},
|
|
||||||
// RFC1918
|
|
||||||
{"RFC1918 10.0.0.1", "10.0.0.1"},
|
|
||||||
{"RFC1918 10.255.255.255", "10.255.255.255"},
|
|
||||||
{"RFC1918 172.16.0.1", "172.16.0.1"},
|
|
||||||
{"RFC1918 172.31.255.255", "172.31.255.255"},
|
|
||||||
{"RFC1918 192.168.0.1", "192.168.0.1"},
|
|
||||||
{"RFC1918 192.168.255.255", "192.168.255.255"},
|
|
||||||
// Link-local (APIPA / AWS metadata)
|
|
||||||
{"link-local 169.254.0.1", "169.254.0.1"},
|
|
||||||
{"link-local 169.254.169.254", "169.254.169.254"},
|
|
||||||
// Shared address space (carrier-grade NAT)
|
|
||||||
{"CGN 100.64.0.1", "100.64.0.1"},
|
|
||||||
{"CGN 100.127.255.255", "100.127.255.255"},
|
|
||||||
// Multicast
|
|
||||||
{"multicast 224.0.0.1", "224.0.0.1"},
|
|
||||||
{"multicast 239.255.255.255", "239.255.255.255"},
|
|
||||||
// Reserved
|
|
||||||
{"reserved 240.0.0.1", "240.0.0.1"},
|
|
||||||
{"broadcast 255.255.255.255", "255.255.255.255"},
|
|
||||||
// IPv6 loopback
|
|
||||||
{"IPv6 loopback ::1", "::1"},
|
|
||||||
// IPv6 unspecified
|
|
||||||
{"IPv6 unspecified ::", "::"},
|
|
||||||
// IPv6 link-local
|
|
||||||
{"IPv6 link-local fe80::1", "fe80::1"},
|
|
||||||
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
|
|
||||||
// IPv6 ULA
|
|
||||||
{"IPv6 ULA fc00::1", "fc00::1"},
|
|
||||||
{"IPv6 ULA fd00::1", "fd00::1"},
|
|
||||||
// IPv6 multicast
|
|
||||||
{"IPv6 multicast ff02::1", "ff02::1"},
|
|
||||||
}
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
for _, tc := range blocked {
|
ip := net.ParseIP(tc.ip)
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
if ip == nil {
|
||||||
ip := net.ParseIP(tc.ip)
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
if ip == nil {
|
}
|
||||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
got := IsBlockedIP(ip)
|
||||||
}
|
want := netutil.IsBlockedIP(ip)
|
||||||
if !IsBlockedIP(ip) {
|
if got != want {
|
||||||
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
|
t.Errorf("gitea.IsBlockedIP(%q) = %v, netutil.IsBlockedIP = %v: forwarding mismatch", tc.ip, got, want)
|
||||||
}
|
}
|
||||||
})
|
if got != tc.blocked {
|
||||||
}
|
t.Errorf("gitea.IsBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked)
|
||||||
|
|
||||||
allowed := []struct {
|
|
||||||
name string
|
|
||||||
ip string
|
|
||||||
}{
|
|
||||||
{"public 8.8.8.8", "8.8.8.8"},
|
|
||||||
{"public 1.1.1.1", "1.1.1.1"},
|
|
||||||
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
|
|
||||||
// not assigned to any real host, but intentionally left unblocked here because
|
|
||||||
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
|
|
||||||
// blocking it would require tracking every RFC5737 range without meaningful
|
|
||||||
// security benefit (no server should ever listen on a TEST-NET address).
|
|
||||||
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
|
||||||
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
|
||||||
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range allowed {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
ip := net.ParseIP(tc.ip)
|
|
||||||
if ip == nil {
|
|
||||||
t.Fatalf("failed to parse IP %q", tc.ip)
|
|
||||||
}
|
|
||||||
if IsBlockedIP(ip) {
|
|
||||||
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
|
|
||||||
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
|
|
||||||
// Construct it manually as a 16-byte IP.
|
|
||||||
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
|
|
||||||
if !IsBlockedIP(mapped) {
|
|
||||||
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
|
|
||||||
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
|
|
||||||
if IsBlockedIP(mappedPublic) {
|
|
||||||
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsBlockedIPEdgeCases(t *testing.T) {
|
|
||||||
// The boundary between RFC1918 and public ranges.
|
|
||||||
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
|
|
||||||
notPrivate := net.ParseIP("172.15.255.255")
|
|
||||||
if IsBlockedIP(notPrivate) {
|
|
||||||
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
|
|
||||||
}
|
|
||||||
// 172.32.0.0 is NOT private (just above 172.31.255.255).
|
|
||||||
notPrivate2 := net.ParseIP("172.32.0.0")
|
|
||||||
if IsBlockedIP(notPrivate2) {
|
|
||||||
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
|
|
||||||
}
|
|
||||||
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
|
|
||||||
notCGN := net.ParseIP("100.63.255.255")
|
|
||||||
if IsBlockedIP(notCGN) {
|
|
||||||
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
|
|
||||||
}
|
|
||||||
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
|
|
||||||
notCGN2 := net.ParseIP("100.128.0.0")
|
|
||||||
if IsBlockedIP(notCGN2) {
|
|
||||||
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
|
|
||||||
// successfully. This catches programming errors in the CIDR list without
|
|
||||||
// requiring a startup panic. The init() function records parse failures in
|
|
||||||
// blockedCIDRParseErrors rather than panicking; this test makes those failures
|
|
||||||
// visible as test failures during CI.
|
|
||||||
func TestBlockedCIDRsValid(t *testing.T) {
|
|
||||||
if len(blockedCIDRParseErrors) > 0 {
|
|
||||||
for _, msg := range blockedCIDRParseErrors {
|
|
||||||
t.Errorf("CIDR parse error: %s", msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Package netutil provides shared network utilities for review-bot.
|
||||||
|
// ipcheck.go implements IP-level SSRF protection by checking resolved addresses
|
||||||
|
// against known blocked CIDR ranges (RFC1918, loopback, link-local, etc.).
|
||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockedCIDRStrings is the canonical list of CIDR strings that should never
|
||||||
|
// be contacted by review-bot. See IsBlockedIP for the full list of covered
|
||||||
|
// address families.
|
||||||
|
//
|
||||||
|
// These are hard-coded literals: any parse failure is a programming error.
|
||||||
|
// Validity is verified by TestBlockedCIDRsValid in ipcheck_test.go.
|
||||||
|
var blockedCIDRStrings = []string{
|
||||||
|
// IPv4 loopback
|
||||||
|
"127.0.0.0/8",
|
||||||
|
// IPv4 unspecified / "this network"
|
||||||
|
"0.0.0.0/8",
|
||||||
|
// RFC1918 private ranges
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
// IPv4 link-local (APIPA, also used by AWS instance metadata 169.254.169.254)
|
||||||
|
"169.254.0.0/16",
|
||||||
|
// IPv4 shared address space (RFC6598, carrier-grade NAT)
|
||||||
|
"100.64.0.0/10",
|
||||||
|
// IPv4 multicast
|
||||||
|
"224.0.0.0/4",
|
||||||
|
// IPv4 reserved / broadcast
|
||||||
|
"240.0.0.0/4",
|
||||||
|
// IPv6 loopback
|
||||||
|
"::1/128",
|
||||||
|
// IPv6 unspecified
|
||||||
|
"::/128",
|
||||||
|
// IPv6 link-local
|
||||||
|
"fe80::/10",
|
||||||
|
// IPv6 unique local (ULA) — RFC4193
|
||||||
|
"fc00::/7",
|
||||||
|
// IPv6 multicast
|
||||||
|
"ff00::/8",
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockedCIDRs is the parsed form of blockedCIDRStrings.
|
||||||
|
// Any entry that fails to parse is recorded in blockedCIDRParseErrors instead
|
||||||
|
// of panicking; tests verify this slice is always empty via TestBlockedCIDRsValid.
|
||||||
|
var (
|
||||||
|
blockedCIDRs []*net.IPNet
|
||||||
|
blockedCIDRParseErrors []string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
|
||||||
|
for _, r := range blockedCIDRStrings {
|
||||||
|
_, cidr, err := net.ParseCIDR(r)
|
||||||
|
if err != nil {
|
||||||
|
// Record the error rather than panicking; TestBlockedCIDRsValid
|
||||||
|
// will catch this during tests, and the CI build will fail.
|
||||||
|
blockedCIDRParseErrors = append(blockedCIDRParseErrors,
|
||||||
|
fmt.Sprintf("ipcheck: invalid built-in CIDR %q: %v", r, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blockedCIDRs = append(blockedCIDRs, cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockedCIDRParseErrors returns any errors encountered parsing the built-in
|
||||||
|
// CIDR list. In correct code this will always be empty; tests assert it is.
|
||||||
|
func BlockedCIDRParseErrors() []string {
|
||||||
|
return blockedCIDRParseErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlockedIP reports whether ip is in a blocked address range.
|
||||||
|
// It is exported for use by the gitea package's safe dialer, the validate-url
|
||||||
|
// subcommand, and tests outside this package.
|
||||||
|
//
|
||||||
|
// IPv6-mapped IPv4 addresses (e.g. ::ffff:192.168.1.1) are normalized to their
|
||||||
|
// IPv4 form before checking so that IPv4 CIDRs catch them.
|
||||||
|
//
|
||||||
|
// Based on:
|
||||||
|
// - RFC1918 private ranges
|
||||||
|
// - RFC5735 / RFC4193 special-use IPv4/IPv6 ranges
|
||||||
|
// - RFC4291 IPv6 link-local / loopback
|
||||||
|
func IsBlockedIP(ip net.IP) bool {
|
||||||
|
// Normalize IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to plain IPv4.
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
ip = v4
|
||||||
|
}
|
||||||
|
for _, cidr := range blockedCIDRs {
|
||||||
|
if cidr.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package netutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsBlockedIP(t *testing.T) {
|
||||||
|
blocked := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
}{
|
||||||
|
// IPv4 loopback
|
||||||
|
{"loopback 127.0.0.1", "127.0.0.1"},
|
||||||
|
{"loopback 127.0.0.2", "127.0.0.2"},
|
||||||
|
{"loopback 127.255.255.255", "127.255.255.255"},
|
||||||
|
// IPv4 unspecified
|
||||||
|
{"unspecified 0.0.0.0", "0.0.0.0"},
|
||||||
|
{"unspecified 0.1.2.3", "0.1.2.3"},
|
||||||
|
// RFC1918
|
||||||
|
{"RFC1918 10.0.0.1", "10.0.0.1"},
|
||||||
|
{"RFC1918 10.255.255.255", "10.255.255.255"},
|
||||||
|
{"RFC1918 172.16.0.1", "172.16.0.1"},
|
||||||
|
{"RFC1918 172.31.255.255", "172.31.255.255"},
|
||||||
|
{"RFC1918 192.168.0.1", "192.168.0.1"},
|
||||||
|
{"RFC1918 192.168.255.255", "192.168.255.255"},
|
||||||
|
// Link-local (APIPA / AWS metadata)
|
||||||
|
{"link-local 169.254.0.1", "169.254.0.1"},
|
||||||
|
{"link-local 169.254.169.254", "169.254.169.254"},
|
||||||
|
// Shared address space (carrier-grade NAT)
|
||||||
|
{"CGN 100.64.0.1", "100.64.0.1"},
|
||||||
|
{"CGN 100.127.255.255", "100.127.255.255"},
|
||||||
|
// Multicast
|
||||||
|
{"multicast 224.0.0.1", "224.0.0.1"},
|
||||||
|
{"multicast 239.255.255.255", "239.255.255.255"},
|
||||||
|
// Reserved
|
||||||
|
{"reserved 240.0.0.1", "240.0.0.1"},
|
||||||
|
{"broadcast 255.255.255.255", "255.255.255.255"},
|
||||||
|
// IPv6 loopback
|
||||||
|
{"IPv6 loopback ::1", "::1"},
|
||||||
|
// IPv6 unspecified
|
||||||
|
{"IPv6 unspecified ::", "::"},
|
||||||
|
// IPv6 link-local
|
||||||
|
{"IPv6 link-local fe80::1", "fe80::1"},
|
||||||
|
{"IPv6 link-local fe80::dead:beef", "fe80::dead:beef"},
|
||||||
|
// IPv6 ULA
|
||||||
|
{"IPv6 ULA fc00::1", "fc00::1"},
|
||||||
|
{"IPv6 ULA fd00::1", "fd00::1"},
|
||||||
|
// IPv6 multicast
|
||||||
|
{"IPv6 multicast ff02::1", "ff02::1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range blocked {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
if !IsBlockedIP(ip) {
|
||||||
|
t.Errorf("IsBlockedIP(%q) = false, want true", tc.ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
}{
|
||||||
|
{"public 8.8.8.8", "8.8.8.8"},
|
||||||
|
{"public 1.1.1.1", "1.1.1.1"},
|
||||||
|
{"public 198.51.100.1", "198.51.100.1"}, // RFC5737 TEST-NET-2 — a documentation-only range;
|
||||||
|
// not assigned to any real host, but intentionally left unblocked here because
|
||||||
|
// it has no special routing treatment (unlike RFC1918/loopback/link-local) and
|
||||||
|
// blocking it would require tracking every RFC5737 range without meaningful
|
||||||
|
// security benefit (no server should ever listen on a TEST-NET address).
|
||||||
|
{"public 151.101.1.1", "151.101.1.1"}, // Fastly
|
||||||
|
{"public IPv6 2001:4860:4860::8888", "2001:4860:4860::8888"}, // Google DNS
|
||||||
|
{"public IPv6 2606:4700:4700::1111", "2606:4700:4700::1111"}, // Cloudflare DNS
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range allowed {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
if IsBlockedIP(ip) {
|
||||||
|
t.Errorf("IsBlockedIP(%q) = true, want false", tc.ip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBlockedIPv6MappedIPv4(t *testing.T) {
|
||||||
|
// ::ffff:192.168.1.1 is an IPv6-mapped IPv4 address — should be blocked as RFC1918.
|
||||||
|
// Construct it manually as a 16-byte IP.
|
||||||
|
mapped := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 1}
|
||||||
|
if !IsBlockedIP(mapped) {
|
||||||
|
t.Errorf("IsBlockedIP(::ffff:192.168.1.1) = false, want true (IPv6-mapped IPv4 must be normalized)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::ffff:8.8.8.8 — IPv6-mapped public IP — should be allowed.
|
||||||
|
mappedPublic := net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8}
|
||||||
|
if IsBlockedIP(mappedPublic) {
|
||||||
|
t.Errorf("IsBlockedIP(::ffff:8.8.8.8) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBlockedIPEdgeCases(t *testing.T) {
|
||||||
|
// The boundary between RFC1918 and public ranges.
|
||||||
|
// 172.15.255.255 is NOT private (just below 172.16.0.0/12).
|
||||||
|
notPrivate := net.ParseIP("172.15.255.255")
|
||||||
|
if IsBlockedIP(notPrivate) {
|
||||||
|
t.Errorf("IsBlockedIP(172.15.255.255) = true, want false (outside 172.16.0.0/12)")
|
||||||
|
}
|
||||||
|
// 172.32.0.0 is NOT private (just above 172.31.255.255).
|
||||||
|
notPrivate2 := net.ParseIP("172.32.0.0")
|
||||||
|
if IsBlockedIP(notPrivate2) {
|
||||||
|
t.Errorf("IsBlockedIP(172.32.0.0) = true, want false (outside 172.16.0.0/12)")
|
||||||
|
}
|
||||||
|
// CGN: 100.63.255.255 is NOT in 100.64.0.0/10.
|
||||||
|
notCGN := net.ParseIP("100.63.255.255")
|
||||||
|
if IsBlockedIP(notCGN) {
|
||||||
|
t.Errorf("IsBlockedIP(100.63.255.255) = true, want false (outside 100.64.0.0/10)")
|
||||||
|
}
|
||||||
|
// CGN: 100.128.0.0 is NOT in 100.64.0.0/10.
|
||||||
|
notCGN2 := net.ParseIP("100.128.0.0")
|
||||||
|
if IsBlockedIP(notCGN2) {
|
||||||
|
t.Errorf("IsBlockedIP(100.128.0.0) = true, want false (outside 100.64.0.0/10)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedCIDRsValid verifies that all entries in blockedCIDRStrings parse
|
||||||
|
// successfully. This catches programming errors in the CIDR list without
|
||||||
|
// requiring a startup panic. The init() function records parse failures in
|
||||||
|
// blockedCIDRParseErrors rather than panicking; this test makes those failures
|
||||||
|
// visible as test failures during CI.
|
||||||
|
func TestBlockedCIDRsValid(t *testing.T) {
|
||||||
|
for _, msg := range BlockedCIDRParseErrors() {
|
||||||
|
t.Errorf("CIDR parse error: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user