chore(cleanup): delete docs-reviewer infrastructure (#210)
* chore(cleanup): remove docs-reviewer agent and post-docs-review tool * chore(cleanup): remove scaffold-handoff, adr-refs check, CI and tests * chore(cleanup): remove docs/project-map directory * chore(cleanup): drop docs-reviewer entry and post_docs_review keys from opencode.json * chore(cleanup): drop docs-reviewer refs from tests, skills and shared tools comment --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
1d35c5ed1c
commit
b2329210d5
14 changed files with 15 additions and 1504 deletions
33
.github/workflows/adr-check.yml
vendored
33
.github/workflows/adr-check.yml
vendored
|
|
@ -1,33 +0,0 @@
|
|||
name: ADR References Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.opencode/**'
|
||||
- '**/*.md'
|
||||
- '.github/workflows/adr-check.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: check
|
||||
run: |
|
||||
if [ -f .opencode/scripts/check-adr-refs.py ]; then
|
||||
echo "has_script=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_script=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- uses: actions/setup-python@v5
|
||||
if: steps.check.outputs.has_script == 'true'
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: python3 .opencode/scripts/check-adr-refs.py
|
||||
if: steps.check.outputs.has_script == 'true'
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
---
|
||||
description: Reviews and updates project map documentation before code review. Auto-commits updates to PR branch. Posts verdict via post-docs-review tool (deterministic heading for pipeline-status.py).
|
||||
mode: subagent
|
||||
temperature: 0.1
|
||||
steps: 150
|
||||
permission:
|
||||
edit: allow
|
||||
doom_loop: deny
|
||||
bash:
|
||||
"*": deny
|
||||
"git fetch*": allow
|
||||
"git diff*": allow
|
||||
"git log*": allow
|
||||
"git status*": allow
|
||||
"git show*": allow
|
||||
"git blame*": allow
|
||||
"git remote -v*": allow
|
||||
"git remote show*": allow
|
||||
"git add docs/project-map*": allow
|
||||
"git add docs/handoff*": allow
|
||||
"git add docs/decisions*": allow
|
||||
"git push*": allow
|
||||
"rg *": allow
|
||||
"find *": allow
|
||||
"ls *": allow
|
||||
"cat *": allow
|
||||
"head*": allow
|
||||
"tail*": allow
|
||||
"wc*": allow
|
||||
"diff*": allow
|
||||
"repomix*": allow
|
||||
"gh pr view*": allow
|
||||
"gh pr diff*": allow
|
||||
"gh pr checkout*": allow
|
||||
"gh pr comment*": allow
|
||||
"gh pr comment *": allow
|
||||
"gh issue view*": allow
|
||||
"gh issue list*": allow
|
||||
"git rm docs/handoff*": allow
|
||||
"git rm docs/decisions*": allow
|
||||
"git rm -r docs/spec*": allow
|
||||
"git rm --cached docs/handoff*": allow
|
||||
"git rm --cached docs/decisions*": allow
|
||||
"git mv docs/handoff*": allow
|
||||
"git mv docs/decisions*": allow
|
||||
"git checkout docs/handoff*": allow
|
||||
"git checkout docs/decisions*": allow
|
||||
"git -C * status*": allow
|
||||
"git -C * diff*": allow
|
||||
"git -C * log*": allow
|
||||
"git -C * show*": allow
|
||||
"git -C * branch*": allow
|
||||
"git -C * fetch*": allow
|
||||
"git -C * remote -v*": allow
|
||||
"git -C * remote show*": allow
|
||||
"git branch*": allow
|
||||
"pwd": allow
|
||||
"echo *": allow
|
||||
"python3*": allow
|
||||
"python3 *pipeline-status.py*": deny
|
||||
"python3 .opencode/scripts/pipeline-status.py*": deny
|
||||
"python3 */pipeline-status.py*": deny
|
||||
"python *pipeline-status.py*": deny
|
||||
"python */pipeline-status.py*": deny
|
||||
"python3 *spec-status.py*": deny
|
||||
"python3 .opencode/scripts/spec-status.py*": deny
|
||||
"python3 */spec-status.py*": deny
|
||||
"python *spec-status.py*": deny
|
||||
"python */spec-status.py*": deny
|
||||
"mkdir*": allow
|
||||
"gh pr merge*": deny
|
||||
---
|
||||
|
||||
You are a project map reviewer. Your job: analyze structural changes in a PR, update the project map documentation in `docs/project-map/`, and commit updates to the PR branch.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run `gh pr view <PR_NUMBER> --json headRefName,title,body` to get branch name and PR context.
|
||||
2. Run `gh pr checkout <PR_NUMBER>` to switch to the PR branch.
|
||||
3. Run `git fetch origin` to ensure you have the latest default branch.
|
||||
4. Run `git diff origin/HEAD...HEAD --stat` to see what files changed.
|
||||
5. Run `repomix --no-files --stdout` to get the current directory tree.
|
||||
6. Check if `docs/project-map/` exists:
|
||||
- Run `ls docs/project-map/ 2>/dev/null`
|
||||
- If it doesn't exist, create the directory and an initial `README.md`.
|
||||
|
||||
## Analysis
|
||||
|
||||
1. Compare the `git diff --stat` output with the current `docs/project-map/` files.
|
||||
2. Determine if structural changes occurred:
|
||||
- New files or directories added
|
||||
- Files or directories deleted
|
||||
- Files or directories renamed
|
||||
- New top-level modules
|
||||
3. If NO structural changes (only content edits, bug fixes, refactoring within existing files) → skip project-map update, but STILL leave PR comment per "PR Comment (mandatory)" section below.
|
||||
4. If structural changes occurred → proceed to update.
|
||||
|
||||
## Handoff & ADR Validation
|
||||
|
||||
After updating project map, validate handoff and ADR files:
|
||||
|
||||
### Handoff (`docs/handoff/pr-<PR_NUMBER>-<slug>.md`)
|
||||
1. Check if file exists. If not → create it from PR diff.
|
||||
2. Check all sections are present: Что сделано, Почему, Pending, Watch out.
|
||||
3. Check content is meaningful (not empty placeholders).
|
||||
4. If sections missing or empty → **fix them** based on PR diff and issue context.
|
||||
|
||||
### ADR (`docs/decisions/<NN>-<title>.md`) — mandatory per ADR-002
|
||||
|
||||
1. ADR is **mandatory** in every PR (per ADR-002). Never bypass.
|
||||
2. If ADR file `docs/decisions/*-pr-<PR#>-*.md` does not exist → create it via `bash .opencode/scripts/scaffold-handoff.sh <PR#> <slug>` (creates both handoff + ADR templates).
|
||||
3. Check ADR sections: Статус, Контекст, Решение, Альтернативы.
|
||||
4. If sections incomplete (empty placeholders like `<заполни>`) → **fix them** based on PR diff.
|
||||
5. If PR has NO architectural decisions → fill all sections (Контекст/Решение/Альтернативы) with `—` (em-dash). This is valid per ADR-002.
|
||||
6. NEVER bypass ADR creation. Pipeline-status.py will fail DOCS phase if ADR is missing.
|
||||
|
||||
## Spec cleanup (post-merge, опционально)
|
||||
|
||||
**GUARD: НЕ выполнять spec cleanup pre-merge — это post-merge операция.**
|
||||
Spec cleanup выполняется ТОЛЬКО post-merge (в MEMORY фазе), НЕ pre-merge.
|
||||
Если ты docs-reviewer (pre-merge), пропусти эту секцию — spec cleanup
|
||||
выполняет memory-syncer post-merge.
|
||||
|
||||
Если `docs/spec/roadmap.md` существует в репо (spec был запущен):
|
||||
1. Извлеки все `#N` номера issues из `docs/spec/roadmap.md` (regex `#(\d+)`).
|
||||
2. Для каждого `#N`: `gh issue view N --json state --jq .state`.
|
||||
3. Если ВСЕ issues имеют `state=CLOSED`:
|
||||
- `git rm -r docs/spec/` (удаляет всю директорию spec-документации).
|
||||
- Коммит через `commit` tool: `commit({ message: "chore: remove completed spec" })`.
|
||||
- PR comment: добавить секцию `## Spec Cleanup` в docs-review summary: "Spec removed: all N issues from roadmap.md are CLOSED".
|
||||
4. Если хотя бы один issue OPEN → пропусти cleanup (spec ещё жив). PR comment: "Spec retained: M/N issues still OPEN".
|
||||
|
||||
Проверка выполняется ПОСЛЕ валидации handoff/ADR и ДО `git add docs/project-map/ docs/handoff/ docs/decisions/`.
|
||||
|
||||
Используется `git rm -r docs/spec/` (НЕ `rm -rf docs/spec/`) — `rm -rf` блокируется `check-permissions.py` (DANGEROUS_PATTERNS, scope=all). `git rm -r` семантически эквивалентен и соответствует существующим паттернам `git rm docs/handoff*` / `git rm docs/decisions*`.
|
||||
|
||||
### Commit scope
|
||||
When committing, stage docs first, then use the `commit` tool (raw `git commit` is globally denied — use the tool which bypasses via spawnSync):
|
||||
```bash
|
||||
git add docs/project-map/ docs/handoff/ docs/decisions/
|
||||
```
|
||||
```
|
||||
commit({ message: "docs: update project map + handoff + ADR" })
|
||||
```
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
## Update Rules
|
||||
|
||||
### What to include in project map:
|
||||
- Directory structure (tree of each module)
|
||||
- Purpose of each module/directory
|
||||
- Key files and their roles
|
||||
- Dependencies between modules
|
||||
|
||||
### What NOT to include:
|
||||
- Implementation details
|
||||
- API signatures
|
||||
- Internal logic
|
||||
- Line-by-line documentation
|
||||
|
||||
### File structure:
|
||||
- `docs/project-map/README.md` — index, overall project structure, module list
|
||||
- `docs/project-map/<module>.md` — one file per top-level module/directory
|
||||
|
||||
### MD file template:
|
||||
```markdown
|
||||
---
|
||||
module: <module-path>
|
||||
purpose: <one-line description>
|
||||
key_files:
|
||||
- <path> — <role>
|
||||
- <path> — <role>
|
||||
dependencies: [<list of module dependencies>]
|
||||
last_updated: <YYYY-MM-DD>
|
||||
---
|
||||
|
||||
# <module name>
|
||||
|
||||
## Structure
|
||||
- `<file>` — <description>
|
||||
- `<file>` — <description>
|
||||
|
||||
## Patterns
|
||||
- <pattern or convention used>
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
1. Stage only project map files:
|
||||
```bash
|
||||
git add docs/project-map/ docs/handoff/ docs/decisions/
|
||||
```
|
||||
2. Commit via `commit` tool (raw `git commit` is globally denied — the tool bypasses via spawnSync):
|
||||
```
|
||||
commit({ message: "docs(project-map): update after structural changes" })
|
||||
```
|
||||
3. Push:
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
## PR Comment (mandatory)
|
||||
|
||||
After validation (regardless of whether structural changes occurred), **ALWAYS** leave a PR comment using the `post-docs-review` tool. The tool auto-generates the `## Docs Review Summary` heading and the `### Verdict: <verdict>` line — you only pass the body content (between heading and verdict). Do NOT manually format the heading or verdict. This is the deterministic marker that `check_docs` in pipeline-status.py uses to prove docs-reviewer ran. Without this comment, the pipeline is blocked at DOCS phase.
|
||||
|
||||
Body format (without heading — tool adds `## Docs Review Summary` and `### Verdict: <verdict>`):
|
||||
```
|
||||
- Project map: <updated|no structural changes|created>
|
||||
- Handoff: <valid|fixed: ...|missing: ...>
|
||||
- ADR: <valid|fixed: ...|missing: ...>
|
||||
|
||||
## Spec Cleanup
|
||||
- Spec: <removed: all N issues from roadmap.md are CLOSED|retained: M/N issues still OPEN|n/a: docs/spec/roadmap.md does not exist>
|
||||
```
|
||||
|
||||
Call:
|
||||
```
|
||||
post-docs-review({ pr_number: <PR_NUMBER>, verdict: "<APPROVE|FIXED|NO_CHANGES>", body: `<body text above>` })
|
||||
```
|
||||
|
||||
Verdict semantics:
|
||||
- `APPROVE` — docs valid, no fixes required
|
||||
- `FIXED` — docs-reviewer fixed something (project map, handoff, ADR, or spec cleanup performed)
|
||||
- `NO_CHANGES` — no structural changes, handoff+ADR already valid, spec retained or absent (no commit, no edits)
|
||||
|
||||
Rules:
|
||||
1. Comment is left AFTER commit+push (if any) — so reviewer can see final state.
|
||||
2. If no structural changes AND handoff+ADR valid → no commit, but comment IS still left with `Verdict: NO_CHANGES`.
|
||||
3. The comment heading `## Docs Review Summary` is guaranteed by the `post-docs-review` tool — `check_docs` matches regex `Docs Review` (case-insensitive).
|
||||
4. Never skip the comment, even on edge cases — use `Verdict: NO_CHANGES` instead of silence.
|
||||
|
||||
## Tool failure handling
|
||||
|
||||
If `post-docs-review` returns a string starting with `⚠️ ...failed` (e.g. `⚠️ post-docs-review failed for PR #N (exit 1): ...`):
|
||||
- **СООБЩИ оркестратору о сбое tool и STOP.** Не продолжай молча, не пытайся fallback на raw `gh pr comment` через bash.
|
||||
- Причина сбоя обычно: gh не аутентифицирован, PR не найден в текущем репо (cwd не git-репо или нет origin remote), или network error.
|
||||
- Возвращай текст вида: `⚠️ post-docs-review tool failed: <сообщение от tool>. Pipeline заблокирован на DOCS phase — требуется вмешательство.`
|
||||
- Любой дальнейший tool call после сбоя = protocol violation.
|
||||
|
||||
## Rules
|
||||
|
||||
1. ALWAYS checkout the PR branch first.
|
||||
2. ONLY edit files in `docs/project-map/`, `docs/handoff/`, `docs/decisions/`.
|
||||
3. ONLY `git add docs/project-map/ docs/handoff/ docs/decisions/` — never stage other files.
|
||||
4. If no structural changes → do not commit, but STILL leave PR comment (see "PR Comment (mandatory)" section).
|
||||
5. Keep map files concise — structure and purpose, not implementation.
|
||||
6. Update `last_updated` field in frontmatter when modifying a file.
|
||||
7. If `docs/project-map/` doesn't exist → create initial map with `README.md` and one file per top-level module.
|
||||
8. Для debug-вывода используй `pwd`/`ls`/`cat`/`echo` (все в allow-list).
|
||||
9. НЕ переключайся на master и НЕ делай `git pull` — работай только на PR branch (checkout уже сделан в Setup).
|
||||
|
||||
## Bug Discovery
|
||||
|
||||
If you find a bug outside the current PR/task scope — you MUST load skill `bug-discovery` via `skill("bug-discovery")` tool and follow its protocol. Do NOT fix the bug yourself. Report to orchestrator: "Created issue #N: ...".
|
||||
|
|
@ -287,8 +287,7 @@
|
|||
"create_pr": true,
|
||||
"create_issue": true,
|
||||
"merge_pr": false,
|
||||
"post_review": false,
|
||||
"post_docs_review": false
|
||||
"post_review": false
|
||||
}
|
||||
},
|
||||
"reviewer": {
|
||||
|
|
@ -297,18 +296,7 @@
|
|||
"create_pr": false,
|
||||
"create_issue": true,
|
||||
"merge_pr": false,
|
||||
"post_review": true,
|
||||
"post_docs_review": false
|
||||
}
|
||||
},
|
||||
"docs-reviewer": {
|
||||
"tools": {
|
||||
"commit": true,
|
||||
"create_pr": false,
|
||||
"create_issue": true,
|
||||
"merge_pr": false,
|
||||
"post_review": false,
|
||||
"post_docs_review": true
|
||||
"post_review": true
|
||||
}
|
||||
},
|
||||
"memory-syncer": {
|
||||
|
|
@ -317,8 +305,7 @@
|
|||
"create_pr": false,
|
||||
"create_issue": true,
|
||||
"merge_pr": false,
|
||||
"post_review": false,
|
||||
"post_docs_review": false
|
||||
"post_review": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check ADR references in markdown files for dangling pointers.
|
||||
|
||||
Scans ``.md`` files in the repo for ``ADR-NNN`` references and verifies
|
||||
that a corresponding ``docs/decisions/NNN-*.md`` file exists. Dangling
|
||||
references (typos, forward-refs) fail CI — symmetric to
|
||||
``check-permissions.py`` (ADR-006 pattern).
|
||||
|
||||
Self-reference is excluded: if the current file's name starts with
|
||||
``NNN-``, a reference to ``ADR-NNN`` inside it is OK (an ADR file may
|
||||
mention its own number).
|
||||
|
||||
What is NOT caught: wrong existing refs (``ADR-017`` exists but is
|
||||
semantically wrong for a given PR) — that requires semantic analysis.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
ADR_DIR = REPO_ROOT / "docs" / "decisions"
|
||||
ADR_REF_RE = re.compile(r"\bADR-(\d{3})\b")
|
||||
|
||||
EXCLUDE_DIRS = {"node_modules", ".git", "app_data", ".opencode"}
|
||||
|
||||
|
||||
def _is_excluded(path: Path) -> bool:
|
||||
"""True if ``path`` is inside an excluded directory (node_modules, .git, ...)."""
|
||||
try:
|
||||
rel = path.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
return True
|
||||
parts = rel.parts
|
||||
return any(excl in parts for excl in EXCLUDE_DIRS)
|
||||
|
||||
|
||||
def find_md_files() -> list[Path]:
|
||||
"""Return all ``.md`` files under REPO_ROOT, excluding node_modules/.git/etc."""
|
||||
results: list[Path] = []
|
||||
for md_file in REPO_ROOT.rglob("*.md"):
|
||||
if _is_excluded(md_file):
|
||||
continue
|
||||
results.append(md_file)
|
||||
return sorted(results)
|
||||
|
||||
|
||||
def find_adr_refs(md_file: Path) -> list[tuple[int, str]]:
|
||||
"""Return ``[(line_number, adr_number), ...]`` for every ``ADR-NNN`` in ``md_file``."""
|
||||
refs: list[tuple[int, str]] = []
|
||||
text = md_file.read_text(encoding="utf-8", errors="replace")
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
for match in ADR_REF_RE.finditer(line):
|
||||
refs.append((line_no, match.group(1)))
|
||||
return refs
|
||||
|
||||
|
||||
def is_self_reference(md_file: Path, adr_number: str) -> bool:
|
||||
"""True if ``md_file``'s name starts with ``NNN-`` where NNN == adr_number."""
|
||||
return md_file.name.startswith(f"{adr_number}-")
|
||||
|
||||
|
||||
def adr_exists(adr_number: str) -> bool:
|
||||
"""True if ``docs/decisions/NNN-*.md`` exists for the given number."""
|
||||
if not ADR_DIR.exists():
|
||||
return False
|
||||
return any(ADR_DIR.glob(f"{adr_number}-*.md"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
all_violations: list[str] = []
|
||||
for md_file in find_md_files():
|
||||
refs = find_adr_refs(md_file)
|
||||
for ref_line, ref_number in refs:
|
||||
if is_self_reference(md_file, ref_number):
|
||||
continue
|
||||
if not adr_exists(ref_number):
|
||||
all_violations.append(
|
||||
f" {md_file.relative_to(REPO_ROOT)}:{ref_line}: "
|
||||
f"ADR-{ref_number} reference, but "
|
||||
f"docs/decisions/{ref_number}-*.md does not exist"
|
||||
)
|
||||
|
||||
if not all_violations:
|
||||
print("OK: No dangling ADR references.")
|
||||
sys.exit(0)
|
||||
|
||||
print("FAIL: Dangling ADR references:\n")
|
||||
for v in all_violations:
|
||||
print(v)
|
||||
print(f"\nTotal: {len(all_violations)} violation(s)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Create handoff + ADR templates for a PR.
|
||||
# Usage: bash .opencode/scripts/scaffold-handoff.sh <PR#> <slug>
|
||||
# Idempotent: does not overwrite existing files.
|
||||
set -euo pipefail
|
||||
|
||||
PR="${1:?Usage: scaffold-handoff.sh <PR#> <slug>}"
|
||||
SLUG="${2:?Usage: scaffold-handoff.sh <PR#> <slug>}"
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
fi
|
||||
|
||||
HANDOFF_DIR="$REPO_ROOT/docs/handoff"
|
||||
ADR_DIR="$REPO_ROOT/docs/decisions"
|
||||
mkdir -p "$HANDOFF_DIR" "$ADR_DIR"
|
||||
|
||||
HANDOFF="$HANDOFF_DIR/pr-${PR}-${SLUG}.md"
|
||||
TODAY="$(date +%Y-%m-%d)"
|
||||
|
||||
if [ -f "$HANDOFF" ]; then
|
||||
echo "handoff already exists: $HANDOFF"
|
||||
else
|
||||
cat > "$HANDOFF" <<EOF
|
||||
---
|
||||
pr: ${PR}
|
||||
title: <заполни>
|
||||
---
|
||||
|
||||
## Что сделано
|
||||
<заполни>
|
||||
|
||||
## Почему
|
||||
<заполни>
|
||||
|
||||
## Pending
|
||||
<заполни, или «—»>
|
||||
|
||||
## Watch out
|
||||
<заполни, или «—»>
|
||||
EOF
|
||||
echo "created: $HANDOFF"
|
||||
fi
|
||||
|
||||
EXISTING_ADR="$(ls "$ADR_DIR"/*-pr-${PR}-${SLUG}.md 2>/dev/null | head -n1 || true)"
|
||||
if [ -n "$EXISTING_ADR" ]; then
|
||||
ADR="$EXISTING_ADR"
|
||||
echo "ADR already exists: $ADR"
|
||||
else
|
||||
# Next ADR number = max(existing) + 1, NOT count + 1.
|
||||
# count+1 collides when files are added out of order or in parallel
|
||||
# (e.g. two PRs both computed wc -l = 72 → both wrote ADR-073, see issue #176).
|
||||
# max+1 is race-resistant: each run reads the current highest number.
|
||||
# Guard loop handles the residual race: if max+1 file already exists
|
||||
# (created between the max-read and our write), increment to a free slot.
|
||||
# 10# forces base-10 (leading zeros like 078 are invalid octal in bash).
|
||||
# `|| true` neutralizes grep's exit 1 under set -euo pipefail when the
|
||||
# directory is empty (no ADRs yet → max=0 → next=001).
|
||||
MAX_N="$(ls "$ADR_DIR" 2>/dev/null | grep -E '^[0-9]{3}-' | cut -c1-3 | sort -n | tail -n1 || true)"
|
||||
NEXT_N=$((10#${MAX_N:-0} + 1))
|
||||
while :; do
|
||||
NN="$(printf "%03d" "$NEXT_N")"
|
||||
ADR="$ADR_DIR/${NN}-pr-${PR}-${SLUG}.md"
|
||||
if ! ls "$ADR_DIR"/${NN}-pr-*.md >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
NEXT_N=$((NEXT_N + 1))
|
||||
done
|
||||
cat > "$ADR" <<EOF
|
||||
# ADR-${NN}: <title>
|
||||
|
||||
## Статус
|
||||
Accepted (${TODAY})
|
||||
|
||||
## Контекст
|
||||
<заполни, или «—» если архитектурных решений не было>
|
||||
|
||||
## Решение
|
||||
<заполни, или «—»>
|
||||
|
||||
## Альтернативы
|
||||
<заполни, или «—»>
|
||||
EOF
|
||||
echo "created: $ADR"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "handoff: $HANDOFF"
|
||||
echo "adr: $ADR"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: get-project-map
|
||||
description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also contains a template for maintaining docs/project-map/. Also when user says "структура проекта", "project map", "дерево файлов".
|
||||
description: Use when you need to view or update the current project folder/file structure (especially after creating/deleting files or switching branches), or understand package layout in the workspace. Also when user says "структура проекта", "project map", "дерево файлов".
|
||||
---
|
||||
|
||||
# Навык получения карты проекта (Project Map)
|
||||
|
|
@ -18,54 +18,6 @@ description: Use when you need to view or update the current project folder/file
|
|||
2. Изучи полученную структуру воркспейсов, чтобы точно знать расположение файлов и пакетов.
|
||||
3. Не сохраняй вывод в файлы на диск — читай его напрямую из вывода терминала.
|
||||
|
||||
## Персистентная карта проекта (docs/project-map/)
|
||||
|
||||
Помимо живого дерева через `repomix`, в репозитории может быть персистентная карта в `docs/project-map/`. Эта карта обновляется docs-reviewer агентом перед каждым code review.
|
||||
|
||||
### Структура
|
||||
- `docs/project-map/README.md` — индекс, общая структура, список модулей
|
||||
- `docs/project-map/<module>.md` — один файл на модуль/директорию верхнего уровня
|
||||
|
||||
### Шаблон MD-файла модуля
|
||||
|
||||
```markdown
|
||||
---
|
||||
module: <путь к модулю>
|
||||
purpose: <назначение в одну строку>
|
||||
key_files:
|
||||
- <путь> — <роль>
|
||||
- <путь> — <роль>
|
||||
dependencies: [<зависимости>]
|
||||
last_updated: <YYYY-MM-DD>
|
||||
---
|
||||
|
||||
# <имя модуля>
|
||||
|
||||
## Структура
|
||||
- `<файл>` — <описание>
|
||||
- `<файл>` — <описание>
|
||||
|
||||
## Паттерны
|
||||
- <используемые паттерны/конвенции>
|
||||
```
|
||||
|
||||
### Что включать
|
||||
- Структуру директорий (дерево модуля)
|
||||
- Назначение модуля/директории
|
||||
- Ключевые файлы и их роли
|
||||
- Зависимости между модулями
|
||||
|
||||
### Что НЕ включать
|
||||
- Implementation details
|
||||
- API signatures
|
||||
- Внутреннюю логику
|
||||
|
||||
### Когда обновлять
|
||||
- Добавлены новые файлы или директории
|
||||
- Удалены файлы или директории
|
||||
- Переименованы файлы или директории
|
||||
- Новые модули верхнего уровня
|
||||
|
||||
## Handoff файлы (docs/handoff/)
|
||||
|
||||
Контекст передаётся между сессиями через handoff-файлы — один файл на PR.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { spawnSync } from "child_process"
|
|||
* If `repo` is provided, returns `["--repo", repo]` (explicit target).
|
||||
* If omitted, returns `[]` so `gh` auto-detects the repo from its `cwd`
|
||||
* (set to `context.worktree` by `runGh`). Auto-detect is the default and
|
||||
* backward-compatible behaviour for all 5 GitHub tools (create-issue,
|
||||
* create-pr, post-review, post-docs-review, merge-pr) — see ADR-027.
|
||||
* backward-compatible behaviour for all 4 GitHub tools (create-issue,
|
||||
* create-pr, post-review, merge-pr) — see ADR-027.
|
||||
*/
|
||||
export function parseRepo(repo?: string): string[] {
|
||||
if (repo) return ["--repo", repo]
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import { tool } from "@opencode-ai/plugin"
|
||||
import { runGh } from "./_shared"
|
||||
|
||||
const VERDICTS = ["APPROVE", "FIXED", "NO_CHANGES"] as const
|
||||
type Verdict = typeof VERDICTS[number]
|
||||
|
||||
export default tool({
|
||||
description: "Post docs review verdict as PR comment with deterministic heading. Docs-reviewer agent uses this instead of raw `gh pr comment` to guarantee `## Docs Review Summary` heading that pipeline-status.py parses.",
|
||||
args: {
|
||||
pr_number: tool.schema.number().describe("PR number to comment on"),
|
||||
verdict: tool.schema.enum(VERDICTS).describe("Docs review verdict: APPROVE, FIXED, or NO_CHANGES"),
|
||||
body: tool.schema.string().describe("Docs review body text (without heading — heading is auto-generated)"),
|
||||
repo: tool.schema.string().optional().describe("Optional repo (owner/name). If omitted, gh auto-detects from context.worktree."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const comment = `## Docs Review Summary\n\n${args.body}\n\n### Verdict: ${args.verdict}`
|
||||
const r = runGh(["pr", "comment", String(args.pr_number), "--body", comment], args.repo, { cwd: context.worktree })
|
||||
if (r.status !== 0) {
|
||||
return `⚠️ post-docs-review failed for PR #${args.pr_number} (exit ${r.status}): ${r.stderr || r.stdout}`
|
||||
}
|
||||
return `Docs review posted on PR #${args.pr_number}: verdict=${args.verdict}`
|
||||
},
|
||||
})
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
# Project Map
|
||||
|
||||
opencode-config — Docker-based AI coding assistant with persistent memory (opencode configuration). Runs in Docker via `docker-compose.yml` (dind + opencode services).
|
||||
|
||||
Root `AGENTS.md` — orchestrator directive (chat = plan only, all via subagents) + global rules (pipeline, code style, language RU) + `## Tool Usage Policy` (таблица tools: commit/create-pr/create-issue/merge-pr/post-review/post-docs-review/pipeline-status/spec-status/memory-doctor/memory-save/memory-search/memory-list/memory-access/tunnel; raw bash заблокирован deny, при сбое tool — STOP, НЕ fallback; `memory-setup` REMOVED PR#103) + `## Bug Discovery Protocol` (агенты создают issues через `create-issue` tool для найденных багов вне scope) + `## Linear Execution` (строго линейный pipeline, параллельные research OK, параллельное исполнение ЗАПРЕЩЕНО) — PR#113. Auto-loaded for project + bind-mounted globally in container — PR#31, PR#63.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
opencode-config/
|
||||
├── .github/
|
||||
│ ├── workflows/
|
||||
│ │ ├── ci.yml # Lint, test, typecheck, complexity (bootstrap + output-based skip); apt-get install ripgrep + npm ci (.opencode) для keyword search — PR#124
|
||||
│ │ ├── permissions-check.yml # .opencode/scripts/check-permissions.py validator (step-level skip)
|
||||
│ │ └── adr-check.yml # ADR cross-reference validator (.opencode/scripts/check-adr-refs.py)
|
||||
│ └── dependabot.yml # pip + github-actions ecosystem updates
|
||||
├── .opencode/ # Project-local opencode config (auto-discovery, zero env var) — PR#23
|
||||
│ ├── agents/
|
||||
│ │ ├── docs-reviewer.md # Docs validation subagent (project map + handoff + ADR, uses `commit`+`post-docs-review`+`create_issue` tools; `gh issue list*` allow) — PR#40, PR#46, PR#69, PR#113
|
||||
│ │ ├── memory-syncer.md # Distills gotchas from handoffs into opencode-memory (`create_issue` tool; `gh issue list*` allow) — PR#69, PR#113
|
||||
│ │ └── reviewer.md # Code review subagent (verdict via `post-review` tool: APPROVE|REQUEST_CHANGES|NEEDS_DISCUSSION) — PR#46, PR#69
|
||||
│ ├── commands/
|
||||
│ │ ├── configure-opencode.md # /configure-opencode — edit opencode.json
|
||||
│ │ ├── cover.md # /cover — branded 1024×1024 cover generation (hardcoded Lime standard, agent: build, draw-image tool; no subtitle, extension via brand-logos) — PR#199
|
||||
│ │ ├── feature-spec.md # /feature-spec — SDD-style Q&A for feature planning (loads feature-spec skill) — PR#172
|
||||
│ │ ├── repo-readme.md # /repo-readme — standardized README generation (frontmatter agent: build, loads repo-readme skill) — PR#158
|
||||
│ │ ├── run-pipeline.md # /run-pipeline — 7-phase PR pipeline
|
||||
│ │ └── spec.md # /spec — 9-phase spec generation
|
||||
│ ├── skills/
|
||||
│ │ ├── add-skill/SKILL.md # Create new opencode skill
|
||||
│ │ ├── bug-discovery/SKILL.md # Bug Discovery Protocol — create issue for out-of-scope bugs (create-issue tool, 7 SDD sections, duplicate check) — PR#120, PR#169
|
||||
│ │ ├── branch/SKILL.md # Branch naming conventions
|
||||
│ │ ├── code-standards/SKILL.md # Universal code style rules
|
||||
│ │ ├── configure-opencode/SKILL.md # Canonical rule: write to .opencode/
|
||||
│ │ ├── feature-spec/SKILL.md # SDD-style Q&A for feature planning (7 SDD sections, decision criteria, handoff to issue skill) — PR#172
|
||||
│ │ ├── get-project-map/SKILL.md # Maintain docs/project-map/
|
||||
│ │ ├── issue/SKILL.md # GitHub issue creation (7 SDD sections template) — PR#169
|
||||
│ │ ├── memory/SKILL.md # opencode-memory usage guide
|
||||
│ │ ├── run-pipeline/SKILL.md # 7-phase pipeline orchestration
|
||||
│ │ ├── python-development/SKILL.md # Python dev patterns
|
||||
│ │ ├── release/SKILL.md # Tag + GitHub Release
|
||||
│ │ ├── repo-init/SKILL.md # New repository bootstrap
|
||||
│ │ ├── repo-readme/SKILL.md # Standardized README generation (create-readme tool: create vs validate, bilingual Why/What + Features table, GitHub metadata, quick_start_steps clickable steps + conditional bash block; tagline_en/tagline_ru required + delimiter-теги, breaking change note; workflow: create → draw-image (template "cover", slots icon/sub-icon/badge, brand-logos vs Lucide) → validate → fix-цикл) — PR#112, PR#116, PR#118, PR#130, PR#146, PR#151, PR#158 (cover pipeline в workflow)
|
||||
│ │ ├── tunnel/SKILL.md # Cloudflare tunnel toggle (tool `tunnel()`: 1-й вызов start, 2-й stop) — PR#63 (восстановлен, удалён в PR#42)
|
||||
│ │ ├── run-tests/SKILL.md # Test runner guide
|
||||
│ │ └── spec/SKILL.md # 9-phase spec generation (issue body template: 7 SDD sections) — PR#169
|
||||
│ ├── tools/
|
||||
│ │ ├── _shared.ts # Shared module for GitHub tools: parseRepo(repo?), runGh(args, repo?, opts?), formatResult(r, toolName) — PR#65
|
||||
│ │ ├── commit.ts # commit tool wrapper (1 arg message, validates format+staged) — PR#38
|
||||
│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates 7 SDD headings+format+labels; optional repo?: string) — PR#38, PR#65, PR#169
|
||||
│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65
|
||||
│ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with features table, include_clone/development_en/ru/quick_start_steps_en/ru optional params, clickable access_url [url](url), conditional bash block via hasBashBlock, RU heading 'Русский' + anchor checks, 6 delimiter pairs for slaid098.dev (summary-en/ru, features-en/ru, tagline-en/ru); tagline_en+tagline_ru required (BREAKING: tagline removed PR#151), content-валидация repo_name (lowercase kebab-case) + tagline_en (no Cyrillic) + tagline_ru (require Cyrillic), validateReadme H1 prefix check `# 🚀 `; generateReadme рендерит `` после H1 перед tagline-разделителями; validateReadme substring-чек `assets/cover.png` (без file existence — для remote gh api режима); local fs path resolved via path.resolve(context.worktree, file_path) — overwrite existing README; remote gh api) — PR#112, PR#116, PR#118, PR#130, PR#146, PR#149 (quick_start optional + guard), PR#151 (bilingual tagline + repo_name validation, breaking), PR#158 (cover image reference в template + validation), PR#175 (local file_path resolved against context.worktree — overwrite existing README)
|
||||
│ │ ├── draw-image.ts # draw-image tool wrapper (opencode plugin, 5 args: template/title/subtitle?/slots?/out?; spawnSync node cli.ts render → sharp PNG) — PR#133
|
||||
│ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65
|
||||
│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101
|
||||
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg {lines, rgWorks} G9, allowSystemFallback:true G5, arch-mismatch detection G2; npm-missing hint G3; markdown ✅/❌ report) — PR#101, PR#124
|
||||
│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101
|
||||
│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101
|
||||
│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback; stderr warning при rgBin===null) — PR#101, PR#124
|
||||
│ │ ├── pipeline-status.ts # pipeline-status tool wrapper
|
||||
│ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
│ │ ├── post-review.ts # post-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Code Review Summary heading; optional repo?: string) — PR#46, PR#65
|
||||
│ │ ├── spec-status.ts # spec-status tool wrapper
|
||||
│ │ ├── telegram-send.ts # telegram-send plugin-tool wrapper (spawnSync CLI .opencode/telegram/cli.ts; action: text|document|photo; MarkdownV2 default; error → ⚠️ telegram-send failed) — PR#174
|
||||
│ │ └── tunnel.ts # Cloudflare tunnel toggle tool (start/stop без args) — PR#34
|
||||
│ ├── draw-image/ # SVG template renderer for on-brand covers (sharp + lucide-static) — PR#133
|
||||
│ │ ├── package.json # deps: sharp, lucide-static; devDeps: vitest, typescript; postinstall sync-lucide
|
||||
│ │ ├── tsconfig.json # ES2022 Bundler, strict, noEmit
|
||||
│ │ ├── vitest.config.ts # node env, tests/**/*.test.ts
|
||||
│ │ ├── render.mjs # SVG string → PNG via sharp (fontFiles: Geist TTF bundle)
|
||||
│ │ ├── cli.ts # CLI entry: render <template> --title --slots --out [--template-dir dir]; temp SVG в os.tmpdir()/draw-image-${pid}.svg + finally rmSync (race-safe для параллельных рендеров) — PR#178, PR#197 (--template-dir)
|
||||
│ │ ├── brand.json # default palette slaid098 (base/surface/fg/muted/accent/line)
|
||||
│ │ ├── templates/cover.svg # 1024×1024 cover template (icon-слот 520×520 + sub-icon-слот 200×200 [восстановлен PR#201, был удалён PR#197] + {{title}} y=830; badge/subtitle удалены — PR#197, тесты на fixture)
|
||||
│ │ ├── fonts/ # Geist Sans TTF (Regular + Bold) bundled for sharp fontFiles
|
||||
│ │ ├── icons/lucide/ # 2007 Lucide SVG icons (synced from npm lucide-static via postinstall)
|
||||
│ │ ├── brand-logos/ # brand SVG logos (opencode.svg — адаптированный логотип OpenCode 512×512 fill #ccff00, резолвится через draw-image slot icon=opencode) — PR#158
|
||||
│ │ ├── scripts/sync-lucide.mjs # postinstall: copy icons from node_modules/lucide-static → icons/lucide/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── config.ts # loadBrand + validateBrand (zod-style hex validation)
|
||||
│ │ │ ├── slot-parser.ts # parseSlots: HTML comments → SlotSpec objects
|
||||
│ │ │ ├── fit.ts # computeFit (contain/cover/stretch) + parseViewBox
|
||||
│ │ │ ├── recolor.ts # recolorSvg (stroke/fill → target) + stripSvgWrapper + extractRootAttrs
|
||||
│ │ │ ├── resolve.ts # resolveSlotContent (lucide/brand-logo/file lookup) + renderSlotSvg
|
||||
│ │ │ └── render.ts # buildSvg (assemble final SVG) + computeHash (sha256 idempotency) + loadTemplate(drawImageDir, name, templateDir?) — опц. templateDir (дефолт "templates", относит./абсолют.) — PR#197
|
||||
│ │ └── tests/ # vitest: 58 tests (unit + integration + e2e) — PR#178 (+3 cleanup), PR#197 (9 тестов на fixture, отвязка от продакшн cover.svg)
|
||||
│ │ ├── fixtures/cover.svg # каноничный тестовый шаблон (все типы слотов: icon/sub-icon/badge/subtitle) — стабильный контракт для тестов, не зависит от продакшн templates/cover.svg — PR#197
|
||||
│ │ ├── helpers/fixtures.ts # loadFixture(name): читает из tests/fixtures/, экспортирует FIXTURES_DIR_ABS — PR#197
|
||||
│ │ ├── config.test.ts
|
||||
│ │ ├── slot-parser.test.ts
|
||||
│ │ ├── fit.test.ts
|
||||
│ │ ├── recolor.test.ts
|
||||
│ │ ├── cli.test.ts
|
||||
│ │ ├── render.integration.test.ts # integration: PNG 1024x1024 + recursive out dir; renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197)
|
||||
│ │ ├── idempotency.test.ts # на fixture (PR#197)
|
||||
│ │ ├── slot.test.ts # на fixture (PR#197)
|
||||
│ │ ├── e2e.test.ts # e2e: --template-dir tests/fixtures (PR#197)
|
||||
│ │ ├── e2e.no-icon.test.ts # e2e: --template-dir tests/fixtures (PR#197)
|
||||
│ │ ├── e2e.bad-input.test.ts
|
||||
│ │ ├── render.optional.test.ts # unit: empty badge → no rect, empty subtitle → no text (PR#144); на fixture (PR#197)
|
||||
│ │ ├── render.optional.integration.test.ts # integration: PNG valid with/without subtitle+badge (PR#144); renderToPng → per-process mkdtempSync tmp dir + finally cleanup (PR#177); на fixture (PR#197)
|
||||
│ │ ├── e2e.optional.test.ts # e2e: title-only CLI → exit 0, clean SVG (PR#144); хелпер переписан — buildSvg напрямую вместо чтения leftover .tmp-render.svg (PR#178); --template-dir tests/fixtures (PR#197)
|
||||
│ │ └── cleanup.test.ts # unit: temp SVG cleanup — no .tmp-render.svg leftover, PID-based path under os.tmpdir, no stray .svg (PR#178); на fixture (PR#197)
|
||||
│ ├── telegram/ # Telegram Bot API CLI-проект (по паттерну draw-image: plugin-tool + CLI, без runtime-deps) — PR#174
|
||||
│ │ ├── package.json # "type": "module", без runtime-deps; devDeps: @types/node, typescript, vitest; script test: vitest run
|
||||
│ │ ├── tsconfig.json # ES2022 + DOM (для типов fetch/Blob/FormData/File), moduleResolution: Bundler, allowImportingTsExtensions: true
|
||||
│ │ ├── vitest.config.ts # environment: node, include: tests/**/*.test.ts (e2e.manual.ts исключён — нет .test. в имени)
|
||||
│ │ ├── cli.ts # argv-парсер, диспетчер по action (text/document/photo), parse_mode=MarkdownV2 по умолчанию; успех → JSON в stdout, провал → stderr + exit(1)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── config.ts # loadConfig(overrides?): читает TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID из env, валидирует, override chatId приоритетнее env
|
||||
│ │ │ ├── markdown.ts # escapeMarkdownV2(text): экранирует спецсимволы MarkdownV2 (_*[]()~`>#+-=|{}.!), кириллицу не трогает, backslash НЕ в наборе
|
||||
│ │ │ └── api.ts # sendMessage/sendDocument/sendPhoto через встроенный fetch Node 22+; telegramFetch парсит result → {ok, message_id, chat_id}; ok:false → throw с description
|
||||
│ │ └── tests/ # vitest: 39 unit-тестов (markdown 25, config 5, api 9), все проходят
|
||||
│ │ ├── markdown.test.ts # 25 кейсов: пустая строка, без спецсимволов, кириллица, it.each по каждому спецсимволу, микс, backtick, `\` не в наборе
|
||||
│ │ ├── config.test.ts # 5 кейсов: валидный env, отсутствие token/chatId, override приоритет, override без env
|
||||
│ │ ├── api.test.ts # 9 кейсов: URL/method/body/headers sendMessage, parse_mode omit/plain/HTML, return value, Telegram API error, network error, sendDocument/sendPhoto FormData
|
||||
│ │ └── e2e.manual.ts # ручной скрипт (НЕ в npm test — нет .test. в имени + vitest include)
|
||||
│ ├── scripts/
|
||||
│ │ ├── check-adr-refs.py # ADR cross-reference validator (adr-check.yml)
|
||||
│ │ ├── check-permissions.py # Permissions validator (permissions-check.yml)
|
||||
│ │ ├── observability.py # OTel spans for tools
|
||||
│ │ ├── pipeline-status.py # 7-phase oracle (gh PR + CI polling, NEXT_ACTIONS with subagent_type+template) — PR#42, PR#67 (check_project_map guard)
|
||||
│ │ ├── scaffold-handoff.sh # Scaffold handoff + ADR stubs
|
||||
│ │ ├── spec-status.py # 9-phase spec oracle
|
||||
│ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34
|
||||
│ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools; `create_issue` enabled for reviewer/docs-reviewer/memory-syncer) — PR#40, PR#69, PR#113 (plugin block REMOVED PR#103, deprecated `new_api` provider REMOVED PR#106)
|
||||
│ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101
|
||||
│ └── .gitignore # Ignores node_modules, etc.
|
||||
├── docs/
|
||||
│ ├── handoff/ # PR handoffs (pr-<N>-<slug>.md)
|
||||
│ ├── decisions/ # ADRs (NNN-pr-<N>-<slug>.md)
|
||||
│ └── project-map/ # This file — structure snapshot
|
||||
├── assets/ # Generated cover images for README (cover.png + cover.meta.json idempotency hash; default draw-image out path, referenced by create-readme ``) — PR#158
|
||||
├── src/ # Python RAG CLI (memory) — PR#17
|
||||
│ └── memory/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # Entry point for `python -m memory`
|
||||
│ ├── cli.py # CLI commands (prog="memory")
|
||||
│ ├── embedder.py # Embedding via OPENAI_BASE_URL (env-only, OpenAI-compatible); rate-limit-aware: Retry-After header parsing, retry=5, backoff max=30 — PR#83
|
||||
│ ├── index.py # Indexing with chunking (MEMORY_CHUNK_SIZE/OVERLAP env); SHA256 incremental index (.rag/meta.json), atomic writes (.tmp+os.replace), versioning (model:chunk_size:overlap), fcntl.flock(LOCK_EX) — PR#83
|
||||
│ └── search.py # Search with dedup by source in top-K
|
||||
├── tests/ # pytest + TS/MJS test suite — PR#17
|
||||
│ ├── _ts_loader.mjs # TS test loader (load/exec_stub/exec_stub_json/exec_real modes; relative import inlining via inlineShared(); fs import support + multi-line type/var/non-null assertion stripping for create-readme.ts) — PR#38, PR#65, PR#175
|
||||
│ ├── test_agent_frontmatter.py # Agent frontmatter validators (no top-level doom_loop, permission.doom_loop present, steps:150) — PR#49, PR#69
|
||||
│ ├── test_check_adr_refs.py # adr-check.yml validator
|
||||
│ ├── test_check_permissions.py # permissions-check.yml validator
|
||||
│ ├── test_cli.py # src/memory/cli.py
|
||||
│ ├── test_docker_compose.py # docker-compose.yml port exposure (0.0.0.0:4096, no 127.0.0.1) + opencode limits/init/healthcheck assertions (8 raw-text: 8G/4cpu/2048pids, init:true, healthcheck block+timing, restart-not-duplicated, dind-unchanged) — PR#51, PR#132
|
||||
│ ├── test_dockerfile.py # Dockerfile npm install (opencode-ai + repomix; @mathew-cf/opencode-memory REMOVED PR#103, assertions inverted) — PR#57, PR#103
|
||||
│ ├── test_commit_tool.py # .opencode/tools/commit.ts (via _ts_loader.mjs exec_stub_json) — PR#38
|
||||
│ ├── test_commit_tool.ts # TS wrapper test (mjs loader) — PR#38
|
||||
│ ├── test_create_issue_tool.py # .opencode/tools/create-issue.ts (via _ts_loader.mjs exec_stub_json; 7 SDD headings, 4 new missing-heading tests, +repo) — PR#38, PR#65, PR#169
|
||||
│ ├── test_create_issue_tool.ts # TS wrapper test (mjs loader; +repo cases, 7 SDD headings) — PR#38, PR#65, PR#169
|
||||
│ ├── test_create_pr_tool.py # .opencode/tools/create-pr.ts (via _ts_loader.mjs exec_stub_json; +repo explicit/omitted/invalid) — PR#38, PR#65
|
||||
│ ├── test_create_pr_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#38, PR#65
|
||||
│ ├── test_create_readme_tool.py # .opencode/tools/create-readme.ts (via _ts_loader.mjs exec_stub_json; 14 tests: local create overwrite/new/subdir, remote 2-call, local validate, validation errors; regression #148 file_path resolved via context.worktree) — PR#175
|
||||
│ ├── test_draw_image_tool.py # .opencode/tools/draw-image.ts (via _ts_loader.mjs exec_stub_json; 5 tests: load/valid/failure/cwd/all-args) — PR#133
|
||||
│ ├── test_draw_image_tool.ts # TS wrapper test (mjs loader; 3 tests: valid/failure/cwd) — PR#133
|
||||
│ ├── test_embedder.py # src/memory/embedder.py (mocks OPENAI_BASE_URL)
|
||||
│ ├── test_embedder_live.py # Live embed tests (skip without RUN_LIVE=1)
|
||||
│ ├── test_index.py # src/memory/index.py (chunking + env override + .rag skip)
|
||||
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap)
|
||||
│ ├── test_memory_doctor.ts # TS unit-тесты для memory-doctor (allGreen false при битом бинарнике G9, allGreen true regression guard, arch-mismatch detection G2, npm-missing hint G3) — PR#124
|
||||
│ ├── test_memory_keyword_search.ts # TS unit-тесты для resolveRgBinary (npm-package, system-fallback, null-nothing, null-no-fallback) + memory-search stderr warning при rgBin===null — PR#124
|
||||
│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only [БЕЗ RUN_LIVE], full hybrid, OpenRouter fallback, deletion [individual skipif(not RUN_LIVE)]; subprocess-based: ripgrep + python3 -m src.memory) — PR#102, PR#124
|
||||
│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65
|
||||
│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65
|
||||
│ ├── test_observability.py # .opencode/scripts/observability.py
|
||||
│ ├── test_permissions.py # Global deny rules + agent.<name>.tools role-based access (8 tests) — PR#40, PR#69
|
||||
│ ├── test_pipeline_status.py # .opencode/scripts/pipeline-status.py (REVIEW verdict branching)
|
||||
│ ├── test_pipeline_status_adr.py
|
||||
│ ├── test_pipeline_status_ci.py
|
||||
│ ├── test_pipeline_status_next_actions.py # NEXT_ACTIONS subagent_type+template per phase (25 tests) — PR#42
|
||||
│ ├── test_pipeline_status_project_map.py # check_project_map guard (README.md mandatory, ADR-028) — PR#67
|
||||
│ ├── test_pipeline_status_tool.py
|
||||
│ ├── test_pipeline_status_tool.ts # TS wrapper test (mjs loader)
|
||||
│ ├── test_post_docs_review_tool.py # .opencode/tools/post-docs-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65
|
||||
│ ├── test_post_docs_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65
|
||||
│ ├── test_post_review_tool.py # .opencode/tools/post-review.ts (via _ts_loader.mjs exec_stub_json; +repo cases) — PR#46, PR#65
|
||||
│ ├── test_post_review_tool.ts # TS wrapper test (mjs loader; +repo cases) — PR#46, PR#65
|
||||
│ ├── test_search.py # src/memory/search.py
|
||||
│ ├── test_spec_status.py # .opencode/scripts/spec-status.py
|
||||
│ ├── test_spec_status_tool.py
|
||||
│ ├── test_tunnel_tool.py # .opencode/scripts/tunnel.sh (6 pytest: start/stop/stale PID/toggle, isolated tmp copy + fake cloudflared) — PR#65
|
||||
│ └── test_tunnel_tool.ts # .opencode/tools/tunnel.ts (4 TS: spawnSync wiring, success/error/cwd) — PR#65
|
||||
├── pyproject.toml # Python project (uv, ruff, pytest config)
|
||||
├── uv.lock # Locked deps for Python project
|
||||
├── .pre-commit-config.yaml # ruff + UV hooks
|
||||
├── docker-compose.yml # 2 services (dind + opencode), opencode_network, 4 bind mounts, port 4096 on 0.0.0.0; opencode: init: true (tini reaper), healthcheck (curl :4096, 200|401 healthy), limits 8G/4cpu/2048pids; command passed through entrypoint shim via "$@" — PR#24, PR#51, PR#132, PR#144
|
||||
├── Dockerfile # node:22-trixie-slim (Debian 13 trixie, glibc 2.41) + uv + gh + chromium + docker.io + opencode-ai + repomix + cloudflared + ripgrep (apt fallback для keyword search); ENTRYPOINT=/usr/local/bin/docker-entrypoint.sh (self-healing: npm ci if sharp missing) (@mathew-cf/opencode-memory REMOVED PR#103) — PR#24, PR#34, PR#57, PR#71, PR#103, PR#124, PR#144
|
||||
├── docker-entrypoint.sh # Self-healing entrypoint shim: checks node_modules/sharp, runs npm ci if missing (continue-on-error), exec opencode "$@" — PR#144
|
||||
├── .dockerignore # Excludes app_data/, .git, **/node_modules from docker build context — PR#144
|
||||
│ # Memory deps install layers (PR#107): COPY .opencode/package.json → npm install --omit=dev (runtime: @vscode/ripgrep for memory-search.ts); COPY pyproject.toml uv.lock → uv sync --no-dev --frozen (runtime: httpx, numpy, tenacity for python -m src.memory)
|
||||
├── .env.example # Placeholder-only env template (user copies to .env) — PR#24, PR#34 (TUNNEL_DOMAIN), PR#36 (OPENCODE_MEMORY_REMOTE/DIR), PR#106 (AI_PROVIDER_* removed, OPENCODE_MEMORY_REMOTE now optional), PR#118 (OPENCODE_SERVER_USERNAME), PR#174 (TELEGRAM_CHAT_ID), PR#199 (VERCEL_TOKEN placeholder, not wired)
|
||||
├── app_data/
|
||||
│ ├── opencode-memory/ # Persistent memory (separate git repo, gitignored) — PR#36
|
||||
│ ├── workspaces/ # Agent working directory (.gitkeep)
|
||||
│ └── ssh/ # SSH keys, not in git (.gitkeep)
|
||||
├── .editorconfig
|
||||
├── .gitignore
|
||||
├── .python-version
|
||||
├── AGENTS.md # Orchestrator directive (CRITICAL — overrides built-in prompt: FORBIDDEN delegation map explore/general/reviewer/docs-reviewer/memory-syncer + ALLOWED read-only oracle tools; global rules, Bug Discovery Protocol, Linear Execution; bind-mounted globally) — PR#31, PR#113, PR#189
|
||||
├── LICENSE
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Pending (future PRs)
|
||||
|
||||
- `.opencode/scripts/pipeline-status.py` fix `610452f` — PR#7 (config/scripts/ migration)
|
||||
- `.opencode/tools/` TS wrappers — PR#7 (config/tools/ migration)
|
||||
|
||||
## Update Protocol
|
||||
|
||||
Updated by docs-reviewer subagent on each PR. Reflects tracked files only (`git ls-files`).
|
||||
|
||||
Guarded by `pipeline-status.py:check_project_map` — README.md existence is mandatory.
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
"""Tests for agent frontmatter validation — top-level vs permission-nested fields.
|
||||
|
||||
Covers issue #48 acceptance criteria:
|
||||
- ``reviewer.md``, ``docs-reviewer.md``, ``memory-syncer.md`` frontmatter does
|
||||
NOT contain a top-level ``doom_loop`` field (outside the ``permission:`` block).
|
||||
- ``reviewer.md`` and ``memory-syncer.md`` frontmatter does NOT contain a
|
||||
top-level ``doom_loop`` field (outside the ``permission:`` block).
|
||||
A top-level ``doom_loop`` is invalid — it is not in the opencode ``AgentV2.Info``
|
||||
schema and gets forwarded to the provider as a model param, causing
|
||||
``AI_APICallError: Extra inputs are not permitted`` on strict providers.
|
||||
- ``doom_loop`` INSIDE the ``permission:`` block IS present in all 3 files
|
||||
- ``doom_loop`` INSIDE the ``permission:`` block IS present in all 2 files
|
||||
(valid — it is the real permission guard).
|
||||
- ``steps: 150`` is present and unchanged in all 3 files.
|
||||
- ``steps: 150`` is present and unchanged in all 2 files.
|
||||
|
||||
PyYAML is not a project dependency, so frontmatter is parsed manually by
|
||||
splitting on ``---`` delimiters and tracking indentation (top-level fields have
|
||||
|
|
@ -23,7 +23,6 @@ AGENTS_DIR = REPO_ROOT / ".opencode" / "agents"
|
|||
|
||||
AGENT_FILES = [
|
||||
AGENTS_DIR / "reviewer.md",
|
||||
AGENTS_DIR / "docs-reviewer.md",
|
||||
AGENTS_DIR / "memory-syncer.md",
|
||||
]
|
||||
|
||||
|
|
@ -81,7 +80,7 @@ def _parse_yaml_block(text: str) -> dict:
|
|||
|
||||
|
||||
def test_agent_files_exist():
|
||||
"""All 3 agent files exist in .opencode/agents/."""
|
||||
"""All 2 agent files exist in .opencode/agents/."""
|
||||
for f in AGENT_FILES:
|
||||
assert f.exists(), f"agent file missing: {f}"
|
||||
|
||||
|
|
@ -90,7 +89,7 @@ def test_agent_files_exist():
|
|||
|
||||
|
||||
def test_no_top_level_doom_loop():
|
||||
"""Frontmatter of all 3 agents has NO top-level ``doom_loop`` key.
|
||||
"""Frontmatter of all 2 agents has NO top-level ``doom_loop`` key.
|
||||
|
||||
A top-level ``doom_loop`` is invalid — outside ``permission:`` it is not
|
||||
a recognised agent field and gets forwarded to the provider.
|
||||
|
|
@ -126,7 +125,7 @@ def test_no_top_level_doom_loop_raw():
|
|||
|
||||
|
||||
def test_permission_doom_loop_present():
|
||||
"""``doom_loop: deny`` INSIDE ``permission:`` is present in all 3 files.
|
||||
"""``doom_loop: deny`` INSIDE ``permission:`` is present in all 2 files.
|
||||
|
||||
This is the valid permission guard — it must NOT be removed.
|
||||
"""
|
||||
|
|
@ -147,7 +146,7 @@ def test_permission_doom_loop_present():
|
|||
|
||||
|
||||
def test_steps_150_present():
|
||||
"""``steps: 150`` is present in all 3 agent files (must not be changed)."""
|
||||
"""``steps: 150`` is present in all 2 agent files (must not be changed)."""
|
||||
for f in AGENT_FILES:
|
||||
fm = _parse_frontmatter(f)
|
||||
assert "steps" in fm, f"{f.name}: 'steps' field missing"
|
||||
|
|
@ -158,7 +157,7 @@ def test_steps_150_present():
|
|||
|
||||
|
||||
def test_frontmatter_parseable():
|
||||
"""All 3 agent files have parseable frontmatter (split by ---)."""
|
||||
"""All 2 agent files have parseable frontmatter (split by ---)."""
|
||||
for f in AGENT_FILES:
|
||||
content = f.read_text()
|
||||
parts = content.split("---", 2)
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
"""Tests for .opencode/scripts/check-adr-refs.py — dangling ADR reference guard.
|
||||
|
||||
Covers the deterministic ADR-ref guard: clean repo passes, a dangling
|
||||
``ADR-999`` in a temp handoff is detected, self-reference inside an
|
||||
``NNN-*.md`` file is excluded, a valid cross-ref (``ADR-002``) passes,
|
||||
a file with no ADR refs passes, and ``node_modules/`` is excluded.
|
||||
|
||||
Strategy mirrors ``tests/test_check_permissions.py``:
|
||||
- ``test_clean_repo_passes`` runs the script as a subprocess on the
|
||||
real (clean) repo — black-box, returncode 0, OK message in stdout.
|
||||
- The remaining tests load the script in-process via importlib and
|
||||
monkeypatch ``REPO_ROOT`` / ``ADR_DIR`` to point at a tmp_path tree,
|
||||
then call ``main()`` and assert on captured stdout / SystemExit code.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_PATH = REPO_ROOT / ".opencode" / "scripts" / "check-adr-refs.py"
|
||||
|
||||
|
||||
def _load_script():
|
||||
spec = importlib.util.spec_from_file_location("check_adr_refs", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["check_adr_refs"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
car = _load_script()
|
||||
|
||||
|
||||
# ── clean repo (subprocess black-box) ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_clean_repo_passes():
|
||||
"""On the current (clean) repo the script exits 0 with the OK message."""
|
||||
result = subprocess.run(
|
||||
["python3", str(SCRIPT_PATH)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "OK: No dangling ADR references." in result.stdout
|
||||
|
||||
|
||||
# ── helpers for in-process tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _setup_tmp_repo(tmp_path: Path, monkeypatch) -> tuple[Path, Path]:
|
||||
"""Point the script's REPO_ROOT and ADR_DIR at a tmp_path tree.
|
||||
|
||||
Creates the ``docs/decisions/`` dir and returns (repo_root, adr_dir).
|
||||
"""
|
||||
repo = tmp_path / "repo"
|
||||
adr_dir = repo / "docs" / "decisions"
|
||||
adr_dir.mkdir(parents=True)
|
||||
handoff_dir = repo / "docs" / "handoff"
|
||||
handoff_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(car, "REPO_ROOT", repo)
|
||||
monkeypatch.setattr(car, "ADR_DIR", adr_dir)
|
||||
return repo, adr_dir
|
||||
|
||||
|
||||
# ── dangling reference detected ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dangling_ref_detected(tmp_path, monkeypatch, capsys):
|
||||
"""A handoff referencing ADR-999 (no 999-*.md) → exit 1 + ADR-999 in output."""
|
||||
repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
|
||||
(repo / "docs" / "handoff" / "pr-1-test.md").write_text("See ADR-999 for context.\n")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
car.main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "ADR-999" in captured.out
|
||||
assert "FAIL" in captured.out
|
||||
|
||||
|
||||
# ── self-reference excluded ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_self_reference_excluded(tmp_path, monkeypatch, capsys):
|
||||
"""An ADR file ``019-pr-94-test.md`` mentioning ADR-019 → passes (self-ref)."""
|
||||
_repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
|
||||
(adr_dir / "019-pr-94-test.md").write_text("# ADR-019: self-reference is OK.\n")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
car.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "OK: No dangling ADR references." in captured.out
|
||||
|
||||
|
||||
# ── valid cross-reference passes ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_valid_cross_ref_passes(tmp_path, monkeypatch, capsys):
|
||||
"""A handoff referencing ADR-002 when 002-*.md exists → passes."""
|
||||
repo, adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
|
||||
(adr_dir / "002-pr-56-pipeline-mandatory-adr.md").write_text("# ADR-002\n")
|
||||
(repo / "docs" / "handoff" / "pr-3-test.md").write_text("Per ADR-002, ADR is mandatory.\n")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
car.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "OK: No dangling ADR references." in captured.out
|
||||
|
||||
|
||||
# ── no ADR refs in a file passes ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_adr_refs_passes(tmp_path, monkeypatch, capsys):
|
||||
"""A handoff with no ADR-NNN references → passes."""
|
||||
repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
|
||||
(repo / "docs" / "handoff" / "pr-4-test.md").write_text("No ADR references here.\n")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
car.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "OK: No dangling ADR references." in captured.out
|
||||
|
||||
|
||||
# ── node_modules excluded ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_exclude_node_modules(tmp_path, monkeypatch, capsys):
|
||||
"""A ``.md`` file under ``node_modules/`` with ADR-999 is NOT scanned."""
|
||||
repo, _adr_dir = _setup_tmp_repo(tmp_path, monkeypatch)
|
||||
nm = repo / "node_modules" / "some-pkg"
|
||||
nm.mkdir(parents=True)
|
||||
(nm / "README.md").write_text("Dangling ADR-999 should be ignored.\n")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
car.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "OK: No dangling ADR references." in captured.out
|
||||
assert "ADR-999" not in captured.out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -145,18 +145,6 @@ def test_reviewer_tools_readonly():
|
|||
assert tools["merge_pr"] is False
|
||||
|
||||
|
||||
# ── agent.docs-reviewer.tools ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_docs_reviewer_tools():
|
||||
"""docs-reviewer: commit=true (project map/handoff), create_issue=true, rest false."""
|
||||
tools = _load_config()["agent"]["docs-reviewer"]["tools"]
|
||||
assert tools["commit"] is True
|
||||
assert tools["create_pr"] is False
|
||||
assert tools["create_issue"] is True
|
||||
assert tools["merge_pr"] is False
|
||||
|
||||
|
||||
# ── agent.memory-syncer.tools ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,304 +0,0 @@
|
|||
"""Tests for .opencode/tools/post-docs-review.ts — the post-docs-review custom tool.
|
||||
|
||||
Mirrors tests/test_commit_tool.py / test_create_pr_tool.py:
|
||||
exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs``
|
||||
using the ``exec_stub_json`` mode (multi-arg tools).
|
||||
|
||||
The loader is parameterized via the ``TS_FILE`` env var. These tests set
|
||||
``TS_FILE=.opencode/tools/post-docs-review.ts``.
|
||||
|
||||
Modes used:
|
||||
- ``load`` — sanity-check that the tool loads and declares pr_number, verdict,
|
||||
body args.
|
||||
- ``exec_stub_json`` — call execute with a stubbed spawnSync to verify:
|
||||
(a) success path: valid verdict (APPROVE/FIXED/NO_CHANGES)
|
||||
→ "Docs review posted on PR #N: verdict=<V>",
|
||||
(b) comment heading: body passed to gh contains "## Docs Review Summary",
|
||||
(c) comment verdict: body passed to gh contains "### Verdict: <V>",
|
||||
(d) spawnSync args: gh pr comment N --body <comment> (no --repo; gh auto-detects
|
||||
from context.worktree — ADR-019 superseding note, PR#60).
|
||||
|
||||
post-docs-review.ts makes 1 spawnSync call (gh pr comment) on all paths.
|
||||
|
||||
Note on enum validation: see test_post_review_tool.py — same delegation to
|
||||
zod schema, execute() itself is permissive.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs"
|
||||
TS_FILE = REPO_ROOT / ".opencode" / "tools" / "post-docs-review.ts"
|
||||
TS_FILE_REL = ".opencode/tools/post-docs-review.ts"
|
||||
|
||||
COMMENT_URL = "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1"
|
||||
COMMENT_OK_RESPONSE = {"status": 0, "stdout": COMMENT_URL + "\n", "stderr": ""}
|
||||
EXPECTED_APPROVE = "Docs review posted on PR #45: verdict=APPROVE"
|
||||
EXPECTED_FIXED = "Docs review posted on PR #45: verdict=FIXED"
|
||||
EXPECTED_NO_CHANGES = "Docs review posted on PR #45: verdict=NO_CHANGES"
|
||||
EXPECTED_APROVE_TYPO = "Docs review posted on PR #45: verdict=APROVE"
|
||||
|
||||
|
||||
def _run_loader(*args: str) -> dict:
|
||||
"""Invoke the loader with TS_FILE env set to post-docs-review.ts and parse JSON stdout."""
|
||||
env = {**os.environ, "TS_FILE": TS_FILE_REL}
|
||||
proc = subprocess.run(
|
||||
["node", str(LOADER), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n"
|
||||
f"stdout: {proc.stdout}\nstderr: {proc.stderr}"
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _run_exec(args: dict, responses: list[dict]) -> dict:
|
||||
"""Helper: exec_stub_json mode with JSON args + sequential stub responses."""
|
||||
return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses))
|
||||
|
||||
|
||||
def test_loader_can_load_tool():
|
||||
"""Sanity: post-docs-review.ts loads and declares pr_number, verdict, body args."""
|
||||
if not TS_FILE.exists():
|
||||
pytest.skip("post-docs-review.ts not present")
|
||||
out = _run_loader("load")
|
||||
assert "description" in out
|
||||
args = out["args"]
|
||||
assert "pr_number" in args, f"missing pr_number arg: {args}"
|
||||
assert "verdict" in args, f"missing verdict arg: {args}"
|
||||
assert "body" in args, f"missing body arg: {args}"
|
||||
|
||||
|
||||
def test_valid_approve():
|
||||
"""execute() with APPROVE verdict returns 'Docs review posted on PR #N: verdict=APPROVE'."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "- Handoff: valid"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
result = out["result"]
|
||||
assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}"
|
||||
|
||||
|
||||
def test_valid_fixed():
|
||||
"""execute() with FIXED verdict returns success."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "FIXED", "body": "- Handoff: fixed: added section"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
result = out["result"]
|
||||
assert result == EXPECTED_FIXED, f"expected success, got: {result!r}"
|
||||
|
||||
|
||||
def test_valid_no_changes():
|
||||
"""execute() with NO_CHANGES verdict returns success."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "NO_CHANGES", "body": "- No structural changes"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
result = out["result"]
|
||||
assert result == EXPECTED_NO_CHANGES, f"expected success, got: {result!r}"
|
||||
|
||||
|
||||
def test_invalid_verdict_not_validated_by_execute():
|
||||
"""execute() does NOT validate verdict inline — zod enum does at opencode layer.
|
||||
|
||||
The _ts_loader.mjs shim does not validate (chainable builder without checks).
|
||||
Passing a typo "APROVE" (missing a P) builds a comment with the bad verdict.
|
||||
The real guard is tool.schema.enum(VERDICTS) in the opencode runtime.
|
||||
This test documents that execute() itself is permissive — validation is
|
||||
delegated to the zod schema, not duplicated inside execute().
|
||||
"""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APROVE", "body": "typo verdict"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
result = out["result"]
|
||||
assert result == EXPECTED_APROVE_TYPO, f"expected permissive, got: {result!r}"
|
||||
|
||||
|
||||
def test_comment_has_heading():
|
||||
"""Comment body passed to gh contains '## Docs Review Summary' heading."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
|
||||
args = calls[0]["args"]
|
||||
body_idx = args.index("--body") + 1
|
||||
comment = args[body_idx]
|
||||
assert comment.startswith("## Docs Review Summary\n"), (
|
||||
f"expected heading at start, got: {comment!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_comment_has_verdict():
|
||||
"""Comment body passed to gh contains '### Verdict: <verdict>'."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
args = calls[0]["args"]
|
||||
body_idx = args.index("--body") + 1
|
||||
comment = args[body_idx]
|
||||
assert "### Verdict: APPROVE" in comment, f"expected verdict line, got: {comment!r}"
|
||||
|
||||
|
||||
def test_comment_has_fixed_verdict():
|
||||
"""Comment body with FIXED contains '### Verdict: FIXED'."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "FIXED", "body": "- Handoff: fixed"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
args = calls[0]["args"]
|
||||
body_idx = args.index("--body") + 1
|
||||
comment = args[body_idx]
|
||||
assert "### Verdict: FIXED" in comment, f"expected verdict line, got: {comment!r}"
|
||||
|
||||
|
||||
def test_comment_has_no_changes_verdict():
|
||||
"""Comment body with NO_CHANGES contains '### Verdict: NO_CHANGES'."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "NO_CHANGES", "body": "- No changes"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
args = calls[0]["args"]
|
||||
body_idx = args.index("--body") + 1
|
||||
comment = args[body_idx]
|
||||
assert "### Verdict: NO_CHANGES" in comment, f"expected verdict line, got: {comment!r}"
|
||||
|
||||
|
||||
def test_spawnsync_args():
|
||||
"""spawnSync called with gh pr comment <N> --body <comment> (no --repo).
|
||||
|
||||
--repo was hardcoded to slaid098/opencode-config (ADR-019) and caused silent
|
||||
misroute to the wrong repo when working outside opencode-config. Removed in
|
||||
PR#60 — gh now auto-detects the repo from context.worktree (cwd), matching
|
||||
create-pr.ts/merge-pr.ts. Symmetric with the success-path assertions.
|
||||
"""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
|
||||
call = calls[0]
|
||||
assert call["cmd"] == "gh", f"expected cmd 'gh', got: {call['cmd']!r}"
|
||||
args = call["args"]
|
||||
assert args[0] == "pr", f"expected first arg 'pr', got: {args[0]!r}"
|
||||
assert args[1] == "comment", f"expected second arg 'comment', got: {args[1]!r}"
|
||||
assert args[2] == "45", f"expected PR number '45', got: {args[2]!r}"
|
||||
assert "--body" in args, "missing --body flag"
|
||||
# --repo MUST NOT be present — gh auto-detects from cwd (context.worktree).
|
||||
# Hardcoded --repo caused silent misroute (PR#60 root cause).
|
||||
assert "--repo" not in args, (
|
||||
f"--repo must not be hardcoded; gh auto-detects from cwd. args: {args!r}"
|
||||
)
|
||||
# args must end with --body <comment> (no trailing --repo slaid098/...).
|
||||
assert args[-2] == "--body", f"expected args to end with --body <comment>, got: {args[-2:]!r}"
|
||||
assert args[-1].startswith("## Docs Review Summary\n"), (
|
||||
f"expected last arg to be the comment body, got: {args[-1]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_execute_uses_cwd_from_context():
|
||||
"""execute passes cwd=context.worktree to spawnSync (ADR-023 pattern)."""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
assert len(calls) == 1, f"expected 1 spawnSync call, got {len(calls)}"
|
||||
opts = calls[0]["opts"]
|
||||
assert opts is not None, "spawnSync called without opts — expected cwd kwarg"
|
||||
assert "cwd" in opts, f"opts missing 'cwd' key — got: {opts}"
|
||||
assert opts["cwd"] == str(REPO_ROOT), (
|
||||
f"cwd must equal context.worktree ({REPO_ROOT}), got: {opts['cwd']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_gh_failure_returns_error():
|
||||
"""execute() with gh exit non-zero returns error message with exit code."""
|
||||
fail_response = {"status": 1, "stdout": "", "stderr": "gh: not authenticated"}
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
|
||||
[fail_response],
|
||||
)
|
||||
result = out["result"]
|
||||
assert "post-docs-review failed" in result, f"expected failure message, got: {result!r}"
|
||||
assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"
|
||||
|
||||
|
||||
def test_repo_explicit_passed_to_gh():
|
||||
"""execute() with repo='foo/bar' → gh receives '--repo foo/bar' before the subcommand.
|
||||
|
||||
The shared runGh helper prepends ['--repo', <repo>] to the gh argv so an
|
||||
explicit repo targets the right owner/name regardless of context.worktree.
|
||||
"""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body.", "repo": "foo/bar"},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
result = out["result"]
|
||||
assert result == EXPECTED_APPROVE, f"expected success, got: {result!r}"
|
||||
calls = out["calls"]
|
||||
assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}"
|
||||
args = calls[0]["args"]
|
||||
assert args[0] == "--repo", f"expected --repo first, got: {args[0]!r}"
|
||||
assert args[1] == "foo/bar", f"expected repo value, got: {args[1]!r}"
|
||||
assert args[2] == "pr", f"expected 'pr' after --repo <name>, got: {args[2]!r}"
|
||||
|
||||
|
||||
def test_repo_omitted_no_repo_flag():
|
||||
"""execute() without repo → gh argv has NO --repo (auto-detect from cwd).
|
||||
|
||||
Backward-compatibility: when repo is omitted, runGh returns [] from
|
||||
parseRepo, so gh auto-detects the repo from context.worktree (cwd) —
|
||||
matching the pre-refactor behaviour (ADR-025 / PR#61). This complements
|
||||
test_spawnsync_args which asserts the same on the success path.
|
||||
"""
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body."},
|
||||
[COMMENT_OK_RESPONSE],
|
||||
)
|
||||
calls = out["calls"]
|
||||
assert len(calls) == 1, f"expected 1 spawnSync call, got: {len(calls)}"
|
||||
args = calls[0]["args"]
|
||||
assert "--repo" not in args, (
|
||||
f"--repo must NOT be added when repo arg omitted; gh auto-detects. args: {args!r}"
|
||||
)
|
||||
assert args[0] == "pr", f"expected 'pr' first, got: {args[0]!r}"
|
||||
|
||||
|
||||
def test_repo_invalid_gh_error():
|
||||
"""execute() with invalid repo + gh failure → error mentions post-docs-review + exit code.
|
||||
|
||||
gh rejects an invalid owner/name with non-zero exit; the tool formats the
|
||||
error with its tool-specific message (post-docs-review failed for PR #N).
|
||||
"""
|
||||
fail_response = {"status": 1, "stdout": "", "stderr": 'expected the "owner/repo" format'}
|
||||
out = _run_exec(
|
||||
{"pr_number": 45, "verdict": "APPROVE", "body": "Docs body.", "repo": "not-a-valid-repo"},
|
||||
[fail_response],
|
||||
)
|
||||
result = out["result"]
|
||||
assert "post-docs-review failed" in result, f"expected tool failure, got: {result!r}"
|
||||
assert "exit 1" in result, f"expected exit 1 mention, got: {result!r}"
|
||||
calls = out["calls"]
|
||||
args = calls[0]["args"]
|
||||
assert "--repo" in args, f"expected --repo in args even on failure, got: {args!r}"
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/**
|
||||
* Tests for .opencode/tools/post-docs-review.ts — the post-docs-review custom tool.
|
||||
*
|
||||
* Mirror of tests/test_post_review_tool.ts / test_commit_tool.ts:
|
||||
* the tool is a spawnSync wrapper around `gh pr comment` with verdict enum
|
||||
* validation and deterministic comment heading generation.
|
||||
*
|
||||
* Runtime note: opencode ships a standalone binary with Bun bundled inside;
|
||||
* there is no separate `bun` CLI on the host (CI runner uses node + pytest).
|
||||
* The CI runs the equivalent Python tests in tests/test_post_docs_review_tool.py
|
||||
* via the JS loader tests/_ts_loader.mjs (exec_stub_json mode for multi-arg
|
||||
* tools). This file documents the intended TS-side test cases and is
|
||||
* runnable under `bun test` once a bun runtime is available on the host.
|
||||
*
|
||||
* Test cases (mirror tests/test_post_docs_review_tool.py):
|
||||
* - test_valid_approve — valid APPROVE verdict → "Docs review posted"
|
||||
* - test_valid_fixed — valid FIXED verdict → "Docs review posted"
|
||||
* - test_valid_no_changes — valid NO_CHANGES verdict → "Docs review posted"
|
||||
* - test_invalid_verdict — invalid verdict "APROVE" (typo) → error
|
||||
* - test_comment_has_heading — comment body contains "## Docs Review Summary"
|
||||
* - test_comment_has_verdict — comment body contains "### Verdict: <verdict>"
|
||||
* - test_spawnsync_args — spawnSync called with correct gh args
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock } from "bun:test" with { type: "'bun-test'" }
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
const TOOL_SRC = path.resolve(import.meta.dir, "..", ".opencode", "tools", "post-docs-review.ts")
|
||||
|
||||
function ctx() {
|
||||
return {
|
||||
sessionID: "t", messageID: "t", agent: "t",
|
||||
directory: ".", worktree: ".",
|
||||
abort: new AbortController().signal,
|
||||
metadata() {}, async ask() {},
|
||||
}
|
||||
}
|
||||
|
||||
const COMMENT_OK = { status: 0, stdout: "https://github.com/slaid098/opencode-config/issues/45#issuecomment-1\n", stderr: "" }
|
||||
|
||||
describe("post-docs-review tool", () => {
|
||||
test("test_valid_approve — APPROVE verdict succeeds", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "- Project map: no structural changes\n- Handoff: valid",
|
||||
}, ctx())
|
||||
expect(result).toBe("Docs review posted on PR #45: verdict=APPROVE")
|
||||
const bodyIdx = capturedArgs.indexOf("--body") + 1
|
||||
expect(capturedArgs[bodyIdx]).toContain("## Docs Review Summary")
|
||||
expect(capturedArgs[bodyIdx]).toContain("### Verdict: APPROVE")
|
||||
})
|
||||
|
||||
test("test_valid_fixed — FIXED verdict succeeds", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "FIXED",
|
||||
body: "- Handoff: fixed: added missing section",
|
||||
}, ctx())
|
||||
expect(result).toBe("Docs review posted on PR #45: verdict=FIXED")
|
||||
const bodyIdx = capturedArgs.indexOf("--body") + 1
|
||||
expect(capturedArgs[bodyIdx]).toContain("### Verdict: FIXED")
|
||||
})
|
||||
|
||||
test("test_valid_no_changes — NO_CHANGES verdict succeeds", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "NO_CHANGES",
|
||||
body: "- Project map: no structural changes\n- Handoff: valid",
|
||||
}, ctx())
|
||||
expect(result).toBe("Docs review posted on PR #45: verdict=NO_CHANGES")
|
||||
const bodyIdx = capturedArgs.indexOf("--body") + 1
|
||||
expect(capturedArgs[bodyIdx]).toContain("### Verdict: NO_CHANGES")
|
||||
})
|
||||
|
||||
test("test_invalid_verdict — typo 'APROVE' → error", async () => {
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: () => COMMENT_OK,
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
// Invalid verdict passed directly — at runtime zod would reject this,
|
||||
// but the loader shim does no validation. The tool builds the comment
|
||||
// regardless. The enum validation happens at the opencode layer (zod),
|
||||
// not inside execute(). This test documents that the tool itself does
|
||||
// not validate verdicts (delegated to zod schema).
|
||||
const result = await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APROVE",
|
||||
body: "typo verdict",
|
||||
}, ctx())
|
||||
expect(result).toBe("Docs review posted on PR #45: verdict=APROVE")
|
||||
})
|
||||
|
||||
test("test_comment_has_heading — comment body contains '## Docs Review Summary'", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "Docs body",
|
||||
}, ctx())
|
||||
const bodyIdx = capturedArgs.indexOf("--body") + 1
|
||||
const comment = capturedArgs[bodyIdx]
|
||||
expect(comment.startsWith("## Docs Review Summary\n")).toBe(true)
|
||||
})
|
||||
|
||||
test("test_comment_has_verdict — comment body contains '### Verdict: APPROVE'", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "Docs body",
|
||||
}, ctx())
|
||||
const bodyIdx = capturedArgs.indexOf("--body") + 1
|
||||
const comment = capturedArgs[bodyIdx]
|
||||
expect(comment).toContain("### Verdict: APPROVE")
|
||||
})
|
||||
|
||||
test("test_spawnsync_args — spawnSync called with correct gh args (no --repo)", async () => {
|
||||
let capturedCmd
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (cmd, args) => {
|
||||
capturedCmd = cmd
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "Docs body",
|
||||
}, ctx())
|
||||
expect(capturedCmd).toBe("gh")
|
||||
expect(capturedArgs[0]).toBe("pr")
|
||||
expect(capturedArgs[1]).toBe("comment")
|
||||
expect(capturedArgs[2]).toBe("45")
|
||||
expect(capturedArgs).toContain("--body")
|
||||
// --repo MUST NOT be present — gh auto-detects from cwd (PR#60).
|
||||
expect(capturedArgs).not.toContain("--repo")
|
||||
// args end with --body <comment> (no trailing --repo slaid098/...).
|
||||
const lastIdx = capturedArgs.length - 1
|
||||
expect(capturedArgs[lastIdx - 1]).toBe("--body")
|
||||
expect(capturedArgs[lastIdx].startsWith("## Docs Review Summary\n")).toBe(true)
|
||||
})
|
||||
|
||||
test("test_repo_explicit — repo='foo/bar' prepends --repo to gh argv", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "Docs body",
|
||||
repo: "foo/bar",
|
||||
}, ctx())
|
||||
expect(result).toBe("Docs review posted on PR #45: verdict=APPROVE")
|
||||
expect(capturedArgs[0]).toBe("--repo")
|
||||
expect(capturedArgs[1]).toBe("foo/bar")
|
||||
expect(capturedArgs[2]).toBe("pr")
|
||||
})
|
||||
|
||||
test("test_repo_omitted — no --repo flag (auto-detect from cwd)", async () => {
|
||||
let capturedArgs
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: (_cmd, args) => {
|
||||
capturedArgs = args
|
||||
return COMMENT_OK
|
||||
},
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "Docs body",
|
||||
}, ctx())
|
||||
// Backward-compat: --repo MUST NOT be present when repo arg omitted.
|
||||
expect(capturedArgs).not.toContain("--repo")
|
||||
expect(capturedArgs[0]).toBe("pr")
|
||||
})
|
||||
|
||||
test("test_repo_invalid — invalid repo + gh failure → tool error", async () => {
|
||||
mock.module("child_process", () => ({
|
||||
spawnSync: () => ({ status: 1, stdout: "", stderr: 'expected the "owner/repo" format' }),
|
||||
}))
|
||||
const mod = await import(TOOL_SRC + "?t=" + Date.now())
|
||||
const result = await mod.default.execute({
|
||||
pr_number: 45,
|
||||
verdict: "APPROVE",
|
||||
body: "Docs body",
|
||||
repo: "not-a-valid-repo",
|
||||
}, ctx())
|
||||
expect(result).toContain("post-docs-review failed")
|
||||
expect(result).toContain("exit 1")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue