Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be601de2a | |||
| e560781c87 | |||
| 9673a9d53c | |||
| eb0ff3aa69 | |||
| c76e7dcd2e | |||
| d6bab7a9cf | |||
| 4359518e50 | |||
| 6e11107c77 | |||
| 7f31475330 | |||
| ec6fdbff42 | |||
| 89596516d7 | |||
| f883f39dbf | |||
| 345f9a5aac | |||
| 0fedefad3f | |||
| 20e9899835 | |||
| d3b9027da3 | |||
| fb7d8d5e3b | |||
| bacb25e029 | |||
| 92efd1af2b | |||
| 7adb296523 | |||
| 282b6e0e86 | |||
| 6cefbb070e | |||
| 838a34aa12 | |||
| 6fa3cb9e13 | |||
| 8ab45becec | |||
| 4311ccfa8f | |||
| fb899ab13e | |||
| da7a5224d6 | |||
| 80b04d1118 | |||
| 9615519386 | |||
| 166078ba46 | |||
| eeff3ea936 | |||
| 39cade6dd9 | |||
| 1f58c658ce | |||
| 02dfc12141 | |||
| b01e3c487f | |||
| b09f12b8ff | |||
| 430e61fdbd | |||
| b8aa63e7ba | |||
| d855064765 | |||
| 38bb01b4b4 | |||
| c96ebcc6e0 | |||
| 34ff4c5c17 | |||
| eb3770e18c | |||
| 77a7f667cb | |||
| 76b6493628 | |||
| 98479c97cf | |||
| 3ce606b14a | |||
| ffbbdf52d8 | |||
| 165034351b | |||
| 6d82535839 | |||
| 823265659a | |||
| 9be46dfbda | |||
| d946db830c | |||
| f7008ab86b | |||
| 1e50a22caa | |||
| 3387456b93 | |||
| 3e33e3d3a0 | |||
| 3433446c19 | |||
| 4dce8e4454 | |||
| 30fe48d265 | |||
| 2dac6ce0c8 | |||
| af8b29fa5d | |||
| 7d7a49e967 | |||
| 83a1835474 | |||
| 5c6758e990 | |||
| 24247a8550 | |||
| b22de19aa1 | |||
| 3f8da76b42 | |||
| 2ecbd86e24 | |||
| 7cdba14181 | |||
| 69da5df254 | |||
| 93268869c5 | |||
| 04b24256c0 | |||
| 1a4bab8ddc | |||
| d0349a6223 |
@@ -141,6 +141,16 @@ inputs:
|
|||||||
description: 'Maximum bytes of injected doc content from doc-map (default 102400 = 100KB)'
|
description: 'Maximum bytes of injected doc content from doc-map (default 102400 = 100KB)'
|
||||||
required: false
|
required: false
|
||||||
default: '102400'
|
default: '102400'
|
||||||
|
doc-map-trusted-ref:
|
||||||
|
description: >-
|
||||||
|
Git ref (branch, tag, or SHA) from which to fetch the doc-map config file
|
||||||
|
via VCS API instead of reading it from the local workspace. Recommended
|
||||||
|
when using doc-map: set this to the default branch (e.g. 'main') so a
|
||||||
|
malicious PR cannot modify the doc-map config to inject arbitrary design
|
||||||
|
docs into the LLM prompt. When unset, the config is read from the local
|
||||||
|
workspace (the PR branch) with a security warning in the logs.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
@@ -487,6 +497,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 }}
|
||||||
@@ -506,6 +517,7 @@ runs:
|
|||||||
PERSONA_FILE: ${{ inputs.persona-file }}
|
PERSONA_FILE: ${{ inputs.persona-file }}
|
||||||
DOC_MAP_FILE: ${{ inputs.doc-map }}
|
DOC_MAP_FILE: ${{ inputs.doc-map }}
|
||||||
DOC_MAP_MAX_BYTES: ${{ inputs.doc-map-max-bytes }}
|
DOC_MAP_MAX_BYTES: ${{ inputs.doc-map-max-bytes }}
|
||||||
|
DOC_MAP_TRUSTED_REF: ${{ inputs.doc-map-trusted-ref }}
|
||||||
AICORE_CLIENT_ID: ${{ inputs.aicore-client-id }}
|
AICORE_CLIENT_ID: ${{ inputs.aicore-client-id }}
|
||||||
AICORE_CLIENT_SECRET: ${{ inputs.aicore-client-secret }}
|
AICORE_CLIENT_SECRET: ${{ inputs.aicore-client-secret }}
|
||||||
AICORE_AUTH_URL: ${{ inputs.aicore-auth-url }}
|
AICORE_AUTH_URL: ${{ inputs.aicore-auth-url }}
|
||||||
|
|||||||
+12
-1
@@ -1,9 +1,20 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
## Unreleased
|
## v0.4.0
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **`validateDocmapPath`: add `EvalSymlinks` to close directory-symlink bypass** ([#150](https://gitea.weiker.me/rodin/review-bot/issues/150)): The previous implementation used `os.Lstat` which only avoids following the *final* path component. An intermediate directory symlink (e.g. `.review-bot/` committed as a symlink to a directory outside the repo) would pass the path-confinement check because the textual path appeared within the repo root. `filepath.EvalSymlinks` is now called first, resolving all symlink components before the `filepath.Rel` confinement check. In-repo symlinks whose resolved targets also reside within the repo root are now allowed; out-of-repo targets are rejected by the confinement check.
|
||||||
|
- **`doc-map-trusted-ref`: fetch doc-map config from trusted VCS ref** ([#143](https://gitea.weiker.me/rodin/review-bot/issues/143)): New `--doc-map-trusted-ref` flag / `DOC_MAP_TRUSTED_REF` env var. When set, the doc-map YAML config is fetched from the specified VCS ref (e.g. `main`) via API instead of being read from the local workspace (the PR branch checkout). This prevents a malicious PR from modifying `.review-bot/doc-map.yml` to inject arbitrary design docs into the LLM prompt. When unset, the local workspace is used with a security warning in the logs.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **`TestValidateDocmapPath_DirSymlinkBypass`**: verifies that a directory symlink inside the repo pointing outside cannot be used to bypass path confinement ([#150](https://gitea.weiker.me/rodin/review-bot/issues/150)).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **`doc-map-trusted-ref` input** (`--doc-map-trusted-ref` flag / `DOC_MAP_TRUSTED_REF` env var): Git ref (branch, tag, or SHA) from which to fetch the doc-map config via VCS API. Recommended for all `doc-map` users. Example: `doc-map-trusted-ref: main`. ([#143](https://gitea.weiker.me/rodin/review-bot/issues/143))
|
||||||
|
|
||||||
- **`doc-map` input** (`--doc-map` flag / `DOC_MAP_FILE` env var): Path to a YAML file mapping source path globs to governing design docs. review-bot intersects the map with changed PR paths and injects matching docs into the system prompt under a `## Design Documents` heading. ([#137](https://gitea.weiker.me/rodin/review-bot/issues/137))
|
- **`doc-map` input** (`--doc-map` flag / `DOC_MAP_FILE` env var): Path to a YAML file mapping source path globs to governing design docs. review-bot intersects the map with changed PR paths and injects matching docs into the system prompt under a `## Design Documents` heading. ([#137](https://gitea.weiker.me/rodin/review-bot/issues/137))
|
||||||
- **`doc-map-max-bytes` input** (`--doc-map-max-bytes` flag / `DOC_MAP_MAX_BYTES` env var): Cap on total injected design doc content in bytes. Default: 102400 (100 KB). Prevents accidental context overflow when a PR touches many modules.
|
- **`doc-map-max-bytes` input** (`--doc-map-max-bytes` flag / `DOC_MAP_MAX_BYTES` env var): Cap on total injected design doc content in bytes. Default: 102400 (100 KB). Prevents accidental context overflow when a PR touches many modules.
|
||||||
- **`DesignDocs` budget section**: Design docs are included in the context budget and trimmed after conventions, before file context, if the total exceeds the model's context limit.
|
- **`DesignDocs` budget section**: Design docs are included in the context budget and trimmed after conventions, before file context, if the total exceeds the model's context limit.
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Dev Loop Health Check — 2026-05-15 03:33 UTC
|
|
||||||
|
|
||||||
## Status: ✅ ACTIVE WORK COMPLETED
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
- All packages: **PASS** ✅ (6/6, fresh -count=1 run)
|
|
||||||
- Build: ✅ successful
|
|
||||||
- Vet: ✅ clean
|
|
||||||
|
|
||||||
### Coverage (current)
|
|
||||||
|
|
||||||
| Package | Coverage |
|
|
||||||
|---------|----------|
|
|
||||||
| budget | 91.8% |
|
|
||||||
| cmd/review-bot | 46.1% |
|
|
||||||
| gitea | 85.2% |
|
|
||||||
| github | 86.3% |
|
|
||||||
| llm | 81.3% |
|
|
||||||
| review | 92.0% |
|
|
||||||
|
|
||||||
### 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._
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
=============================================================================
|
|
||||||
REVIEW-BOT DEV LOOP STATUS — 2026-05-15 01:48 UTC (post-sync)
|
|
||||||
=============================================================================
|
|
||||||
|
|
||||||
OVERALL STATUS: ✅ OPTIMAL
|
|
||||||
|
|
||||||
Test Results (fresh run post-sync):
|
|
||||||
- All 6 packages: PASS ✅
|
|
||||||
- Build: ✅ clean
|
|
||||||
- Vet: ✅ clean
|
|
||||||
- Fresh run: -count=1 verified
|
|
||||||
|
|
||||||
Recent Major Changes (synced from origin/main):
|
|
||||||
- Significant new GitHub client methods (~360 lines added)
|
|
||||||
- New validateurl package for URL validation
|
|
||||||
- New vcs adapter layer for VCS abstraction
|
|
||||||
- New gitea/ipcheck package for IP validation
|
|
||||||
- Expanded integration tests in cmd/review-bot
|
|
||||||
- All changes verified passing tests
|
|
||||||
|
|
||||||
Coverage (current post-sync):
|
|
||||||
- review: 92.0%
|
|
||||||
- budget: 91.8%
|
|
||||||
- github: 86.3%
|
|
||||||
- gitea: 85.2%
|
|
||||||
- llm: 81.3%
|
|
||||||
- cmd/review-bot: 46.1%
|
|
||||||
|
|
||||||
Repository:
|
|
||||||
- Branch: main (synced with origin — 4ffa6b6)
|
|
||||||
- Working tree: clean
|
|
||||||
- Open issues: 0
|
|
||||||
- Open PRs: 0
|
|
||||||
|
|
||||||
System Health: ✅ GREEN
|
|
||||||
✓ All tests passing (33 commits synced)
|
|
||||||
✓ No warnings
|
|
||||||
✓ Code clean
|
|
||||||
✓ Ready for feature work
|
|
||||||
|
|
||||||
Next Cycle: Ready to pick up feature work
|
|
||||||
|
|
||||||
=============================================================================
|
|
||||||
-175
@@ -1,175 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -210,6 +210,7 @@ AI Core handles OAuth token management and deployment discovery automatically. M
|
|||||||
| `system-prompt-file` | No | `""` | Local file with additional system prompt instructions |
|
| `system-prompt-file` | No | `""` | Local file with additional system prompt instructions |
|
||||||
| `doc-map` | No | `""` | Path to a YAML file mapping source path globs to governing design docs |
|
| `doc-map` | No | `""` | Path to a YAML file mapping source path globs to governing design docs |
|
||||||
| `doc-map-max-bytes` | No | `102400` | Maximum bytes of injected doc content from doc-map (default 100KB) |
|
| `doc-map-max-bytes` | No | `102400` | Maximum bytes of injected doc content from doc-map (default 100KB) |
|
||||||
|
| `doc-map-trusted-ref` | No | `""` | Git ref (e.g. `main`) to fetch the doc-map config from via VCS API instead of local workspace. **Recommended for security** — prevents a PR from modifying the doc-map config to inject arbitrary docs. |
|
||||||
| `persona` | No | `""` | Built-in persona name (security, architect, docs) |
|
| `persona` | No | `""` | Built-in persona name (security, architect, docs) |
|
||||||
| `persona-file` | No | `""` | Path to persona file (YAML or JSON) with custom review focus |
|
| `persona-file` | No | `""` | Path to persona file (YAML or JSON) with custom review focus |
|
||||||
| `temperature` | No | `0` | LLM temperature (0 = server default) |
|
| `temperature` | No | `0` | LLM temperature (0 = server default) |
|
||||||
@@ -288,7 +289,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" \
|
||||||
@@ -296,6 +297,40 @@ review-bot \
|
|||||||
--conventions-file CONVENTIONS.md
|
--conventions-file CONVENTIONS.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
### `validate-docmap`
|
||||||
|
|
||||||
|
Verifies that a `doc-map.yml` is consistent before running a review. Two checks:
|
||||||
|
|
||||||
|
1. **Coverage**: every changed file is matched by at least one `paths:` glob.
|
||||||
|
2. **Stale docs**: every `docs:` entry exists on disk under `--repo-root`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Typical CI usage — pipe git diff into the command
|
||||||
|
git diff --name-only origin/main HEAD | \
|
||||||
|
review-bot validate-docmap \
|
||||||
|
--docmap .review-bot/doc-map.yml \
|
||||||
|
--repo-root .
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Required | Default | Description |
|
||||||
|
|------|----------|---------|-------------|
|
||||||
|
| `--docmap` | Yes | — | Path to doc-map YAML file |
|
||||||
|
| `--repo-root` | No | `.` (cwd) | Root for resolving `docs:` paths |
|
||||||
|
|
||||||
|
Exit codes: `0`=clean, `1`=failures found, `2`=usage/parse error.
|
||||||
|
|
||||||
|
### `validate-url`
|
||||||
|
|
||||||
|
Resolves a URL and verifies all IPs are publicly routable (used in CI to prevent SSRF).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
review-bot validate-url https://gitea.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes: `0`=safe, `1`=blocked/private IP, `2`=error.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
All flags have environment variable equivalents:
|
All flags have environment variable equivalents:
|
||||||
@@ -303,7 +338,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`
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
## Dev Loop Status: 2026-05-15 02:28 UTC
|
|
||||||
|
|
||||||
**Repository:** review-bot (rodin/review-bot on Gitea)
|
|
||||||
**Status:** ✅ OPTIMAL
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
|
|
||||||
- **Working tree:** clean
|
|
||||||
- **Branch:** main (up to date with origin)
|
|
||||||
- **Build:** ✅ passes (`go build ./cmd/review-bot`)
|
|
||||||
- **Tests:** ✅ ALL PASS (6/6 packages)
|
|
||||||
- **Vet:** ✅ clean
|
|
||||||
- **Open issues:** 0
|
|
||||||
- **Open PRs:** 0
|
|
||||||
|
|
||||||
### Recent Changes
|
|
||||||
|
|
||||||
Last commit: `dcfd360` (2026-05-15 01:48) — health check post-sync
|
|
||||||
|
|
||||||
### Coverage
|
|
||||||
|
|
||||||
| Package | Coverage |
|
|
||||||
|---------|----------|
|
|
||||||
| cmd/review-bot | 46.1% |
|
|
||||||
| gitea | 85.2% |
|
|
||||||
| github | 86.3% |
|
|
||||||
| review | 92.0% |
|
|
||||||
|
|
||||||
### Next Priority
|
|
||||||
|
|
||||||
- Increase cmd/review-bot coverage (lowest at 46.1%)
|
|
||||||
- Monitor prod logs for edge cases
|
|
||||||
- VCS integration stable; GitHub + Gitea paths clear
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Dev-loop cycle complete at 02:28 UTC._
|
|
||||||
+63
-15
@@ -64,6 +64,8 @@ func main() {
|
|||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "validate-url":
|
case "validate-url":
|
||||||
os.Exit(runValidateURL(os.Args[2:]))
|
os.Exit(runValidateURL(os.Args[2:]))
|
||||||
|
case "validate-docmap":
|
||||||
|
os.Exit(runValidateDocmap(os.Args[2:]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ func main() {
|
|||||||
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
||||||
docMapFile := flag.String("doc-map", envOrDefault("DOC_MAP_FILE", ""), "Path to YAML file mapping source path globs to governing design docs")
|
docMapFile := flag.String("doc-map", envOrDefault("DOC_MAP_FILE", ""), "Path to YAML file mapping source path globs to governing design docs")
|
||||||
docMapMaxBytes := flag.Int("doc-map-max-bytes", envOrDefaultInt("DOC_MAP_MAX_BYTES", review.DefaultDocMapMaxBytes), "Maximum bytes of injected doc content (default 102400)")
|
docMapMaxBytes := flag.Int("doc-map-max-bytes", envOrDefaultInt("DOC_MAP_MAX_BYTES", review.DefaultDocMapMaxBytes), "Maximum bytes of injected doc content (default 102400)")
|
||||||
|
docMapTrustedRef := flag.String("doc-map-trusted-ref", envOrDefault("DOC_MAP_TRUSTED_REF", ""), "Git ref (e.g. main) to fetch the doc-map config from via VCS API instead of local workspace. Recommended to prevent PR branch from controlling which docs are injected.")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -171,6 +174,20 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early validation of filesystem-path flags (fail fast before network I/O).
|
||||||
|
// Skip local-path validation when --doc-map-trusted-ref is set: the flag
|
||||||
|
// value is used as a VCS API path, not a local filesystem path, and the
|
||||||
|
// file may not exist in the local checkout (sparse, PR-deleted, etc.).
|
||||||
|
var resolvedDocMapFile string
|
||||||
|
if *docMapFile != "" && *docMapTrustedRef == "" {
|
||||||
|
resolved, err := validateWorkspacePath(*docMapFile, "doc-map")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("invalid doc-map path", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
resolvedDocMapFile = resolved
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize clients
|
// Initialize clients
|
||||||
// Detect VCS type: explicit flag > env var > URL heuristic (default: gitea).
|
// Detect VCS type: explicit flag > env var > URL heuristic (default: gitea).
|
||||||
vcsType := envOrDefault("VCS_TYPE", "")
|
vcsType := envOrDefault("VCS_TYPE", "")
|
||||||
@@ -355,15 +372,45 @@ func main() {
|
|||||||
// Step 6c: Load path-scoped design docs if doc-map specified
|
// Step 6c: Load path-scoped design docs if doc-map specified
|
||||||
designDocs := ""
|
designDocs := ""
|
||||||
if *docMapFile != "" {
|
if *docMapFile != "" {
|
||||||
resolvedDocMap, err := validateWorkspacePath(*docMapFile, "doc-map")
|
var docMapCfg *review.DocMapConfig
|
||||||
if err != nil {
|
|
||||||
slog.Error("invalid doc-map path", "error", err)
|
if *docMapTrustedRef != "" {
|
||||||
os.Exit(1)
|
// Fetch doc-map config from a trusted VCS ref (e.g. the default branch).
|
||||||
}
|
// This prevents a malicious PR from modifying the doc-map config to
|
||||||
docMapCfg, err := review.ParseDocMapConfig(resolvedDocMap)
|
// inject arbitrary docs into the LLM prompt.
|
||||||
if err != nil {
|
slog.Info("doc-map: fetching config from trusted ref",
|
||||||
slog.Error("failed to parse doc-map file", "file", *docMapFile, "error", err)
|
"path", *docMapFile,
|
||||||
os.Exit(1)
|
"ref", *docMapTrustedRef)
|
||||||
|
content, fetchErr := vcs.GetFileContentRef(ctx, owner, repoName, *docMapFile, *docMapTrustedRef)
|
||||||
|
if fetchErr != nil {
|
||||||
|
slog.Error("doc-map: failed to fetch config from trusted ref",
|
||||||
|
"path", *docMapFile,
|
||||||
|
"ref", *docMapTrustedRef,
|
||||||
|
"error", fetchErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
source := fmt.Sprintf("%s/%s@%s:%s", owner, repoName, *docMapTrustedRef, *docMapFile)
|
||||||
|
var parseErr error
|
||||||
|
docMapCfg, parseErr = review.ParseDocMapConfigContent(content, source)
|
||||||
|
if parseErr != nil {
|
||||||
|
slog.Error("doc-map: failed to parse fetched config",
|
||||||
|
"source", source,
|
||||||
|
"error", parseErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local workspace fallback — the doc-map is read from the PR branch checkout.
|
||||||
|
// SECURITY WARNING: a malicious PR can modify this file to inject arbitrary
|
||||||
|
// docs. Set --doc-map-trusted-ref (or DOC_MAP_TRUSTED_REF) to a trusted ref
|
||||||
|
// (e.g. "main") to fetch the config from the default branch instead.
|
||||||
|
slog.Warn("doc-map: loading config from local workspace (PR branch) — " +
|
||||||
|
"set --doc-map-trusted-ref to fetch from a trusted ref for security")
|
||||||
|
var parseErr error
|
||||||
|
docMapCfg, parseErr = review.ParseDocMapConfig(resolvedDocMapFile)
|
||||||
|
if parseErr != nil {
|
||||||
|
slog.Error("failed to parse doc-map file", "file", *docMapFile, "error", parseErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect changed file paths from the PR for intersection.
|
// Collect changed file paths from the PR for intersection.
|
||||||
@@ -377,10 +424,11 @@ func main() {
|
|||||||
|
|
||||||
if len(matchedDocs) > 0 {
|
if len(matchedDocs) > 0 {
|
||||||
docMapOpts := review.DocMapOptions{MaxBytes: *docMapMaxBytes}
|
docMapOpts := review.DocMapOptions{MaxBytes: *docMapMaxBytes}
|
||||||
designDocs, err = review.LoadMatchingDocs(ctx, vcs, owner, repoName, matchedDocs, docMapOpts)
|
var loadErr error
|
||||||
if err != nil {
|
designDocs, loadErr = review.LoadMatchingDocs(ctx, vcs, owner, repoName, matchedDocs, docMapOpts)
|
||||||
|
if loadErr != nil {
|
||||||
// Non-fatal: individual missing files are already warned; log and continue.
|
// Non-fatal: individual missing files are already warned; log and continue.
|
||||||
slog.Warn("doc-map: partial failure loading docs", "error", err)
|
slog.Warn("doc-map: partial failure loading docs", "error", loadErr)
|
||||||
}
|
}
|
||||||
if designDocs != "" {
|
if designDocs != "" {
|
||||||
slog.Info("doc-map: injected design docs", "matched", len(matchedDocs), "bytes", len(designDocs))
|
slog.Info("doc-map: injected design docs", "matched", len(matchedDocs), "bytes", len(designDocs))
|
||||||
@@ -508,9 +556,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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+182
-56
@@ -880,16 +880,9 @@ func TestMainSubprocess_MissingFlags(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--gitea-url", "http://localhost",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-name", "invalid name",
|
"--reviewer-name", "invalid name",
|
||||||
"--reviewer-token", "tok",
|
)
|
||||||
"--llm-base-url", "http://localhost",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "model",
|
|
||||||
}
|
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -908,15 +901,20 @@ func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
args := baseSubprocessArgs()
|
||||||
"--gitea-url", "http://localhost",
|
// Replace the canonical --repo value with an invalid one.
|
||||||
"--repo", "invalidrepo",
|
found := false
|
||||||
"--pr", "1",
|
for i, a := range args {
|
||||||
"--reviewer-token", "tok",
|
if a == "--repo" && i+1 < len(args) {
|
||||||
"--llm-base-url", "http://localhost",
|
args[i+1] = "invalidrepo"
|
||||||
"--llm-api-key", "key",
|
found = true
|
||||||
"--llm-model", "model",
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("baseSubprocessArgs() does not contain --repo; test is broken")
|
||||||
|
}
|
||||||
|
os.Args = args
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -935,15 +933,20 @@ func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
args := baseSubprocessArgs()
|
||||||
"--gitea-url", "http://localhost",
|
// Replace the canonical --pr value with a non-numeric string.
|
||||||
"--repo", "owner/repo",
|
found := false
|
||||||
"--pr", "notanumber",
|
for i, a := range args {
|
||||||
"--reviewer-token", "tok",
|
if a == "--pr" && i+1 < len(args) {
|
||||||
"--llm-base-url", "http://localhost",
|
args[i+1] = "notanumber"
|
||||||
"--llm-api-key", "key",
|
found = true
|
||||||
"--llm-model", "model",
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("baseSubprocessArgs() does not contain --pr; test is broken")
|
||||||
|
}
|
||||||
|
os.Args = args
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -962,16 +965,9 @@ func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--gitea-url", "http://localhost",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-token", "tok",
|
|
||||||
"--llm-base-url", "http://localhost",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "model",
|
|
||||||
"--llm-temperature", "5.0",
|
"--llm-temperature", "5.0",
|
||||||
}
|
)
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -990,16 +986,9 @@ func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--gitea-url", "http://localhost",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-token", "tok",
|
|
||||||
"--llm-base-url", "http://localhost",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "model",
|
|
||||||
"--llm-provider", "invalid-provider",
|
"--llm-provider", "invalid-provider",
|
||||||
}
|
)
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1015,6 +1004,25 @@ func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baseSubprocessArgs returns the base set of required flags for subprocess tests
|
||||||
|
// that need a fully-configured main() invocation. Each test appends its own
|
||||||
|
// test-specific flags on top of this base.
|
||||||
|
//
|
||||||
|
// Using a helper here means that when the set of required flags changes, only
|
||||||
|
// this function needs updating (instead of every test that passes all flags).
|
||||||
|
func baseSubprocessArgs() []string {
|
||||||
|
return []string{
|
||||||
|
"review-bot",
|
||||||
|
"--vcs-url", "https://gitea.example.com",
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "https://api.example.com",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "gpt-4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cleanEnv returns environ without any GITEA/LLM/REVIEWER/VCS env vars that would
|
// cleanEnv returns environ without any GITEA/LLM/REVIEWER/VCS env vars that would
|
||||||
// interfere with testing missing-flag scenarios.
|
// interfere with testing missing-flag scenarios.
|
||||||
func cleanEnv() []string {
|
func cleanEnv() []string {
|
||||||
@@ -1389,13 +1397,14 @@ func TestFetchPatterns_MultipleRepos(t *testing.T) {
|
|||||||
func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
|
func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
// Note: cannot use baseSubprocessArgs() here because --llm-base-url and
|
||||||
|
// --llm-api-key are intentionally omitted to test the missing-URL error.
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--vcs-url", "https://gitea.example.com",
|
"--vcs-url", "https://gitea.example.com",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--pr", "1",
|
"--pr", "1",
|
||||||
"--reviewer-token", "tok",
|
"--reviewer-token", "tok",
|
||||||
"--llm-model", "gpt-4",
|
"--llm-model", "gpt-4",
|
||||||
// --llm-base-url and --llm-api-key intentionally omitted
|
|
||||||
}
|
}
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
@@ -1417,6 +1426,8 @@ func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
|
|||||||
func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
|
func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
// Note: cannot use baseSubprocessArgs() here because aicore provider
|
||||||
|
// does not require --llm-base-url / --llm-api-key; those are omitted.
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--vcs-url", "https://gitea.example.com",
|
"--vcs-url", "https://gitea.example.com",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
@@ -1446,17 +1457,10 @@ func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
|
|||||||
func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
|
func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--vcs-url", "https://gitea.example.com",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-token", "tok",
|
|
||||||
"--llm-base-url", "https://api.example.com",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "gpt-4",
|
|
||||||
"--persona", "security",
|
"--persona", "security",
|
||||||
"--persona-file", "custom.json",
|
"--persona-file", "custom.json",
|
||||||
}
|
)
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1477,9 +1481,9 @@ func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
|
|||||||
func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
|
func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
// Set required flags but omit --vcs-url; GITEA_URL should be picked up.
|
// Note: cannot use baseSubprocessArgs() here because --vcs-url must be
|
||||||
// The test will exit with an error after VCS init (no PR to fetch), but
|
// omitted — this test verifies that GITEA_URL env var is picked up as a
|
||||||
// the deprecation warning must appear.
|
// deprecated fallback when --vcs-url is absent.
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
// No --vcs-url: should fall back to GITEA_URL env var
|
// No --vcs-url: should fall back to GITEA_URL env var
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
@@ -1506,3 +1510,125 @@ func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
|
|||||||
t.Errorf("expected deprecation warning for GITEA_URL, got: %s", out)
|
t.Errorf("expected deprecation warning for GITEA_URL, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_InvalidDocMapPath confirms that --doc-map with a path traversal
|
||||||
|
// attempt is rejected before any network I/O.
|
||||||
|
func TestMainSubprocess_InvalidDocMapPath(t *testing.T) {
|
||||||
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
os.Args = []string{"review-bot",
|
||||||
|
"--vcs-url", "https://gitea.example.com",
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "https://api.example.com",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "gpt-4",
|
||||||
|
"--doc-map", "../../../etc/passwd",
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapPath")
|
||||||
|
// t.TempDir() is evaluated here in the outer process, producing a real directory
|
||||||
|
// that is passed as the GITHUB_WORKSPACE env var string to the subprocess.
|
||||||
|
cmd.Env = append(cleanEnv(),
|
||||||
|
"TEST_SUBPROCESS_MAIN=1",
|
||||||
|
"GITHUB_WORKSPACE="+t.TempDir(),
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit with path traversal doc-map, got success")
|
||||||
|
}
|
||||||
|
output := string(out)
|
||||||
|
if !strings.Contains(output, "doc-map") {
|
||||||
|
t.Errorf("expected error mentioning doc-map, got: %s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "resolves outside workspace") {
|
||||||
|
t.Errorf("expected error about path traversal, got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_InvalidDocMapFile confirms that --doc-map with a nonexistent file
|
||||||
|
// is rejected before any network I/O.
|
||||||
|
func TestMainSubprocess_InvalidDocMapFile(t *testing.T) {
|
||||||
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
os.Args = []string{"review-bot",
|
||||||
|
"--vcs-url", "https://gitea.example.com",
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "https://api.example.com",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "gpt-4",
|
||||||
|
"--doc-map", "nonexistent.yml",
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapFile")
|
||||||
|
// t.TempDir() is evaluated here in the outer process, producing a real directory
|
||||||
|
// that is passed as the GITHUB_WORKSPACE env var string to the subprocess.
|
||||||
|
cmd.Env = append(cleanEnv(),
|
||||||
|
"TEST_SUBPROCESS_MAIN=1",
|
||||||
|
"GITHUB_WORKSPACE="+t.TempDir(),
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit with nonexistent doc-map file, got success")
|
||||||
|
}
|
||||||
|
output := string(out)
|
||||||
|
if !strings.Contains(output, "doc-map") {
|
||||||
|
t.Errorf("expected error mentioning doc-map, got: %s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "failed to resolve") {
|
||||||
|
t.Errorf("expected error about failed resolution, got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_DocMapTrustedRefSkipsLocalValidation confirms that
|
||||||
|
// --doc-map-trusted-ref bypasses local filesystem validation for --doc-map.
|
||||||
|
// When the trusted-ref flag is set, the doc-map value is used as a VCS API
|
||||||
|
// path; a nonexistent local file must not cause an early exit before network I/O.
|
||||||
|
func TestMainSubprocess_DocMapTrustedRefSkipsLocalValidation(t *testing.T) {
|
||||||
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
os.Args = []string{"review-bot",
|
||||||
|
"--vcs-url", "https://gitea.example.com",
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "https://api.example.com",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "gpt-4",
|
||||||
|
"--doc-map", "nonexistent-local.yml",
|
||||||
|
"--doc-map-trusted-ref", "main",
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_DocMapTrustedRefSkipsLocalValidation")
|
||||||
|
cmd.Env = append(cleanEnv(),
|
||||||
|
"TEST_SUBPROCESS_MAIN=1",
|
||||||
|
"GITHUB_WORKSPACE="+t.TempDir(),
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
output := string(out)
|
||||||
|
|
||||||
|
// The test must fail (network I/O or VCS API failure) but must NOT
|
||||||
|
// fail with the local filesystem validation error.
|
||||||
|
// "failed to resolve" would indicate the early validateWorkspacePath ran —
|
||||||
|
// that would be the bug this test is catching.
|
||||||
|
if strings.Contains(output, "failed to resolve") {
|
||||||
|
t.Errorf("--doc-map-trusted-ref should skip local path validation, but got filesystem error: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It must still exit non-zero (real VCS call to example.com will fail).
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit when VCS API is unreachable, got success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxDocmapBytes is the maximum size of the doc-map YAML file that will be
|
||||||
|
// read. Files larger than this are rejected before reading to prevent memory
|
||||||
|
// exhaustion from an oversized PR-controlled file.
|
||||||
|
const maxDocmapBytes int64 = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
|
// validateDocmapPath checks that localPath is safe to read as the doc-map
|
||||||
|
// file. It enforces three invariants before the file is opened:
|
||||||
|
//
|
||||||
|
// 1. The path resolves to a regular file within resolvedRoot (path
|
||||||
|
// confinement): prevents a PR-controlled --docmap from reading arbitrary
|
||||||
|
// host files via absolute paths or ".." traversal.
|
||||||
|
// 2. The resolved path is within resolvedRoot: in-repo file-level symlinks
|
||||||
|
// are allowed when their resolved target is still inside the root;
|
||||||
|
// symlinks that escape the root are rejected by the confinement check.
|
||||||
|
// 3. The file does not exceed maxDocmapBytes: prevents memory exhaustion
|
||||||
|
// from an oversized but legitimately committed doc-map file.
|
||||||
|
//
|
||||||
|
// resolvedRoot must already be an absolute, symlink-free path (obtained from
|
||||||
|
// filepath.Abs + filepath.EvalSymlinks).
|
||||||
|
func validateDocmapPath(localPath, resolvedRoot string) (string, error) {
|
||||||
|
// Resolve the docmap path to an absolute path.
|
||||||
|
absPath, err := filepath.Abs(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot resolve path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve ALL symlink components, not just the final one.
|
||||||
|
// os.Lstat only avoids following the *final* path component; intermediate
|
||||||
|
// directory symlinks are still followed. EvalSymlinks resolves every
|
||||||
|
// component, closing the directory-symlink bypass: a PR that commits
|
||||||
|
// .review-bot/ as a directory symlink pointing outside the repo would
|
||||||
|
// otherwise pass the filepath.Rel confinement check because the textual
|
||||||
|
// path is inside the root while the actual destination is not.
|
||||||
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot resolve path (symlink): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat the resolved path for size and existence checks — EvalSymlinks
|
||||||
|
// guarantees no symlink components remain, so ModeSymlink can never be set.
|
||||||
|
fi, err := os.Lstat(resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot stat file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject anything that is not a regular file (directories, FIFOs, device
|
||||||
|
// nodes, etc.) — ParseDocMapConfig expects a plain YAML file and would
|
||||||
|
// produce a confusing error on non-regular entries.
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return "", fmt.Errorf("docmap must be a regular file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confine to resolvedRoot: use the fully-resolved path so that a directory
|
||||||
|
// symlink inside the repo cannot carry the path outside the root.
|
||||||
|
rel, err := filepath.Rel(resolvedRoot, resolvedPath)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
|
return "", fmt.Errorf("path must be within --repo-root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce size cap before reading to prevent memory exhaustion.
|
||||||
|
if fi.Size() > maxDocmapBytes {
|
||||||
|
return "", fmt.Errorf("file size %d bytes exceeds %d-byte limit", fi.Size(), maxDocmapBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runValidateDocmap implements the `review-bot validate-docmap` subcommand.
|
||||||
|
//
|
||||||
|
// It reads changed file paths from stdin (one per line, as produced by
|
||||||
|
// `git diff --name-only`), parses a doc-map YAML file, and performs two checks:
|
||||||
|
//
|
||||||
|
// 1. Coverage check: every changed file must be matched by at least one
|
||||||
|
// paths: glob in the docmap. Fails if any file is uncovered.
|
||||||
|
//
|
||||||
|
// 2. Stale-docs check: every docs: entry in the docmap must exist on disk
|
||||||
|
// (relative to --repo-root). Fails if any path is missing.
|
||||||
|
//
|
||||||
|
// Both checks always run — all failures are reported before exiting.
|
||||||
|
//
|
||||||
|
// Exit codes:
|
||||||
|
//
|
||||||
|
// 0 — clean (all files covered, all docs exist)
|
||||||
|
// 1 — one or more coverage or stale-doc failures
|
||||||
|
// 2 — usage error, missing flag, or YAML parse error
|
||||||
|
func runValidateDocmap(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("validate-docmap", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(errWriter)
|
||||||
|
|
||||||
|
docmapFlag := fs.String("docmap", "", "Path to doc-map YAML file (required)")
|
||||||
|
repoRootFlag := fs.String("repo-root", ".", "Repo root for resolving docs: paths (default: cwd)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
// flag.ContinueOnError already wrote the error to errWriter.
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if *docmapFlag == "" {
|
||||||
|
fmt.Fprintln(errWriter, "Error: --docmap is required")
|
||||||
|
fmt.Fprintln(errWriter, "")
|
||||||
|
fmt.Fprintln(errWriter, "usage: review-bot validate-docmap --docmap <path> [--repo-root <dir>]")
|
||||||
|
fmt.Fprintln(errWriter, " Changed files are read from stdin, one per line.")
|
||||||
|
fmt.Fprintln(errWriter, " Example: git diff --name-only origin/main HEAD | review-bot validate-docmap --docmap .review-bot/doc-map.yml")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve repoRoot first — the docmap path is validated against it below.
|
||||||
|
// Use an absolute, symlink-free path so a symlinked --repo-root cannot
|
||||||
|
// bypass the escape guard in validateDocmapPath or checkStaleDocs.
|
||||||
|
absRoot, err := filepath.Abs(*repoRootFlag)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to resolve --repo-root %q: %v\n", *repoRootFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
resolvedRoot, err := filepath.EvalSymlinks(absRoot)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(errWriter, "Error: --repo-root %q does not exist\n", *repoRootFlag)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to resolve --repo-root %q: %v\n", *repoRootFlag, err)
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Harden the docmap file path before reading it. The --docmap flag value
|
||||||
|
// may reference a PR-controlled file (e.g. .review-bot/doc-map.yml).
|
||||||
|
// Validate that it:
|
||||||
|
// 1. Resolves within resolvedRoot (prevent reading arbitrary host files).
|
||||||
|
// 2. Resolved target stays within the root (in-repo symlinks are allowed
|
||||||
|
// if they resolve to a path inside the root).
|
||||||
|
// 3. Does not exceed maxDocmapBytes (prevent memory exhaustion from an
|
||||||
|
// oversized committed file).
|
||||||
|
// validateDocmapPath returns the resolved path; use it directly to
|
||||||
|
// eliminate any TOCTOU race between validation and use.
|
||||||
|
resolvedDocmap, err := validateDocmapPath(*docmapFlag, resolvedRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: --docmap %q is invalid: %v\n", *docmapFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open and read the docmap with a LimitedReader — closes the residual TOCTOU
|
||||||
|
// window between the Lstat size check in validateDocmapPath and the file open
|
||||||
|
// here. The limit is maxDocmapBytes+1 so we can detect a file that grew past
|
||||||
|
// the cap after the stat without reading unbounded bytes.
|
||||||
|
//
|
||||||
|
// Defense-in-depth: stat the path immediately before and after open so we can
|
||||||
|
// detect a file swap between validateDocmapPath's validation and this open via
|
||||||
|
// os.SameFile. An attacker with workspace write access could otherwise replace
|
||||||
|
// the validated file with a symlink in the gap between validation and use.
|
||||||
|
preStat, err := os.Lstat(resolvedDocmap)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to stat docmap before open %q: %v\n", *docmapFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
f, err := os.Open(resolvedDocmap)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to open docmap %q: %v\n", *docmapFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
// Verify we opened the same file that was validated — rejects a swap between
|
||||||
|
// the pre-open Lstat and the open call.
|
||||||
|
postStat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to stat open docmap %q: %v\n", *docmapFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if !os.SameFile(preStat, postStat) {
|
||||||
|
fmt.Fprintf(errWriter, "Error: --docmap %q changed between validation and open\n", *docmapFlag)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
docmapData, err := io.ReadAll(io.LimitReader(f, maxDocmapBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to read docmap %q: %v\n", *docmapFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if int64(len(docmapData)) > maxDocmapBytes {
|
||||||
|
fmt.Fprintf(errWriter, "Error: --docmap %q exceeded %d-byte limit after open\n", *docmapFlag, maxDocmapBytes)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
cfg, err := review.ParseDocMapConfigContent(string(docmapData), *docmapFlag)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to parse docmap %q: %v\n", *docmapFlag, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read changed files from stdin.
|
||||||
|
changedFiles, err := readLines(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errWriter, "Error: failed to read stdin: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := false
|
||||||
|
|
||||||
|
// --- Check 1: Coverage ---
|
||||||
|
// Note: an empty docmap (no mappings) means every changed file is
|
||||||
|
// uncovered — there are no patterns to match against. This is intentional:
|
||||||
|
// if you declare a doc-map, every changed file must be accounted for.
|
||||||
|
// On empty stdin the check is vacuously true (no files to cover).
|
||||||
|
var uncovered []string
|
||||||
|
for _, f := range changedFiles {
|
||||||
|
// Normalize Windows-style backslashes to forward slashes so that
|
||||||
|
// changed-file paths from git on Windows match doc-map globs.
|
||||||
|
f = strings.ReplaceAll(f, "\\", "/")
|
||||||
|
// Strip a leading "./" emitted by non-git tools (e.g. `find`) so that
|
||||||
|
// paths like "./cmd/foo.go" match doc-map globs written as "cmd/**".
|
||||||
|
f = strings.TrimPrefix(f, "./")
|
||||||
|
if !review.FileCoveredByDocMap(cfg, f) {
|
||||||
|
uncovered = append(uncovered, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(uncovered) > 0 {
|
||||||
|
failed = true
|
||||||
|
fmt.Fprintln(errWriter, "ERROR: changed files with no docmap coverage:")
|
||||||
|
for _, f := range uncovered {
|
||||||
|
fmt.Fprintf(errWriter, " %s\n", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Check 2: Stale docs ---
|
||||||
|
// checkStaleDocs validates each path before touching the filesystem; see
|
||||||
|
// its documentation for the path-traversal hardening applied.
|
||||||
|
staleDocs := checkStaleDocs(cfg, resolvedRoot)
|
||||||
|
if len(staleDocs) > 0 {
|
||||||
|
failed = true
|
||||||
|
fmt.Fprintln(errWriter, "ERROR: stale docmap entries (paths do not exist):")
|
||||||
|
for _, d := range staleDocs {
|
||||||
|
fmt.Fprintf(errWriter, " %s\n", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(outWriter, "OK: docmap is valid")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStaleDocs returns deduplicated docs: entries that do not exist under
|
||||||
|
// repoRoot.
|
||||||
|
//
|
||||||
|
// Path-traversal hardening: each docPath is validated with
|
||||||
|
// review.ValidateDocPath (rejects absolute paths and ".." segments) and then
|
||||||
|
// confined to repoRoot via filepath.Clean + filepath.Rel before os.Lstat is
|
||||||
|
// called. Symlinks are treated as stale — a CI tool running against
|
||||||
|
// PR-controlled content must not follow symlinks that could probe arbitrary
|
||||||
|
// host paths. Paths that fail any check are treated as invalid (reported as
|
||||||
|
// stale) without following any symlinks.
|
||||||
|
func checkStaleDocs(cfg *review.DocMapConfig, repoRoot string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var stale []string
|
||||||
|
|
||||||
|
for _, mapping := range cfg.Mappings {
|
||||||
|
for _, docPath := range mapping.Docs {
|
||||||
|
if docPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[docPath]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[docPath] = struct{}{}
|
||||||
|
|
||||||
|
// Guard 1: reject absolute paths and ".." segments sourced from
|
||||||
|
// PR-controlled YAML before joining with repoRoot.
|
||||||
|
if err := review.ValidateDocPath(docPath); err != nil {
|
||||||
|
stale = append(stale, docPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard 2: verify the cleaned joined path does not escape repoRoot.
|
||||||
|
// filepath.Clean resolves any remaining ".." after the join; the
|
||||||
|
// filepath.Rel check confirms the path is still under repoRoot.
|
||||||
|
fullPath := filepath.Clean(filepath.Join(repoRoot, filepath.FromSlash(docPath)))
|
||||||
|
rel, err := filepath.Rel(repoRoot, fullPath)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
|
stale = append(stale, docPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Lstat (not Stat) so symlinks are never followed. A symlink
|
||||||
|
// under repoRoot could point anywhere on the host, allowing a
|
||||||
|
// malicious PR to probe file existence. Treat symlinks as stale.
|
||||||
|
fi, err := os.Lstat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
stale = append(stale, docPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi.Mode()&os.ModeSymlink != 0 {
|
||||||
|
stale = append(stale, docPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLines reads all non-empty trimmed lines from r.
|
||||||
|
func readLines(r io.Reader) ([]string, error) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
var lines []string
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line != "" {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines, scanner.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeDocmapYAML writes a YAML string to a temp file and returns its path.
|
||||||
|
// The file is created in t.TempDir() — use makeDocmapInDir when the docmap
|
||||||
|
// must be located inside a specific repo-root directory.
|
||||||
|
func makeDocmapYAML(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.CreateTemp(t.TempDir(), "doc-map-*.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
t.Fatalf("WriteString: %v", err)
|
||||||
|
}
|
||||||
|
return f.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeDocmapInDir writes a YAML string to a file inside dir and returns the
|
||||||
|
// file path. Use this instead of makeDocmapYAML when also passing --repo-root,
|
||||||
|
// because validateDocmapPath requires the docmap to be within the repo root.
|
||||||
|
func makeDocmapInDir(t *testing.T, dir, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, ".review-bot", "doc-map.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeDocFile creates a file (and any parent dirs) at the given path relative to dir.
|
||||||
|
func makeDocFile(t *testing.T, dir, rel string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(dir, rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte("# doc\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureOutput redirects outWriter/errWriter to buffers for the duration of f.
|
||||||
|
func captureOutput(f func()) (stdout, stderr string) {
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
origOut, origErr := outWriter, errWriter
|
||||||
|
outWriter = &outBuf
|
||||||
|
errWriter = &errBuf
|
||||||
|
defer func() {
|
||||||
|
outWriter = origOut
|
||||||
|
errWriter = origErr
|
||||||
|
}()
|
||||||
|
f()
|
||||||
|
return outBuf.String(), errBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_Clean(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
makeDocFile(t, dir, "docs/foo.md")
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
// A covered file with all docs existing → clean.
|
||||||
|
code, stdout, _ := stdinValidateDocmap(t,
|
||||||
|
"lib/foo/bar.ex\n",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected exit 0 for clean, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout, "OK") {
|
||||||
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_MissingDocmapFlag(t *testing.T) {
|
||||||
|
var code int
|
||||||
|
_, stderr := captureOutput(func() {
|
||||||
|
code = runValidateDocmap([]string{})
|
||||||
|
})
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit 2 for missing --docmap, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "--docmap") {
|
||||||
|
t.Errorf("expected --docmap in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_BadYAML(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
docmap := makeDocmapInDir(t, dir, "mappings: [{{invalid")
|
||||||
|
var code int
|
||||||
|
_, stderr := captureOutput(func() {
|
||||||
|
code = runValidateDocmap([]string{"--docmap", docmap, "--repo-root", dir})
|
||||||
|
})
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit 2 for bad YAML, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "failed to parse") {
|
||||||
|
t.Errorf("expected parse error in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_StaleDocs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// docs/foo.md does NOT exist on disk.
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
var code int
|
||||||
|
_, stderr := captureOutput(func() {
|
||||||
|
code = runValidateDocmap([]string{
|
||||||
|
"--docmap", docmap,
|
||||||
|
"--repo-root", dir,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("expected exit 1 for stale docs, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "docs/foo.md") {
|
||||||
|
t.Errorf("expected stale path in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "stale docmap") {
|
||||||
|
t.Errorf("expected 'stale docmap' in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdinValidateDocmap runs runValidateDocmap with a synthetic stdin.
|
||||||
|
//
|
||||||
|
// Implementation note: we write stdinContent to a temp file and point
|
||||||
|
// os.Stdin at it. The defer f.Close() fires after stdinValidateDocmap
|
||||||
|
// returns, which is after runValidateDocmap has finished reading stdin
|
||||||
|
// synchronously — so the file is not closed while still in use.
|
||||||
|
// Tests must not call t.Parallel() while sharing the global os.Stdin.
|
||||||
|
func stdinValidateDocmap(t *testing.T, stdinContent string, args []string) (code int, stdout, stderr string) {
|
||||||
|
t.Helper()
|
||||||
|
// Write stdin content to a temp file and redirect os.Stdin.
|
||||||
|
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp for stdin: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(stdinContent); err != nil {
|
||||||
|
t.Fatalf("WriteString for stdin: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(0, 0); err != nil {
|
||||||
|
t.Fatalf("Seek for stdin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origStdin := os.Stdin
|
||||||
|
os.Stdin = f
|
||||||
|
defer func() { os.Stdin = origStdin }()
|
||||||
|
|
||||||
|
stdout, stderr = captureOutput(func() {
|
||||||
|
code = runValidateDocmap(args)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_UncoveredFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
makeDocFile(t, dir, "docs/foo.md")
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"lib/bar/uncovered.ex\n",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("expected exit 1 for uncovered file, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "lib/bar/uncovered.ex") {
|
||||||
|
t.Errorf("expected uncovered file in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "no docmap coverage") {
|
||||||
|
t.Errorf("expected 'no docmap coverage' in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_BothFailures(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// docs/foo.md intentionally missing
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"lib/bar/uncovered.ex\n",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("expected exit 1 for both failures, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "no docmap coverage") {
|
||||||
|
t.Errorf("expected coverage error in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "stale docmap") {
|
||||||
|
t.Errorf("expected stale-docs error in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_EmptyStdin(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
makeDocFile(t, dir, "docs/foo.md")
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, stdout, _ := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected exit 0 for empty stdin, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout, "OK") {
|
||||||
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_BlankLinesSkipped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
makeDocFile(t, dir, "docs/foo.md")
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
// stdin with only blank lines → effectively empty, should be clean
|
||||||
|
code, stdout, _ := stdinValidateDocmap(t,
|
||||||
|
"\n \n\n",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected exit 0 for blank-only stdin, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout, "OK") {
|
||||||
|
t.Errorf("expected 'OK' in stdout for blank-only stdin, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunValidateDocmap_DuplicateDocsDeduped(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// docs/shared.md intentionally missing — but it appears in TWO mappings.
|
||||||
|
// Should appear only once in stale list.
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/shared.md
|
||||||
|
- paths:
|
||||||
|
- "lib/bar/**"
|
||||||
|
docs:
|
||||||
|
- docs/shared.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("expected exit 1 for stale doc, got %d", code)
|
||||||
|
}
|
||||||
|
count := strings.Count(stderr, "docs/shared.md")
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected docs/shared.md to appear exactly once in stderr (deduplicated), got %d occurrences: %q", count, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckStaleDocs_PathTraversal verifies that checkStaleDocs rejects
|
||||||
|
// traversal and absolute paths without touching the host filesystem.
|
||||||
|
func TestCheckStaleDocs_PathTraversal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Baseline: a valid doc that exists.
|
||||||
|
makeDocFile(t, dir, "docs/valid.md")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
docPath string
|
||||||
|
wantStale bool
|
||||||
|
}{
|
||||||
|
{"dot-dot traversal", "../../etc/passwd", true},
|
||||||
|
{"dot-dot single", "../outside", true},
|
||||||
|
{"absolute path", "/etc/passwd", true},
|
||||||
|
{"valid present path", "docs/valid.md", false},
|
||||||
|
{"valid missing path", "docs/missing.md", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/**"
|
||||||
|
docs:
|
||||||
|
- `+tc.docPath+`
|
||||||
|
`)
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
|
||||||
|
if tc.wantStale {
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("path %q: expected exit 1 (stale/invalid), got %d; stderr: %q", tc.docPath, code, stderr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("path %q: expected exit 0 (valid), got %d; stderr: %q", tc.docPath, code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckStaleDocs_SymlinkOutside verifies that a symlink under repoRoot
|
||||||
|
// pointing outside the repo is treated as stale (not followed).
|
||||||
|
func TestCheckStaleDocs_SymlinkOutside(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a symlink inside repoRoot pointing to a file outside the repo.
|
||||||
|
// We point at /etc/hostname (exists on Linux CI) but the test does not
|
||||||
|
// depend on that file existing — Lstat must reject the symlink itself.
|
||||||
|
linkPath := filepath.Join(dir, "docs", "secret.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("/etc/hostname", linkPath); err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/**"
|
||||||
|
docs:
|
||||||
|
- docs/secret.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("expected exit 1 for symlink doc, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "docs/secret.md") {
|
||||||
|
t.Errorf("expected stale path in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckStaleDocs_SymlinkInsideRepo verifies that a symlink pointing to
|
||||||
|
// another file *within* the repo is also treated as stale. We refuse all
|
||||||
|
// symlinks regardless of target to keep the check simple and safe.
|
||||||
|
func TestCheckStaleDocs_SymlinkInsideRepo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Real doc file.
|
||||||
|
makeDocFile(t, dir, "docs/real.md")
|
||||||
|
|
||||||
|
// Symlink inside repo pointing at the real file.
|
||||||
|
linkPath := filepath.Join(dir, "docs", "link.md")
|
||||||
|
if err := os.Symlink(filepath.Join(dir, "docs", "real.md"), linkPath); err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/**"
|
||||||
|
docs:
|
||||||
|
- docs/link.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 1 {
|
||||||
|
t.Errorf("expected exit 1 for symlink doc (even intra-repo), got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunValidateDocmap_SymlinkRepoRoot verifies that a --repo-root that is
|
||||||
|
// itself a symlink to a valid directory resolves correctly.
|
||||||
|
func TestRunValidateDocmap_SymlinkRepoRoot(t *testing.T) {
|
||||||
|
realDir := t.TempDir()
|
||||||
|
makeDocFile(t, realDir, "docs/foo.md")
|
||||||
|
|
||||||
|
// Create a symlink pointing at realDir.
|
||||||
|
symlinkDir := filepath.Join(t.TempDir(), "link-root")
|
||||||
|
if err := os.Symlink(realDir, symlinkDir); err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place the docmap inside realDir so it passes the confinement check.
|
||||||
|
// (symlinkDir resolves to realDir, so files inside realDir are also inside
|
||||||
|
// the resolved repo-root.)
|
||||||
|
docmap := makeDocmapInDir(t, realDir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Using the symlinked repo-root: the real doc exists → should be clean.
|
||||||
|
code, stdout, stderr := stdinValidateDocmap(t,
|
||||||
|
"lib/foo.go\n",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", symlinkDir},
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected exit 0 for symlinked repo-root with existing doc, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout, "OK") {
|
||||||
|
t.Errorf("expected 'OK' in stdout, got %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_Symlink verifies that --docmap pointing at a symlink
|
||||||
|
// whose resolved target is outside --repo-root is rejected (prevents reading
|
||||||
|
// arbitrary host files via PR-controlled symlinks).
|
||||||
|
//
|
||||||
|
// Note: after the EvalSymlinks fix (issue #150), in-repo symlinks whose
|
||||||
|
// targets also reside within the repo root are now allowed — the confinement
|
||||||
|
// check is applied to the resolved path, not the symlink entry itself. The
|
||||||
|
// security invariant is: the resolved destination must be within the root.
|
||||||
|
func TestValidateDocmapPath_Symlink(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
|
// Create a docmap file OUTSIDE the repo root to serve as the symlink
|
||||||
|
// target. EvalSymlinks will resolve to this path, which the Rel check
|
||||||
|
// must then reject.
|
||||||
|
if err := os.MkdirAll(filepath.Join(outside, ".review-bot"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
outsideDocmap := filepath.Join(outside, ".review-bot", "doc-map.yml")
|
||||||
|
if err := os.WriteFile(outsideDocmap, []byte("mappings: []\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a symlink inside dir pointing to the file outside the repo.
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
|
||||||
|
if err := os.Symlink(outsideDocmap, symlinkPath); err != nil {
|
||||||
|
t.Fatalf("Symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", symlinkPath, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit 2 for out-of-repo symlink docmap, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "invalid") && !strings.Contains(stderr, "repo-root") {
|
||||||
|
t.Errorf("expected confinement rejection in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_OutsideRepoRoot verifies that --docmap pointing
|
||||||
|
// outside --repo-root is rejected (prevents reading arbitrary host files).
|
||||||
|
func TestValidateDocmapPath_OutsideRepoRoot(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a docmap in a separate temp dir (outside the repo root).
|
||||||
|
outside := makeDocmapYAML(t, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", outside, "--repo-root", repoDir},
|
||||||
|
)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit 2 for docmap outside repo-root, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "invalid") && !strings.Contains(stderr, "repo-root") {
|
||||||
|
t.Errorf("expected confinement rejection in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_SizeLimit verifies that --docmap files exceeding
|
||||||
|
// maxDocmapBytes are rejected before reading (prevents memory exhaustion).
|
||||||
|
func TestValidateDocmapPath_SizeLimit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write a file larger than maxDocmapBytes.
|
||||||
|
bigPath := filepath.Join(dir, ".review-bot", "big-doc-map.yml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(bigPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
// Exceed the limit by one byte.
|
||||||
|
bigContent := make([]byte, maxDocmapBytes+1)
|
||||||
|
if err := os.WriteFile(bigPath, bigContent, 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", bigPath, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit 2 for oversized docmap, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "limit") && !strings.Contains(stderr, "size") && !strings.Contains(stderr, "invalid") {
|
||||||
|
t.Errorf("expected size limit error in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_DirSymlinkBypass verifies that a directory-symlink
|
||||||
|
// inside the repo pointing outside cannot be used to read arbitrary host files.
|
||||||
|
//
|
||||||
|
// Attack vector: a PR commits .review-bot/ as a directory symlink targeting a
|
||||||
|
// directory outside the repo. The textual path of the docmap file is inside
|
||||||
|
// the repo root, so the old Rel-only check passed — but the actual file is
|
||||||
|
// outside. This is closed by calling EvalSymlinks on the full path before the
|
||||||
|
// confinement check.
|
||||||
|
func TestValidateDocmapPath_DirSymlinkBypass(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
outsideDir := t.TempDir()
|
||||||
|
|
||||||
|
// Secret file outside the repo.
|
||||||
|
secretPath := filepath.Join(outsideDir, "secret.yml")
|
||||||
|
if err := os.WriteFile(secretPath, []byte("mappings: []\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .review-bot/ as a directory symlink pointing outside the repo.
|
||||||
|
reviewBotDir := filepath.Join(repoDir, ".review-bot")
|
||||||
|
if err := os.Symlink(outsideDir, reviewBotDir); err != nil {
|
||||||
|
t.Skipf("cannot create dir symlink (platform may not support it): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textually inside repo — .review-bot/secret.yml — but resolves outside.
|
||||||
|
attackPath := filepath.Join(repoDir, ".review-bot", "secret.yml")
|
||||||
|
|
||||||
|
// Resolve repoDir to a symlink-free path, as runValidateDocmap does.
|
||||||
|
resolvedRoot, err := filepath.EvalSymlinks(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvalSymlinks(repoDir): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := validateDocmapPath(attackPath, resolvedRoot); err == nil {
|
||||||
|
t.Error("expected rejection of dir-symlink bypass, got nil error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_NonRegularFile verifies that --docmap pointing at a
|
||||||
|
// non-regular file (e.g. a directory) is rejected with a clear error before
|
||||||
|
// ParseDocMapConfig is called.
|
||||||
|
func TestValidateDocmapPath_NonRegularFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Use the directory itself as the docmap path — directories pass Lstat but
|
||||||
|
// are not regular files.
|
||||||
|
reviewBotDir := filepath.Join(dir, ".review-bot")
|
||||||
|
if err := os.MkdirAll(reviewBotDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"",
|
||||||
|
[]string{"--docmap", reviewBotDir, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("expected exit 2 for directory docmap, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "regular file") && !strings.Contains(stderr, "invalid") {
|
||||||
|
t.Errorf("expected regular-file rejection in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunValidateDocmap_DotSlashPrefix verifies that paths emitted with a
|
||||||
|
// leading "./" (e.g. from `find` or `ls`) match doc-map globs correctly.
|
||||||
|
// Without TrimPrefix, "./cmd/foo.go" would not match the pattern "cmd/**".
|
||||||
|
func TestRunValidateDocmap_DotSlashPrefix(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
makeDocFile(t, dir, "docs/foo.md")
|
||||||
|
|
||||||
|
docmap := makeDocmapInDir(t, dir, `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "cmd/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
// File with a leading "./" should be treated as covered.
|
||||||
|
code, _, stderr := stdinValidateDocmap(t,
|
||||||
|
"./cmd/foo.go\n",
|
||||||
|
[]string{"--docmap", docmap, "--repo-root", dir},
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected exit 0 for './' prefixed covered file, got %d; stderr: %q", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_InRepoSymlinkAllowed verifies that an in-repo
|
||||||
|
// file-level symlink whose resolved target is still within the repo root is
|
||||||
|
// accepted. This is the positive case for the issue #150 behavioral change:
|
||||||
|
// only symlinks that escape the root are rejected; intra-repo symlinks are
|
||||||
|
// allowed because EvalSymlinks resolves the target and the confinement check
|
||||||
|
// is applied to the resolved path, not the symlink entry itself.
|
||||||
|
func TestValidateDocmapPath_InRepoSymlinkAllowed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create the real docmap file inside the repo root.
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
realDocmap := filepath.Join(dir, ".review-bot", "doc-map-real.yml")
|
||||||
|
if err := os.WriteFile(realDocmap, []byte("mappings: []\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a symlink inside the repo root that points to the real file
|
||||||
|
// (also inside the root).
|
||||||
|
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
|
||||||
|
if err := os.Symlink(realDocmap, symlinkPath); err != nil {
|
||||||
|
t.Skipf("cannot create symlink (platform may not support it): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve dir to a symlink-free root, as runValidateDocmap does.
|
||||||
|
resolvedRoot, err := filepath.EvalSymlinks(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvalSymlinks(dir): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-repo symlink whose target is within root: must be accepted.
|
||||||
|
resolved, err := validateDocmapPath(symlinkPath, resolvedRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected in-repo symlink to be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
// The returned resolved path must be the real file (not the symlink entry).
|
||||||
|
// validateDocmapPath calls filepath.EvalSymlinks internally, so the returned
|
||||||
|
// path is always the fully-resolved real path — it can never equal the
|
||||||
|
// symlink entry itself.
|
||||||
|
if resolved == symlinkPath {
|
||||||
|
t.Errorf("expected resolved path to differ from symlink path")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
# Design: doc-map input for path-scoped design doc injection (Issue #137)
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
review-bot can inject context via `patterns-repo` (external VCS repos) and `conventions-file`
|
|
||||||
(a single file from the reviewed repo). There is no mechanism to inject local repo documentation
|
|
||||||
files scoped to the paths changed in a PR.
|
|
||||||
|
|
||||||
First consumer: `grgl/gargoyle#778` needs a "doc adherence" reviewer that checks code against the
|
|
||||||
module's governing design doc, without injecting every doc in the tree.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### New: `doc-map` input
|
|
||||||
|
|
||||||
A `.review-bot/doc-map.yml` config file in the reviewed repo maps source path globs to governing
|
|
||||||
design docs. review-bot reads the map, intersects it with changed PR paths, and injects only the
|
|
||||||
relevant docs into the system prompt.
|
|
||||||
|
|
||||||
### Config format
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
mappings:
|
|
||||||
- paths:
|
|
||||||
- "lib/gargoyle/engine/signal_risk/**"
|
|
||||||
docs:
|
|
||||||
- docs/domain/contexts/risk/risk-controls.md
|
|
||||||
- paths:
|
|
||||||
- "lib/gargoyle/trading/**"
|
|
||||||
docs:
|
|
||||||
- docs/domain/contexts/trading/
|
|
||||||
```
|
|
||||||
|
|
||||||
- `paths` — glob patterns (including `**`) matched against changed file paths in the PR
|
|
||||||
- `docs` — file paths or directory paths (all `.md` files under a directory) to inject
|
|
||||||
- Docs are deduplicated across mappings
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
| Component | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `review/docmap.go` | YAML parsing, glob matching with `**` support, doc loading via VCS |
|
|
||||||
| `cmd/review-bot/main.go` | Step 6c: parses config, intersects with changed files, calls LoadMatchingDocs |
|
|
||||||
| `budget/budget.go` | New `DesignDocs` section — injected after Conventions in system prompt |
|
|
||||||
| `action.yml` | `doc-map` and `doc-map-max-bytes` inputs, wired to `DOC_MAP_FILE`/`DOC_MAP_MAX_BYTES` |
|
|
||||||
|
|
||||||
### Doc file loading
|
|
||||||
|
|
||||||
- The `doc-map` YAML file is read from the local workspace (like `system-prompt-file`).
|
|
||||||
- Doc files listed in the config are fetched via VCS API (same as `conventions-file`),
|
|
||||||
enabling them to be loaded from any branch without a local checkout.
|
|
||||||
- `GetAllFilesInPath` is tried first; if it returns files, they are treated as a directory listing.
|
|
||||||
If it returns empty, `GetFileContent` is tried as a fallback (single file).
|
|
||||||
|
|
||||||
### Glob matching
|
|
||||||
|
|
||||||
`**` is implemented by splitting patterns and paths on `/`, then matching segment-by-segment.
|
|
||||||
A `**` segment consumes zero or more path segments (not just one level like `*`).
|
|
||||||
|
|
||||||
### Budget integration
|
|
||||||
|
|
||||||
`DesignDocs` is added to `budget.Sections` between `Conventions` and `FileContext`.
|
|
||||||
Trim order: Patterns → Conventions → DesignDocs → FileContext → Diff.
|
|
||||||
Design docs appear in the system prompt under `## Design Documents`.
|
|
||||||
|
|
||||||
### Context size guard
|
|
||||||
|
|
||||||
Default: 100 KB. Configurable via `--doc-map-max-bytes` / `DOC_MAP_MAX_BYTES`.
|
|
||||||
Truncation is noted inline with a `⚠️` message.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
| Situation | Behavior |
|
|
||||||
|-----------|----------|
|
|
||||||
| `--doc-map` file not found | Fatal error (like `--system-prompt-file`) |
|
|
||||||
| `--doc-map` file invalid YAML | Fatal error with descriptive message |
|
|
||||||
| Unknown YAML keys | Log warning, continue |
|
|
||||||
| Doc file not found in VCS | Log warning, skip |
|
|
||||||
| Doc directory empty or no `.md` files | Log debug, skip |
|
|
||||||
| Total size exceeds limit | Truncate with notice, log warning |
|
|
||||||
| No changed paths match any mapping | No docs injected, review runs normally |
|
|
||||||
| `paths` or `docs` list empty in a mapping | Skip that mapping |
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
# Design: Role-based Review Personas (Issue #51)
|
|
||||||
|
|
||||||
> **Note:** This design was revised during implementation to use JSON instead of YAML
|
|
||||||
> to maintain the repository's zero-external-dependencies convention. All persona
|
|
||||||
> files use JSON format. See "Design Revision" section at the end for details.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Current review-bot performs generic code review. Every reviewer (regardless of `reviewer-name`) uses the same base prompt and evaluates the same concerns. This leads to:
|
|
||||||
|
|
||||||
1. **Redundancy** — Two reviewers (e.g., GPT + Claude twins) often flag identical issues
|
|
||||||
2. **Gaps** — Generic reviewers miss specialized concerns (security, domain logic, architecture)
|
|
||||||
3. **Noise** — NITs about style mixed with critical security findings
|
|
||||||
4. **No ownership** — Findings lack clear domain attribution
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Must work with existing CLI flags and CI workflow patterns
|
|
||||||
- Must not break backwards compatibility (existing configs still work)
|
|
||||||
- Must integrate cleanly with the budget system (personas add to context)
|
|
||||||
- Multiple personas running in parallel must not interfere with each other
|
|
||||||
- Each persona must have clear scope boundaries (no duplication)
|
|
||||||
|
|
||||||
## Proposed Approach
|
|
||||||
|
|
||||||
### 1. Persona Definition
|
|
||||||
|
|
||||||
A persona is a named review role with:
|
|
||||||
- **Identity** — Who am I? What's my expertise?
|
|
||||||
- **Focus** — What do I look for?
|
|
||||||
- **Scope boundaries** — What do I explicitly NOT comment on?
|
|
||||||
- **Severity calibration** — What counts as MAJOR/MINOR/NIT for MY domain?
|
|
||||||
|
|
||||||
Personas are defined in JSON files that can live:
|
|
||||||
1. In the pattern repos (shared across projects)
|
|
||||||
2. In the target repo (project-specific personas)
|
|
||||||
3. Inline via a new `--persona-file` flag (JSON format)
|
|
||||||
|
|
||||||
### 2. Persona File Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
# .review/personas/security.yaml
|
|
||||||
name: security
|
|
||||||
display_name: Security Specialist
|
|
||||||
model_preference: opus # optional hint for expensive analysis
|
|
||||||
|
|
||||||
identity: |
|
|
||||||
You are a security specialist reviewing code for vulnerabilities.
|
|
||||||
Your expertise: OWASP Top 10, injection attacks, auth/authz, secrets management,
|
|
||||||
event sourcing security (replay attacks, event injection).
|
|
||||||
|
|
||||||
focus:
|
|
||||||
- Injection attacks (SQL, command, path traversal, template)
|
|
||||||
- Authentication and authorization gaps
|
|
||||||
- Secrets exposure (hardcoded credentials, tokens in logs)
|
|
||||||
- Input validation (unsanitized input, unsafe deserialization)
|
|
||||||
- Race conditions with security implications
|
|
||||||
- Event sourcing attack vectors
|
|
||||||
|
|
||||||
ignore:
|
|
||||||
- Code style and naming conventions
|
|
||||||
- Performance (unless security-related)
|
|
||||||
- Documentation
|
|
||||||
- General code quality
|
|
||||||
- Test coverage
|
|
||||||
|
|
||||||
severity:
|
|
||||||
critical: "Remote code execution, auth bypass, data exfiltration"
|
|
||||||
major: "Privilege escalation, information disclosure, DoS"
|
|
||||||
minor: "Missing rate limiting, verbose errors"
|
|
||||||
nit: "Theoretical risk with low exploitability"
|
|
||||||
|
|
||||||
output_format: |
|
|
||||||
For each finding:
|
|
||||||
- Severity: [CRITICAL|MAJOR|MINOR|NIT]
|
|
||||||
- Attack vector: How could this be exploited?
|
|
||||||
- Evidence: Code snippet showing the vulnerability
|
|
||||||
- Recommendation: Specific fix
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. New CLI Flags
|
|
||||||
|
|
||||||
```
|
|
||||||
--persona-file PATH Path to persona JSON file (local or in repo)
|
|
||||||
--persona NAME Built-in persona name (security, architect, domain)
|
|
||||||
```
|
|
||||||
|
|
||||||
Either flag sets the persona. If neither is provided, behavior is unchanged (generic review).
|
|
||||||
|
|
||||||
### 4. Prompt Assembly
|
|
||||||
|
|
||||||
Current flow:
|
|
||||||
```
|
|
||||||
SystemBase → Patterns → Conventions → [LLM]
|
|
||||||
```
|
|
||||||
|
|
||||||
New flow with persona:
|
|
||||||
```
|
|
||||||
PersonaPrompt (from YAML) → Patterns (filtered?) → Conventions → [LLM]
|
|
||||||
```
|
|
||||||
|
|
||||||
The persona's identity/focus/ignore/severity sections become the system prompt, replacing the generic "You are an expert code reviewer" base.
|
|
||||||
|
|
||||||
### 5. Built-in Personas
|
|
||||||
|
|
||||||
Ship with these built-in personas (loadable via `--persona NAME`):
|
|
||||||
|
|
||||||
| Name | Focus |
|
|
||||||
|------|-------|
|
|
||||||
| `security` | Vulnerabilities, auth, secrets |
|
|
||||||
| `architect` | Patterns, consistency, design |
|
|
||||||
| `domain` | Business logic (requires repo-specific config) |
|
|
||||||
| `docs` | Documentation, API clarity |
|
|
||||||
|
|
||||||
Built-in personas live in `review/personas/` as embedded Go assets or YAML shipped with the binary.
|
|
||||||
|
|
||||||
### 6. CI Workflow Integration
|
|
||||||
|
|
||||||
Single persona:
|
|
||||||
```yaml
|
|
||||||
- uses: rodin/review-bot/.gitea/actions/review@v1
|
|
||||||
with:
|
|
||||||
reviewer-name: security
|
|
||||||
persona: security
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple personas (parallel jobs):
|
|
||||||
```yaml
|
|
||||||
jobs:
|
|
||||||
review:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: security
|
|
||||||
persona: security
|
|
||||||
- name: architect
|
|
||||||
persona: architect
|
|
||||||
steps:
|
|
||||||
- uses: rodin/review-bot/.gitea/actions/review@v1
|
|
||||||
with:
|
|
||||||
reviewer-name: ${{ matrix.name }}
|
|
||||||
persona: ${{ matrix.persona }}
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom persona from repo:
|
|
||||||
```yaml
|
|
||||||
- uses: rodin/review-bot/.gitea/actions/review@v1
|
|
||||||
with:
|
|
||||||
reviewer-name: trading
|
|
||||||
persona-file: .review/personas/trading.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Persona + Patterns Interaction
|
|
||||||
|
|
||||||
Some personas benefit from filtered patterns:
|
|
||||||
- Security → only security-related patterns
|
|
||||||
- Architect → all patterns (structural focus)
|
|
||||||
- Domain → domain docs, not language patterns
|
|
||||||
|
|
||||||
For v1, keep it simple: all patterns are included regardless of persona. Future enhancement could add `patterns_filter` to persona YAML.
|
|
||||||
|
|
||||||
### 8. Output Format Changes
|
|
||||||
|
|
||||||
Persona name appears in the review header:
|
|
||||||
```markdown
|
|
||||||
# Security Review
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
No critical vulnerabilities found in this change.
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
| # | Severity | File | Line | Finding |
|
|
||||||
...
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
**APPROVE** — No security-relevant issues detected.
|
|
||||||
|
|
||||||
---
|
|
||||||
*Review by security*
|
|
||||||
<!-- review-bot:security -->
|
|
||||||
```
|
|
||||||
|
|
||||||
## State/Data Model
|
|
||||||
|
|
||||||
### Persona struct
|
|
||||||
|
|
||||||
```go
|
|
||||||
// review/persona.go
|
|
||||||
type Persona struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
DisplayName string `yaml:"display_name"`
|
|
||||||
ModelPref string `yaml:"model_preference,omitempty"`
|
|
||||||
Identity string `yaml:"identity"`
|
|
||||||
Focus []string `yaml:"focus"`
|
|
||||||
Ignore []string `yaml:"ignore"`
|
|
||||||
Severity Severity `yaml:"severity"`
|
|
||||||
OutputFormat string `yaml:"output_format,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Severity struct {
|
|
||||||
Critical string `yaml:"critical"`
|
|
||||||
Major string `yaml:"major"`
|
|
||||||
Minor string `yaml:"minor"`
|
|
||||||
Nit string `yaml:"nit"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading precedence
|
|
||||||
|
|
||||||
1. `--persona-file PATH` → load from local file system
|
|
||||||
2. `--persona NAME` → load from embedded built-ins
|
|
||||||
3. Neither → use generic system prompt (current behavior)
|
|
||||||
|
|
||||||
## Error Cases
|
|
||||||
|
|
||||||
| Error | Handling |
|
|
||||||
|-------|----------|
|
|
||||||
| Persona file not found | Fatal exit with clear message |
|
|
||||||
| Invalid YAML in persona file | Fatal exit with parse error |
|
|
||||||
| Both `--persona` and `--persona-file` specified | Fatal exit: mutually exclusive |
|
|
||||||
| Unknown built-in persona name | Fatal exit with list of valid names |
|
|
||||||
| Empty identity in persona | Warning, fall back to generic prompt |
|
|
||||||
|
|
||||||
## Edge Cases
|
|
||||||
|
|
||||||
- **Empty focus list**: Valid — persona relies on identity alone
|
|
||||||
- **Empty ignore list**: Valid — no explicit scope exclusions
|
|
||||||
- **No severity section**: Use default MAJOR/MINOR/NIT definitions
|
|
||||||
- **Model preference set but budget insufficient**: Ignore preference, log warning
|
|
||||||
- **Persona file in pattern repo**: Fetch like other pattern files
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit tests
|
|
||||||
- `persona_test.go`: Parse valid/invalid YAML, validate required fields
|
|
||||||
- `prompt_test.go`: Verify persona prompt assembly
|
|
||||||
- Integration with budget: persona prompts count toward token limit
|
|
||||||
|
|
||||||
### Integration tests
|
|
||||||
- End-to-end with `--persona security` (built-in)
|
|
||||||
- End-to-end with `--persona-file custom.yaml`
|
|
||||||
- Backwards compatibility: no flags = generic behavior
|
|
||||||
|
|
||||||
### Manual verification
|
|
||||||
- Run security persona on a PR with obvious vulnerability
|
|
||||||
- Verify security persona ignores style issues
|
|
||||||
- Verify non-security persona doesn't flag security issues
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Persona types and loading
|
|
||||||
- [ ] `review/persona.go`: Persona struct + YAML parsing
|
|
||||||
- [ ] `review/persona_test.go`: Unit tests
|
|
||||||
- [ ] Embed built-in personas in binary
|
|
||||||
- [ ] Compiles clean, tests pass
|
|
||||||
|
|
||||||
### Phase 2: Prompt generation
|
|
||||||
- [ ] `review/prompt.go`: `BuildPersonaPrompt(p Persona) string`
|
|
||||||
- [ ] Modify `BuildSystemBase()` to accept optional persona
|
|
||||||
- [ ] Integrate persona prompt with budget system
|
|
||||||
- [ ] Tests for prompt assembly
|
|
||||||
|
|
||||||
### Phase 3: CLI integration
|
|
||||||
- [ ] Add `--persona` and `--persona-file` flags
|
|
||||||
- [ ] Flag validation (mutually exclusive, valid names)
|
|
||||||
- [ ] Load persona based on flags
|
|
||||||
- [ ] Pass persona to prompt builder
|
|
||||||
|
|
||||||
### Phase 4: Action integration
|
|
||||||
- [ ] Add `persona` and `persona-file` inputs to action.yml
|
|
||||||
- [ ] Update README with persona examples
|
|
||||||
- [ ] End-to-end CI test
|
|
||||||
|
|
||||||
### Phase 5: Built-in personas
|
|
||||||
- [ ] `security.yaml` built-in
|
|
||||||
- [ ] `architect.yaml` built-in
|
|
||||||
- [ ] `docs.yaml` built-in
|
|
||||||
- [ ] Document each persona's focus
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Persona file location in repo**: Should we support `--persona-file .review/security.yaml` where the file is fetched from the PR's repo (like conventions)? This adds complexity but enables project-specific personas without action changes.
|
|
||||||
|
|
||||||
2. **Model preference enforcement**: If persona specifies `model_preference: opus` but the action uses a different model, should we warn? Override? Ignore? Current thinking: log warning, use the specified model (user controls model via action input).
|
|
||||||
|
|
||||||
3. **Severity override output**: If persona defines custom severity levels (CRITICAL), should the JSON output include them, or map back to standard MAJOR/MINOR/NIT? Current thinking: keep standard output format, use severity calibration only for prompt guidance.
|
|
||||||
|
|
||||||
## Completion Checklist
|
|
||||||
|
|
||||||
1. Persona struct matches YAML schema exactly?
|
|
||||||
2. Built-in personas embedded in binary (not external files)?
|
|
||||||
3. `--persona` and `--persona-file` are mutually exclusive?
|
|
||||||
4. Unknown persona name produces clear error with valid options?
|
|
||||||
5. Empty persona file fields have sensible defaults?
|
|
||||||
6. Persona prompt integrates with budget system (token counting)?
|
|
||||||
7. Backwards compatibility: no flags = current behavior?
|
|
||||||
8. Review header shows persona display name?
|
|
||||||
9. Sentinel still uses reviewer-name (not persona name)?
|
|
||||||
10. Unit tests cover parse errors, missing fields, valid YAML?
|
|
||||||
|
|
||||||
## Design Review Findings (Self-Review)
|
|
||||||
|
|
||||||
### Finding 1: Severity Mapping
|
|
||||||
The persona YAML allows `critical` severity, but the LLM output parser (`review/parser.go`) only accepts MAJOR/MINOR/NIT.
|
|
||||||
|
|
||||||
**Resolution:** Keep standard output format. Persona severity section is ONLY for calibrating the LLM's judgment (prompt guidance). Output must still use MAJOR/MINOR/NIT. Document this clearly in persona format docs.
|
|
||||||
|
|
||||||
### Finding 2: Embedding Built-in Personas
|
|
||||||
Go doesn't natively embed YAML. Must use `//go:embed` directive (Go 1.16+).
|
|
||||||
|
|
||||||
**Resolution:** Create `review/personas/` directory with YAML files and use:
|
|
||||||
```go
|
|
||||||
//go:embed personas/*.yaml
|
|
||||||
var embeddedPersonas embed.FS
|
|
||||||
```
|
|
||||||
|
|
||||||
### Finding 3: display_name vs reviewer-name
|
|
||||||
Design says header shows "persona display name" but sentinel uses "reviewer-name". This is correct - they serve different purposes:
|
|
||||||
- `display_name` → human-readable header ("Security Specialist Review")
|
|
||||||
- `reviewer-name` → machine sentinel for cleanup (`<!-- review-bot:security -->`)
|
|
||||||
|
|
||||||
When persona is used, `display_name` takes precedence for the header title, but `reviewer-name` (CLI flag) is still used for the sentinel.
|
|
||||||
|
|
||||||
## Design Revision: YAML with gopkg.in/yaml.v3
|
|
||||||
|
|
||||||
**Decision:** Add `gopkg.in/yaml.v3` as a dependency.
|
|
||||||
|
|
||||||
YAML is preferred over JSON for persona files because:
|
|
||||||
- Multi-line strings are cleaner (no escaping quotes in identity/focus text)
|
|
||||||
- Comments are supported for documentation
|
|
||||||
- More human-readable for complex persona definitions
|
|
||||||
|
|
||||||
The implementation supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) for backwards compatibility, with YAML as the default for built-in personas.
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Design: YAML Support for Persona Files (#57)
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
JSON is awkward for persona files that contain multi-line text (identity, severity descriptions). YAML supports cleaner multi-line strings and comments, improving readability and maintainability.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Backwards compatibility: existing JSON personas must continue to work
|
|
||||||
- Security: protect against DoS via deeply nested YAML (AIKIDO-2024-10486)
|
|
||||||
- Consistency: use `.yaml` extension (not `.yml`)
|
|
||||||
- Library: use `github.com/goccy/go-yaml` v1.16.0+ (approved in CONVENTIONS.md); we implement custom AST-based depth/node-count checks for precise alias-aware validation
|
|
||||||
|
|
||||||
## Proposed Approach
|
|
||||||
|
|
||||||
1. **Update `parsePersona`** to detect format from file extension
|
|
||||||
2. **Add YAML parsing** with explicit depth limit (defense in depth)
|
|
||||||
3. **Keep JSON as fallback** for files without `.yaml`/`.yml` extension
|
|
||||||
4. **Convert built-in personas** to YAML format
|
|
||||||
5. **Update embed directive** to include both formats
|
|
||||||
|
|
||||||
### File Extension Detection
|
|
||||||
|
|
||||||
```go
|
|
||||||
func parsePersona(data []byte, source string) (*Persona, error) {
|
|
||||||
isYAML := strings.HasSuffix(source, ".yaml") || strings.HasSuffix(source, ".yml")
|
|
||||||
if isYAML {
|
|
||||||
return parseYAML(data, source)
|
|
||||||
}
|
|
||||||
return parseJSON(data, source)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### YAML Parsing with Depth Protection
|
|
||||||
|
|
||||||
We implement a custom AST-based depth/node-count walk (`checkYAMLDepth` in
|
|
||||||
`review/persona.go`) rather than relying on library decoder options. Key design
|
|
||||||
decisions:
|
|
||||||
|
|
||||||
- **Library:** `github.com/goccy/go-yaml` with `ast.Node`-based traversal
|
|
||||||
- **Dual-map tracking:** `validated` (depth-aware short-circuit) + `visiting` (cycle detection)
|
|
||||||
- **Node-count limit:** Conservative overcounting bounds total validation work
|
|
||||||
- **Alias-aware depth:** Aliases increment depth and are re-checked when encountered at greater depths
|
|
||||||
|
|
||||||
See `review/persona.go:checkYAMLDepth` for the authoritative implementation.
|
|
||||||
|
|
||||||
## State/Data Model
|
|
||||||
|
|
||||||
No new state. Same `Persona` struct, just different parsing.
|
|
||||||
|
|
||||||
## Error Cases
|
|
||||||
|
|
||||||
| Error | Handling |
|
|
||||||
|-------|----------|
|
|
||||||
| Invalid YAML syntax | Return parse error with source file |
|
|
||||||
| Deeply nested YAML | Custom AST walk (`checkYAMLDepth`) rejects before decode |
|
|
||||||
| Unknown extension | Fall back to JSON parsing |
|
|
||||||
| Missing required fields | Validation rejects after parse |
|
|
||||||
|
|
||||||
## Edge Cases
|
|
||||||
|
|
||||||
- File with `.json` extension but YAML content → JSON parse fails, user sees error
|
|
||||||
- File with no extension → defaults to JSON
|
|
||||||
- Embedded persona reference like `builtin:security` → detect by embed path (`personas/X.yaml`)
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
1. Unit tests for YAML parsing (valid, invalid, deeply nested)
|
|
||||||
2. Unit tests for extension detection
|
|
||||||
3. Integration test for built-in personas (now YAML)
|
|
||||||
4. Backwards compat test: verify JSON still works for external files
|
|
||||||
|
|
||||||
## Completion Checklist
|
|
||||||
|
|
||||||
1. [ ] `go-yaml` dependency added at v1.16.0+
|
|
||||||
2. [ ] Extension detection uses case-insensitive comparison
|
|
||||||
3. [ ] YAML parse errors include source file name
|
|
||||||
4. [ ] JSON parsing still works for `.json` files
|
|
||||||
5. [ ] Built-in personas converted to YAML with readable multi-line strings
|
|
||||||
6. [ ] Embed directive updated to include `*.yaml`
|
|
||||||
7. [ ] Test for deeply nested YAML rejection
|
|
||||||
8. [ ] All existing tests pass
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Should we support both `.yaml` AND `.yml`? Issue says `.yaml` only for consistency, but some users expect `.yml`. **Decision:** Support both for reading, recommend `.yaml` in docs.
|
|
||||||
- Should we add a "format" field to detect mismatched extension/content? **Decision:** No, keep it simple. Extension determines format.
|
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# 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 |
|
||||||
|
| S9 | Zero close-PR API calls in dispatch script (`state=closed` does not appear) |
|
||||||
|
| S10 | No close-PR API calls in any worker template; every worker template contains `NEVER close a PR` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
### Worker Absolute Constraints
|
||||||
|
|
||||||
|
Every worker template begins with an `⛔ ABSOLUTE CONSTRAINTS` section containing these rules:
|
||||||
|
|
||||||
|
- **NEVER close a PR.** Never call `PATCH /pulls/{id}` with `state=closed`. Closing a PR requires human action. "Duplicate", "superseded", or "already done" are never a worker's call.
|
||||||
|
- **NEVER merge a PR.** Never call the merge API. Merging requires human approval.
|
||||||
|
- **NEVER use the gitea-aweiker token.** All API calls use the gitea-rodin token only.
|
||||||
|
- **NEVER act on a PR with active REQUEST_CHANGES.** Fix the findings first.
|
||||||
|
|
||||||
|
The first two constraints are statically enforced by `check-invariants.sh`: S1 and S9 cover the dispatch script (no merge, no close); S8 covers worker templates (no merge calls); S10 covers worker templates (no close calls, with NEVER-close text verified present in each). The remaining two constraints (token usage and REQUEST_CHANGES gate) are enforced by runtime logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fixes for Issues #144, #145, and #157
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Issue #157** (autonomous PR close):
|
||||||
|
Worker templates were missing an explicit constraint against closing PRs. The dispatch
|
||||||
|
script never had a close call, but workers could reason their way into calling
|
||||||
|
`PATCH /pulls/{id}` with `state=closed`. All worker templates now include
|
||||||
|
`NEVER close a PR` in their ABSOLUTE CONSTRAINTS section. Invariant S9 verifies
|
||||||
|
the dispatch script contains no close calls. Invariant S10 verifies
|
||||||
|
worker templates contain no close calls and each contains the NEVER-close text.
|
||||||
|
|
||||||
|
Regression tests in `dispatch.bats` statically verify all of these constraints.
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-8
@@ -52,20 +52,48 @@ func ParseDocMapConfig(localPath string) (*DocMapConfig, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read doc-map file %q: %w", localPath, err)
|
return nil, fmt.Errorf("read doc-map file %q: %w", localPath, err)
|
||||||
}
|
}
|
||||||
|
return parseDocMapBytes(data, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDocMapConfigContent parses a doc-map YAML config from an in-memory
|
||||||
|
// string. The source parameter is used only for error messages and log entries
|
||||||
|
// (e.g. "owner/repo@main:.review-bot/doc-map.yml").
|
||||||
|
//
|
||||||
|
// Use this when the config content has been fetched from a trusted VCS ref
|
||||||
|
// rather than read from the local workspace.
|
||||||
|
func ParseDocMapConfigContent(content, source string) (*DocMapConfig, error) {
|
||||||
|
data := []byte(content)
|
||||||
|
return parseDocMapBytes(data, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDocMapBytes is the shared YAML parse implementation used by
|
||||||
|
// ParseDocMapConfig and ParseDocMapConfigContent.
|
||||||
|
func parseDocMapBytes(data []byte, source string) (*DocMapConfig, error) {
|
||||||
var cfg DocMapConfig
|
var cfg DocMapConfig
|
||||||
if err := yaml.UnmarshalWithOptions(data, &cfg, yaml.Strict()); err != nil {
|
if err := yaml.UnmarshalWithOptions(data, &cfg, yaml.Strict()); err != nil {
|
||||||
// Re-parse without strict mode to log which keys are unknown.
|
// Re-parse without strict mode to log which keys are unknown.
|
||||||
var relaxed DocMapConfig
|
var relaxed DocMapConfig
|
||||||
if err2 := yaml.Unmarshal(data, &relaxed); err2 != nil {
|
if err2 := yaml.Unmarshal(data, &relaxed); err2 != nil {
|
||||||
return nil, fmt.Errorf("parse doc-map YAML %q: %w", localPath, err)
|
return nil, fmt.Errorf("parse doc-map YAML %q: %w", source, err)
|
||||||
}
|
}
|
||||||
slog.Warn("doc-map YAML contains unknown keys (ignored)", "file", localPath, "error", err)
|
slog.Warn("doc-map YAML contains unknown keys (ignored)", "file", source, "error", err)
|
||||||
cfg = relaxed
|
cfg = relaxed
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileCoveredByDocMap reports whether at least one paths: glob in any mapping
|
||||||
|
// of cfg matches the given file path. It is used by static validation tooling
|
||||||
|
// (e.g. the validate-docmap subcommand) to check per-file docmap coverage.
|
||||||
|
func FileCoveredByDocMap(cfg *DocMapConfig, file string) bool {
|
||||||
|
for _, mapping := range cfg.Mappings {
|
||||||
|
if mappingMatches(mapping.Paths, []string{file}) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MatchDocs returns deduplicated doc paths for the given changed file paths.
|
// MatchDocs returns deduplicated doc paths for the given changed file paths.
|
||||||
// A mapping matches if any of its path globs matches any of the changed files.
|
// A mapping matches if any of its path globs matches any of the changed files.
|
||||||
func MatchDocs(cfg *DocMapConfig, changedFiles []string) []string {
|
func MatchDocs(cfg *DocMapConfig, changedFiles []string) []string {
|
||||||
@@ -245,7 +273,7 @@ type docEntry struct {
|
|||||||
// If the path is a directory, all .md files under it are returned.
|
// If the path is a directory, all .md files under it are returned.
|
||||||
// If it's a file, a single entry is returned.
|
// If it's a file, a single entry is returned.
|
||||||
func loadDocEntries(ctx context.Context, fetcher DocFetcher, owner, repo, docPath string) ([]docEntry, error) {
|
func loadDocEntries(ctx context.Context, fetcher DocFetcher, owner, repo, docPath string) ([]docEntry, error) {
|
||||||
if err := validateDocPath(docPath); err != nil {
|
if err := ValidateDocPath(docPath); err != nil {
|
||||||
return nil, fmt.Errorf("doc path %q rejected: %w", docPath, err)
|
return nil, fmt.Errorf("doc path %q rejected: %w", docPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +326,15 @@ func readFileBytes(path string) ([]byte, error) {
|
|||||||
return os.ReadFile(path)
|
return os.ReadFile(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateDocPath rejects doc paths that could cause path traversal via the
|
// ValidateDocPath rejects doc paths that could cause path traversal
|
||||||
// VCS API (absolute paths, any ".." segment). Defense-in-depth: the VCS API
|
// (absolute paths, any ".." segment, backslashes). Defense-in-depth: callers
|
||||||
// should already scope paths to the repo, but we validate locally to avoid
|
// must also confine the joined path to the repo root via filepath.Rel before
|
||||||
// any quirk in backend path handling.
|
// any filesystem access. Backslashes are rejected explicitly to prevent
|
||||||
func validateDocPath(p string) error {
|
// Windows platform edge cases.
|
||||||
|
func ValidateDocPath(p string) error {
|
||||||
|
if strings.Contains(p, "\\") {
|
||||||
|
return fmt.Errorf("backslashes not allowed in doc paths")
|
||||||
|
}
|
||||||
if filepath.IsAbs(p) {
|
if filepath.IsAbs(p) {
|
||||||
return fmt.Errorf("absolute paths not allowed")
|
return fmt.Errorf("absolute paths not allowed")
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-3
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
// fakeDocFetcher is a mock DocFetcher for tests.
|
// fakeDocFetcher is a mock DocFetcher for tests.
|
||||||
type fakeDocFetcher struct {
|
type fakeDocFetcher struct {
|
||||||
files map[string]string // path -> content
|
files map[string]string // path -> content
|
||||||
dirs map[string]map[string]string // dir path -> (file path -> content)
|
dirs map[string]map[string]string // dir path -> (file path -> content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ func TestValidateDocPath(t *testing.T) {
|
|||||||
"a/b/c",
|
"a/b/c",
|
||||||
}
|
}
|
||||||
for _, p := range valid {
|
for _, p := range valid {
|
||||||
if err := validateDocPath(p); err != nil {
|
if err := ValidateDocPath(p); err != nil {
|
||||||
t.Errorf("expected valid path %q to pass, got error: %v", p, err)
|
t.Errorf("expected valid path %q to pass, got error: %v", p, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,9 +395,13 @@ func TestValidateDocPath(t *testing.T) {
|
|||||||
"docs/../../../etc/passwd",
|
"docs/../../../etc/passwd",
|
||||||
"../sibling-repo/file.md",
|
"../sibling-repo/file.md",
|
||||||
"a/b/../c",
|
"a/b/../c",
|
||||||
|
// Backslashes must be rejected (Finding #3 — Windows platform edge cases).
|
||||||
|
`docs\foo.md`,
|
||||||
|
`docs\..\secret`,
|
||||||
|
`\absolute`,
|
||||||
}
|
}
|
||||||
for _, p := range invalid {
|
for _, p := range invalid {
|
||||||
if err := validateDocPath(p); err == nil {
|
if err := ValidateDocPath(p); err == nil {
|
||||||
t.Errorf("expected path %q to be rejected, but it was accepted", p)
|
t.Errorf("expected path %q to be rejected, but it was accepted", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,6 +424,27 @@ func TestLoadMatchingDocs_PathTraversalRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateDocPath_Backslash verifies that backslash-bearing paths are
|
||||||
|
// rejected to prevent Windows platform edge cases where a path separator
|
||||||
|
// could be normalised differently by the host OS or VCS backend.
|
||||||
|
func TestValidateDocPath_Backslash(t *testing.T) {
|
||||||
|
backslashPaths := []string{
|
||||||
|
`docs\foo.md`,
|
||||||
|
`docs\subdir\file.md`,
|
||||||
|
`\absolute`,
|
||||||
|
}
|
||||||
|
for _, p := range backslashPaths {
|
||||||
|
if err := ValidateDocPath(p); err == nil {
|
||||||
|
t.Errorf("expected backslash path %q to be rejected, but it was accepted", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: forward-slash path must still be accepted.
|
||||||
|
if err := ValidateDocPath("docs/foo.md"); err != nil {
|
||||||
|
t.Errorf("expected forward-slash path to be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -436,3 +461,112 @@ func writeTempYAML(t *testing.T, content string) string {
|
|||||||
}
|
}
|
||||||
return filepath.Clean(f.Name())
|
return filepath.Clean(f.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FileCoveredByDocMap
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestFileCoveredByDocMap(t *testing.T) {
|
||||||
|
cfg := &DocMapConfig{
|
||||||
|
Mappings: []DocMapping{
|
||||||
|
{
|
||||||
|
Paths: []string{"lib/foo/**", "lib/bar/*.go"},
|
||||||
|
Docs: []string{"docs/foo.md"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Paths: []string{"cmd/**"},
|
||||||
|
Docs: []string{"docs/cmd.md"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
file string
|
||||||
|
covered bool
|
||||||
|
}{
|
||||||
|
{"lib/foo/baz.ex", true},
|
||||||
|
{"lib/foo/sub/deep.ex", true},
|
||||||
|
{"lib/bar/util.go", true},
|
||||||
|
{"lib/bar/sub/util.go", false}, // *.go only matches one level
|
||||||
|
{"cmd/main.go", true},
|
||||||
|
{"cmd/sub/main.go", true},
|
||||||
|
{"internal/secret.go", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.file, func(t *testing.T) {
|
||||||
|
got := FileCoveredByDocMap(cfg, tc.file)
|
||||||
|
if got != tc.covered {
|
||||||
|
t.Errorf("FileCoveredByDocMap(%q) = %v, want %v", tc.file, got, tc.covered)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCoveredByDocMap_EmptyConfig(t *testing.T) {
|
||||||
|
cfg := &DocMapConfig{}
|
||||||
|
if FileCoveredByDocMap(cfg, "lib/foo/bar.go") {
|
||||||
|
t.Error("expected false for empty config, got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ParseDocMapConfigContent
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestParseDocMapConfigContent_Valid(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/foo/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
`
|
||||||
|
cfg, err := ParseDocMapConfigContent(content, "owner/repo@main:.review-bot/doc-map.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Mappings) != 1 {
|
||||||
|
t.Fatalf("expected 1 mapping, got %d", len(cfg.Mappings))
|
||||||
|
}
|
||||||
|
if len(cfg.Mappings[0].Docs) != 1 || cfg.Mappings[0].Docs[0] != "docs/foo.md" {
|
||||||
|
t.Errorf("unexpected mapping: %+v", cfg.Mappings[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDocMapConfigContent_EmptyContent(t *testing.T) {
|
||||||
|
cfg, err := ParseDocMapConfigContent("", "test-source")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for empty content: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Mappings) != 0 {
|
||||||
|
t.Errorf("expected 0 mappings for empty content, got %d", len(cfg.Mappings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDocMapConfigContent_InvalidYAML(t *testing.T) {
|
||||||
|
_, err := ParseDocMapConfigContent("mappings: [{{invalid", "test-source")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid YAML, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDocMapConfigContent_UnknownKeys(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
mappings:
|
||||||
|
- paths:
|
||||||
|
- "lib/**"
|
||||||
|
docs:
|
||||||
|
- docs/foo.md
|
||||||
|
unknown_top_level_key: "should be warned but not fatal"
|
||||||
|
`
|
||||||
|
// Unknown top-level keys produce a warning but not an error.
|
||||||
|
cfg, err := ParseDocMapConfigContent(content, "test-source")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for unknown keys: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Mappings) == 0 {
|
||||||
|
t.Error("expected mappings to be parsed despite unknown key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user