Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d75e737f07 | |||
| 1e3d86b604 | |||
| 60c6bd9f49 | |||
| cc053cfede |
+26
-26
@@ -1,6 +1,6 @@
|
|||||||
# Dev Loop Health Check — 2026-05-15 01:33 UTC
|
# Dev Loop Health Check — 2026-05-15 03:33 UTC
|
||||||
|
|
||||||
## Status: ✅ OPTIMAL
|
## Status: ✅ ACTIVE WORK COMPLETED
|
||||||
|
|
||||||
### Test Results
|
### Test Results
|
||||||
- All packages: **PASS** ✅ (6/6, fresh -count=1 run)
|
- All packages: **PASS** ✅ (6/6, fresh -count=1 run)
|
||||||
@@ -18,33 +18,33 @@
|
|||||||
| llm | 81.3% |
|
| llm | 81.3% |
|
||||||
| review | 92.0% |
|
| review | 92.0% |
|
||||||
|
|
||||||
### Recent Activity (since last check 01:28 UTC)
|
### PR #138 Status
|
||||||
- Pulled `d0b0b0b` (dev-loop health update from 01:28 cycle)
|
|
||||||
- No new commits from dev work
|
|
||||||
- No open issues or PRs
|
|
||||||
- Working tree: clean, up to date with origin/main
|
|
||||||
|
|
||||||
### Notes on Coverage
|
- **Branch:** issue-137
|
||||||
- `cmd/review-bot` at 46.1% — main() itself at 26.5%; lowest coverage package
|
- **Feature:** feat(#137): add doc-map input for path-scoped doc injection
|
||||||
- Potential: integration test harness (issue #TBD)
|
- **Review status:** ✅ All 3 bots approved (sonnet, gpt, security)
|
||||||
- `vcs.go` adapter wrappers intentionally 0% — thin delegation, real logic tested in gitea/github packages
|
- **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 Phase Priorities
|
### Next Priority
|
||||||
1. **PR Submission (#132+)** — Enable review-bot to create PRs
|
|
||||||
2. **`github.Client.DismissReview`** — method referenced in orphaned files, not in client.go; file issue
|
|
||||||
3. **GitHub Enterprise Support** — Enterprise URL patterns, token scopes
|
|
||||||
4. **Increase cmd/review-bot coverage** — integration test harness for main()
|
|
||||||
5. **Performance & Observability** — Metrics, load testing, audit logging
|
|
||||||
|
|
||||||
### System Health
|
- Await merge of PR #138
|
||||||
- ✅ All tests passing
|
- After merge: increase cmd/review-bot coverage (46.1% → target 60%+)
|
||||||
- ✅ No warnings or lint issues
|
- Issue #132+: PR Submission feature
|
||||||
- ✅ Code clean, working tree clean
|
- `github.Client.DismissReview` method referenced but missing — file issue
|
||||||
- ✅ No open issues or PRs on Gitea
|
|
||||||
- ✅ Ready for next development cycle
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Previous check:** 2026-05-15 01:28 UTC
|
_Dev-loop cycle complete at 03:33 UTC._
|
||||||
**This check:** 2026-05-15 01:33 UTC
|
|
||||||
**Action:** NONE — healthy, no work to do
|
|
||||||
|
|||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
# Plan: Issue #137 — doc-map input for path-scoped doc injection
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
review-bot currently injects 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` wants a "doc adherence" reviewer that checks code against the module's governing design doc, without injecting every doc in the tree.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Must work with existing `budget.Fit` architecture (docs go into `SystemBase` section, never trimmed — or added as a new section below `Conventions`)
|
|
||||||
- Must not fail the review if doc files are missing (warn + skip)
|
|
||||||
- Context guard: default 100KB total injected doc content (configurable)
|
|
||||||
- YAML parsing must use `github.com/goccy/go-yaml` (the only approved YAML library)
|
|
||||||
- No new third-party dependencies (Go standard library + approved packages only)
|
|
||||||
- Path security: doc files must be read via VCS API (not local filesystem), so they are always fetched from the PR head ref within the repo workspace — same path used by `conventions-file` loading
|
|
||||||
|
|
||||||
Wait — re-reading the issue: the issue says "local repo files". In the CI action context, the action runner has the repo checked out. The design doc says "read each doc file from the local checkout". But review-bot has no local checkout — it runs as a binary and reads files via VCS API. Let me reconcile:
|
|
||||||
|
|
||||||
- `conventions-file` uses `vcs.GetFileContent` (fetches from VCS API, default branch)
|
|
||||||
- The doc-map docs should also be read via VCS API
|
|
||||||
- The doc-map config file itself (`doc-map` input) is a local file in the workspace (like `system-prompt-file`)
|
|
||||||
- The doc paths inside the config ARE relative to the repo root, to be fetched via VCS API
|
|
||||||
|
|
||||||
**Conclusion:** The `doc-map` YAML file is read from local filesystem (like `system-prompt-file`). The doc files listed inside are fetched from the VCS API.
|
|
||||||
|
|
||||||
Actually, re-reading more carefully: "Read each doc file (or all .md files under a directory) from the local checkout". But review-bot doesn't have a local checkout. Since `system-prompt-file` and `conventions-file` are both read locally, I should follow the same approach consistently.
|
|
||||||
|
|
||||||
**Final decision:** The `doc-map` config file is local (passed via `--doc-map` flag, read with `os.ReadFile` after workspace validation). The listed doc paths (and directory expansion) are read via VCS `GetFileContent` / `GetAllFilesInPath` — matching the `conventions-file` pattern for consistency, and enabling it to work on any branch (not just the checked-out one).
|
|
||||||
|
|
||||||
## Proposed Approach
|
|
||||||
|
|
||||||
### New files
|
|
||||||
|
|
||||||
1. `review/docmap.go` — `DocMap` type, YAML parsing, glob matching, doc loading logic
|
|
||||||
2. `review/docmap_test.go` — unit tests
|
|
||||||
|
|
||||||
### Modified files
|
|
||||||
|
|
||||||
1. `cmd/review-bot/main.go` — add `--doc-map` flag, wire up in Step 6c
|
|
||||||
2. `.gitea/actions/review/action.yml` — add `doc-map` input, pass as `DOC_MAP_FILE` env var
|
|
||||||
3. `budget/budget.go` — add `DesignDocs` section (between `SystemBase`/`Conventions` and `Diff`)
|
|
||||||
4. `CHANGELOG.md` — update
|
|
||||||
|
|
||||||
### DocMap types (review/docmap.go)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// DocMapping maps a set of path globs to doc files/directories.
|
|
||||||
type DocMapping struct {
|
|
||||||
Paths []string `yaml:"paths"` // glob patterns
|
|
||||||
Docs []string `yaml:"docs"` // file paths or directories
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocMapConfig is the top-level YAML structure.
|
|
||||||
type DocMapConfig struct {
|
|
||||||
Mappings []DocMapping `yaml:"mappings"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocMapOptions controls doc loading behavior.
|
|
||||||
type DocMapOptions struct {
|
|
||||||
MaxBytes int // default 100*1024
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key functions
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ParseDocMapConfig parses the YAML config file from a local path.
|
|
||||||
func ParseDocMapConfig(path string) (*DocMapConfig, error)
|
|
||||||
|
|
||||||
// MatchDocs returns deduplicated doc paths for the given changed files.
|
|
||||||
func MatchDocs(cfg *DocMapConfig, changedFiles []string) []string
|
|
||||||
|
|
||||||
// LoadMatchingDocs fetches doc content via VCS, respecting size limit.
|
|
||||||
// Returns (content, error). Missing files are warned and skipped.
|
|
||||||
func LoadMatchingDocs(ctx context.Context, fetcher DocFetcher, owner, repo string, docPaths []string, opts DocMapOptions) (string, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
### DocFetcher interface
|
|
||||||
|
|
||||||
```go
|
|
||||||
// DocFetcher fetches files and directory listings from VCS.
|
|
||||||
// Subset of vcsClient, defined here to keep review package free of cmd-level deps.
|
|
||||||
type DocFetcher interface {
|
|
||||||
GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error)
|
|
||||||
GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Glob matching
|
|
||||||
|
|
||||||
Use `path.Match` from the Go standard library. It matches patterns like `lib/gargoyle/engine/signal_risk/**`. The `**` glob is NOT natively supported by `path.Match`, so we need either:
|
|
||||||
|
|
||||||
a) Use `filepath.Match` which also doesn't support `**`
|
|
||||||
b) Implement simple `**` support: `**` matches any number of path segments
|
|
||||||
|
|
||||||
**Decision:** Implement minimal `**` support: split path on `/`, split pattern on `/`, match each segment with `filepath.Match`. When a pattern segment is `**`, it consumes any number of remaining segments. This covers the primary use case without a new dependency.
|
|
||||||
|
|
||||||
### Budget integration
|
|
||||||
|
|
||||||
Add `DesignDocs` field to `budget.Sections`. Position: after `Conventions`, before `FileContext` (trimming order: Patterns → Conventions → DesignDocs → FileContext → Diff). Inject under `## Design Documents` heading in system prompt.
|
|
||||||
|
|
||||||
### Context size guard
|
|
||||||
|
|
||||||
Accumulate doc content bytes. If total would exceed `MaxBytes`, truncate last doc with a notice and stop loading more.
|
|
||||||
|
|
||||||
## State/Data Model
|
|
||||||
|
|
||||||
```
|
|
||||||
DocMapConfig
|
|
||||||
└── []DocMapping
|
|
||||||
├── Paths []string (glob patterns against changed file paths)
|
|
||||||
└── Docs []string (local doc paths or directories in target repo)
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
1. Parse doc-map YAML → DocMapConfig
|
|
||||||
2. GetPullRequestFiles → []string of changed paths
|
|
||||||
3. MatchDocs(cfg, changedPaths) → deduplicated []string doc paths
|
|
||||||
4. For each doc path:
|
|
||||||
- If path ends with "/" or is a "directory" → GetAllFilesInPath, filter .md
|
|
||||||
- Otherwise → GetFileContent
|
|
||||||
5. Accumulate, respect size limit
|
|
||||||
6. Inject into system prompt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Cases
|
|
||||||
|
|
||||||
| Situation | Behavior |
|
|
||||||
|-----------|----------|
|
|
||||||
| `--doc-map` file not found | Fatal error (like `--system-prompt-file`) |
|
|
||||||
| `--doc-map` file invalid YAML | Fatal error with descriptive message |
|
|
||||||
| Unknown keys in YAML | Log warning, continue |
|
|
||||||
| Doc file not found in VCS | Log warning, skip |
|
|
||||||
| Doc directory empty | Log debug, skip |
|
|
||||||
| Total size exceeds limit | Truncate with notice, log warning |
|
|
||||||
| No changed paths match | No docs injected, review runs normally |
|
|
||||||
| `paths` list empty in a mapping | Skip that mapping (no match possible) |
|
|
||||||
| `docs` list empty in a mapping | Skip that mapping (nothing to inject) |
|
|
||||||
|
|
||||||
## Edge Cases
|
|
||||||
|
|
||||||
- Empty `mappings` list → no docs injected, no error
|
|
||||||
- Same doc matched by multiple mappings → deduplicate by path
|
|
||||||
- Directory with no `.md` files → skip silently (log debug)
|
|
||||||
- Very large single doc file → counts against limit, may truncate
|
|
||||||
- Symlinks/special files in VCS → GetFileContent handles or errors (warn + skip)
|
|
||||||
- `doc-map` path outside workspace → fatal error (validateWorkspacePath)
|
|
||||||
- Directory path specified as `docs` entry without trailing `/` → check if it's a directory via ListContents or GetAllFilesInPath; if error, try GetFileContent
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit tests (review/docmap_test.go)
|
|
||||||
|
|
||||||
1. **ParseDocMapConfig** — valid YAML, invalid YAML, unknown keys (warning), empty file
|
|
||||||
2. **MatchDocs** — no match, single match, multi-match, deduplication, `**` glob, exact match
|
|
||||||
3. **LoadMatchingDocs** — with mock DocFetcher:
|
|
||||||
- file path → content returned
|
|
||||||
- missing file → warned + skipped
|
|
||||||
- directory path → expands .md files
|
|
||||||
- directory with no .md → empty
|
|
||||||
- size guard → truncation with notice
|
|
||||||
- deduplication in combined results
|
|
||||||
|
|
||||||
### Integration coverage
|
|
||||||
|
|
||||||
The existing `main_test.go` tests cover flag wiring — add a test for `--doc-map` flag parsing and workspace path validation.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Directory detection**: The issue says "directory paths expand to all .md files". But review-bot has no local filesystem. When a `docs` entry is `docs/domain/contexts/trading/`, we can call `GetAllFilesInPath`. But what if someone writes `docs/domain/contexts/trading` (no trailing slash)? We could try GetFileContent first, and if it fails with a 404 or "is directory" error, fall back to GetAllFilesInPath. OR we could just always call GetAllFilesInPath and if it returns content, use it; if it returns empty, try GetFileContent.
|
|
||||||
**Decision**: Try GetAllFilesInPath first (always). If it returns ≥1 file, treat as directory. If it returns 0 files AND no error, try GetFileContent. If GetAllFilesInPath returns an error, try GetFileContent.
|
|
||||||
|
|
||||||
2. **Budget section placement**: The issue says docs go in "system prompt after system-prompt-file content". That means docs are part of the system prompt. Current budget: SystemBase (includes additionalPrompt) → Patterns → Conventions. I'll add DesignDocs after Conventions (trim after Conventions). Docs are injected into system prompt via `buildResult`.
|
|
||||||
**Decision**: DesignDocs section in budget, trimmed after Conventions, before FileContext.
|
|
||||||
|
|
||||||
3. **Configurable size limit**: The issue says "configurable". Add `--doc-map-max-bytes` flag (default 102400). Pass via `DocMapOptions`.
|
|
||||||
**Decision**: Add flag. Default 100KB (102400 bytes).
|
|
||||||
|
|
||||||
## Completion Checklist
|
|
||||||
|
|
||||||
1. `doc-map` input added to action.yml with correct env var passthrough
|
|
||||||
2. `--doc-map` and `--doc-map-max-bytes` flags parsed in main.go
|
|
||||||
3. `doc-map` file validated with `validateWorkspacePath` before reading
|
|
||||||
4. YAML parsed with `go-yaml`, unknown keys warned not errored
|
|
||||||
5. Glob matching handles `**` segments
|
|
||||||
6. Changed files list from PR drives intersection (not hardcoded)
|
|
||||||
7. Docs deduplicated before fetching
|
|
||||||
8. Missing doc files: warn + skip, not fatal
|
|
||||||
9. Context size guard truncates with notice, logs warning
|
|
||||||
10. `DesignDocs` section added to `budget.Sections` and `buildResult`
|
|
||||||
11. Tests cover: match, no-match, dedup, missing file, directory expansion, size guard, YAML parse error
|
|
||||||
12. `go test ./...` passes
|
|
||||||
13. `go vet ./...` passes
|
|
||||||
14. CHANGELOG updated
|
|
||||||
@@ -200,3 +200,72 @@ func TestFit_NeverExceedsLimit(t *testing.T) {
|
|||||||
t.Errorf("EstTokens %d exceeds limit %d (trimmed: %v)", result.EstTokens, limit, result.Trimmed)
|
t.Errorf("EstTokens %d exceeds limit %d (trimmed: %v)", result.EstTokens, limit, result.Trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFit_DesignDocsInSystemPrompt verifies that DesignDocs content appears in the
|
||||||
|
// system prompt under the expected heading.
|
||||||
|
func TestFit_DesignDocsInSystemPrompt(t *testing.T) {
|
||||||
|
s := Sections{
|
||||||
|
SystemBase: "base instructions",
|
||||||
|
DesignDocs: "# Foo Design\n\nSome design content.",
|
||||||
|
Diff: "diff content",
|
||||||
|
UserMeta: "PR meta",
|
||||||
|
}
|
||||||
|
result := Fit("gpt-4.1", s)
|
||||||
|
|
||||||
|
if !strings.Contains(result.SystemPrompt, "## Design Documents") {
|
||||||
|
t.Errorf("expected ## Design Documents heading in system prompt, got:\n%s", result.SystemPrompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.SystemPrompt, "# Foo Design") {
|
||||||
|
t.Errorf("expected design doc content in system prompt, got:\n%s", result.SystemPrompt)
|
||||||
|
}
|
||||||
|
// Sanity: design docs should NOT appear in user prompt.
|
||||||
|
if strings.Contains(result.UserPrompt, "## Design Documents") {
|
||||||
|
t.Errorf("design docs heading should not be in user prompt, got:\n%s", result.UserPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFit_DesignDocsTrimmedBeforeFileContext verifies trim ordering:
|
||||||
|
// DesignDocs is trimmed (third) before FileContext (fourth), after Conventions.
|
||||||
|
func TestFit_DesignDocsTrimmedBeforeFileContext(t *testing.T) {
|
||||||
|
// Fill budget so design docs and file context can't both fit.
|
||||||
|
// gpt-4.1 limit = 128_000 - 4_000 = 124_000 tokens.
|
||||||
|
// SystemBase = 480_000 bytes ≈ 120_000 tokens → leaves ~4_000 tokens.
|
||||||
|
// Diff = 8_000 bytes ≈ 2_000 tokens.
|
||||||
|
// DesignDocs = 20_000 bytes ≈ 5_000 tokens → exceeds remaining 2_000.
|
||||||
|
// Expected: DesignDocs trimmed; FileContext (very small) survives.
|
||||||
|
s := Sections{
|
||||||
|
SystemBase: strings.Repeat("s", 480_000),
|
||||||
|
DesignDocs: strings.Repeat("d", 20_000),
|
||||||
|
FileContext: "important_file_context",
|
||||||
|
Diff: strings.Repeat("x", 8_000),
|
||||||
|
UserMeta: "PR meta",
|
||||||
|
}
|
||||||
|
result := Fit("gpt-4.1", s)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, item := range result.Trimmed {
|
||||||
|
if strings.HasPrefix(item, "design docs") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected 'design docs' in trimmed list, got: %v", result.Trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFit_DesignDocsEmptyNoHeading verifies that an empty DesignDocs field
|
||||||
|
// does not inject the ## Design Documents heading into the system prompt.
|
||||||
|
func TestFit_DesignDocsEmptyNoHeading(t *testing.T) {
|
||||||
|
s := Sections{
|
||||||
|
SystemBase: "base",
|
||||||
|
DesignDocs: "",
|
||||||
|
Diff: "diff",
|
||||||
|
UserMeta: "meta",
|
||||||
|
}
|
||||||
|
result := Fit("gpt-4.1", s)
|
||||||
|
|
||||||
|
if strings.Contains(result.SystemPrompt, "## Design Documents") {
|
||||||
|
t.Errorf("empty DesignDocs should not inject heading, got:\n%s", result.SystemPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1383,3 +1383,126 @@ func TestFetchPatterns_MultipleRepos(t *testing.T) {
|
|||||||
t.Errorf("expected Elixir pipes content, got: %q", got)
|
t.Errorf("expected Elixir pipes content, got: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_MissingLLMBaseURL confirms that --llm-base-url is required
|
||||||
|
// when provider=openai (the default).
|
||||||
|
func TestMainSubprocess_MissingLLMBaseURL(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-model", "gpt-4",
|
||||||
|
// --llm-base-url and --llm-api-key intentionally omitted
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_MissingLLMBaseURL")
|
||||||
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit when llm-base-url is missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "llm-base-url") {
|
||||||
|
t.Errorf("expected error mentioning llm-base-url, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_MissingAICoreCredentials confirms that aicore-specific credentials
|
||||||
|
// are required when provider=aicore.
|
||||||
|
func TestMainSubprocess_MissingAICoreCredentials(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-model", "gpt-4",
|
||||||
|
"--llm-provider", "aicore",
|
||||||
|
// aicore-client-id, aicore-client-secret, aicore-auth-url, aicore-api-url omitted
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_MissingAICoreCredentials")
|
||||||
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit when aicore credentials are missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "AI Core credentials") {
|
||||||
|
t.Errorf("expected error about AI Core credentials, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_ConflictingPersonaFlags confirms that --persona and --persona-file
|
||||||
|
// cannot be used together.
|
||||||
|
func TestMainSubprocess_ConflictingPersonaFlags(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",
|
||||||
|
"--persona", "security",
|
||||||
|
"--persona-file", "custom.json",
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_ConflictingPersonaFlags")
|
||||||
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit with both --persona and --persona-file set")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "mutually exclusive") {
|
||||||
|
t.Errorf("expected error about mutually exclusive flags, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainSubprocess_DeprecatedGiteaURLEnv confirms that GITEA_URL env var still works
|
||||||
|
// as a deprecated fallback for VCS_URL.
|
||||||
|
func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
|
||||||
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
// Set required flags but omit --vcs-url; GITEA_URL should be picked up.
|
||||||
|
// The test will exit with an error after VCS init (no PR to fetch), but
|
||||||
|
// the deprecation warning must appear.
|
||||||
|
os.Args = []string{"review-bot",
|
||||||
|
// No --vcs-url: should fall back to GITEA_URL env var
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "https://api.example.com",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "gpt-4",
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_DeprecatedGiteaURLEnv")
|
||||||
|
// Inject GITEA_URL but NOT VCS_URL.
|
||||||
|
env := append(cleanEnv(),
|
||||||
|
"TEST_SUBPROCESS_MAIN=1",
|
||||||
|
"GITEA_URL=https://gitea.example.com",
|
||||||
|
)
|
||||||
|
cmd.Env = env
|
||||||
|
out, _ := cmd.CombinedOutput()
|
||||||
|
// The process will fail (no real server), but the deprecation warning must appear.
|
||||||
|
if !strings.Contains(string(out), "deprecated") {
|
||||||
|
t.Errorf("expected deprecation warning for GITEA_URL, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,3 +125,60 @@ func TestRunValidateURL_WithCapture(t *testing.T) {
|
|||||||
t.Errorf("expected error about https in stderr, got %q", errBuf.String())
|
t.Errorf("expected error about https in stderr, got %q", errBuf.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsValidateError_Nil confirms that isValidateError returns false for a nil error.
|
||||||
|
func TestIsValidateError_Nil(t *testing.T) {
|
||||||
|
var ve *validateError
|
||||||
|
if isValidateError(nil, &ve) {
|
||||||
|
t.Error("isValidateError(nil, ...) should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateURL_EmptyHost confirms that a URL with no hostname returns a code-2 error.
|
||||||
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
|
// "https://" parses fine but has no hostname.
|
||||||
|
err := validateURL("https://")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for URL with no host, got nil")
|
||||||
|
}
|
||||||
|
var ve *validateError
|
||||||
|
if !isValidateError(err, &ve) {
|
||||||
|
t.Fatalf("expected *validateError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.code != 2 {
|
||||||
|
t.Errorf("expected code 2, got %d (msg=%s)", ve.code, ve.message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ve.message, "no host") {
|
||||||
|
t.Errorf("expected 'no host' in error message, got %q", ve.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunValidateURL_Success confirms that a resolvable public URL prints "OK" and returns 0.
|
||||||
|
// This test requires external DNS; it is skipped in environments without network access.
|
||||||
|
func TestRunValidateURL_Success(t *testing.T) {
|
||||||
|
// Pre-check: validate that DNS is available before exercising the success path.
|
||||||
|
err := validateURL("https://example.com/")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping success-path test: DNS unavailable or example.com blocked (%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
origOut, origErr := outWriter, errWriter
|
||||||
|
outWriter = &outBuf
|
||||||
|
errWriter = &errBuf
|
||||||
|
defer func() {
|
||||||
|
outWriter = origOut
|
||||||
|
errWriter = origErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
code := runValidateURL([]string{"https://example.com/"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected exit code 0 for safe URL, got %d (stderr: %s)", code, errBuf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(outBuf.String(), "OK:") {
|
||||||
|
t.Errorf("expected 'OK:' in stdout, got %q", outBuf.String())
|
||||||
|
}
|
||||||
|
if errBuf.Len() != 0 {
|
||||||
|
t.Errorf("expected no stderr for safe URL, got %q", errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 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 |
|
||||||
Reference in New Issue
Block a user