Compare commits
3 Commits
04b24256c0
...
7cdba14181
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cdba14181 | |||
| 69da5df254 | |||
| 93268869c5 |
@@ -296,6 +296,40 @@ review-bot \
|
||||
--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
|
||||
|
||||
All flags have environment variable equivalents:
|
||||
|
||||
@@ -64,6 +64,8 @@ func main() {
|
||||
switch os.Args[1] {
|
||||
case "validate-url":
|
||||
os.Exit(runValidateURL(os.Args[2:]))
|
||||
case "validate-docmap":
|
||||
os.Exit(runValidateDocmap(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.weiker.me/rodin/review-bot/review"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Parse docmap YAML.
|
||||
cfg, err := review.ParseDocMapConfig(*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 ---
|
||||
var uncovered []string
|
||||
for _, f := range changedFiles {
|
||||
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 ---
|
||||
repoRoot := filepath.Clean(*repoRootFlag)
|
||||
staleDocs := checkStaleDocs(cfg, repoRoot)
|
||||
if len(staleDocs) > 0 {
|
||||
failed = true
|
||||
fmt.Fprintln(errWriter, "ERROR: stale docmap docs: 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.
|
||||
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{}{}
|
||||
|
||||
fullPath := filepath.Join(repoRoot, filepath.FromSlash(docPath))
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
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,296 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeDocmapYAML writes a YAML string to a temp file and returns its path.
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 := makeDocmapYAML(t, `
|
||||
mappings:
|
||||
- paths:
|
||||
- "lib/foo/**"
|
||||
docs:
|
||||
- docs/foo.md
|
||||
`)
|
||||
|
||||
var code int
|
||||
stdout, _ := captureOutput(func() {
|
||||
code = runValidateDocmap([]string{
|
||||
"--docmap", docmap,
|
||||
"--repo-root", dir,
|
||||
})
|
||||
})
|
||||
// Provide stdin via the global os.Stdin would require process tricks.
|
||||
// The implementation reads os.Stdin directly; for tests we need a different approach.
|
||||
// See TestRunValidateDocmap_WithStdin_* below which use a helper that replaces os.Stdin.
|
||||
// This case: empty stdin (no changed files) → clean.
|
||||
if code != 0 {
|
||||
t.Errorf("expected exit 0 for clean (empty stdin), 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) {
|
||||
docmap := makeDocmapYAML(t, "mappings: [{{invalid")
|
||||
var code int
|
||||
_, stderr := captureOutput(func() {
|
||||
code = runValidateDocmap([]string{"--docmap", docmap})
|
||||
})
|
||||
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 := makeDocmapYAML(t, `
|
||||
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.
|
||||
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 := makeDocmapYAML(t, `
|
||||
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 := makeDocmapYAML(t, `
|
||||
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 := makeDocmapYAML(t, `
|
||||
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 := makeDocmapYAML(t, `
|
||||
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 := makeDocmapYAML(t, `
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,18 @@ func ParseDocMapConfig(localPath string) (*DocMapConfig, error) {
|
||||
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.
|
||||
// A mapping matches if any of its path globs matches any of the changed files.
|
||||
func MatchDocs(cfg *DocMapConfig, changedFiles []string) []string {
|
||||
|
||||
@@ -436,3 +436,52 @@ func writeTempYAML(t *testing.T, content string) string {
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user