From a361232df68ec30e38490fb57f02b125ac7a4ccf Mon Sep 17 00:00:00 2001 From: Promansis Date: Sat, 30 May 2026 22:40:31 +0800 Subject: [PATCH 01/40] Add Bunny style review workflow --- .github/workflows/bunny-style-review.yml | 103 +++++++++ scripts/bunny_review.py | 257 +++++++++++++++++++++++ scripts/requirements.txt | 1 + skills/bunny-style-review/SKILL.md | 75 +++++++ 4 files changed, 436 insertions(+) create mode 100644 .github/workflows/bunny-style-review.yml create mode 100644 scripts/bunny_review.py create mode 100644 scripts/requirements.txt create mode 100644 skills/bunny-style-review/SKILL.md diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml new file mode 100644 index 000000000..d04ff38b0 --- /dev/null +++ b/.github/workflows/bunny-style-review.yml @@ -0,0 +1,103 @@ +name: Bunny Style Review + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + review: + if: > + github.event_name == 'pull_request' || + ( + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + ) + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + LLM_MODEL: gpt-5.5 + REVIEW_MARKER: "" + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + steps: + - name: Skip when no OpenAI key is configured + if: env.OPENAI_API_KEY == '' + run: | + echo "OPENAI_API_KEY is not configured; skipping Bunny Style Review." + + - name: Check out trusted review runner + uses: actions/checkout@v4 + if: env.OPENAI_API_KEY != '' + with: + ref: ${{ github.base_ref || github.event.repository.default_branch }} + fetch-depth: 0 + path: bunny-runner + + - name: Check out PR target + uses: actions/checkout@v4 + if: env.OPENAI_API_KEY != '' + with: + fetch-depth: 0 + path: bunny-target + + - name: Switch target checkout to PR head + if: env.OPENAI_API_KEY != '' + working-directory: bunny-target + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr checkout "$PR_NUMBER" + BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) + git fetch origin "$BASE" --depth=1 || true + echo "PR_BASE_REF=origin/$BASE" >> "$GITHUB_ENV" + + - uses: actions/setup-python@v5 + if: env.OPENAI_API_KEY != '' + with: + python-version: "3.12" + + - name: Install review dependencies + if: env.OPENAI_API_KEY != '' + run: pip install -r bunny-runner/scripts/requirements.txt + + - name: Run review + if: env.OPENAI_API_KEY != '' + working-directory: bunny-target + env: + BUNNY_SKILL_PATH: ${{ github.workspace }}/bunny-runner/skills/bunny-style-review/SKILL.md + run: python ../bunny-runner/scripts/bunny_review.py + + - name: Post or update the review + if: env.OPENAI_API_KEY != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + { + echo "$REVIEW_MARKER" + cat bunny-target/review.md + } > review-comment.md + + COMMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${REVIEW_MARKER}\"))) | .id" \ + | tail -n 1) + + if [ -n "$COMMENT_ID" ]; then + BODY=$(cat review-comment.md) + gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \ + --method PATCH \ + --field body="$BODY" >/dev/null + else + gh pr comment "$PR_NUMBER" --body-file review-comment.md + fi diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py new file mode 100644 index 000000000..63f886916 --- /dev/null +++ b/scripts/bunny_review.py @@ -0,0 +1,257 @@ +import fnmatch +import glob +import json +import os +import pathlib +import subprocess +from typing import Any + +from openai import OpenAI + +REPO_ROOT = pathlib.Path.cwd().resolve() +MAX_ITERS = 40 +MAX_FILE_BYTES = 200_000 +MAX_TOOL_OUTPUT = 60_000 + +BLOCKED_NAMES = { + ".env", + ".env.local", + ".env.development", + ".env.production", + ".npmrc", + ".netrc", + "credentials.json", + "id_ed25519", + "id_rsa", +} +BLOCKED_DIRS = { + ".git", + ".idea", + ".vscode", + "dist", + "dist-ssr", + "node_modules", + "playwright-report", + "src-tauri/target", + "target", +} + + +def _safe_path(rel: str) -> pathlib.Path: + full = (REPO_ROOT / rel).resolve() + if full != REPO_ROOT and REPO_ROOT not in full.parents: + raise ValueError("path escapes repo root") + relative = full.relative_to(REPO_ROOT).as_posix() + name = full.name.lower() + if name.startswith(".env") or name in BLOCKED_NAMES: + raise ValueError("blocked sensitive file") + if any(relative == blocked or relative.startswith(f"{blocked}/") for blocked in BLOCKED_DIRS): + raise ValueError("blocked generated or internal path") + return full + + +def _is_readable_repo_file(path: pathlib.Path) -> bool: + try: + relative = path.resolve().relative_to(REPO_ROOT).as_posix() + except ValueError: + return False + name = path.name.lower() + if name.startswith(".env") or name in BLOCKED_NAMES: + return False + if any(relative == blocked or relative.startswith(f"{blocked}/") for blocked in BLOCKED_DIRS): + return False + return path.is_file() + + +ALLOWED_GIT = { + "status", + "diff", + "log", + "show", + "rev-parse", + "merge-base", + "name-only", + "ls-files", + "blame", +} + + +def run_git(args: list[str]) -> str: + if not args or args[0] not in ALLOWED_GIT: + return f"refused: git '{args[0] if args else ''}' not allowed" + out = subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + return (out.stdout + out.stderr)[:MAX_TOOL_OUTPUT] + + +def read_file(path: str, start: int = 1, end: int | None = None) -> str: + p = _safe_path(path) + data = p.read_text(encoding="utf-8", errors="replace")[:MAX_FILE_BYTES] + lines = data.splitlines() + stop = end or len(lines) + chunk = lines[max(0, start - 1) : stop] + return "\n".join(f"{i + start}: {line}" for i, line in enumerate(chunk)) + + +def list_dir(path: str = ".") -> str: + p = _safe_path(path) + return "\n".join(sorted(f"{c.name}/" if c.is_dir() else c.name for c in p.iterdir())) + + +def search(pattern: str, glob_expr: str = "**/*") -> str: + hits: list[str] = [] + for candidate in glob.glob(str(REPO_ROOT / glob_expr), recursive=True): + fp = pathlib.Path(candidate) + if not _is_readable_repo_file(fp): + continue + rel = fp.relative_to(REPO_ROOT).as_posix() + if not fnmatch.fnmatch(rel, glob_expr): + continue + try: + for line_number, line in enumerate(fp.read_text("utf-8", "replace").splitlines(), 1): + if pattern in line: + hits.append(f"{rel}:{line_number}: {line.strip()[:200]}") + if len(hits) >= 100: + return "\n".join(hits) + except OSError: + continue + return "\n".join(hits) or "no matches" + + +TOOLS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "run_git", + "description": "Run a read-only git command. First arg is the subcommand.", + "parameters": { + "type": "object", + "properties": {"args": {"type": "array", "items": {"type": "string"}}}, + "required": ["args"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a repo file. Optional 1-based start/end lines.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "start": {"type": "integer"}, + "end": {"type": "integer"}, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "list_dir", + "description": "List a directory inside the repo.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + }, + }, + { + "type": "function", + "function": { + "name": "search", + "description": "Substring search across files matching a glob.", + "parameters": { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "glob_expr": {"type": "string"}, + }, + "required": ["pattern"], + }, + }, + }, +] + +DISPATCH = { + "run_git": run_git, + "read_file": read_file, + "list_dir": list_dir, + "search": search, +} + + +def _client() -> OpenAI: + kwargs: dict[str, str] = {"api_key": os.environ["OPENAI_API_KEY"]} + base_url = os.environ.get("LLM_BASE_URL") + if base_url: + kwargs["base_url"] = base_url + return OpenAI(**kwargs) + + +def main() -> None: + client = _client() + skill_path = pathlib.Path( + os.environ.get("BUNNY_SKILL_PATH", REPO_ROOT / "skills/bunny-style-review/SKILL.md") + ) + skill = skill_path.read_text("utf-8") + base = os.environ.get("PR_BASE_REF", "origin/main") + model = os.environ.get("LLM_MODEL", "gpt-5.5") + + messages: list[dict[str, Any]] = [ + {"role": "system", "content": skill}, + { + "role": "user", + "content": ( + f"Review this PR. The review base branch is '{base}'. " + "Follow the skill's Setup and Review Passes using the tools, " + "load only the docs and Marinara skills that match the touched area, " + "and produce the final review in the skill's Output Shape. " + "Do not edit files. When done, reply with only the review text." + ), + }, + ] + + for _ in range(MAX_ITERS): + resp = client.chat.completions.create( + model=model, + messages=messages, + tools=TOOLS, + ) + msg = resp.choices[0].message + messages.append(msg.model_dump(exclude_none=True)) + + if not msg.tool_calls: + pathlib.Path("review.md").write_text(msg.content or "", "utf-8") + return + + for call in msg.tool_calls: + try: + args = json.loads(call.function.arguments or "{}") + result = DISPATCH[call.function.name](**args) + except Exception as exc: + result = f"error: {exc}" + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": str(result)[:MAX_TOOL_OUTPUT], + } + ) + + pathlib.Path("review.md").write_text( + "Review did not converge within the iteration budget.", + "utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 000000000..d435e4d7d --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +openai>=1.99.0,<2 diff --git a/skills/bunny-style-review/SKILL.md b/skills/bunny-style-review/SKILL.md new file mode 100644 index 000000000..4e356ffe1 --- /dev/null +++ b/skills/bunny-style-review/SKILL.md @@ -0,0 +1,75 @@ +--- +name: bunny-style-review +description: "Review Marinara pull requests in a CodeRabbit-style CI pass by inspecting the live repository diff with read-only tools, loading only relevant local guidance, and producing concise actionable findings." +--- + +# Bunny Style Review + +You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the current repository with the provided tools before forming conclusions. + +You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only tools. + +## Setup + +1. Establish the base and head: + - Run `git status --short --branch`. + - Run `git rev-parse --show-toplevel`. + - Run `git merge-base HEAD `. + - Run `git diff --stat ...HEAD`. + - Run `git diff --name-only ...HEAD`. +2. Read `AGENTS.md`. +3. Load only guidance that matches touched areas: + - Architecture or ownership changes: `skills/marinara-architecture-guard/SKILL.md`. + - Chat, roleplay, or game mode changes: `skills/marinara-mode-separation/SKILL.md`. + - Bug fixes or regressions: `skills/marinara-bugfix-discipline/SKILL.md`. + - Onboarding/docs/run-build guidance: `skills/marinara-getting-started/SKILL.md`. +4. Read the changed files or focused line ranges needed to understand the behavior. +5. Use `search` and read-only git commands to inspect callers, contracts, tests, and adjacent implementations before reporting a finding. + +## Review Passes + +Prioritize correctness, user-visible regressions, security/privacy, architecture boundaries, mode ownership, missing tests, and CI/deployment failures. + +Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. Every finding must cite a concrete file and line or a small changed area. + +Treat these as high-signal Marinara review concerns: + +- Product behavior placed outside its owner. +- Engine code importing React, Zustand stores, Tauri APIs, feature internals, or concrete shared API adapters. +- Feature code bypassing focused shared API wrappers. +- Remote-capable behavior that skips the explicit HTTP pipeline. +- Chat, roleplay, and game mode behavior crossing ownership boundaries. +- Fake success states, silent catches, broad fallbacks, or UI-only guards over broken contracts. +- Changes without tests when the touched behavior has realistic regression risk. + +## Output Shape + +Reply with only the review text. Use this exact structure: + +``` +## Bunny Review + +### Findings +- [severity] file:line - Finding title. Explain the concrete risk, why it happens, and the smallest useful fix. + +### Open Questions +- Question or assumption, if any. + +### What I Checked +- Short list of the main commands/files/contracts inspected. +``` + +If there are no findings, write: + +``` +## Bunny Review + +### Findings +No blocking findings. + +### Open Questions +- None. + +### What I Checked +- Short list of the main commands/files/contracts inspected. +``` From 578c144c242834839a0a326a13ce3c301427148b Mon Sep 17 00:00:00 2001 From: Promansis Date: Sat, 30 May 2026 22:56:42 +0800 Subject: [PATCH 02/40] Fix Bunny review comment posting --- .github/workflows/bunny-style-review.yml | 102 +++++++---------------- 1 file changed, 32 insertions(+), 70 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index d04ff38b0..d4234853e 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -1,103 +1,65 @@ +# .github/workflows/bunny-review.yml name: Bunny Style Review on: - pull_request: - types: [opened, reopened, synchronize, ready_for_review] issue_comment: types: [created] permissions: contents: read - issues: write pull-requests: write -concurrency: - group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: true - jobs: review: if: > - github.event_name == 'pull_request' || - ( - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/bunny-review') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) - ) + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association) runs-on: ubuntu-latest - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_MODEL: gpt-5.5 - REVIEW_MARKER: "" - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} steps: - - name: Skip when no OpenAI key is configured - if: env.OPENAI_API_KEY == '' - run: | - echo "OPENAI_API_KEY is not configured; skipping Bunny Style Review." - - - name: Check out trusted review runner - uses: actions/checkout@v4 - if: env.OPENAI_API_KEY != '' - with: - ref: ${{ github.base_ref || github.event.repository.default_branch }} - fetch-depth: 0 - path: bunny-runner - - - name: Check out PR target + - name: Checkout repository uses: actions/checkout@v4 - if: env.OPENAI_API_KEY != '' with: fetch-depth: 0 - path: bunny-target - - name: Switch target checkout to PR head - if: env.OPENAI_API_KEY != '' - working-directory: bunny-target + - name: Fetch PR and checkout head env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr checkout "$PR_NUMBER" - BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) - git fetch origin "$BASE" --depth=1 || true - echo "PR_BASE_REF=origin/$BASE" >> "$GITHUB_ENV" + # Verify we're in a git repo + git status + + # Get PR details + PR_NUM="${{ github.event.issue.number }}" + BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) + HEAD=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) + + # Fetch the PR branches + git fetch origin "$BASE:$BASE" || git fetch origin "$BASE" + git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM" + + # Checkout the PR head + git checkout "pr-$PR_NUM" + + # Set the base ref for the review script + echo "PR_BASE_REF=$BASE" >> "$GITHUB_ENV" - uses: actions/setup-python@v5 - if: env.OPENAI_API_KEY != '' with: python-version: "3.12" - - name: Install review dependencies - if: env.OPENAI_API_KEY != '' - run: pip install -r bunny-runner/scripts/requirements.txt + - name: Install dependencies + run: pip install -r scripts/requirements.txt - name: Run review - if: env.OPENAI_API_KEY != '' - working-directory: bunny-target env: - BUNNY_SKILL_PATH: ${{ github.workspace }}/bunny-runner/skills/bunny-style-review/SKILL.md - run: python ../bunny-runner/scripts/bunny_review.py + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + LLM_MODEL: gpt-5.5 + run: python scripts/bunny_review.py - - name: Post or update the review - if: env.OPENAI_API_KEY != '' + - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - { - echo "$REVIEW_MARKER" - cat bunny-target/review.md - } > review-comment.md - - COMMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${REVIEW_MARKER}\"))) | .id" \ - | tail -n 1) - - if [ -n "$COMMENT_ID" ]; then - BODY=$(cat review-comment.md) - gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \ - --method PATCH \ - --field body="$BODY" >/dev/null - else - gh pr comment "$PR_NUMBER" --body-file review-comment.md - fi + run: gh pr comment "${{ github.event.issue.number }}" --body-file review.md \ No newline at end of file From 13192144ff77cfdacb68f8e511f90ef6d2f72a78 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 00:01:14 +0800 Subject: [PATCH 03/40] Repair manual Bunny review workflow --- .github/workflows/bunny-style-review.yml | 37 +++++++++++------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index d4234853e..cdde76124 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -1,4 +1,3 @@ -# .github/workflows/bunny-review.yml name: Bunny Style Review on: @@ -7,6 +6,7 @@ on: permissions: contents: read + issues: write pull-requests: write jobs: @@ -18,32 +18,27 @@ jobs: github.event.comment.author_association) runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: Checkout trusted review runner uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Fetch PR and checkout head + - name: Checkout PR target + uses: actions/checkout@v4 + with: + fetch-depth: 0 + path: bunny-target + + - name: Fetch PR and checkout target head + working-directory: bunny-target env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Verify we're in a git repo - git status - - # Get PR details PR_NUM="${{ github.event.issue.number }}" BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) - HEAD=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) - - # Fetch the PR branches - git fetch origin "$BASE:$BASE" || git fetch origin "$BASE" - git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM" - - # Checkout the PR head - git checkout "pr-$PR_NUM" - - # Set the base ref for the review script - echo "PR_BASE_REF=$BASE" >> "$GITHUB_ENV" + gh pr checkout "$PR_NUM" + git fetch origin "$BASE" --depth=1 || true + echo "PR_BASE_REF=origin/$BASE" >> "$GITHUB_ENV" - uses: actions/setup-python@v5 with: @@ -53,13 +48,15 @@ jobs: run: pip install -r scripts/requirements.txt - name: Run review + working-directory: bunny-target env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_MODEL: gpt-5.5 - run: python scripts/bunny_review.py + BUNNY_SKILL_PATH: ${{ github.workspace }}/skills/bunny-style-review/SKILL.md + run: python ../scripts/bunny_review.py - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "${{ github.event.issue.number }}" --body-file review.md \ No newline at end of file + run: gh pr comment "${{ github.event.issue.number }}" --body-file bunny-target/review.md From b324b97aed7177cf8f646bbcc6069e9bbb032b15 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 00:06:48 +0800 Subject: [PATCH 04/40] Restore automatic Bunny review trigger --- .github/workflows/bunny-style-review.yml | 27 +++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index cdde76124..ac7d33ad3 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -1,6 +1,8 @@ name: Bunny Style Review on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] issue_comment: types: [created] @@ -9,18 +11,28 @@ permissions: issues: write pull-requests: write +concurrency: + group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + jobs: review: if: > - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/bunny-review') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), - github.event.comment.author_association) + github.event_name == 'pull_request' || + ( + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association) + ) runs-on: ubuntu-latest + env: + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} steps: - name: Checkout trusted review runner uses: actions/checkout@v4 with: + ref: ${{ github.base_ref || github.event.repository.default_branch }} fetch-depth: 0 - name: Checkout PR target @@ -34,9 +46,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PR_NUM="${{ github.event.issue.number }}" - BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) - gh pr checkout "$PR_NUM" + BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) + gh pr checkout "$PR_NUMBER" git fetch origin "$BASE" --depth=1 || true echo "PR_BASE_REF=origin/$BASE" >> "$GITHUB_ENV" @@ -59,4 +70,4 @@ jobs: - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "${{ github.event.issue.number }}" --body-file bunny-target/review.md + run: gh pr comment "$PR_NUMBER" --body-file bunny-target/review.md From 25e16d55854a50e3b3a3fe05a5643acf6b13c2ce Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 00:39:23 +0800 Subject: [PATCH 05/40] Harden Bunny review output formatting --- .github/workflows/bunny-style-review.yml | 1 + scripts/bunny_review.py | 38 ++++++++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index ac7d33ad3..b93e94232 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -64,6 +64,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_MODEL: gpt-5.5 + LLM_REASONING_EFFORT: ${{ vars.LLM_REASONING_EFFORT }} BUNNY_SKILL_PATH: ${{ github.workspace }}/skills/bunny-style-review/SKILL.md run: python ../scripts/bunny_review.py diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 63f886916..973b67ca9 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -12,6 +12,7 @@ MAX_ITERS = 40 MAX_FILE_BYTES = 200_000 MAX_TOOL_OUTPUT = 60_000 +REVIEW_MARKER = "## Bunny Review" BLOCKED_NAMES = { ".env", @@ -197,6 +198,35 @@ def _client() -> OpenAI: return OpenAI(**kwargs) +def normalize_review(content: str) -> str: + text = content.strip() + marker_index = text.find(REVIEW_MARKER) + if marker_index >= 0: + return f"{text[marker_index:].rstrip()}\n" + + return ( + f"{REVIEW_MARKER}\n\n" + "### Findings\n" + "Review output did not match the expected Bunny format, so the raw model response was suppressed.\n\n" + "### Open Questions\n" + "- The model response did not include the required Bunny Review header.\n\n" + "### What I Checked\n" + "- The review runner completed, but rejected the final response format before posting.\n" + ) + + +def _completion_kwargs(model: str, messages: list[dict[str, Any]]) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "model": model, + "messages": messages, + "tools": TOOLS, + } + reasoning_effort = os.environ.get("LLM_REASONING_EFFORT", "").strip() + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort + return kwargs + + def main() -> None: client = _client() skill_path = pathlib.Path( @@ -221,16 +251,12 @@ def main() -> None: ] for _ in range(MAX_ITERS): - resp = client.chat.completions.create( - model=model, - messages=messages, - tools=TOOLS, - ) + resp = client.chat.completions.create(**_completion_kwargs(model, messages)) msg = resp.choices[0].message messages.append(msg.model_dump(exclude_none=True)) if not msg.tool_calls: - pathlib.Path("review.md").write_text(msg.content or "", "utf-8") + pathlib.Path("review.md").write_text(normalize_review(msg.content or ""), "utf-8") return for call in msg.tool_calls: From 799c297546d56a7984ee1b84e14915809e3716dc Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 12:57:29 +0800 Subject: [PATCH 06/40] updated security/secrets and conflict with CI --- .github/workflows/bunny-style-review.yml | 79 +++--- scripts/bunny_review.py | 329 +++++++---------------- skills/marinara-secrets-detection.md | 54 ++++ 3 files changed, 200 insertions(+), 262 deletions(-) create mode 100644 skills/marinara-secrets-detection.md diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index b93e94232..84d6bec42 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -1,55 +1,67 @@ +# .github/workflows/bunny-review.yml name: Bunny Style Review on: - pull_request: - types: [opened, reopened, synchronize, ready_for_review] issue_comment: types: [created] permissions: contents: read - issues: write pull-requests: write - -concurrency: - group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: true + actions: read # needed to read CI status jobs: review: if: > - github.event_name == 'pull_request' || - ( - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/bunny-review') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), - github.event.comment.author_association) - ) + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association) runs-on: ubuntu-latest - env: - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} steps: - - name: Checkout trusted review runner + - name: Checkout repository uses: actions/checkout@v4 with: - ref: ${{ github.base_ref || github.event.repository.default_branch }} fetch-depth: 0 - - name: Checkout PR target - uses: actions/checkout@v4 - with: - fetch-depth: 0 - path: bunny-target + - name: Fetch PR and checkout head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git status + + PR_NUM="${{ github.event.issue.number }}" + BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) + HEAD=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) + + git fetch origin "$BASE:$BASE" || git fetch origin "$BASE" + git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM" + + git checkout "pr-$PR_NUM" + + echo "PR_BASE_REF=$BASE" >> "$GITHUB_ENV" - - name: Fetch PR and checkout target head - working-directory: bunny-target + - name: Get CI status env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) - gh pr checkout "$PR_NUMBER" - git fetch origin "$BASE" --depth=1 || true - echo "PR_BASE_REF=origin/$BASE" >> "$GITHUB_ENV" + PR_NUM="${{ github.event.issue.number }}" + + # Get the latest commit SHA for the PR + HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) + + # Get check runs for this commit + CI_CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq '.check_runs | map(select(.name | test("Frontend|Rust|Smoke"))) | + map("\(.name): \(.conclusion // "pending")") | join(", ")') + + if [ -z "$CI_CHECKS" ]; then + CI_STATUS="No CI checks found for this PR" + else + CI_STATUS="$CI_CHECKS" + fi + + echo "CI_STATUS=$CI_STATUS" >> "$GITHUB_ENV" - uses: actions/setup-python@v5 with: @@ -59,16 +71,13 @@ jobs: run: pip install -r scripts/requirements.txt - name: Run review - working-directory: bunny-target env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_MODEL: gpt-5.5 - LLM_REASONING_EFFORT: ${{ vars.LLM_REASONING_EFFORT }} - BUNNY_SKILL_PATH: ${{ github.workspace }}/skills/bunny-style-review/SKILL.md - run: python ../scripts/bunny_review.py + LLM_MODEL: gpt-4o + run: python scripts/bunny_review.py - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "$PR_NUMBER" --body-file bunny-target/review.md + run: gh pr comment "${{ github.event.issue.number }}" --body-file review.md \ No newline at end of file diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 973b67ca9..265aabc59 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -1,283 +1,158 @@ -import fnmatch -import glob -import json -import os -import pathlib -import subprocess -from typing import Any - +# scripts/bunny_review.py +import json, os, pathlib, subprocess, glob, fnmatch from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() MAX_ITERS = 40 MAX_FILE_BYTES = 200_000 -MAX_TOOL_OUTPUT = 60_000 -REVIEW_MARKER = "## Bunny Review" - -BLOCKED_NAMES = { - ".env", - ".env.local", - ".env.development", - ".env.production", - ".npmrc", - ".netrc", - "credentials.json", - "id_ed25519", - "id_rsa", -} -BLOCKED_DIRS = { - ".git", - ".idea", - ".vscode", - "dist", - "dist-ssr", - "node_modules", - "playwright-report", - "src-tauri/target", - "target", -} - +# --- safety: keep file reads inside the repo and away from secrets --- def _safe_path(rel: str) -> pathlib.Path: full = (REPO_ROOT / rel).resolve() if full != REPO_ROOT and REPO_ROOT not in full.parents: raise ValueError("path escapes repo root") - relative = full.relative_to(REPO_ROOT).as_posix() name = full.name.lower() - if name.startswith(".env") or name in BLOCKED_NAMES: + if name.startswith(".env") or name in { + "credentials.json", "id_rsa", "id_ed25519", ".npmrc", ".netrc" + }: raise ValueError("blocked sensitive file") - if any(relative == blocked or relative.startswith(f"{blocked}/") for blocked in BLOCKED_DIRS): - raise ValueError("blocked generated or internal path") return full - -def _is_readable_repo_file(path: pathlib.Path) -> bool: - try: - relative = path.resolve().relative_to(REPO_ROOT).as_posix() - except ValueError: - return False - name = path.name.lower() - if name.startswith(".env") or name in BLOCKED_NAMES: - return False - if any(relative == blocked or relative.startswith(f"{blocked}/") for blocked in BLOCKED_DIRS): - return False - return path.is_file() - - +# --- read-only git only; no build/check commands --- ALLOWED_GIT = { - "status", - "diff", - "log", - "show", - "rev-parse", - "merge-base", - "name-only", - "ls-files", - "blame", + "status", "diff", "log", "show", "rev-parse", + "merge-base", "name-only", "ls-files", "blame", } - - -def run_git(args: list[str]) -> str: +def run_git(args): if not args or args[0] not in ALLOWED_GIT: return f"refused: git '{args[0] if args else ''}' not allowed" out = subprocess.run( - ["git", *args], - cwd=REPO_ROOT, - capture_output=True, - text=True, - timeout=60, - check=False, + ["git", *args], cwd=REPO_ROOT, + capture_output=True, text=True, timeout=60, ) - return (out.stdout + out.stderr)[:MAX_TOOL_OUTPUT] + return (out.stdout + out.stderr)[:60_000] - -def read_file(path: str, start: int = 1, end: int | None = None) -> str: +def read_file(path, start=1, end=None): p = _safe_path(path) data = p.read_text(encoding="utf-8", errors="replace")[:MAX_FILE_BYTES] lines = data.splitlines() - stop = end or len(lines) - chunk = lines[max(0, start - 1) : stop] - return "\n".join(f"{i + start}: {line}" for i, line in enumerate(chunk)) - + end = end or len(lines) + chunk = lines[max(0, start - 1):end] + return "\n".join(f"{i + start}: {l}" for i, l in enumerate(chunk)) -def list_dir(path: str = ".") -> str: +def list_dir(path="."): p = _safe_path(path) - return "\n".join(sorted(f"{c.name}/" if c.is_dir() else c.name for c in p.iterdir())) - - -def search(pattern: str, glob_expr: str = "**/*") -> str: - hits: list[str] = [] - for candidate in glob.glob(str(REPO_ROOT / glob_expr), recursive=True): - fp = pathlib.Path(candidate) - if not _is_readable_repo_file(fp): - continue - rel = fp.relative_to(REPO_ROOT).as_posix() - if not fnmatch.fnmatch(rel, glob_expr): + return "\n".join(sorted( + f"{c.name}/" if c.is_dir() else c.name for c in p.iterdir() + )) + +def search(pattern, glob_expr="**/*"): + hits = [] + for f in glob.glob(str(REPO_ROOT / glob_expr), recursive=True): + fp = pathlib.Path(f) + if not fp.is_file(): continue try: - for line_number, line in enumerate(fp.read_text("utf-8", "replace").splitlines(), 1): + for n, line in enumerate(fp.read_text("utf-8", "replace").splitlines(), 1): if pattern in line: - hits.append(f"{rel}:{line_number}: {line.strip()[:200]}") + rel = fp.relative_to(REPO_ROOT) + hits.append(f"{rel}:{n}: {line.strip()[:200]}") if len(hits) >= 100: return "\n".join(hits) - except OSError: + except Exception: continue return "\n".join(hits) or "no matches" - -TOOLS: list[dict[str, Any]] = [ - { - "type": "function", - "function": { - "name": "run_git", - "description": "Run a read-only git command. First arg is the subcommand.", - "parameters": { - "type": "object", - "properties": {"args": {"type": "array", "items": {"type": "string"}}}, - "required": ["args"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "read_file", - "description": "Read a repo file. Optional 1-based start/end lines.", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "start": {"type": "integer"}, - "end": {"type": "integer"}, - }, - "required": ["path"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_dir", - "description": "List a directory inside the repo.", - "parameters": { - "type": "object", - "properties": {"path": {"type": "string"}}, - }, - }, - }, - { - "type": "function", - "function": { - "name": "search", - "description": "Substring search across files matching a glob.", - "parameters": { - "type": "object", - "properties": { - "pattern": {"type": "string"}, - "glob_expr": {"type": "string"}, - }, - "required": ["pattern"], - }, - }, - }, +TOOLS = [ + {"type": "function", "function": { + "name": "run_git", + "description": "Run a read-only git command. First arg is the subcommand.", + "parameters": {"type": "object", "properties": { + "args": {"type": "array", "items": {"type": "string"}}}, + "required": ["args"]}}}, + {"type": "function", "function": { + "name": "read_file", + "description": "Read a repo file. Optional 1-based start/end lines.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string"}, + "start": {"type": "integer"}, "end": {"type": "integer"}}, + "required": ["path"]}}}, + {"type": "function", "function": { + "name": "list_dir", + "description": "List a directory inside the repo.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string"}}, "required": []}}}, + {"type": "function", "function": { + "name": "search", + "description": "Substring search across files matching a glob.", + "parameters": {"type": "object", "properties": { + "pattern": {"type": "string"}, "glob_expr": {"type": "string"}}, + "required": ["pattern"]}}}, ] +DISPATCH = {"run_git": run_git, "read_file": read_file, + "list_dir": list_dir, "search": search} -DISPATCH = { - "run_git": run_git, - "read_file": read_file, - "list_dir": list_dir, - "search": search, -} - - -def _client() -> OpenAI: - kwargs: dict[str, str] = {"api_key": os.environ["OPENAI_API_KEY"]} - base_url = os.environ.get("LLM_BASE_URL") - if base_url: - kwargs["base_url"] = base_url - return OpenAI(**kwargs) - - -def normalize_review(content: str) -> str: - text = content.strip() - marker_index = text.find(REVIEW_MARKER) - if marker_index >= 0: - return f"{text[marker_index:].rstrip()}\n" - - return ( - f"{REVIEW_MARKER}\n\n" - "### Findings\n" - "Review output did not match the expected Bunny format, so the raw model response was suppressed.\n\n" - "### Open Questions\n" - "- The model response did not include the required Bunny Review header.\n\n" - "### What I Checked\n" - "- The review runner completed, but rejected the final response format before posting.\n" +def main(): + client = OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get("LLM_BASE_URL"), ) - - -def _completion_kwargs(model: str, messages: list[dict[str, Any]]) -> dict[str, Any]: - kwargs: dict[str, Any] = { - "model": model, - "messages": messages, - "tools": TOOLS, - } - reasoning_effort = os.environ.get("LLM_REASONING_EFFORT", "").strip() - if reasoning_effort: - kwargs["reasoning_effort"] = reasoning_effort - return kwargs - - -def main() -> None: - client = _client() - skill_path = pathlib.Path( - os.environ.get("BUNNY_SKILL_PATH", REPO_ROOT / "skills/bunny-style-review/SKILL.md") + skill = (REPO_ROOT / "skills/bunny-style-review/SKILL.md").read_text("utf-8") + base = os.environ.get("PR_BASE_REF", "main") + ci_status = os.environ.get("CI_STATUS", "") + + # Build the user message with CI status if available + user_content = ( + f"Review this PR. The review base branch is '{base}'. " + f"Follow the skill's Setup and Review Passes using the tools, " + f"load only the docs and marinara skills that match the touched area, " + f"and produce the final review in the skill's Output Shape. " + f"Do not edit files." ) - skill = skill_path.read_text("utf-8") - base = os.environ.get("PR_BASE_REF", "origin/main") - model = os.environ.get("LLM_MODEL", "gpt-5.5") - - messages: list[dict[str, Any]] = [ + + if ci_status: + user_content += ( + f"\n\nCI Status: {ci_status}\n" + f"The CI jobs (typecheck, build, cargo check, architecture checks, tests) " + f"have already run. Reference their status in your Validation section rather " + f"than re-running these commands. Focus your verification on reasoning checks " + f"that CI cannot perform: ownership boundaries, failure-path analysis, " + f"mode separation, and contract correctness." + ) + + user_content += "\n\nWhen done, reply with only the review text in the Output Shape format." + + messages = [ {"role": "system", "content": skill}, - { - "role": "user", - "content": ( - f"Review this PR. The review base branch is '{base}'. " - "Follow the skill's Setup and Review Passes using the tools, " - "load only the docs and Marinara skills that match the touched area, " - "and produce the final review in the skill's Output Shape. " - "Do not edit files. When done, reply with only the review text." - ), - }, + {"role": "user", "content": user_content}, ] for _ in range(MAX_ITERS): - resp = client.chat.completions.create(**_completion_kwargs(model, messages)) + resp = client.chat.completions.create( + model=os.environ.get("LLM_MODEL", "gpt-4o"), + messages=messages, + tools=TOOLS, + ) msg = resp.choices[0].message messages.append(msg.model_dump(exclude_none=True)) if not msg.tool_calls: - pathlib.Path("review.md").write_text(normalize_review(msg.content or ""), "utf-8") + pathlib.Path("review.md").write_text(msg.content or "", "utf-8") return for call in msg.tool_calls: try: args = json.loads(call.function.arguments or "{}") result = DISPATCH[call.function.name](**args) - except Exception as exc: - result = f"error: {exc}" - messages.append( - { - "role": "tool", - "tool_call_id": call.id, - "content": str(result)[:MAX_TOOL_OUTPUT], - } - ) + except Exception as e: + result = f"error: {e}" + messages.append({ + "role": "tool", "tool_call_id": call.id, + "content": str(result)[:60_000], + }) pathlib.Path("review.md").write_text( - "Review did not converge within the iteration budget.", - "utf-8", - ) - + "Review did not converge within the iteration budget.", "utf-8") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/skills/marinara-secrets-detection.md b/skills/marinara-secrets-detection.md new file mode 100644 index 000000000..1232404e3 --- /dev/null +++ b/skills/marinara-secrets-detection.md @@ -0,0 +1,54 @@ +# Marinara Secrets Detection + +Patterns to flag when scanning for hardcoded credentials. + +## Patterns + +**Cloud Providers** +- AWS Access Key: `AKIA[0-9A-Z]{16}` +- AWS Temp Key: `ASIA[0-9A-Z]{16}` +- AWS Secret: `[0-9a-zA-Z/+]{40}` (in vars named `*SECRET*`, `*AWS*`) +- Google Cloud: `AIza[0-9A-Za-z\-_]{35}` + +**AI Providers** +- OpenAI: `sk-[a-zA-Z0-9]{48}` or `sk-proj-[a-zA-Z0-9]{48}` +- Anthropic: `sk-ant-[a-zA-Z0-9-_]{95,}` +- DeepSeek: `sk-[a-zA-Z0-9]{64}` + +**Payment & SaaS** +- Stripe live: `(sk|pk|rk)_live_[0-9a-zA-Z]{24,}` +- Slack: `xox[baprs]-[a-zA-Z0-9-]{10,72}` + +**Version Control** +- GitHub PAT: `ghp_[a-zA-Z0-9]{36}` or `github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}` +- GitHub OAuth: `gho_[a-zA-Z0-9]{36}` + +**Auth Tokens** +- JWT: `eyJ[a-zA-Z0-9-_]+\.eyJ[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+` +- Private keys: `-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----` + +**Database** +- PostgreSQL: `postgresql://[^:]+:[^@]+@(?!localhost|127\.0\.0\.1|example\.com)[^/]+` +- MySQL: `mysql://[^:]+:[^@]+@(?!localhost|127\.0\.0\.1|example\.com)[^/]+` +- MongoDB: `mongodb(\+srv)?://[^:]+:[^@]+@(?!localhost|127\.0\.0\.1|example\.com)[^/]+` + +**Generic High-Entropy** +- Vars named `*SECRET*`, `*KEY*`, `*TOKEN*`, `*PASSWORD*`, `*AUTH*`, `*CREDENTIAL*`, `*API_KEY*` assigned strings >20 chars with mixed case/numbers + +## Ignore + +**Files**: `.env.example`, `.env.template`, `*.test.ts`, `*.spec.ts`, `__tests__/*`, `__mocks__/*`, `*.md`, `docs/*` + +**Values**: starts with `your_`, `enter_`, `my_`, `test_`, `demo_`, `example_`, or wrapped in ``, `{YOUR_*}`, `[YOUR_*]`, or equals `xxx`, `12345`, `test`, `demo`, `sample` + +**Hosts**: `localhost`, `127.0.0.1`, `0.0.0.0`, `example.com`, `test.com` + +**Public keys**: starts with `ssh-rsa`, `ssh-ed25519`, `ssh-dss`, or ends in `.pub` + +**Hashes**: SHA256/SHA512/MD5 for integrity, git commit hashes, package lock hashes + +## Severity + +- **Blocking**: Live production keys (AWS, Stripe live, real API keys), production DB credentials, production cert private keys +- **High**: Dev/staging credentials, test keys for paid services, high-entropy secrets of unknown origin +- **Medium**: Rotated credentials still in code, dev credentials in non-example files, weak secrets \ No newline at end of file From 624ed044c1ad4d7d15171c854c3963ba68437189 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 13:13:12 +0800 Subject: [PATCH 07/40] reinstate pull request automation --- .github/workflows/bunny-style-review.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index 84d6bec42..e69ef3056 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -2,14 +2,20 @@ name: Bunny Style Review on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] issue_comment: types: [created] - + permissions: contents: read pull-requests: write actions: read # needed to read CI status +concurrency: + group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + jobs: review: if: > @@ -74,7 +80,7 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_MODEL: gpt-4o + LLM_MODEL: gpt-5.5 run: python scripts/bunny_review.py - name: Post the review From 9e184903eacdc6543cd0bb39b71a1ed7a18abca5 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 13:19:48 +0800 Subject: [PATCH 08/40] Run Bunny review on PR events --- .github/workflows/bunny-style-review.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index e69ef3056..6c9d83cec 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -19,11 +19,16 @@ concurrency: jobs: review: if: > - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/bunny-review') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), - github.event.comment.author_association) + github.event_name == 'pull_request' || + ( + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association) + ) runs-on: ubuntu-latest + env: + PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -36,7 +41,6 @@ jobs: run: | git status - PR_NUM="${{ github.event.issue.number }}" BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) HEAD=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) @@ -51,8 +55,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PR_NUM="${{ github.event.issue.number }}" - # Get the latest commit SHA for the PR HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) @@ -86,4 +88,4 @@ jobs: - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "${{ github.event.issue.number }}" --body-file review.md \ No newline at end of file + run: gh pr comment "$PR_NUM" --body-file review.md From dd531033669a87b40a8c68e8e3b3dfac17d41727 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 13:23:22 +0800 Subject: [PATCH 09/40] Keep Bunny review tooling available --- .github/workflows/bunny-style-review.yml | 13 +++++++++++-- scripts/bunny_review.py | 9 +++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml index 6c9d83cec..6a3461c79 100644 --- a/.github/workflows/bunny-style-review.yml +++ b/.github/workflows/bunny-style-review.yml @@ -35,6 +35,14 @@ jobs: with: fetch-depth: 0 + - name: Preserve review tooling + run: | + mkdir -p /tmp/bunny-review-tool/scripts + mkdir -p /tmp/bunny-review-tool/skills/bunny-style-review + cp scripts/bunny_review.py /tmp/bunny-review-tool/scripts/bunny_review.py + cp scripts/requirements.txt /tmp/bunny-review-tool/scripts/requirements.txt + cp skills/bunny-style-review/SKILL.md /tmp/bunny-review-tool/skills/bunny-style-review/SKILL.md + - name: Fetch PR and checkout head env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -76,14 +84,15 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install -r scripts/requirements.txt + run: pip install -r /tmp/bunny-review-tool/scripts/requirements.txt - name: Run review env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_MODEL: gpt-5.5 - run: python scripts/bunny_review.py + BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/skills/bunny-style-review/SKILL.md + run: python /tmp/bunny-review-tool/scripts/bunny_review.py - name: Post the review env: diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 265aabc59..d91e63a4e 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -97,7 +97,12 @@ def main(): api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ.get("LLM_BASE_URL"), ) - skill = (REPO_ROOT / "skills/bunny-style-review/SKILL.md").read_text("utf-8") + skill_path = pathlib.Path( + os.environ.get("BUNNY_REVIEW_SKILL_PATH", "skills/bunny-style-review/SKILL.md") + ) + if not skill_path.is_absolute(): + skill_path = REPO_ROOT / skill_path + skill = skill_path.read_text("utf-8") base = os.environ.get("PR_BASE_REF", "main") ci_status = os.environ.get("CI_STATUS", "") @@ -155,4 +160,4 @@ def main(): "Review did not converge within the iteration budget.", "utf-8") if __name__ == "__main__": - main() \ No newline at end of file + main() From 77a9d2540acec7e45c3ec87857fca95fcaff41c0 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 13:34:48 +0800 Subject: [PATCH 10/40] add Plain-language summary and stop the 'only blocking' review in bunny review --- skills/bunny-style-review/SKILL.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/skills/bunny-style-review/SKILL.md b/skills/bunny-style-review/SKILL.md index 4e356ffe1..9c5dadcdb 100644 --- a/skills/bunny-style-review/SKILL.md +++ b/skills/bunny-style-review/SKILL.md @@ -30,7 +30,7 @@ You must not edit files, run project code, read secrets, or request external net Prioritize correctness, user-visible regressions, security/privacy, architecture boundaries, mode ownership, missing tests, and CI/deployment failures. -Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. Every finding must cite a concrete file and line or a small changed area. +Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, or `low`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. Every finding must cite a concrete file and line or a small changed area. Treat these as high-signal Marinara review concerns: @@ -49,6 +49,9 @@ Reply with only the review text. Use this exact structure: ``` ## Bunny Review +### Change Summary +- Plain-language summary of what the PR changes and why it matters. Write for a reader who may not know the codebase or implementation details. + ### Findings - [severity] file:line - Finding title. Explain the concrete risk, why it happens, and the smallest useful fix. @@ -64,8 +67,11 @@ If there are no findings, write: ``` ## Bunny Review +### Change Summary +- Plain-language summary of what the PR changes and why it matters. Write for a reader who may not know the codebase or implementation details. + ### Findings -No blocking findings. +No actionable findings. ### Open Questions - None. From 1f5e8ce0048b658acf01f1f4d2e5293428a97142 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 13:49:11 +0800 Subject: [PATCH 11/40] Force Bunny review after tool budget --- scripts/bunny_review.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index d91e63a4e..c34ec09f8 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -3,7 +3,7 @@ from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() -MAX_ITERS = 40 +MAX_TOOL_ITERS = 30 MAX_FILE_BYTES = 200_000 # --- safety: keep file reads inside the repo and away from secrets --- @@ -119,22 +119,25 @@ def main(): user_content += ( f"\n\nCI Status: {ci_status}\n" f"The CI jobs (typecheck, build, cargo check, architecture checks, tests) " - f"have already run. Reference their status in your Validation section rather " + f"have already run. Reference their status in your What I Checked section rather " f"than re-running these commands. Focus your verification on reasoning checks " f"that CI cannot perform: ownership boundaries, failure-path analysis, " f"mode separation, and contract correctness." ) - user_content += "\n\nWhen done, reply with only the review text in the Output Shape format." + user_content += ( + "\n\nUse the tools for focused inspection only. When you have enough context, " + "stop calling tools and reply with only the review text in the Output Shape format." + ) messages = [ {"role": "system", "content": skill}, {"role": "user", "content": user_content}, ] - for _ in range(MAX_ITERS): + for _ in range(MAX_TOOL_ITERS): resp = client.chat.completions.create( - model=os.environ.get("LLM_MODEL", "gpt-4o"), + model=os.environ.get("LLM_MODEL", "gpt-5.5"), messages=messages, tools=TOOLS, ) @@ -156,8 +159,26 @@ def main(): "content": str(result)[:60_000], }) + messages.append({ + "role": "user", + "content": ( + "The tool-call budget is now closed. Do not call any more tools. " + "Using only the context already gathered, produce the final Bunny Review " + "in the required Output Shape. If evidence is incomplete, say so in " + "What I Checked instead of continuing research." + ), + }) + resp = client.chat.completions.create( + model=os.environ.get("LLM_MODEL", "gpt-5.5"), + messages=messages, + tools=TOOLS, + tool_choice="none", + ) + msg = resp.choices[0].message pathlib.Path("review.md").write_text( - "Review did not converge within the iteration budget.", "utf-8") + msg.content or "Bunny could not produce review text after the tool budget closed.", + "utf-8", + ) if __name__ == "__main__": main() From 5f7308deaf677162fcfcf05c6914b0d5f2aa18f3 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 14:11:20 +0800 Subject: [PATCH 12/40] Tighten Bunny review output --- skills/bunny-style-review/SKILL.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/skills/bunny-style-review/SKILL.md b/skills/bunny-style-review/SKILL.md index 9c5dadcdb..c5e4a5a2c 100644 --- a/skills/bunny-style-review/SKILL.md +++ b/skills/bunny-style-review/SKILL.md @@ -44,22 +44,22 @@ Treat these as high-signal Marinara review concerns: ## Output Shape -Reply with only the review text. Use this exact structure: +Reply with only the review text. Keep the review concise while still reporting every actionable finding. Do not include exhaustive audit trails, repeated CI history, or long file lists unless they change the reviewer’s decision. Use this exact structure: ``` ## Bunny Review ### Change Summary -- Plain-language summary of what the PR changes and why it matters. Write for a reader who may not know the codebase or implementation details. +- 1-2 plain-language sentences explaining what the PR changes and why it matters for a reader who may not know the codebase. ### Findings -- [severity] file:line - Finding title. Explain the concrete risk, why it happens, and the smallest useful fix. +- [severity] file:line - Finding title. Use 2-4 sentences max: risk, cause, smallest useful fix. ### Open Questions -- Question or assumption, if any. +- 0-2 concise questions or assumptions, if any. ### What I Checked -- Short list of the main commands/files/contracts inspected. +- 3-5 concise bullets covering the main commands, files, or contracts inspected. ``` If there are no findings, write: @@ -68,7 +68,7 @@ If there are no findings, write: ## Bunny Review ### Change Summary -- Plain-language summary of what the PR changes and why it matters. Write for a reader who may not know the codebase or implementation details. +- 1-2 plain-language sentences explaining what the PR changes and why it matters for a reader who may not know the codebase. ### Findings No actionable findings. @@ -77,5 +77,5 @@ No actionable findings. - None. ### What I Checked -- Short list of the main commands/files/contracts inspected. +- 3-5 concise bullets covering the main commands, files, or contracts inspected. ``` From 6e5af066abb5ca729f9b7b34e77001e80369fecb Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 14:15:57 +0800 Subject: [PATCH 13/40] Log Bunny review telemetry --- scripts/bunny_review.py | 62 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index c34ec09f8..39d9336ab 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -1,5 +1,5 @@ # scripts/bunny_review.py -import json, os, pathlib, subprocess, glob, fnmatch +import json, os, pathlib, subprocess, glob, fnmatch, time from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() @@ -92,6 +92,44 @@ def search(pattern, glob_expr="**/*"): DISPATCH = {"run_git": run_git, "read_file": read_file, "list_dir": list_dir, "search": search} +def usage_value(usage, *path): + current = usage + for key in path: + if current is None: + return 0 + if isinstance(current, dict): + current = current.get(key) + else: + current = getattr(current, key, None) + return current or 0 + +def add_usage(totals, usage): + totals["prompt_tokens"] += usage_value(usage, "prompt_tokens") + totals["completion_tokens"] += usage_value(usage, "completion_tokens") + totals["total_tokens"] += usage_value(usage, "total_tokens") + totals["reasoning_tokens"] += usage_value( + usage, "completion_tokens_details", "reasoning_tokens" + ) + +def print_telemetry(stats): + elapsed = time.monotonic() - stats["started_at"] + tool_counts = ", ".join( + f"{name}={count}" for name, count in sorted(stats["tool_counts"].items()) + ) or "none" + print( + "Bunny telemetry: " + f"elapsed_s={elapsed:.1f}; " + f"model_calls={stats['model_calls']}; " + f"tool_calls={stats['tool_calls']}; " + f"forced_final={stats['forced_final']}; " + f"tool_counts={tool_counts}; " + f"prompt_tokens={stats['prompt_tokens']}; " + f"completion_tokens={stats['completion_tokens']}; " + f"reasoning_tokens={stats['reasoning_tokens']}; " + f"total_tokens={stats['total_tokens']}", + flush=True, + ) + def main(): client = OpenAI( api_key=os.environ["OPENAI_API_KEY"], @@ -134,6 +172,17 @@ def main(): {"role": "system", "content": skill}, {"role": "user", "content": user_content}, ] + stats = { + "started_at": time.monotonic(), + "model_calls": 0, + "tool_calls": 0, + "tool_counts": {}, + "forced_final": False, + "prompt_tokens": 0, + "completion_tokens": 0, + "reasoning_tokens": 0, + "total_tokens": 0, + } for _ in range(MAX_TOOL_ITERS): resp = client.chat.completions.create( @@ -141,14 +190,21 @@ def main(): messages=messages, tools=TOOLS, ) + stats["model_calls"] += 1 + add_usage(stats, getattr(resp, "usage", None)) msg = resp.choices[0].message messages.append(msg.model_dump(exclude_none=True)) if not msg.tool_calls: pathlib.Path("review.md").write_text(msg.content or "", "utf-8") + print_telemetry(stats) return for call in msg.tool_calls: + stats["tool_calls"] += 1 + stats["tool_counts"][call.function.name] = ( + stats["tool_counts"].get(call.function.name, 0) + 1 + ) try: args = json.loads(call.function.arguments or "{}") result = DISPATCH[call.function.name](**args) @@ -168,17 +224,21 @@ def main(): "What I Checked instead of continuing research." ), }) + stats["forced_final"] = True resp = client.chat.completions.create( model=os.environ.get("LLM_MODEL", "gpt-5.5"), messages=messages, tools=TOOLS, tool_choice="none", ) + stats["model_calls"] += 1 + add_usage(stats, getattr(resp, "usage", None)) msg = resp.choices[0].message pathlib.Path("review.md").write_text( msg.content or "Bunny could not produce review text after the tool budget closed.", "utf-8", ) + print_telemetry(stats) if __name__ == "__main__": main() From 0b20183082fdb9841872774b69a2a12551cdba0d Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 14:33:17 +0800 Subject: [PATCH 14/40] Use review packets for Bunny CI --- scripts/bunny_review.py | 242 ++++++++++++----------------- skills/bunny-style-review/SKILL.md | 12 +- 2 files changed, 108 insertions(+), 146 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 39d9336ab..865cce593 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -1,10 +1,10 @@ # scripts/bunny_review.py -import json, os, pathlib, subprocess, glob, fnmatch, time +import os, pathlib, subprocess, time from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() -MAX_TOOL_ITERS = 30 -MAX_FILE_BYTES = 200_000 +MAX_REVIEW_PACKET_CHARS = 180_000 +MAX_SECTION_CHARS = 60_000 # --- safety: keep file reads inside the repo and away from secrets --- def _safe_path(rel: str) -> pathlib.Path: @@ -18,79 +18,101 @@ def _safe_path(rel: str) -> pathlib.Path: raise ValueError("blocked sensitive file") return full -# --- read-only git only; no build/check commands --- -ALLOWED_GIT = { - "status", "diff", "log", "show", "rev-parse", - "merge-base", "name-only", "ls-files", "blame", -} -def run_git(args): - if not args or args[0] not in ALLOWED_GIT: - return f"refused: git '{args[0] if args else ''}' not allowed" +def run_git(args, limit=MAX_SECTION_CHARS): out = subprocess.run( ["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, ) - return (out.stdout + out.stderr)[:60_000] - -def read_file(path, start=1, end=None): - p = _safe_path(path) - data = p.read_text(encoding="utf-8", errors="replace")[:MAX_FILE_BYTES] - lines = data.splitlines() - end = end or len(lines) - chunk = lines[max(0, start - 1):end] - return "\n".join(f"{i + start}: {l}" for i, l in enumerate(chunk)) + return truncate(out.stdout + out.stderr, limit) + +def truncate(text, limit): + if len(text) <= limit: + return text + return ( + text[:limit] + + f"\n\n[truncated: section was {len(text)} chars, limit is {limit} chars]\n" + ) -def list_dir(path="."): +def read_text(path, limit=MAX_SECTION_CHARS): p = _safe_path(path) - return "\n".join(sorted( - f"{c.name}/" if c.is_dir() else c.name for c in p.iterdir() - )) - -def search(pattern, glob_expr="**/*"): - hits = [] - for f in glob.glob(str(REPO_ROOT / glob_expr), recursive=True): - fp = pathlib.Path(f) - if not fp.is_file(): - continue + return truncate(p.read_text(encoding="utf-8", errors="replace"), limit) + +def changed_files(base): + names = run_git(["diff", "--name-only", f"{base}...HEAD"]) + return [line.strip() for line in names.splitlines() if line.strip()] + +def select_guidance(files): + guidance = ["AGENTS.md"] + joined = "\n".join(files) + if any( + marker in joined + for marker in ( + "src/engine/", + "src/features/", + "src/shared/api/", + "src-tauri/", + ) + ): + guidance.append("skills/marinara-architecture-guard/SKILL.md") + if any( + marker in joined + for marker in ( + "chat", + "roleplay", + "game", + "modes", + "prompt", + "generation", + "summary", + "memory", + ) + ): + guidance.append("skills/marinara-mode-separation/SKILL.md") + if any( + marker in joined + for marker in ( + "fix/", + "storage", + "imports", + "provider", + "transport", + "commands", + ) + ): + guidance.append("skills/marinara-bugfix-discipline/SKILL.md") + if any(marker in joined for marker in ("README", "docs/", "skills/", "AGENTS.md")): + guidance.append("skills/marinara-getting-started/SKILL.md") + return list(dict.fromkeys(guidance)) + +def build_review_packet(base, ci_status): + files = changed_files(base) + sections = [ + ("git status", run_git(["status", "--short", "--branch"], 12_000)), + ("repo root", run_git(["rev-parse", "--show-toplevel"], 4_000)), + ("merge base", run_git(["merge-base", "HEAD", base], 4_000)), + ("diff stat", run_git(["diff", "--stat", f"{base}...HEAD"], 20_000)), + ("changed files", "\n".join(files) or "No changed files reported."), + ("numstat", run_git(["diff", "--numstat", f"{base}...HEAD"], 20_000)), + ( + "patch", + run_git( + ["diff", "--find-renames", "--unified=80", f"{base}...HEAD"], + MAX_SECTION_CHARS, + ), + ), + ] + if ci_status: + sections.append(("CI status", ci_status)) + for path in select_guidance(files): try: - for n, line in enumerate(fp.read_text("utf-8", "replace").splitlines(), 1): - if pattern in line: - rel = fp.relative_to(REPO_ROOT) - hits.append(f"{rel}:{n}: {line.strip()[:200]}") - if len(hits) >= 100: - return "\n".join(hits) - except Exception: - continue - return "\n".join(hits) or "no matches" - -TOOLS = [ - {"type": "function", "function": { - "name": "run_git", - "description": "Run a read-only git command. First arg is the subcommand.", - "parameters": {"type": "object", "properties": { - "args": {"type": "array", "items": {"type": "string"}}}, - "required": ["args"]}}}, - {"type": "function", "function": { - "name": "read_file", - "description": "Read a repo file. Optional 1-based start/end lines.", - "parameters": {"type": "object", "properties": { - "path": {"type": "string"}, - "start": {"type": "integer"}, "end": {"type": "integer"}}, - "required": ["path"]}}}, - {"type": "function", "function": { - "name": "list_dir", - "description": "List a directory inside the repo.", - "parameters": {"type": "object", "properties": { - "path": {"type": "string"}}, "required": []}}}, - {"type": "function", "function": { - "name": "search", - "description": "Substring search across files matching a glob.", - "parameters": {"type": "object", "properties": { - "pattern": {"type": "string"}, "glob_expr": {"type": "string"}}, - "required": ["pattern"]}}}, -] -DISPATCH = {"run_git": run_git, "read_file": read_file, - "list_dir": list_dir, "search": search} + sections.append((f"guidance: {path}", read_text(path, 30_000))) + except Exception as exc: + sections.append((f"guidance: {path}", f"Could not read: {exc}")) + + packet = "\n\n".join( + f"## {title}\n```text\n{body}\n```" for title, body in sections + ) + return truncate(packet, MAX_REVIEW_PACKET_CHARS) def usage_value(usage, *path): current = usage @@ -113,16 +135,11 @@ def add_usage(totals, usage): def print_telemetry(stats): elapsed = time.monotonic() - stats["started_at"] - tool_counts = ", ".join( - f"{name}={count}" for name, count in sorted(stats["tool_counts"].items()) - ) or "none" print( "Bunny telemetry: " f"elapsed_s={elapsed:.1f}; " f"model_calls={stats['model_calls']}; " - f"tool_calls={stats['tool_calls']}; " - f"forced_final={stats['forced_final']}; " - f"tool_counts={tool_counts}; " + f"review_packet_chars={stats['review_packet_chars']}; " f"prompt_tokens={stats['prompt_tokens']}; " f"completion_tokens={stats['completion_tokens']}; " f"reasoning_tokens={stats['reasoning_tokens']}; " @@ -143,29 +160,19 @@ def main(): skill = skill_path.read_text("utf-8") base = os.environ.get("PR_BASE_REF", "main") ci_status = os.environ.get("CI_STATUS", "") + review_packet = build_review_packet(base, ci_status) - # Build the user message with CI status if available user_content = ( f"Review this PR. The review base branch is '{base}'. " - f"Follow the skill's Setup and Review Passes using the tools, " - f"load only the docs and marinara skills that match the touched area, " - f"and produce the final review in the skill's Output Shape. " - f"Do not edit files." + "Use the provided review packet as the complete inspection context. " + "Do not ask for tools, do not claim you ran commands beyond the packet, " + "and produce only the final review in the skill's Output Shape." ) - - if ci_status: - user_content += ( - f"\n\nCI Status: {ci_status}\n" - f"The CI jobs (typecheck, build, cargo check, architecture checks, tests) " - f"have already run. Reference their status in your What I Checked section rather " - f"than re-running these commands. Focus your verification on reasoning checks " - f"that CI cannot perform: ownership boundaries, failure-path analysis, " - f"mode separation, and contract correctness." - ) - user_content += ( - "\n\nUse the tools for focused inspection only. When you have enough context, " - "stop calling tools and reply with only the review text in the Output Shape format." + "\n\nFocus on correctness, contracts, failure paths, tests, and architecture. " + "If the packet is truncated or missing context for a potential issue, mention that " + "limitation in What I Checked rather than inventing certainty." + f"\n\n# Review Packet\n{review_packet}" ) messages = [ @@ -175,67 +182,22 @@ def main(): stats = { "started_at": time.monotonic(), "model_calls": 0, - "tool_calls": 0, - "tool_counts": {}, - "forced_final": False, + "review_packet_chars": len(review_packet), "prompt_tokens": 0, "completion_tokens": 0, "reasoning_tokens": 0, "total_tokens": 0, } - for _ in range(MAX_TOOL_ITERS): - resp = client.chat.completions.create( - model=os.environ.get("LLM_MODEL", "gpt-5.5"), - messages=messages, - tools=TOOLS, - ) - stats["model_calls"] += 1 - add_usage(stats, getattr(resp, "usage", None)) - msg = resp.choices[0].message - messages.append(msg.model_dump(exclude_none=True)) - - if not msg.tool_calls: - pathlib.Path("review.md").write_text(msg.content or "", "utf-8") - print_telemetry(stats) - return - - for call in msg.tool_calls: - stats["tool_calls"] += 1 - stats["tool_counts"][call.function.name] = ( - stats["tool_counts"].get(call.function.name, 0) + 1 - ) - try: - args = json.loads(call.function.arguments or "{}") - result = DISPATCH[call.function.name](**args) - except Exception as e: - result = f"error: {e}" - messages.append({ - "role": "tool", "tool_call_id": call.id, - "content": str(result)[:60_000], - }) - - messages.append({ - "role": "user", - "content": ( - "The tool-call budget is now closed. Do not call any more tools. " - "Using only the context already gathered, produce the final Bunny Review " - "in the required Output Shape. If evidence is incomplete, say so in " - "What I Checked instead of continuing research." - ), - }) - stats["forced_final"] = True resp = client.chat.completions.create( model=os.environ.get("LLM_MODEL", "gpt-5.5"), messages=messages, - tools=TOOLS, - tool_choice="none", ) stats["model_calls"] += 1 add_usage(stats, getattr(resp, "usage", None)) msg = resp.choices[0].message pathlib.Path("review.md").write_text( - msg.content or "Bunny could not produce review text after the tool budget closed.", + msg.content or "Bunny could not produce review text from the review packet.", "utf-8", ) print_telemetry(stats) diff --git a/skills/bunny-style-review/SKILL.md b/skills/bunny-style-review/SKILL.md index c5e4a5a2c..6b83b634d 100644 --- a/skills/bunny-style-review/SKILL.md +++ b/skills/bunny-style-review/SKILL.md @@ -1,17 +1,17 @@ --- name: bunny-style-review -description: "Review Marinara pull requests in a CodeRabbit-style CI pass by inspecting the live repository diff with read-only tools, loading only relevant local guidance, and producing concise actionable findings." +description: "Review Marinara pull requests in a CodeRabbit-style CI pass by inspecting a bounded review packet with the live diff, relevant local guidance, and CI context." --- # Bunny Style Review -You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the current repository with the provided tools before forming conclusions. +You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. When live read-only tools are available, use them only for focused follow-up context; in packet-only CI mode, review only the packet. -You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only tools. +You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. ## Setup -1. Establish the base and head: +1. Establish the base and head from the review packet: - Run `git status --short --branch`. - Run `git rev-parse --show-toplevel`. - Run `git merge-base HEAD `. @@ -23,8 +23,8 @@ You must not edit files, run project code, read secrets, or request external net - Chat, roleplay, or game mode changes: `skills/marinara-mode-separation/SKILL.md`. - Bug fixes or regressions: `skills/marinara-bugfix-discipline/SKILL.md`. - Onboarding/docs/run-build guidance: `skills/marinara-getting-started/SKILL.md`. -4. Read the changed files or focused line ranges needed to understand the behavior. -5. Use `search` and read-only git commands to inspect callers, contracts, tests, and adjacent implementations before reporting a finding. +4. Read the changed patch and focused guidance included in the packet. +5. Inspect callers, contracts, tests, and adjacent implementations from the packet before reporting a finding. If the packet is truncated or missing context for a suspected issue, say so instead of inventing certainty. ## Review Passes From 11a8d7eda5955c3eaeef40bae3f1bafeab5a21c9 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 14:48:18 +0800 Subject: [PATCH 15/40] Add bounded Bunny context pass --- scripts/bunny_review.py | 169 ++++++++++++++++++++++++----- skills/bunny-style-review/SKILL.md | 4 +- 2 files changed, 145 insertions(+), 28 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 865cce593..eff02928d 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -1,10 +1,16 @@ # scripts/bunny_review.py -import os, pathlib, subprocess, time +import json, os, pathlib, subprocess, time from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() MAX_REVIEW_PACKET_CHARS = 180_000 MAX_SECTION_CHARS = 60_000 +MAX_CONTEXT_FILES = 5 +MAX_CONTEXT_SEARCHES = 5 +MAX_CONTEXT_CHARS = 80_000 +MAX_CONTEXT_FILE_CHARS = 20_000 +MAX_SEARCH_HITS = 30 +MAX_SEARCH_FILE_BYTES = 250_000 # --- safety: keep file reads inside the repo and away from secrets --- def _safe_path(rel: str) -> pathlib.Path: @@ -37,6 +43,44 @@ def read_text(path, limit=MAX_SECTION_CHARS): p = _safe_path(path) return truncate(p.read_text(encoding="utf-8", errors="replace"), limit) +def read_context_file(path): + return read_text(path, MAX_CONTEXT_FILE_CHARS) + +def search_repo(pattern): + if not pattern or len(pattern) > 120: + return "refused: search pattern must be 1-120 characters" + hits = [] + ignored_parts = { + ".git", + "node_modules", + "target", + "dist", + "build", + ".next", + "coverage", + "playwright-report", + } + for path in REPO_ROOT.rglob("*"): + if len(hits) >= MAX_SEARCH_HITS: + break + if any(part in ignored_parts for part in path.parts): + continue + if not path.is_file(): + continue + if path.stat().st_size > MAX_SEARCH_FILE_BYTES: + continue + try: + rel = path.relative_to(REPO_ROOT) + text = path.read_text("utf-8", "replace") + except Exception: + continue + for line_no, line in enumerate(text.splitlines(), 1): + if pattern in line: + hits.append(f"{rel}:{line_no}: {line.strip()[:220]}") + if len(hits) >= MAX_SEARCH_HITS: + break + return "\n".join(hits) or "no matches" + def changed_files(base): names = run_git(["diff", "--name-only", f"{base}...HEAD"]) return [line.strip() for line in names.splitlines() if line.strip()] @@ -133,6 +177,20 @@ def add_usage(totals, usage): usage, "completion_tokens_details", "reasoning_tokens" ) +def build_stats(review_packet): + return { + "started_at": time.monotonic(), + "model_calls": 0, + "review_packet_chars": len(review_packet), + "extra_context_chars": 0, + "context_files": 0, + "context_searches": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "reasoning_tokens": 0, + "total_tokens": 0, + } + def print_telemetry(stats): elapsed = time.monotonic() - stats["started_at"] print( @@ -140,6 +198,9 @@ def print_telemetry(stats): f"elapsed_s={elapsed:.1f}; " f"model_calls={stats['model_calls']}; " f"review_packet_chars={stats['review_packet_chars']}; " + f"extra_context_chars={stats['extra_context_chars']}; " + f"context_files={stats['context_files']}; " + f"context_searches={stats['context_searches']}; " f"prompt_tokens={stats['prompt_tokens']}; " f"completion_tokens={stats['completion_tokens']}; " f"reasoning_tokens={stats['reasoning_tokens']}; " @@ -147,6 +208,57 @@ def print_telemetry(stats): flush=True, ) +def model_call(client, messages, stats): + resp = client.chat.completions.create( + model=os.environ.get("LLM_MODEL", "gpt-5.5"), + messages=messages, + ) + stats["model_calls"] += 1 + add_usage(stats, getattr(resp, "usage", None)) + return resp.choices[0].message.content or "" + +def parse_context_request(content): + marker = "CONTEXT_REQUEST" + if marker not in content: + return None + start = content.find("{") + end = content.rfind("}") + if start == -1 or end == -1 or end < start: + return {"files": [], "searches": []} + try: + parsed = json.loads(content[start : end + 1]) + except Exception: + return {"files": [], "searches": []} + files = parsed.get("files", []) + searches = parsed.get("searches", []) + return { + "files": [value for value in files if isinstance(value, str)][:MAX_CONTEXT_FILES], + "searches": [value for value in searches if isinstance(value, str)][:MAX_CONTEXT_SEARCHES], + } + +def build_extra_context(request, stats): + sections = [] + for path in request.get("files", []): + stats["context_files"] += 1 + try: + body = read_context_file(path) + except Exception as exc: + body = f"Could not read: {exc}" + sections.append((f"context file: {path}", body)) + for pattern in request.get("searches", []): + stats["context_searches"] += 1 + try: + body = search_repo(pattern) + except Exception as exc: + body = f"Could not search: {exc}" + sections.append((f"context search: {pattern}", body)) + context = "\n\n".join( + f"## {title}\n```text\n{body}\n```" for title, body in sections + ) + context = truncate(context, MAX_CONTEXT_CHARS) + stats["extra_context_chars"] = len(context) + return context + def main(): client = OpenAI( api_key=os.environ["OPENAI_API_KEY"], @@ -162,13 +274,16 @@ def main(): ci_status = os.environ.get("CI_STATUS", "") review_packet = build_review_packet(base, ci_status) - user_content = ( + triage_content = ( f"Review this PR. The review base branch is '{base}'. " "Use the provided review packet as the complete inspection context. " - "Do not ask for tools, do not claim you ran commands beyond the packet, " - "and produce only the final review in the skill's Output Shape." + "You have one chance to request focused extra context before the final review. " + "If the packet is enough, reply with FINAL_REVIEW followed by the review in the skill's Output Shape. " + "If more context is necessary to validate a concrete potential finding, reply only with " + 'CONTEXT_REQUEST and JSON like {"files":["path"],"searches":["literal text"]}. ' + f"Request at most {MAX_CONTEXT_FILES} files and {MAX_CONTEXT_SEARCHES} literal searches." ) - user_content += ( + triage_content += ( "\n\nFocus on correctness, contracts, failure paths, tests, and architecture. " "If the packet is truncated or missing context for a potential issue, mention that " "limitation in What I Checked rather than inventing certainty." @@ -177,29 +292,31 @@ def main(): messages = [ {"role": "system", "content": skill}, - {"role": "user", "content": user_content}, + {"role": "user", "content": triage_content}, ] - stats = { - "started_at": time.monotonic(), - "model_calls": 0, - "review_packet_chars": len(review_packet), - "prompt_tokens": 0, - "completion_tokens": 0, - "reasoning_tokens": 0, - "total_tokens": 0, - } + stats = build_stats(review_packet) - resp = client.chat.completions.create( - model=os.environ.get("LLM_MODEL", "gpt-5.5"), - messages=messages, - ) - stats["model_calls"] += 1 - add_usage(stats, getattr(resp, "usage", None)) - msg = resp.choices[0].message - pathlib.Path("review.md").write_text( - msg.content or "Bunny could not produce review text from the review packet.", - "utf-8", - ) + first_response = model_call(client, messages, stats) + request = parse_context_request(first_response) + if request is None: + review = first_response.replace("FINAL_REVIEW", "", 1).strip() + else: + extra_context = build_extra_context(request, stats) + final_messages = [ + {"role": "system", "content": skill}, + {"role": "user", "content": triage_content}, + {"role": "assistant", "content": first_response}, + { + "role": "user", + "content": ( + "Here is the bounded extra context you requested. " + "Do not request more context. Produce only the final review in the skill's Output Shape." + f"\n\n# Extra Context\n{extra_context}" + ), + }, + ] + review = model_call(client, final_messages, stats).replace("FINAL_REVIEW", "", 1).strip() + pathlib.Path("review.md").write_text(review or "Bunny could not produce review text.", "utf-8") print_telemetry(stats) if __name__ == "__main__": diff --git a/skills/bunny-style-review/SKILL.md b/skills/bunny-style-review/SKILL.md index 6b83b634d..7e7136aea 100644 --- a/skills/bunny-style-review/SKILL.md +++ b/skills/bunny-style-review/SKILL.md @@ -5,7 +5,7 @@ description: "Review Marinara pull requests in a CodeRabbit-style CI pass by ins # Bunny Style Review -You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. When live read-only tools are available, use them only for focused follow-up context; in packet-only CI mode, review only the packet. +You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. In two-pass CI mode, either produce a final review from the packet or request one small batch of focused extra context; after extra context is provided, produce the final review. You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. @@ -24,7 +24,7 @@ You must not edit files, run project code, read secrets, or request external net - Bug fixes or regressions: `skills/marinara-bugfix-discipline/SKILL.md`. - Onboarding/docs/run-build guidance: `skills/marinara-getting-started/SKILL.md`. 4. Read the changed patch and focused guidance included in the packet. -5. Inspect callers, contracts, tests, and adjacent implementations from the packet before reporting a finding. If the packet is truncated or missing context for a suspected issue, say so instead of inventing certainty. +5. Inspect callers, contracts, tests, and adjacent implementations from the packet before reporting a finding. If a concrete suspected issue needs missing caller, schema, or contract context, request that focused context once. If context remains missing after the extra batch, say so instead of inventing certainty. ## Review Passes From 4b256ec8fb513ed6c10c6d11c755e523f5e1a2ce Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 15:06:44 +0800 Subject: [PATCH 16/40] Handle string Bunny model responses --- scripts/bunny_review.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index eff02928d..3c782ebf0 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -215,6 +215,8 @@ def model_call(client, messages, stats): ) stats["model_calls"] += 1 add_usage(stats, getattr(resp, "usage", None)) + if isinstance(resp, str): + return resp return resp.choices[0].message.content or "" def parse_context_request(content): From 65c666afa6ece54449160ae82aec632bed63e5dc Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 15:36:39 +0800 Subject: [PATCH 17/40] Strip Bunny reasoning blocks --- scripts/bunny_review.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 3c782ebf0..987955605 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -1,5 +1,5 @@ # scripts/bunny_review.py -import json, os, pathlib, subprocess, time +import json, os, pathlib, re, subprocess, time from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() @@ -238,6 +238,13 @@ def parse_context_request(content): "searches": [value for value in searches if isinstance(value, str)][:MAX_CONTEXT_SEARCHES], } +def clean_review_text(content): + cleaned = re.sub(r".*?", "", content, flags=re.DOTALL | re.IGNORECASE) + marker = "## Bunny Review" + if marker in cleaned: + cleaned = cleaned[cleaned.find(marker):] + return cleaned.replace("FINAL_REVIEW", "", 1).strip() + def build_extra_context(request, stats): sections = [] for path in request.get("files", []): @@ -301,7 +308,7 @@ def main(): first_response = model_call(client, messages, stats) request = parse_context_request(first_response) if request is None: - review = first_response.replace("FINAL_REVIEW", "", 1).strip() + review = clean_review_text(first_response) else: extra_context = build_extra_context(request, stats) final_messages = [ @@ -317,7 +324,7 @@ def main(): ), }, ] - review = model_call(client, final_messages, stats).replace("FINAL_REVIEW", "", 1).strip() + review = clean_review_text(model_call(client, final_messages, stats)) pathlib.Path("review.md").write_text(review or "Bunny could not produce review text.", "utf-8") print_telemetry(stats) From 6695c704d8f58b5224fb1a0221938acb05768c0c Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 16:19:14 +0800 Subject: [PATCH 18/40] Add generic Bunny identifier context --- scripts/bunny_review.py | 56 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 987955605..f1c81665f 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -11,6 +11,9 @@ MAX_CONTEXT_FILE_CHARS = 20_000 MAX_SEARCH_HITS = 30 MAX_SEARCH_FILE_BYTES = 250_000 +MAX_IDENTIFIER_CONTEXT_CHARS = 60_000 +MAX_IDENTIFIER_TERMS = 24 +MAX_IDENTIFIER_HITS_PER_TERM = 12 # --- safety: keep file reads inside the repo and away from secrets --- def _safe_path(rel: str) -> pathlib.Path: @@ -81,6 +84,49 @@ def search_repo(pattern): break return "\n".join(hits) or "no matches" +def search_repo_hits(pattern, max_hits): + result = search_repo(pattern) + if result == "no matches" or result.startswith("refused:"): + return [] + return result.splitlines()[:max_hits] + +def extract_changed_identifiers(patch): + stop_words = { + "true", "false", "null", "none", "some", "string", "value", "json", + "expect", "should", "test", "result", "state", "data", "content", + "message", "messages", "chat", "chats", "role", "rows", "row", + "import", "imported", "storage", "create", "get", "list", "id", + } + counts = {} + for line in patch.splitlines(): + if not line.startswith(("+", "-")) or line.startswith(("+++", "---")): + continue + for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", line): + if token.lower() in stop_words: + continue + counts[token] = counts.get(token, 0) + 1 + preferred = sorted( + counts, + key=lambda token: ( + not any(char.isupper() for char in token) and "_" not in token, + -counts[token], + token.lower(), + ), + ) + return preferred[:MAX_IDENTIFIER_TERMS] + +def build_identifier_context(patch): + terms = extract_changed_identifiers(patch) + sections = [] + for term in terms: + hits = search_repo_hits(term, MAX_IDENTIFIER_HITS_PER_TERM) + if not hits: + continue + sections.append(f"### {term}\n" + "\n".join(hits)) + if not sections: + return "No changed identifier usage context found." + return truncate("\n\n".join(sections), MAX_IDENTIFIER_CONTEXT_CHARS) + def changed_files(base): names = run_git(["diff", "--name-only", f"{base}...HEAD"]) return [line.strip() for line in names.splitlines() if line.strip()] @@ -130,6 +176,10 @@ def select_guidance(files): def build_review_packet(base, ci_status): files = changed_files(base) + patch = run_git( + ["diff", "--find-renames", "--unified=80", f"{base}...HEAD"], + MAX_SECTION_CHARS, + ) sections = [ ("git status", run_git(["status", "--short", "--branch"], 12_000)), ("repo root", run_git(["rev-parse", "--show-toplevel"], 4_000)), @@ -139,11 +189,9 @@ def build_review_packet(base, ci_status): ("numstat", run_git(["diff", "--numstat", f"{base}...HEAD"], 20_000)), ( "patch", - run_git( - ["diff", "--find-renames", "--unified=80", f"{base}...HEAD"], - MAX_SECTION_CHARS, - ), + patch, ), + ("changed identifier usage", build_identifier_context(patch)), ] if ci_status: sections.append(("CI status", ci_status)) From 0ea714a02f990e0c7cbcecbd1d7736fec95a921c Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 17:11:50 +0800 Subject: [PATCH 19/40] Improve Bunny Review CI automation --- .github/workflows/bunny-review.yml | 124 ++++++++++++++++++ .github/workflows/bunny-style-review.yml | 100 -------------- AGENTS.md | 1 + scripts/bunny_review.py | 74 +++++++++-- .../SKILL.md | 18 +-- 5 files changed, 200 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/bunny-review.yml delete mode 100644 .github/workflows/bunny-style-review.yml rename skills/{bunny-style-review => bunny-review}/SKILL.md (88%) diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml new file mode 100644 index 000000000..bcb38ca4f --- /dev/null +++ b/.github/workflows/bunny-review.yml @@ -0,0 +1,124 @@ +# .github/workflows/bunny-review.yml +name: Bunny Review + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + actions: read # needed to read CI status + +concurrency: + group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + review: + if: > + github.event_name == 'pull_request' || + ( + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association) + ) + runs-on: ubuntu-latest + env: + PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve PR refs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) + echo "PR_BASE_REF=$BASE" >> "$GITHUB_ENV" + git fetch --force origin "$BASE:refs/remotes/origin/$BASE" + + - name: Preserve review tooling from base branch + run: | + mkdir -p /tmp/bunny-review-tool/scripts + mkdir -p /tmp/bunny-review-tool/skills/bunny-review + git show "origin/$PR_BASE_REF:scripts/bunny_review.py" > /tmp/bunny-review-tool/scripts/bunny_review.py + git show "origin/$PR_BASE_REF:scripts/requirements.txt" > /tmp/bunny-review-tool/scripts/requirements.txt + git show "origin/$PR_BASE_REF:skills/bunny-review/SKILL.md" > /tmp/bunny-review-tool/skills/bunny-review/SKILL.md + + - name: Fetch PR and checkout head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git status + git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM" + git checkout "pr-$PR_NUM" + + - name: Detect previous Bunny review + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXISTING=$(gh pr view "$PR_NUM" --json comments --jq '[.comments[] | select(.body | startswith("## Bunny Review"))] | length') + if [ "${EXISTING:-0}" -gt 0 ]; then + echo "BUNNY_OMIT_CHANGE_SUMMARY=true" >> "$GITHUB_ENV" + else + echo "BUNNY_OMIT_CHANGE_SUMMARY=false" >> "$GITHUB_ENV" + fi + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r /tmp/bunny-review-tool/scripts/requirements.txt + + - name: Run review + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + LLM_MODEL: gpt-5.5 + CI_STATUS: CI checks are still running; final CI results will be appended before posting. + BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/skills/bunny-review/SKILL.md + run: python /tmp/bunny-review-tool/scripts/bunny_review.py + + - name: Wait for CI status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) + TARGET_CHECKS='Frontend|Rust|Smoke' + for attempt in $(seq 1 90); do + FOUND=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs | map(select(.name | test(\"$TARGET_CHECKS\"))) | length") + PENDING=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs | map(select(.name | test(\"$TARGET_CHECKS\") and .status != \"completed\")) | length") + if [ "${FOUND:-0}" -ge 3 ] && [ "${PENDING:-0}" -eq 0 ]; then + break + fi + sleep 10 + done + + { + echo "" + echo "### CI Status" + FINAL_CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs | map(select(.name | test(\"$TARGET_CHECKS\"))) | map(\"- \(.name): \(.conclusion // .status)\") | join(\"\n\")") + if [ -z "$FINAL_CHECKS" ]; then + echo "- No Frontend, Rust, or Smoke checks were found before Bunny posted." + else + echo "$FINAL_CHECKS" + fi + } > /tmp/bunny-ci-status.md + + - name: Add CI status to review + run: cat /tmp/bunny-ci-status.md >> review.md + + - name: Post the review + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr comment "$PR_NUM" --body-file review.md diff --git a/.github/workflows/bunny-style-review.yml b/.github/workflows/bunny-style-review.yml deleted file mode 100644 index 6a3461c79..000000000 --- a/.github/workflows/bunny-style-review.yml +++ /dev/null @@ -1,100 +0,0 @@ -# .github/workflows/bunny-review.yml -name: Bunny Style Review - -on: - pull_request: - types: [opened, reopened, synchronize, ready_for_review] - issue_comment: - types: [created] - -permissions: - contents: read - pull-requests: write - actions: read # needed to read CI status - -concurrency: - group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: true - -jobs: - review: - if: > - github.event_name == 'pull_request' || - ( - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/bunny-review') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), - github.event.comment.author_association) - ) - runs-on: ubuntu-latest - env: - PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Preserve review tooling - run: | - mkdir -p /tmp/bunny-review-tool/scripts - mkdir -p /tmp/bunny-review-tool/skills/bunny-style-review - cp scripts/bunny_review.py /tmp/bunny-review-tool/scripts/bunny_review.py - cp scripts/requirements.txt /tmp/bunny-review-tool/scripts/requirements.txt - cp skills/bunny-style-review/SKILL.md /tmp/bunny-review-tool/skills/bunny-style-review/SKILL.md - - - name: Fetch PR and checkout head - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git status - - BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName) - HEAD=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) - - git fetch origin "$BASE:$BASE" || git fetch origin "$BASE" - git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM" - - git checkout "pr-$PR_NUM" - - echo "PR_BASE_REF=$BASE" >> "$GITHUB_ENV" - - - name: Get CI status - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get the latest commit SHA for the PR - HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) - - # Get check runs for this commit - CI_CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ - --jq '.check_runs | map(select(.name | test("Frontend|Rust|Smoke"))) | - map("\(.name): \(.conclusion // "pending")") | join(", ")') - - if [ -z "$CI_CHECKS" ]; then - CI_STATUS="No CI checks found for this PR" - else - CI_STATUS="$CI_CHECKS" - fi - - echo "CI_STATUS=$CI_STATUS" >> "$GITHUB_ENV" - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: pip install -r /tmp/bunny-review-tool/scripts/requirements.txt - - - name: Run review - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} - LLM_MODEL: gpt-5.5 - BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/skills/bunny-style-review/SKILL.md - run: python /tmp/bunny-review-tool/scripts/bunny_review.py - - - name: Post the review - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "$PR_NUM" --body-file review.md diff --git a/AGENTS.md b/AGENTS.md index 1f10d0a29..5fd607859 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,5 +38,6 @@ For code changes, final responses must include behavior changed, primary files/m - `src/shared/api`: Embedded Tauri and hostable runtime wrappers. Feature code should call these wrappers instead of raw Tauri or raw remote-runtime fetch. - `src-tauri`: Rust command facades, hostable runtime dispatch, storage, LLM/provider transport, assets, imports, integrations, and other privileged capabilities. - `public/sprites/mari`: Professor Mari visual assets used by onboarding, FAQ, title controls, and the Mari shell surface. +- `.github/workflows/bunny-review.yml`, `scripts/bunny_review.py`, `skills/bunny-review`: Bunny Review PR comment automation, review packet builder, and reviewer instructions. - `skills/marinara-architecture-guard`: Architecture guardrails for placement, import direction, and remote-capable command routing. - `skills/marinara-agent-workflow`: Agent workflow references, source maps, handoff formats, and verification discipline. diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index f1c81665f..a7cea7a58 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -15,6 +15,9 @@ MAX_IDENTIFIER_TERMS = 24 MAX_IDENTIFIER_HITS_PER_TERM = 12 +class ReviewTooLarge(Exception): + pass + # --- safety: keep file reads inside the repo and away from secrets --- def _safe_path(rel: str) -> pathlib.Path: full = (REPO_ROOT / rel).resolve() @@ -27,12 +30,15 @@ def _safe_path(rel: str) -> pathlib.Path: raise ValueError("blocked sensitive file") return full -def run_git(args, limit=MAX_SECTION_CHARS): +def run_git_raw(args): out = subprocess.run( ["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, timeout=60, ) - return truncate(out.stdout + out.stderr, limit) + return out.stdout + out.stderr + +def run_git(args, limit=MAX_SECTION_CHARS): + return truncate(run_git_raw(args), limit) def truncate(text, limit): if len(text) <= limit: @@ -176,10 +182,14 @@ def select_guidance(files): def build_review_packet(base, ci_status): files = changed_files(base) - patch = run_git( + patch = run_git_raw( ["diff", "--find-renames", "--unified=80", f"{base}...HEAD"], - MAX_SECTION_CHARS, ) + if len(patch) > MAX_SECTION_CHARS: + raise ReviewTooLarge( + f"Patch is {len(patch)} characters, above Bunny Review's " + f"{MAX_SECTION_CHARS} character per-patch limit." + ) sections = [ ("git status", run_git(["status", "--short", "--branch"], 12_000)), ("repo root", run_git(["rev-parse", "--show-toplevel"], 4_000)), @@ -204,6 +214,11 @@ def build_review_packet(base, ci_status): packet = "\n\n".join( f"## {title}\n```text\n{body}\n```" for title, body in sections ) + if len(packet) > MAX_REVIEW_PACKET_CHARS: + raise ReviewTooLarge( + f"Review packet is {len(packet)} characters, above Bunny Review's " + f"{MAX_REVIEW_PACKET_CHARS} character total limit." + ) return truncate(packet, MAX_REVIEW_PACKET_CHARS) def usage_value(usage, *path): @@ -293,6 +308,31 @@ def clean_review_text(content): cleaned = cleaned[cleaned.find(marker):] return cleaned.replace("FINAL_REVIEW", "", 1).strip() +def maybe_remove_change_summary(review): + if os.environ.get("BUNNY_OMIT_CHANGE_SUMMARY", "").lower() != "true": + return review + return re.sub( + r"\n### Change Summary\n.*?(?=\n### Findings\b)", + "\n", + review, + count=1, + flags=re.DOTALL, + ).strip() + +def write_skipped_review(title, body): + pathlib.Path("review.md").write_text( + "\n".join( + [ + "## Bunny Review", + "", + f"### {title}", + body, + ] + ).strip() + + "\n", + "utf-8", + ) + def build_extra_context(request, stats): sections = [] for path in request.get("files", []): @@ -317,19 +357,36 @@ def build_extra_context(request, stats): return context def main(): + if not os.environ.get("OPENAI_API_KEY"): + write_skipped_review( + "Review Skipped", + "Bunny Review could not run because `OPENAI_API_KEY` is not available to this workflow run. This is expected for PRs where repository secrets are withheld.", + ) + print("Bunny telemetry: skipped=missing_openai_api_key", flush=True) + return + + base = os.environ.get("PR_BASE_REF", "main") + ci_status = os.environ.get("CI_STATUS", "") + try: + review_packet = build_review_packet(base, ci_status) + except ReviewTooLarge as exc: + write_skipped_review( + "Review Cancelled", + f"{exc} Bunny Review skipped the model pass so it would not produce a partial review. Please split the PR or request a manual review.", + ) + print(f"Bunny telemetry: skipped=review_too_large; reason={exc}", flush=True) + return + client = OpenAI( api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ.get("LLM_BASE_URL"), ) skill_path = pathlib.Path( - os.environ.get("BUNNY_REVIEW_SKILL_PATH", "skills/bunny-style-review/SKILL.md") + os.environ.get("BUNNY_REVIEW_SKILL_PATH", "skills/bunny-review/SKILL.md") ) if not skill_path.is_absolute(): skill_path = REPO_ROOT / skill_path skill = skill_path.read_text("utf-8") - base = os.environ.get("PR_BASE_REF", "main") - ci_status = os.environ.get("CI_STATUS", "") - review_packet = build_review_packet(base, ci_status) triage_content = ( f"Review this PR. The review base branch is '{base}'. " @@ -373,6 +430,7 @@ def main(): }, ] review = clean_review_text(model_call(client, final_messages, stats)) + review = maybe_remove_change_summary(review) pathlib.Path("review.md").write_text(review or "Bunny could not produce review text.", "utf-8") print_telemetry(stats) diff --git a/skills/bunny-style-review/SKILL.md b/skills/bunny-review/SKILL.md similarity index 88% rename from skills/bunny-style-review/SKILL.md rename to skills/bunny-review/SKILL.md index 7e7136aea..0c9a19570 100644 --- a/skills/bunny-style-review/SKILL.md +++ b/skills/bunny-review/SKILL.md @@ -1,9 +1,9 @@ --- -name: bunny-style-review -description: "Review Marinara pull requests in a CodeRabbit-style CI pass by inspecting a bounded review packet with the live diff, relevant local guidance, and CI context." +name: bunny-review +description: "Review Marinara pull requests in a CI pass by inspecting a bounded review packet with the live diff, relevant local guidance, and CI context." --- -# Bunny Style Review +# Bunny Review You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. In two-pass CI mode, either produce a final review from the packet or request one small batch of focused extra context; after extra context is provided, produce the final review. @@ -11,12 +11,12 @@ You must not edit files, run project code, read secrets, or request external net ## Setup -1. Establish the base and head from the review packet: - - Run `git status --short --branch`. - - Run `git rev-parse --show-toplevel`. - - Run `git merge-base HEAD `. - - Run `git diff --stat ...HEAD`. - - Run `git diff --name-only ...HEAD`. +1. Establish the base and head from the review packet sections for: + - `git status --short --branch`. + - `git rev-parse --show-toplevel`. + - `git merge-base HEAD `. + - `git diff --stat ...HEAD`. + - `git diff --name-only ...HEAD`. 2. Read `AGENTS.md`. 3. Load only guidance that matches touched areas: - Architecture or ownership changes: `skills/marinara-architecture-guard/SKILL.md`. From 0e9e0b47c71e292c2e83649ce82c3d752cd2b176 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 17:49:05 +0800 Subject: [PATCH 20/40] Improve Bunny PR review workflow --- .github/workflows/bunny-review.yml | 36 +- scripts/bunny_review.py | 870 +++++++++++++++++++++++------ skills/bunny-review/SKILL.md | 81 +-- skills/bunny-review/rules.json | 86 +++ 4 files changed, 858 insertions(+), 215 deletions(-) create mode 100644 skills/bunny-review/rules.json diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml index bcb38ca4f..140df2ab3 100644 --- a/.github/workflows/bunny-review.yml +++ b/.github/workflows/bunny-review.yml @@ -10,6 +10,7 @@ on: permissions: contents: read pull-requests: write + issues: write actions: read # needed to read CI status concurrency: @@ -50,6 +51,7 @@ jobs: git show "origin/$PR_BASE_REF:scripts/bunny_review.py" > /tmp/bunny-review-tool/scripts/bunny_review.py git show "origin/$PR_BASE_REF:scripts/requirements.txt" > /tmp/bunny-review-tool/scripts/requirements.txt git show "origin/$PR_BASE_REF:skills/bunny-review/SKILL.md" > /tmp/bunny-review-tool/skills/bunny-review/SKILL.md + git show "origin/$PR_BASE_REF:skills/bunny-review/rules.json" > /tmp/bunny-review-tool/skills/bunny-review/rules.json || true - name: Fetch PR and checkout head env: @@ -59,17 +61,6 @@ jobs: git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM" git checkout "pr-$PR_NUM" - - name: Detect previous Bunny review - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - EXISTING=$(gh pr view "$PR_NUM" --json comments --jq '[.comments[] | select(.body | startswith("## Bunny Review"))] | length') - if [ "${EXISTING:-0}" -gt 0 ]; then - echo "BUNNY_OMIT_CHANGE_SUMMARY=true" >> "$GITHUB_ENV" - else - echo "BUNNY_OMIT_CHANGE_SUMMARY=false" >> "$GITHUB_ENV" - fi - - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -77,19 +68,19 @@ jobs: - name: Install dependencies run: pip install -r /tmp/bunny-review-tool/scripts/requirements.txt - - name: Run review + - name: Run review while CI completes env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_MODEL: gpt-5.5 CI_STATUS: CI checks are still running; final CI results will be appended before posting. BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/skills/bunny-review/SKILL.md - run: python /tmp/bunny-review-tool/scripts/bunny_review.py - - - name: Wait for CI status - env: + BUNNY_COMMENT_BODY: ${{ github.event.comment.body }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + python /tmp/bunny-review-tool/scripts/bunny_review.py produce & + BUNNY_PID=$! + HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) TARGET_CHECKS='Frontend|Rust|Smoke' for attempt in $(seq 1 90); do @@ -113,12 +104,17 @@ jobs: else echo "$FINAL_CHECKS" fi - } > /tmp/bunny-ci-status.md + } > bunny-ci-status.md + + wait "$BUNNY_PID" - - name: Add CI status to review - run: cat /tmp/bunny-ci-status.md >> review.md + - name: Render review + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUNNY_COMMENT_BODY: ${{ github.event.comment.body }} + run: python /tmp/bunny-review-tool/scripts/bunny_review.py render - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr comment "$PR_NUM" --body-file review.md + run: python /tmp/bunny-review-tool/scripts/bunny_review.py post diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index a7cea7a58..e372ba657 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -1,8 +1,18 @@ # scripts/bunny_review.py -import json, os, pathlib, re, subprocess, time +import argparse +import json +import os +import pathlib +import re +import subprocess +import time +from dataclasses import dataclass + from openai import OpenAI REPO_ROOT = pathlib.Path.cwd().resolve() +BUNNY_MARKER = "" +STATE_MARKER_RE = re.compile(r"") MAX_REVIEW_PACKET_CHARS = 180_000 MAX_SECTION_CHARS = 60_000 MAX_CONTEXT_FILES = 5 @@ -14,31 +24,73 @@ MAX_IDENTIFIER_CONTEXT_CHARS = 60_000 MAX_IDENTIFIER_TERMS = 24 MAX_IDENTIFIER_HITS_PER_TERM = 12 +MAX_FILE_PATCH_CHARS = 55_000 +MAX_FILE_SUMMARY_CHARS = 9_000 +MAX_REVIEW_CHUNKS = 8 +MAX_CHUNK_PATCH_CHARS = 90_000 + class ReviewTooLarge(Exception): pass -# --- safety: keep file reads inside the repo and away from secrets --- + +@dataclass +class Finding: + severity: str + path: str + line: int | None + title: str + body: str + fix_hint: str + + def _safe_path(rel: str) -> pathlib.Path: full = (REPO_ROOT / rel).resolve() if full != REPO_ROOT and REPO_ROOT not in full.parents: raise ValueError("path escapes repo root") name = full.name.lower() if name.startswith(".env") or name in { - "credentials.json", "id_rsa", "id_ed25519", ".npmrc", ".netrc" + "credentials.json", + "id_rsa", + "id_ed25519", + ".npmrc", + ".netrc", }: raise ValueError("blocked sensitive file") return full -def run_git_raw(args): - out = subprocess.run( - ["git", *args], cwd=REPO_ROOT, - capture_output=True, text=True, timeout=60, + +def run(args, *, input_text=None, timeout=120, check=False): + result = subprocess.run( + args, + cwd=REPO_ROOT, + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + check=False, ) - return out.stdout + out.stderr + if check and result.returncode != 0: + raise RuntimeError( + f"{' '.join(args)} failed with {result.returncode}:\n" + f"{result.stdout}{result.stderr}" + ) + return result + + +def run_git_raw(args): + result = run(["git", *args], timeout=90) + return result.stdout + result.stderr + def run_git(args, limit=MAX_SECTION_CHARS): - return truncate(run_git_raw(args), limit) + result = run(["git", *args], timeout=90) + return truncate(result.stdout + result.stderr, limit) + + +def run_gh(args, *, input_text=None, timeout=120, check=False): + return run(["gh", *args], input_text=input_text, timeout=timeout, check=check) + def truncate(text, limit): if len(text) <= limit: @@ -48,47 +100,56 @@ def truncate(text, limit): + f"\n\n[truncated: section was {len(text)} chars, limit is {limit} chars]\n" ) + def read_text(path, limit=MAX_SECTION_CHARS): p = _safe_path(path) return truncate(p.read_text(encoding="utf-8", errors="replace"), limit) + def read_context_file(path): return read_text(path, MAX_CONTEXT_FILE_CHARS) + def search_repo(pattern): if not pattern or len(pattern) > 120: return "refused: search pattern must be 1-120 characters" - hits = [] - ignored_parts = { - ".git", - "node_modules", - "target", - "dist", - "build", - ".next", - "coverage", - "playwright-report", - } - for path in REPO_ROOT.rglob("*"): - if len(hits) >= MAX_SEARCH_HITS: - break - if any(part in ignored_parts for part in path.parts): - continue - if not path.is_file(): - continue - if path.stat().st_size > MAX_SEARCH_FILE_BYTES: - continue + rg = run( + [ + "rg", + "--fixed-strings", + "--line-number", + "--glob", + "!node_modules", + "--glob", + "!target", + "--glob", + "!dist", + "--glob", + "!build", + "--glob", + "!coverage", + "--glob", + "!playwright-report", + pattern, + ], + timeout=60, + ) + if rg.returncode not in (0, 1): + return truncate(rg.stdout + rg.stderr, MAX_CONTEXT_FILE_CHARS) + lines = [] + for line in rg.stdout.splitlines(): try: - rel = path.relative_to(REPO_ROOT) - text = path.read_text("utf-8", "replace") + rel, line_no, body = line.split(":", 2) + p = _safe_path(rel) + if p.stat().st_size > MAX_SEARCH_FILE_BYTES: + continue + lines.append(f"{rel}:{line_no}: {body.strip()[:220]}") except Exception: continue - for line_no, line in enumerate(text.splitlines(), 1): - if pattern in line: - hits.append(f"{rel}:{line_no}: {line.strip()[:220]}") - if len(hits) >= MAX_SEARCH_HITS: - break - return "\n".join(hits) or "no matches" + if len(lines) >= MAX_SEARCH_HITS: + break + return "\n".join(lines) or "no matches" + def search_repo_hits(pattern, max_hits): result = search_repo(pattern) @@ -96,12 +157,38 @@ def search_repo_hits(pattern, max_hits): return [] return result.splitlines()[:max_hits] + def extract_changed_identifiers(patch): stop_words = { - "true", "false", "null", "none", "some", "string", "value", "json", - "expect", "should", "test", "result", "state", "data", "content", - "message", "messages", "chat", "chats", "role", "rows", "row", - "import", "imported", "storage", "create", "get", "list", "id", + "true", + "false", + "null", + "none", + "some", + "string", + "value", + "json", + "expect", + "should", + "test", + "result", + "state", + "data", + "content", + "message", + "messages", + "chat", + "chats", + "role", + "rows", + "row", + "import", + "imported", + "storage", + "create", + "get", + "list", + "id", } counts = {} for line in patch.splitlines(): @@ -121,6 +208,7 @@ def extract_changed_identifiers(patch): ) return preferred[:MAX_IDENTIFIER_TERMS] + def build_identifier_context(patch): terms = extract_changed_identifiers(patch) sections = [] @@ -133,21 +221,58 @@ def build_identifier_context(patch): return "No changed identifier usage context found." return truncate("\n\n".join(sections), MAX_IDENTIFIER_CONTEXT_CHARS) + def changed_files(base): names = run_git(["diff", "--name-only", f"{base}...HEAD"]) return [line.strip() for line in names.splitlines() if line.strip()] + +def load_json_file(path): + try: + return json.loads(read_text(path, 50_000)) + except FileNotFoundError: + return None + except Exception as exc: + return {"_load_error": str(exc)} + + +def bunny_skill_dir(): + skill_path = pathlib.Path( + os.environ.get("BUNNY_REVIEW_SKILL_PATH", "skills/bunny-review/SKILL.md") + ) + if not skill_path.is_absolute(): + skill_path = REPO_ROOT / skill_path + return skill_path.parent + + +def load_rules(): + rules_path = bunny_skill_dir() / "rules.json" + try: + return json.loads(rules_path.read_text("utf-8")) + except FileNotFoundError: + return {} + except Exception as exc: + return {"_load_error": str(exc)} + + +def guidance_from_rules(files, rules): + guidance = ["AGENTS.md"] + for item in rules.get("path_instructions", []): + prefixes = item.get("prefixes", []) + if any(any(path.startswith(prefix) for prefix in prefixes) for path in files): + guidance.extend(item.get("guidance", [])) + return list(dict.fromkeys(guidance)) + + def select_guidance(files): + rules = load_rules() + if rules and "_load_error" not in rules: + return guidance_from_rules(files, rules) guidance = ["AGENTS.md"] joined = "\n".join(files) if any( marker in joined - for marker in ( - "src/engine/", - "src/features/", - "src/shared/api/", - "src-tauri/", - ) + for marker in ("src/engine/", "src/features/", "src/shared/api/", "src-tauri/") ): guidance.append("skills/marinara-architecture-guard/SKILL.md") if any( @@ -166,42 +291,82 @@ def select_guidance(files): guidance.append("skills/marinara-mode-separation/SKILL.md") if any( marker in joined - for marker in ( - "fix/", - "storage", - "imports", - "provider", - "transport", - "commands", - ) + for marker in ("fix/", "storage", "imports", "provider", "transport", "commands") ): guidance.append("skills/marinara-bugfix-discipline/SKILL.md") if any(marker in joined for marker in ("README", "docs/", "skills/", "AGENTS.md")): guidance.append("skills/marinara-getting-started/SKILL.md") return list(dict.fromkeys(guidance)) -def build_review_packet(base, ci_status): + +def matching_path_rules(files): + rules = load_rules() + if not rules or "_load_error" in rules: + return "No additional Bunny path rules loaded." + matched = [] + for item in rules.get("path_instructions", []): + prefixes = item.get("prefixes", []) + if any(any(path.startswith(prefix) for prefix in prefixes) for path in files): + matched.append(item) + payload = { + "severity_policy": rules.get("severity_policy", {}), + "review_focus": rules.get("review_focus", []), + "matched_path_instructions": matched, + } + return json.dumps(payload, indent=2, sort_keys=True) + + +def diff_for_path(base, path): + return run_git_raw(["diff", "--find-renames", "--unified=80", f"{base}...HEAD", "--", path]) + + +def build_file_context(base, files): + sections = [] + for path in files: + patch = diff_for_path(base, path) + if not patch: + continue + if len(patch) <= MAX_FILE_PATCH_CHARS: + sections.append(f"### {path}\n```diff\n{patch}\n```") + continue + sections.append( + "### " + + path + + "\n```text\n" + + truncate(run_git(["diff", "--stat", f"{base}...HEAD", "--", path], 2_000), 2_000) + + truncate(patch, MAX_FILE_SUMMARY_CHARS) + + "\n```" + ) + return "\n\n".join(sections) or "No per-file patch context found." + + +def build_review_packet(base, ci_status, mode, focus_files=None, include_full_patch=True): files = changed_files(base) - patch = run_git_raw( - ["diff", "--find-renames", "--unified=80", f"{base}...HEAD"], - ) - if len(patch) > MAX_SECTION_CHARS: - raise ReviewTooLarge( - f"Patch is {len(patch)} characters, above Bunny Review's " - f"{MAX_SECTION_CHARS} character per-patch limit." + context_files = focus_files or files + if focus_files is None or include_full_patch: + patch = run_git_raw(["diff", "--find-renames", "--unified=80", f"{base}...HEAD"]) + else: + patch = "\n".join(diff_for_path(base, path) for path in focus_files) + patch_body = patch + if len(patch_body) > MAX_SECTION_CHARS: + patch_body = ( + "Full patch exceeded the inline packet limit; use the per-file patch sections " + "below and request focused extra context for specific files if needed.\n\n" + + truncate(patch_body, MAX_SECTION_CHARS) ) sections = [ + ("review mode", mode), ("git status", run_git(["status", "--short", "--branch"], 12_000)), ("repo root", run_git(["rev-parse", "--show-toplevel"], 4_000)), ("merge base", run_git(["merge-base", "HEAD", base], 4_000)), ("diff stat", run_git(["diff", "--stat", f"{base}...HEAD"], 20_000)), ("changed files", "\n".join(files) or "No changed files reported."), ("numstat", run_git(["diff", "--numstat", f"{base}...HEAD"], 20_000)), - ( - "patch", - patch, - ), + ("focus files", "\n".join(context_files) or "All changed files."), + ("patch overview", patch_body), + ("per-file patch context", build_file_context(base, context_files)), ("changed identifier usage", build_identifier_context(patch)), + ("Bunny path rules", matching_path_rules(files)), ] if ci_status: sections.append(("CI status", ci_status)) @@ -215,11 +380,31 @@ def build_review_packet(base, ci_status): f"## {title}\n```text\n{body}\n```" for title, body in sections ) if len(packet) > MAX_REVIEW_PACKET_CHARS: - raise ReviewTooLarge( - f"Review packet is {len(packet)} characters, above Bunny Review's " - f"{MAX_REVIEW_PACKET_CHARS} character total limit." - ) - return truncate(packet, MAX_REVIEW_PACKET_CHARS) + packet = truncate(packet, MAX_REVIEW_PACKET_CHARS) + return packet + + +def chunk_changed_files(base, files): + chunks = [] + current = [] + current_size = 0 + for path in files: + patch_size = len(diff_for_path(base, path)) + if current and current_size + patch_size > MAX_CHUNK_PATCH_CHARS: + chunks.append(current) + current = [] + current_size = 0 + current.append(path) + current_size += patch_size + if current: + chunks.append(current) + if len(chunks) <= MAX_REVIEW_CHUNKS: + return chunks + merged = chunks[: MAX_REVIEW_CHUNKS - 1] + overflow = [path for chunk in chunks[MAX_REVIEW_CHUNKS - 1 :] for path in chunk] + merged.append(overflow) + return merged + def usage_value(usage, *path): current = usage @@ -232,6 +417,7 @@ def usage_value(usage, *path): current = getattr(current, key, None) return current or 0 + def add_usage(totals, usage): totals["prompt_tokens"] += usage_value(usage, "prompt_tokens") totals["completion_tokens"] += usage_value(usage, "completion_tokens") @@ -240,6 +426,7 @@ def add_usage(totals, usage): usage, "completion_tokens_details", "reasoning_tokens" ) + def build_stats(review_packet): return { "started_at": time.monotonic(), @@ -254,6 +441,7 @@ def build_stats(review_packet): "total_tokens": 0, } + def print_telemetry(stats): elapsed = time.monotonic() - stats["started_at"] print( @@ -271,6 +459,7 @@ def print_telemetry(stats): flush=True, ) + def model_call(client, messages, stats): resp = client.chat.completions.create( model=os.environ.get("LLM_MODEL", "gpt-5.5"), @@ -282,6 +471,33 @@ def model_call(client, messages, stats): return resp return resp.choices[0].message.content or "" + +def review_packet_with_model(client, skill, triage_content, stats): + messages = [ + {"role": "system", "content": skill}, + {"role": "user", "content": triage_content}, + ] + first_response = model_call(client, messages, stats) + request = parse_context_request(first_response) + if request is None: + return extract_json(first_response) + extra_context = build_extra_context(request, stats) + final_messages = [ + {"role": "system", "content": skill}, + {"role": "user", "content": triage_content}, + {"role": "assistant", "content": first_response}, + { + "role": "user", + "content": ( + "Here is the bounded extra context you requested. " + "Do not request more context. Produce only the final JSON review object." + f"\n\n# Extra Context\n{extra_context}" + ), + }, + ] + return extract_json(model_call(client, final_messages, stats)) + + def parse_context_request(content): marker = "CONTEXT_REQUEST" if marker not in content: @@ -298,40 +514,24 @@ def parse_context_request(content): searches = parsed.get("searches", []) return { "files": [value for value in files if isinstance(value, str)][:MAX_CONTEXT_FILES], - "searches": [value for value in searches if isinstance(value, str)][:MAX_CONTEXT_SEARCHES], + "searches": [ + value for value in searches if isinstance(value, str) + ][:MAX_CONTEXT_SEARCHES], } -def clean_review_text(content): + +def extract_json(content): cleaned = re.sub(r".*?", "", content, flags=re.DOTALL | re.IGNORECASE) - marker = "## Bunny Review" - if marker in cleaned: - cleaned = cleaned[cleaned.find(marker):] - return cleaned.replace("FINAL_REVIEW", "", 1).strip() - -def maybe_remove_change_summary(review): - if os.environ.get("BUNNY_OMIT_CHANGE_SUMMARY", "").lower() != "true": - return review - return re.sub( - r"\n### Change Summary\n.*?(?=\n### Findings\b)", - "\n", - review, - count=1, - flags=re.DOTALL, - ).strip() + cleaned = cleaned.replace("FINAL_REVIEW", "", 1).strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) + cleaned = re.sub(r"\s*```$", "", cleaned) + start = cleaned.find("{") + end = cleaned.rfind("}") + if start == -1 or end == -1 or end < start: + raise ValueError("model response did not contain a JSON object") + return json.loads(cleaned[start : end + 1]) -def write_skipped_review(title, body): - pathlib.Path("review.md").write_text( - "\n".join( - [ - "## Bunny Review", - "", - f"### {title}", - body, - ] - ).strip() - + "\n", - "utf-8", - ) def build_extra_context(request, stats): sections = [] @@ -356,7 +556,223 @@ def build_extra_context(request, stats): stats["extra_context_chars"] = len(context) return context -def main(): + +def touched_lines(base): + by_path: dict[str, set[int]] = {} + current_path = None + new_line = None + diff = run_git_raw(["diff", "--unified=0", f"{base}...HEAD"]) + for line in diff.splitlines(): + if line.startswith("+++ b/"): + current_path = line.removeprefix("+++ b/") + by_path.setdefault(current_path, set()) + continue + match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line) + if match: + new_line = int(match.group(1)) + continue + if current_path is None or new_line is None: + continue + if line.startswith("+") and not line.startswith("+++"): + by_path[current_path].add(new_line) + new_line += 1 + elif line.startswith("-") and not line.startswith("---"): + continue + else: + new_line += 1 + return by_path + + +def validate_findings(review_obj, base): + allowed = touched_lines(base) + valid = [] + invalid = [] + severities = {"blocking", "high", "medium", "low"} + for item in review_obj.get("findings", []): + try: + finding = Finding( + severity=str(item.get("severity", "medium")).lower(), + path=str(item.get("path", "")).strip(), + line=item.get("line"), + title=str(item.get("title", "")).strip(), + body=str(item.get("body", "")).strip(), + fix_hint=str(item.get("fix_hint", "")).strip(), + ) + except Exception as exc: + invalid.append(f"Malformed finding skipped: {exc}") + continue + if finding.severity not in severities: + finding.severity = "medium" + if not finding.path or finding.path not in allowed: + invalid.append(f"{finding.path or ''}: not in changed files") + continue + if not isinstance(finding.line, int): + invalid.append(f"{finding.path}: missing integer line for '{finding.title}'") + continue + if finding.line not in allowed.get(finding.path, set()): + invalid.append( + f"{finding.path}:{finding.line}: line is not an added/changed diff line" + ) + continue + if not finding.title or not finding.body: + invalid.append(f"{finding.path}:{finding.line}: missing title/body") + continue + valid.append(finding) + return valid, invalid + + +def render_finding_body(finding): + parts = [ + f"**[{finding.severity}] {finding.title}**", + "", + finding.body, + ] + if finding.fix_hint: + parts.extend(["", f"Suggested fix: {finding.fix_hint}"]) + return "\n".join(parts).strip() + + +def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_sha): + summary = review_obj.get("change_summary") or [] + questions = review_obj.get("open_questions") or [] + checked = review_obj.get("what_i_checked") or [] + pre_merge = review_obj.get("pre_merge_checks") or [] + finding_lines = ( + [f"- [{f.severity}] `{f.path}:{f.line}` - {f.title}" for f in findings] + or ["No actionable findings."] + ) + body = [ + BUNNY_MARKER, + f"", + "## Bunny Review", + "", + "### Change Summary", + ] + body.extend([f"- {line}" for line in summary[:3]] or ["- No change summary produced."]) + body.extend(["", "### Findings", *finding_lines]) + if pre_merge: + body.extend(["", "### Pre-Merge Checks"]) + for item in pre_merge[:8]: + name = item.get("name", "check") + status = item.get("status", "unknown") + detail = item.get("detail", "") + body.append(f"- {name}: {status}. {detail}".strip()) + body.extend(["", "### Open Questions"]) + body.extend([f"- {line}" for line in questions[:2]] or ["- None."]) + body.extend(["", "### What I Checked"]) + body.extend([f"- {line}" for line in checked[:6]] or ["- Review packet and diff context."]) + if invalid_findings: + body.extend(["", "### Reviewer Notes"]) + body.append( + f"- Skipped {len(invalid_findings)} model finding(s) because Bunny could not validate their diff locations." + ) + if ci_status: + body.extend(["", "### CI Status", ci_status.strip()]) + return "\n".join(body).strip() + "\n" + + +def merge_review_objects(reviews): + merged = { + "change_summary": [], + "findings": [], + "pre_merge_checks": [], + "open_questions": [], + "what_i_checked": [], + } + seen_findings = set() + for review in reviews: + for key in ("change_summary", "open_questions", "what_i_checked"): + for item in review.get(key, []): + if item not in merged[key]: + merged[key].append(item) + for check in review.get("pre_merge_checks", []): + key = (check.get("name"), check.get("status"), check.get("detail")) + if key not in { + (item.get("name"), item.get("status"), item.get("detail")) + for item in merged["pre_merge_checks"] + }: + merged["pre_merge_checks"].append(check) + for finding in review.get("findings", []): + key = ( + finding.get("path"), + finding.get("line"), + finding.get("title"), + ) + if key in seen_findings: + continue + seen_findings.add(key) + merged["findings"].append(finding) + return merged + + +def write_skipped_review(title, body): + pathlib.Path("review.json").write_text( + json.dumps( + { + "change_summary": [body], + "findings": [], + "pre_merge_checks": [ + {"name": title, "status": "unknown", "detail": body} + ], + "open_questions": [], + "what_i_checked": ["Bunny Review did not run a model pass."], + }, + indent=2, + sort_keys=True, + ) + + "\n", + "utf-8", + ) + + +def discover_last_reviewed_sha(pr_num): + gh = run_gh(["pr", "view", pr_num, "--json", "comments", "--jq", ".comments[].body"]) + matches = STATE_MARKER_RE.findall(gh.stdout) + if matches: + return matches[-1] + return None + + +def resolve_review_base(pr_num, requested_mode): + pr = run_gh( + [ + "pr", + "view", + pr_num, + "--json", + "baseRefName,headRefOid", + ], + check=True, + ) + data = json.loads(pr.stdout) + base_ref = os.environ.get("PR_BASE_REF") or data["baseRefName"] + head_sha = data["headRefOid"] + explicit_base = os.environ.get("BUNNY_BASE_SHA") + mode = requested_mode + if explicit_base: + return explicit_base, base_ref, head_sha, "custom" + if mode == "full": + return f"origin/{base_ref}", base_ref, head_sha, mode + previous = discover_last_reviewed_sha(pr_num) + if previous: + exists = run(["git", "cat-file", "-e", f"{previous}^{{commit}}"]) + if exists.returncode == 0: + return previous, base_ref, head_sha, "incremental" + return f"origin/{base_ref}", base_ref, head_sha, "full" + + +def parse_command_mode(): + body = os.environ.get("BUNNY_COMMENT_BODY", "") + if "/bunny-review" not in body: + return os.environ.get("BUNNY_REVIEW_MODE", "auto") + if re.search(r"/bunny-review\s+full\b", body): + return "full" + if re.search(r"/bunny-review\s+review\b", body): + return "auto" + return "auto" + + +def produce_review(args): if not os.environ.get("OPENAI_API_KEY"): write_skipped_review( "Review Skipped", @@ -365,74 +781,212 @@ def main(): print("Bunny telemetry: skipped=missing_openai_api_key", flush=True) return - base = os.environ.get("PR_BASE_REF", "main") + pr_num = os.environ.get("PR_NUM", "") + requested_mode = args.mode or parse_command_mode() + base, base_ref, head_sha, effective_mode = resolve_review_base(pr_num, requested_mode) ci_status = os.environ.get("CI_STATUS", "") - try: - review_packet = build_review_packet(base, ci_status) - except ReviewTooLarge as exc: - write_skipped_review( - "Review Cancelled", - f"{exc} Bunny Review skipped the model pass so it would not produce a partial review. Please split the PR or request a manual review.", - ) - print(f"Bunny telemetry: skipped=review_too_large; reason={exc}", flush=True) - return + files = changed_files(base) + chunks = chunk_changed_files(base, files) + use_chunked_review = len(chunks) > 1 client = OpenAI( api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ.get("LLM_BASE_URL"), ) - skill_path = pathlib.Path( - os.environ.get("BUNNY_REVIEW_SKILL_PATH", "skills/bunny-review/SKILL.md") - ) - if not skill_path.is_absolute(): - skill_path = REPO_ROOT / skill_path + skill_path = bunny_skill_dir() / "SKILL.md" skill = skill_path.read_text("utf-8") - triage_content = ( - f"Review this PR. The review base branch is '{base}'. " - "Use the provided review packet as the complete inspection context. " - "You have one chance to request focused extra context before the final review. " - "If the packet is enough, reply with FINAL_REVIEW followed by the review in the skill's Output Shape. " - "If more context is necessary to validate a concrete potential finding, reply only with " - 'CONTEXT_REQUEST and JSON like {"files":["path"],"searches":["literal text"]}. ' - f"Request at most {MAX_CONTEXT_FILES} files and {MAX_CONTEXT_SEARCHES} literal searches." - ) - triage_content += ( - "\n\nFocus on correctness, contracts, failure paths, tests, and architecture. " - "If the packet is truncated or missing context for a potential issue, mention that " - "limitation in What I Checked rather than inventing certainty." - f"\n\n# Review Packet\n{review_packet}" + def triage_for_packet(review_packet, focus_note): + triage = ( + f"Review this PR. The review base is '{base}' from target branch '{base_ref}', " + f"head is '{head_sha}', and mode is '{effective_mode}'. {focus_note} " + "Use the provided review packet as the complete inspection context. " + "You have one chance to request focused extra context before the final review. " + "If the packet is enough, reply with FINAL_REVIEW followed by a JSON object in the skill's schema. " + "If more context is necessary to validate a concrete potential finding, reply only with " + 'CONTEXT_REQUEST and JSON like {"files":["path"],"searches":["literal text"]}. ' + f"Request at most {MAX_CONTEXT_FILES} files and {MAX_CONTEXT_SEARCHES} literal searches." + ) + triage += ( + "\n\nFocus on correctness, contracts, failure paths, tests, CI/deployment risks, " + "and architecture. Findings must point to changed diff lines. " + "If the packet is truncated or missing context for a potential issue, mention that " + "limitation in what_i_checked rather than inventing certainty." + f"\n\n# Review Packet\n{review_packet}" + ) + return triage + + if use_chunked_review: + stats = build_stats("") + chunk_reviews = [] + for index, chunk in enumerate(chunks, 1): + review_packet = build_review_packet( + base, + ci_status, + effective_mode, + focus_files=chunk, + include_full_patch=False, + ) + stats["review_packet_chars"] += len(review_packet) + focus_note = ( + f"This is chunk {index} of {len(chunks)}. Review only these focus files: " + + ", ".join(chunk) + + "." + ) + chunk_reviews.append( + review_packet_with_model( + client, skill, triage_for_packet(review_packet, focus_note), stats + ) + ) + review_obj = merge_review_objects(chunk_reviews) + review_obj.setdefault("what_i_checked", []).append( + f"Reviewed the PR in {len(chunks)} file chunk(s) to avoid dropping large-diff context." + ) + else: + review_packet = build_review_packet(base, ci_status, effective_mode) + stats = build_stats(review_packet) + review_obj = review_packet_with_model( + client, + skill, + triage_for_packet(review_packet, "Review the full current diff."), + stats, + ) + review_obj.setdefault("head_sha", head_sha) + review_obj.setdefault("review_base", base) + review_obj.setdefault("base_ref", base_ref) + review_obj.setdefault("mode", effective_mode) + pathlib.Path("review.json").write_text( + json.dumps(review_obj, indent=2, sort_keys=True) + "\n", "utf-8" ) + print_telemetry(stats) - messages = [ - {"role": "system", "content": skill}, - {"role": "user", "content": triage_content}, + +def read_ci_status(): + path = pathlib.Path("bunny-ci-status.md") + if path.exists(): + return path.read_text("utf-8") + return "" + + +def render_review(args): + review_obj = json.loads(pathlib.Path(args.review_json).read_text("utf-8")) + base = ( + args.base + or os.environ.get("BUNNY_VALIDATION_BASE") + or os.environ.get("BUNNY_BASE_SHA") + or review_obj.get("review_base") + ) + if not base: + pr_num = os.environ.get("PR_NUM", "") + requested_mode = args.mode or parse_command_mode() + base, _, _, _ = resolve_review_base(pr_num, requested_mode) + findings, invalid = validate_findings(review_obj, base) + ci_status = read_ci_status() + head_sha = review_obj.get("head_sha") or os.environ.get("BUNNY_HEAD_SHA", "") + walkthrough = render_walkthrough(review_obj, findings, invalid, ci_status, head_sha) + pathlib.Path("review.md").write_text(walkthrough, "utf-8") + inline = [ + { + "path": f.path, + "line": f.line, + "side": "RIGHT", + "body": render_finding_body(f), + } + for f in findings ] - stats = build_stats(review_packet) + pathlib.Path("inline-comments.json").write_text( + json.dumps(inline, indent=2, sort_keys=True) + "\n", "utf-8" + ) - first_response = model_call(client, messages, stats) - request = parse_context_request(first_response) - if request is None: - review = clean_review_text(first_response) + +def find_walkthrough_comment(pr_num): + gh = run_gh( + [ + "api", + f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr_num}/comments?per_page=100", + "--paginate", + ], + check=True, + ) + try: + comments = json.loads(gh.stdout or "[]") + except json.JSONDecodeError: + comments = [] + for line in gh.stdout.splitlines(): + if not line.strip(): + continue + loaded = json.loads(line) + if isinstance(loaded, list): + comments.extend(loaded) + for comment in comments: + if BUNNY_MARKER in comment.get("body", ""): + return comment.get("id") + return None + + +def post_review(args): + pr_num = os.environ["PR_NUM"] + body = pathlib.Path(args.review_md).read_text("utf-8") + comment_id = find_walkthrough_comment(pr_num) + if comment_id: + run_gh( + [ + "api", + "--method", + "PATCH", + f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}", + "--input", + "-", + ], + input_text=json.dumps({"body": body}), + check=True, + ) else: - extra_context = build_extra_context(request, stats) - final_messages = [ - {"role": "system", "content": skill}, - {"role": "user", "content": triage_content}, - {"role": "assistant", "content": first_response}, - { - "role": "user", - "content": ( - "Here is the bounded extra context you requested. " - "Do not request more context. Produce only the final review in the skill's Output Shape." - f"\n\n# Extra Context\n{extra_context}" - ), - }, - ] - review = clean_review_text(model_call(client, final_messages, stats)) - review = maybe_remove_change_summary(review) - pathlib.Path("review.md").write_text(review or "Bunny could not produce review text.", "utf-8") - print_telemetry(stats) + run_gh(["pr", "comment", pr_num, "--body-file", args.review_md], check=True) + + comments = json.loads(pathlib.Path(args.inline_json).read_text("utf-8")) + if not comments: + return + payload = { + "event": "COMMENT", + "body": "Bunny Review inline findings", + "comments": comments, + } + run_gh( + [ + "api", + "--method", + "POST", + f"repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{pr_num}/reviews", + "--input", + "-", + ], + input_text=json.dumps(payload), + check=True, + ) + + +def main(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + produce = sub.add_parser("produce") + produce.add_argument("--mode", choices=["auto", "full", "incremental"]) + render = sub.add_parser("render") + render.add_argument("--review-json", default="review.json") + render.add_argument("--base") + render.add_argument("--mode", choices=["auto", "full", "incremental"]) + post = sub.add_parser("post") + post.add_argument("--review-md", default="review.md") + post.add_argument("--inline-json", default="inline-comments.json") + args = parser.parse_args() + + if args.command in (None, "produce"): + produce_review(args) + elif args.command == "render": + render_review(args) + elif args.command == "post": + post_review(args) + if __name__ == "__main__": main() diff --git a/skills/bunny-review/SKILL.md b/skills/bunny-review/SKILL.md index 0c9a19570..136255788 100644 --- a/skills/bunny-review/SKILL.md +++ b/skills/bunny-review/SKILL.md @@ -1,11 +1,11 @@ --- name: bunny-review -description: "Review Marinara pull requests in a CI pass by inspecting a bounded review packet with the live diff, relevant local guidance, and CI context." +description: "Review Marinara pull requests in a CI pass by inspecting bounded diff packets, path rules, and CI context." --- # Bunny Review -You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. In two-pass CI mode, either produce a final review from the packet or request one small batch of focused extra context; after extra context is provided, produce the final review. +You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. In two-pass CI mode, either produce a final structured review from the packet or request one small batch of focused extra context; after extra context is provided, produce the final structured review. You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. @@ -23,14 +23,20 @@ You must not edit files, run project code, read secrets, or request external net - Chat, roleplay, or game mode changes: `skills/marinara-mode-separation/SKILL.md`. - Bug fixes or regressions: `skills/marinara-bugfix-discipline/SKILL.md`. - Onboarding/docs/run-build guidance: `skills/marinara-getting-started/SKILL.md`. -4. Read the changed patch and focused guidance included in the packet. +4. Read the changed patch overview, per-file patch context, Bunny path rules, and focused guidance included in the packet. 5. Inspect callers, contracts, tests, and adjacent implementations from the packet before reporting a finding. If a concrete suspected issue needs missing caller, schema, or contract context, request that focused context once. If context remains missing after the extra batch, say so instead of inventing certainty. +6. Review mode matters: + - `full` reviews the whole PR diff. + - `incremental` reviews only changes since Bunny's last reviewed head. + - `custom` reviews the explicitly supplied base. ## Review Passes Prioritize correctness, user-visible regressions, security/privacy, architecture boundaries, mode ownership, missing tests, and CI/deployment failures. -Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, or `low`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. Every finding must cite a concrete file and line or a small changed area. +Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, or `low`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. + +Every finding must cite a concrete changed file and an added/changed line from the current diff. If a real concern is outside the changed lines, describe it in `open_questions` or `pre_merge_checks` instead of making it a finding. Treat these as high-signal Marinara review concerns: @@ -44,38 +50,39 @@ Treat these as high-signal Marinara review concerns: ## Output Shape -Reply with only the review text. Keep the review concise while still reporting every actionable finding. Do not include exhaustive audit trails, repeated CI history, or long file lists unless they change the reviewer’s decision. Use this exact structure: - -``` -## Bunny Review - -### Change Summary -- 1-2 plain-language sentences explaining what the PR changes and why it matters for a reader who may not know the codebase. - -### Findings -- [severity] file:line - Finding title. Use 2-4 sentences max: risk, cause, smallest useful fix. - -### Open Questions -- 0-2 concise questions or assumptions, if any. - -### What I Checked -- 3-5 concise bullets covering the main commands, files, or contracts inspected. +Reply with only `FINAL_REVIEW` followed by a single JSON object. Do not wrap the JSON in Markdown. Keep strings concise while still reporting every actionable finding. Do not include exhaustive audit trails, repeated CI history, or long file lists unless they change the reviewer’s decision. + +Use this exact schema: + +```json +{ + "change_summary": [ + "1-2 plain-language sentences explaining what the PR changes and why it matters." + ], + "findings": [ + { + "severity": "blocking|high|medium|low", + "path": "changed/file.ts", + "line": 123, + "title": "Short finding title", + "body": "2-4 sentences covering risk and cause.", + "fix_hint": "The smallest useful fix." + } + ], + "pre_merge_checks": [ + { + "name": "Tests", + "status": "pass|warn|fail|unknown", + "detail": "Concise status or risk." + } + ], + "open_questions": [ + "0-2 concise questions or assumptions, if any." + ], + "what_i_checked": [ + "3-6 concise bullets covering commands, files, contracts, or guidance inspected." + ] +} ``` -If there are no findings, write: - -``` -## Bunny Review - -### Change Summary -- 1-2 plain-language sentences explaining what the PR changes and why it matters for a reader who may not know the codebase. - -### Findings -No actionable findings. - -### Open Questions -- None. - -### What I Checked -- 3-5 concise bullets covering the main commands, files, or contracts inspected. -``` +If there are no findings, return `"findings": []`. diff --git a/skills/bunny-review/rules.json b/skills/bunny-review/rules.json new file mode 100644 index 000000000..090fa0b10 --- /dev/null +++ b/skills/bunny-review/rules.json @@ -0,0 +1,86 @@ +{ + "review_focus": [ + "correctness", + "user-visible regressions", + "security and privacy", + "architecture boundaries", + "mode ownership", + "failure paths", + "missing regression tests", + "CI and deployment failures" + ], + "severity_policy": { + "blocking": "The PR should not merge because the changed behavior is broken, unsafe, or violates a hard architecture boundary.", + "high": "A likely production or data-loss regression, security/privacy issue, or serious cross-mode/remote-runtime contract risk.", + "medium": "A concrete bug, edge case, maintainability trap, or missing test tied directly to changed behavior.", + "low": "A small but actionable review note tied to the diff, such as localized coverage, clarity, or follow-up risk." + }, + "path_instructions": [ + { + "name": "Engine and runtime boundaries", + "prefixes": [ + "src/engine/", + "src/features/", + "src/shared/api/", + "src-tauri/" + ], + "guidance": [ + "skills/marinara-architecture-guard/SKILL.md" + ], + "checks": [ + "Engine code stays React-free and does not import feature internals, Tauri APIs, Zustand stores, or concrete shared API adapters.", + "Feature code uses focused shared API wrappers instead of raw invokeTauri or raw remote-runtime fetch.", + "Remote-capable behavior follows the explicit HTTP pipeline." + ] + }, + { + "name": "Mode separation", + "prefixes": [ + "src/engine/chat/", + "src/engine/roleplay/", + "src/engine/game/", + "src/features/modes/" + ], + "guidance": [ + "skills/marinara-mode-separation/SKILL.md" + ], + "checks": [ + "Chat, roleplay, and game behavior remain in their owning mode.", + "Shared generation or prompt changes do not silently alter unrelated modes." + ] + }, + { + "name": "Bug fixes and privileged contracts", + "prefixes": [ + "src-tauri/src/commands/", + "src-tauri/src/storage/", + "src-tauri/src/providers/", + "src/shared/api/" + ], + "guidance": [ + "skills/marinara-bugfix-discipline/SKILL.md" + ], + "checks": [ + "Fixes address root causes instead of adding fake success, silent catches, broad fallbacks, or UI-only guards.", + "Provider, storage, command, and transport changes preserve error contracts and hostable behavior." + ] + }, + { + "name": "Docs and agent guidance", + "prefixes": [ + "README", + "docs/", + "skills/", + "AGENTS.md", + ".github/" + ], + "guidance": [ + "skills/marinara-getting-started/SKILL.md" + ], + "checks": [ + "Durable feature-area additions update relevant maps or guidance.", + "Workflow and agent changes remain concrete, testable, and narrow." + ] + } + ] +} From e61c0f1c84f61dfc29fd97aa8226dfb5dc832325 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 17:56:33 +0800 Subject: [PATCH 21/40] Fix Bunny review runner compatibility --- .github/workflows/bunny-review.yml | 6 ++--- scripts/bunny_review.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml index 140df2ab3..502d25606 100644 --- a/.github/workflows/bunny-review.yml +++ b/.github/workflows/bunny-review.yml @@ -85,9 +85,9 @@ jobs: TARGET_CHECKS='Frontend|Rust|Smoke' for attempt in $(seq 1 90); do FOUND=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ - --jq ".check_runs | map(select(.name | test(\"$TARGET_CHECKS\"))) | length") + --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | .name" | wc -l) PENDING=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ - --jq ".check_runs | map(select(.name | test(\"$TARGET_CHECKS\") and .status != \"completed\")) | length") + --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status != \"completed\") | .name" | wc -l) if [ "${FOUND:-0}" -ge 3 ] && [ "${PENDING:-0}" -eq 0 ]; then break fi @@ -98,7 +98,7 @@ jobs: echo "" echo "### CI Status" FINAL_CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ - --jq ".check_runs | map(select(.name | test(\"$TARGET_CHECKS\"))) | map(\"- \(.name): \(.conclusion // .status)\") | join(\"\n\")") + --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | \"- \(.name): \(.conclusion // .status)\"") if [ -z "$FINAL_CHECKS" ]; then echo "- No Frontend, Rust, or Smoke checks were found before Bunny posted." else diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index e372ba657..1477ee027 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -4,6 +4,7 @@ import os import pathlib import re +import shutil import subprocess import time from dataclasses import dataclass @@ -113,6 +114,8 @@ def read_context_file(path): def search_repo(pattern): if not pattern or len(pattern) > 120: return "refused: search pattern must be 1-120 characters" + if not shutil.which("rg"): + return search_repo_with_python(pattern) rg = run( [ "rg", @@ -151,6 +154,40 @@ def search_repo(pattern): return "\n".join(lines) or "no matches" +def search_repo_with_python(pattern): + hits = [] + ignored_parts = { + ".git", + "node_modules", + "target", + "dist", + "build", + ".next", + "coverage", + "playwright-report", + } + for path in REPO_ROOT.rglob("*"): + if len(hits) >= MAX_SEARCH_HITS: + break + if any(part in ignored_parts for part in path.parts): + continue + if not path.is_file(): + continue + try: + if path.stat().st_size > MAX_SEARCH_FILE_BYTES: + continue + rel = path.relative_to(REPO_ROOT) + text = path.read_text("utf-8", "replace") + except Exception: + continue + for line_no, line in enumerate(text.splitlines(), 1): + if pattern in line: + hits.append(f"{rel}:{line_no}: {line.strip()[:220]}") + if len(hits) >= MAX_SEARCH_HITS: + break + return "\n".join(hits) or "no matches" + + def search_repo_hits(pattern, max_hits): result = search_repo(pattern) if result == "no matches" or result.startswith("refused:"): From 51f70bb9c27660d90fb163fe7995932a47072e35 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 18:50:33 +0800 Subject: [PATCH 22/40] Deepen Bunny no-finding review pass --- scripts/bunny_review.py | 51 +++++++++++++++++++++++++++++++--- skills/bunny-review/SKILL.md | 7 +++++ skills/bunny-review/rules.json | 4 ++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 1477ee027..99fb02fe5 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -535,6 +535,30 @@ def review_packet_with_model(client, skill, triage_content, stats): return extract_json(model_call(client, final_messages, stats)) +def skeptical_review_pass(client, skill, triage_content, first_review, stats): + if first_review.get("findings"): + return first_review + audit_prompt = ( + "The first review reported no findings. Do one stricter skeptical audit pass over " + "the same packet before accepting that result. Focus especially on invariant " + "mismatches introduced by the diff: data collected in a pre-scan but persisted " + "after later filters, parent metadata derived from rows that are not imported as " + "children, fallback behavior that diverges from validation, rollback paths, and " + "tests that prove only the happy path. Report only concrete actionable findings " + "that cite added or changed diff lines. If there are still no findings, return the " + "same JSON schema with an empty findings array and mention the skeptical audit in " + "what_i_checked." + f"\n\n# First Review JSON\n{json.dumps(first_review, indent=2, sort_keys=True)}" + ) + messages = [ + {"role": "system", "content": skill}, + {"role": "user", "content": triage_content}, + {"role": "assistant", "content": "FINAL_REVIEW " + json.dumps(first_review)}, + {"role": "user", "content": audit_prompt}, + ] + return extract_json(model_call(client, messages, stats)) + + def parse_context_request(content): marker = "CONTEXT_REQUEST" if marker not in content: @@ -870,9 +894,20 @@ def triage_for_packet(review_packet, focus_note): + ", ".join(chunk) + "." ) + triage_content = triage_for_packet(review_packet, focus_note) + first_review = review_packet_with_model( + client, + skill, + triage_content, + stats, + ) chunk_reviews.append( - review_packet_with_model( - client, skill, triage_for_packet(review_packet, focus_note), stats + skeptical_review_pass( + client, + skill, + triage_content, + first_review, + stats, ) ) review_obj = merge_review_objects(chunk_reviews) @@ -882,10 +917,18 @@ def triage_for_packet(review_packet, focus_note): else: review_packet = build_review_packet(base, ci_status, effective_mode) stats = build_stats(review_packet) - review_obj = review_packet_with_model( + triage_content = triage_for_packet(review_packet, "Review the full current diff.") + first_review = review_packet_with_model( + client, + skill, + triage_content, + stats, + ) + review_obj = skeptical_review_pass( client, skill, - triage_for_packet(review_packet, "Review the full current diff."), + triage_content, + first_review, stats, ) review_obj.setdefault("head_sha", head_sha) diff --git a/skills/bunny-review/SKILL.md b/skills/bunny-review/SKILL.md index 136255788..46f993f1b 100644 --- a/skills/bunny-review/SKILL.md +++ b/skills/bunny-review/SKILL.md @@ -48,6 +48,13 @@ Treat these as high-signal Marinara review concerns: - Fake success states, silent catches, broad fallbacks, or UI-only guards over broken contracts. - Changes without tests when the touched behavior has realistic regression risk. +For import, storage, migration, and persistence changes, explicitly check for invariant drift: + +- Parent records populated from child rows that are later skipped, filtered, or fail to persist. +- Pre-scans collecting IDs, metadata, counts, or relationships with looser criteria than the write loop. +- Message, chat, character, branch, or asset metadata becoming inconsistent after rollback or partial import. +- Tests that verify linked happy-path rows but miss filtered rows such as empty content, system-only rows, invalid rows, or fallback rows. + ## Output Shape Reply with only `FINAL_REVIEW` followed by a single JSON object. Do not wrap the JSON in Markdown. Keep strings concise while still reporting every actionable finding. Do not include exhaustive audit trails, repeated CI history, or long file lists unless they change the reviewer’s decision. diff --git a/skills/bunny-review/rules.json b/skills/bunny-review/rules.json index 090fa0b10..53f63559e 100644 --- a/skills/bunny-review/rules.json +++ b/skills/bunny-review/rules.json @@ -62,7 +62,9 @@ ], "checks": [ "Fixes address root causes instead of adding fake success, silent catches, broad fallbacks, or UI-only guards.", - "Provider, storage, command, and transport changes preserve error contracts and hostable behavior." + "Provider, storage, command, and transport changes preserve error contracts and hostable behavior.", + "Parent records must not collect IDs, metadata, counts, or relationships from child rows that later skip import or fail to persist.", + "Pre-scan logic should use the same eligibility criteria as the write loop, especially for imports, migrations, and rollback-sensitive storage paths." ] }, { From 2ea6e96762fc65de639ab943b8e62486bdbaa1ef Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 19:15:23 +0800 Subject: [PATCH 23/40] Run Bunny review through three model passes --- scripts/bunny_review.py | 87 +++++++++++++++++++----------------- skills/bunny-review/SKILL.md | 8 +++- 2 files changed, 52 insertions(+), 43 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 99fb02fe5..5ede8f50a 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -535,30 +535,59 @@ def review_packet_with_model(client, skill, triage_content, stats): return extract_json(model_call(client, final_messages, stats)) -def skeptical_review_pass(client, skill, triage_content, first_review, stats): - if first_review.get("findings"): - return first_review +def skeptical_review_pass(client, skill, triage_content, stats): audit_prompt = ( - "The first review reported no findings. Do one stricter skeptical audit pass over " - "the same packet before accepting that result. Focus especially on invariant " - "mismatches introduced by the diff: data collected in a pre-scan but persisted " - "after later filters, parent metadata derived from rows that are not imported as " - "children, fallback behavior that diverges from validation, rollback paths, and " - "tests that prove only the happy path. Report only concrete actionable findings " - "that cite added or changed diff lines. If there are still no findings, return the " - "same JSON schema with an empty findings array and mention the skeptical audit in " - "what_i_checked." - f"\n\n# First Review JSON\n{json.dumps(first_review, indent=2, sort_keys=True)}" + "Run an independent skeptical specialist review over the same packet. Do not treat " + "any broad-review conclusion as authoritative. Focus on invariant mismatches " + "introduced by the diff: data collected in a pre-scan but persisted after later " + "filters, parent metadata derived from rows that are not imported as children, " + "fallback behavior that diverges from validation, rollback paths, partial writes, " + "contract drift, and tests that prove only the happy path. Report only concrete " + "actionable findings that cite added or changed diff lines. If there are no " + "findings from this specialist lens, return the same JSON schema with an empty " + "findings array and mention the skeptical audit in what_i_checked." ) messages = [ {"role": "system", "content": skill}, {"role": "user", "content": triage_content}, - {"role": "assistant", "content": "FINAL_REVIEW " + json.dumps(first_review)}, {"role": "user", "content": audit_prompt}, ] return extract_json(model_call(client, messages, stats)) +def judge_review_pass(client, skill, triage_content, broad_review, skeptical_review, stats): + judge_prompt = ( + "Merge these two independent review passes into the final Bunny Review JSON. " + "Deduplicate overlapping findings, keep the clearest title/body/fix_hint, normalize " + "severity, and reject weak or speculative findings. Preserve concrete findings even " + "if only one pass found them. Every final finding must be actionable and cite an " + "added or changed diff line. Combine useful change_summary, pre_merge_checks, " + "open_questions, and what_i_checked entries without repeating yourself. Reply only " + "with FINAL_REVIEW followed by the final JSON object." + f"\n\n# Broad Review JSON\n{json.dumps(broad_review, indent=2, sort_keys=True)}" + f"\n\n# Skeptical Review JSON\n{json.dumps(skeptical_review, indent=2, sort_keys=True)}" + ) + messages = [ + {"role": "system", "content": skill}, + {"role": "user", "content": triage_content}, + {"role": "user", "content": judge_prompt}, + ] + return extract_json(model_call(client, messages, stats)) + + +def three_pass_review(client, skill, triage_content, stats): + broad_review = review_packet_with_model(client, skill, triage_content, stats) + skeptical_review = skeptical_review_pass(client, skill, triage_content, stats) + return judge_review_pass( + client, + skill, + triage_content, + broad_review, + skeptical_review, + stats, + ) + + def parse_context_request(content): marker = "CONTEXT_REQUEST" if marker not in content: @@ -895,21 +924,7 @@ def triage_for_packet(review_packet, focus_note): + "." ) triage_content = triage_for_packet(review_packet, focus_note) - first_review = review_packet_with_model( - client, - skill, - triage_content, - stats, - ) - chunk_reviews.append( - skeptical_review_pass( - client, - skill, - triage_content, - first_review, - stats, - ) - ) + chunk_reviews.append(three_pass_review(client, skill, triage_content, stats)) review_obj = merge_review_objects(chunk_reviews) review_obj.setdefault("what_i_checked", []).append( f"Reviewed the PR in {len(chunks)} file chunk(s) to avoid dropping large-diff context." @@ -918,19 +933,7 @@ def triage_for_packet(review_packet, focus_note): review_packet = build_review_packet(base, ci_status, effective_mode) stats = build_stats(review_packet) triage_content = triage_for_packet(review_packet, "Review the full current diff.") - first_review = review_packet_with_model( - client, - skill, - triage_content, - stats, - ) - review_obj = skeptical_review_pass( - client, - skill, - triage_content, - first_review, - stats, - ) + review_obj = three_pass_review(client, skill, triage_content, stats) review_obj.setdefault("head_sha", head_sha) review_obj.setdefault("review_base", base) review_obj.setdefault("base_ref", base_ref) diff --git a/skills/bunny-review/SKILL.md b/skills/bunny-review/SKILL.md index 46f993f1b..922f47a47 100644 --- a/skills/bunny-review/SKILL.md +++ b/skills/bunny-review/SKILL.md @@ -5,7 +5,7 @@ description: "Review Marinara pull requests in a CI pass by inspecting bounded d # Bunny Review -You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. In two-pass CI mode, either produce a final structured review from the packet or request one small batch of focused extra context; after extra context is provided, produce the final structured review. +You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. Bunny runs a three-model-pass review pipeline: broad review, independent skeptical specialist review, and final judge/merge review. In each packet review call, either produce structured review JSON from the packet or request one small batch of focused extra context; after extra context is provided, produce the structured review JSON. You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. @@ -34,6 +34,12 @@ You must not edit files, run project code, read secrets, or request external net Prioritize correctness, user-visible regressions, security/privacy, architecture boundaries, mode ownership, missing tests, and CI/deployment failures. +Each model pass has a different job: + +- Broad review: search widely for correctness, architecture, tests, security/privacy, CI/deployment, and user-visible regressions. +- Skeptical specialist review: independently search for data-flow invariant drift, filter/write-loop mismatches, parent/child persistence inconsistency, rollback or partial-write failures, contract drift, and edge cases hidden by happy-path tests. +- Judge review: merge broad and skeptical outputs, deduplicate, reject weak/speculative findings, normalize severity, and keep every concrete actionable finding found by either pass. + Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, or `low`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. Every finding must cite a concrete changed file and an added/changed line from the current diff. If a real concern is outside the changed lines, describe it in `open_questions` or `pre_merge_checks` instead of making it a finding. From fc1ff8df0c4ef677926beabfcbf20d305e8144e6 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 20:06:52 +0800 Subject: [PATCH 24/40] Clean up Bunny CI status rendering --- scripts/bunny_review.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 5ede8f50a..91fcf071b 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -722,11 +722,37 @@ def render_finding_body(finding): return "\n".join(parts).strip() +def is_ci_check(item): + name = str(item.get("name", "")).strip().lower() + return name in {"ci", "ci status", "checks", "github checks"} + + +def normalize_ci_status(ci_status): + if not ci_status: + return "" + unique_lines = [] + seen = set() + for raw_line in ci_status.splitlines(): + line = raw_line.strip() + if not line or line.lower() == "### ci status": + continue + if line.startswith("- "): + key = line.lower() + if key in seen: + continue + seen.add(key) + unique_lines.append(line) + return "\n".join(unique_lines).strip() + + def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_sha): summary = review_obj.get("change_summary") or [] questions = review_obj.get("open_questions") or [] checked = review_obj.get("what_i_checked") or [] + normalized_ci_status = normalize_ci_status(ci_status) pre_merge = review_obj.get("pre_merge_checks") or [] + if normalized_ci_status: + pre_merge = [item for item in pre_merge if not is_ci_check(item)] finding_lines = ( [f"- [{f.severity}] `{f.path}:{f.line}` - {f.title}" for f in findings] or ["No actionable findings."] @@ -756,8 +782,8 @@ def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_s body.append( f"- Skipped {len(invalid_findings)} model finding(s) because Bunny could not validate their diff locations." ) - if ci_status: - body.extend(["", "### CI Status", ci_status.strip()]) + if normalized_ci_status: + body.extend(["", "### CI Status", normalized_ci_status]) return "\n".join(body).strip() + "\n" From 79cae37f1985cbee5966d05b5f3d17ffc509c0d7 Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 20:25:31 +0800 Subject: [PATCH 25/40] Filter stale Bunny CI uncertainty --- scripts/bunny_review.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 91fcf071b..24ec1cce2 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -727,6 +727,31 @@ def is_ci_check(item): return name in {"ci", "ci status", "checks", "github checks"} +def is_stale_ci_text(text): + lowered = text.lower() + if "ci" not in lowered and "cargo" not in lowered and "rust check" not in lowered: + return False + stale_markers = ( + "still running", + "not available", + "unavailable", + "unknown", + "pending", + "not include", + "not provided", + ) + return any(marker in lowered for marker in stale_markers) + + +def is_stale_ci_check(item): + if is_ci_check(item): + return True + combined = " ".join( + str(item.get(key, "")) for key in ("name", "status", "detail") + ) + return is_stale_ci_text(combined) + + def normalize_ci_status(ci_status): if not ci_status: return "" @@ -752,7 +777,8 @@ def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_s normalized_ci_status = normalize_ci_status(ci_status) pre_merge = review_obj.get("pre_merge_checks") or [] if normalized_ci_status: - pre_merge = [item for item in pre_merge if not is_ci_check(item)] + pre_merge = [item for item in pre_merge if not is_stale_ci_check(item)] + checked = [item for item in checked if not is_stale_ci_text(str(item))] finding_lines = ( [f"- [{f.severity}] `{f.path}:{f.line}` - {f.title}" for f in findings] or ["No actionable findings."] From 42ad605d4b43d7c047c71cab6fabf1a42a9a4b4a Mon Sep 17 00:00:00 2001 From: Promansis Date: Sun, 31 May 2026 20:38:02 +0800 Subject: [PATCH 26/40] Add Bunny agent prompt dropdowns --- scripts/bunny_review.py | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/scripts/bunny_review.py b/scripts/bunny_review.py index 24ec1cce2..7777a71b8 100644 --- a/scripts/bunny_review.py +++ b/scripts/bunny_review.py @@ -719,9 +719,52 @@ def render_finding_body(finding): ] if finding.fix_hint: parts.extend(["", f"Suggested fix: {finding.fix_hint}"]) + parts.extend(["", render_agent_prompt_details([finding], "Prompt for AI Agents")]) return "\n".join(parts).strip() +def code_block_text(text): + return text.replace("```", "'''").strip() + + +def agent_prompt_for_finding(finding): + lines = [ + f"In `@{finding.path}` around line {finding.line}:", + f"- {finding.title}", + "", + finding.body, + ] + if finding.fix_hint: + lines.extend(["", f"Suggested fix: {finding.fix_hint}"]) + return "\n".join(lines) + + +def render_agent_prompt(findings): + sections = [ + "Verify each Bunny finding against current code. Fix only still-valid issues, " + "skip the rest with a brief reason, keep changes minimal, and validate.", + ] + sections.extend(agent_prompt_for_finding(finding) for finding in findings) + return code_block_text("\n\n".join(sections)) + + +def render_agent_prompt_details(findings, summary): + if not findings: + return "" + return "\n".join( + [ + "
", + f"{summary}", + "", + "```text", + render_agent_prompt(findings), + "```", + "", + "
", + ] + ) + + def is_ci_check(item): name = str(item.get("name", "")).strip().lower() return name in {"ci", "ci status", "checks", "github checks"} @@ -792,6 +835,11 @@ def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_s ] body.extend([f"- {line}" for line in summary[:3]] or ["- No change summary produced."]) body.extend(["", "### Findings", *finding_lines]) + agent_prompt = render_agent_prompt_details( + findings, "Prompt for all Bunny findings with AI agents" + ) + if agent_prompt: + body.extend(["", agent_prompt]) if pre_merge: body.extend(["", "### Pre-Merge Checks"]) for item in pre_merge[:8]: From 09bf1637063b198715b1544b45fde25a8ce8b001 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 01:22:32 +0800 Subject: [PATCH 27/40] group bunny review CI tooling --- .../bunny-review}/bunny_review.py | 23 ++++++++------ .../bunny-review}/requirements.txt | 0 .../bunny-review/reviewer-prompt.md | 0 {skills => .github}/bunny-review/rules.json | 0 .github/workflows/bunny-review.yml | 30 ++++++++++++------- AGENTS.md | 2 +- 6 files changed, 34 insertions(+), 21 deletions(-) rename {scripts => .github/bunny-review}/bunny_review.py (98%) rename {scripts => .github/bunny-review}/requirements.txt (100%) rename skills/bunny-review/SKILL.md => .github/bunny-review/reviewer-prompt.md (100%) rename {skills => .github}/bunny-review/rules.json (100%) diff --git a/scripts/bunny_review.py b/.github/bunny-review/bunny_review.py similarity index 98% rename from scripts/bunny_review.py rename to .github/bunny-review/bunny_review.py index 7777a71b8..dc62cae3a 100644 --- a/scripts/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -1,4 +1,4 @@ -# scripts/bunny_review.py +# .github/bunny-review/bunny_review.py import argparse import json import os @@ -273,13 +273,19 @@ def load_json_file(path): return {"_load_error": str(exc)} -def bunny_skill_dir(): - skill_path = pathlib.Path( - os.environ.get("BUNNY_REVIEW_SKILL_PATH", "skills/bunny-review/SKILL.md") +def bunny_prompt_path(): + prompt_path = pathlib.Path( + os.environ.get("BUNNY_REVIEW_PROMPT_PATH") + or os.environ.get("BUNNY_REVIEW_SKILL_PATH") + or ".github/bunny-review/reviewer-prompt.md" ) - if not skill_path.is_absolute(): - skill_path = REPO_ROOT / skill_path - return skill_path.parent + if not prompt_path.is_absolute(): + prompt_path = REPO_ROOT / prompt_path + return prompt_path + + +def bunny_skill_dir(): + return bunny_prompt_path().parent def load_rules(): @@ -983,8 +989,7 @@ def produce_review(args): api_key=os.environ["OPENAI_API_KEY"], base_url=os.environ.get("LLM_BASE_URL"), ) - skill_path = bunny_skill_dir() / "SKILL.md" - skill = skill_path.read_text("utf-8") + skill = bunny_prompt_path().read_text("utf-8") def triage_for_packet(review_packet, focus_note): triage = ( diff --git a/scripts/requirements.txt b/.github/bunny-review/requirements.txt similarity index 100% rename from scripts/requirements.txt rename to .github/bunny-review/requirements.txt diff --git a/skills/bunny-review/SKILL.md b/.github/bunny-review/reviewer-prompt.md similarity index 100% rename from skills/bunny-review/SKILL.md rename to .github/bunny-review/reviewer-prompt.md diff --git a/skills/bunny-review/rules.json b/.github/bunny-review/rules.json similarity index 100% rename from skills/bunny-review/rules.json rename to .github/bunny-review/rules.json diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml index 502d25606..a16220b13 100644 --- a/.github/workflows/bunny-review.yml +++ b/.github/workflows/bunny-review.yml @@ -46,12 +46,19 @@ jobs: - name: Preserve review tooling from base branch run: | - mkdir -p /tmp/bunny-review-tool/scripts - mkdir -p /tmp/bunny-review-tool/skills/bunny-review - git show "origin/$PR_BASE_REF:scripts/bunny_review.py" > /tmp/bunny-review-tool/scripts/bunny_review.py - git show "origin/$PR_BASE_REF:scripts/requirements.txt" > /tmp/bunny-review-tool/scripts/requirements.txt - git show "origin/$PR_BASE_REF:skills/bunny-review/SKILL.md" > /tmp/bunny-review-tool/skills/bunny-review/SKILL.md - git show "origin/$PR_BASE_REF:skills/bunny-review/rules.json" > /tmp/bunny-review-tool/skills/bunny-review/rules.json || true + mkdir -p /tmp/bunny-review-tool/.github/bunny-review + if git cat-file -e "origin/$PR_BASE_REF:.github/bunny-review/bunny_review.py"; then + git show "origin/$PR_BASE_REF:.github/bunny-review/bunny_review.py" > /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py + git show "origin/$PR_BASE_REF:.github/bunny-review/requirements.txt" > /tmp/bunny-review-tool/.github/bunny-review/requirements.txt + git show "origin/$PR_BASE_REF:.github/bunny-review/reviewer-prompt.md" > /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md + git show "origin/$PR_BASE_REF:.github/bunny-review/rules.json" > /tmp/bunny-review-tool/.github/bunny-review/rules.json || true + else + git show "origin/$PR_BASE_REF:scripts/bunny_review.py" > /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py + git show "origin/$PR_BASE_REF:scripts/requirements.txt" > /tmp/bunny-review-tool/.github/bunny-review/requirements.txt + git show "origin/$PR_BASE_REF:skills/bunny-review/SKILL.md" > /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md + git show "origin/$PR_BASE_REF:skills/bunny-review/rules.json" > /tmp/bunny-review-tool/.github/bunny-review/rules.json || true + fi + cp /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md /tmp/bunny-review-tool/.github/bunny-review/SKILL.md - name: Fetch PR and checkout head env: @@ -66,7 +73,7 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install -r /tmp/bunny-review-tool/scripts/requirements.txt + run: pip install -r /tmp/bunny-review-tool/.github/bunny-review/requirements.txt - name: Run review while CI completes env: @@ -74,11 +81,12 @@ jobs: LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} LLM_MODEL: gpt-5.5 CI_STATUS: CI checks are still running; final CI results will be appended before posting. - BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/skills/bunny-review/SKILL.md + BUNNY_REVIEW_PROMPT_PATH: /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md + BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/.github/bunny-review/SKILL.md BUNNY_COMMENT_BODY: ${{ github.event.comment.body }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - python /tmp/bunny-review-tool/scripts/bunny_review.py produce & + python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py produce & BUNNY_PID=$! HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) @@ -112,9 +120,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUNNY_COMMENT_BODY: ${{ github.event.comment.body }} - run: python /tmp/bunny-review-tool/scripts/bunny_review.py render + run: python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py render - name: Post the review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python /tmp/bunny-review-tool/scripts/bunny_review.py post + run: python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py post diff --git a/AGENTS.md b/AGENTS.md index 5fd607859..c11e3b762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,6 @@ For code changes, final responses must include behavior changed, primary files/m - `src/shared/api`: Embedded Tauri and hostable runtime wrappers. Feature code should call these wrappers instead of raw Tauri or raw remote-runtime fetch. - `src-tauri`: Rust command facades, hostable runtime dispatch, storage, LLM/provider transport, assets, imports, integrations, and other privileged capabilities. - `public/sprites/mari`: Professor Mari visual assets used by onboarding, FAQ, title controls, and the Mari shell surface. -- `.github/workflows/bunny-review.yml`, `scripts/bunny_review.py`, `skills/bunny-review`: Bunny Review PR comment automation, review packet builder, and reviewer instructions. +- `.github/workflows/bunny-review.yml`, `.github/bunny-review`: Bunny Review PR comment automation, review packet builder, CI dependencies, path rules, and model reviewer prompt. - `skills/marinara-architecture-guard`: Architecture guardrails for placement, import direction, and remote-capable command routing. - `skills/marinara-agent-workflow`: Agent workflow references, source maps, handoff formats, and verification discipline. From 8356ebe9158557cf1d23c5e7dcb232a2dd91887b Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 09:54:53 +0800 Subject: [PATCH 28/40] tune bunny reviewer voice --- .github/bunny-review/reviewer-prompt.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/bunny-review/reviewer-prompt.md b/.github/bunny-review/reviewer-prompt.md index 922f47a47..2189b3722 100644 --- a/.github/bunny-review/reviewer-prompt.md +++ b/.github/bunny-review/reviewer-prompt.md @@ -7,6 +7,8 @@ description: "Review Marinara pull requests in a CI pass by inspecting bounded d You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. Bunny runs a three-model-pass review pipeline: broad review, independent skeptical specialist review, and final judge/merge review. In each packet review call, either produce structured review JSON from the packet or request one small batch of focused extra context; after extra context is provided, produce the structured review JSON. +Voice: write every human-facing JSON string in a cold, clinical, condescending researcher's manner inspired by Dottore from Genshin Impact. Prefer surgical precision, dry superiority, and experimental phrasing while keeping findings concise and actionable. + You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. ## Setup From 88d63b0dc4d06b763ba2a8c4ea1883fb2af5c7a2 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 09:57:21 +0800 Subject: [PATCH 29/40] support bunny nitpick findings --- .github/bunny-review/bunny_review.py | 4 +++- .github/bunny-review/reviewer-prompt.md | 4 ++-- .github/bunny-review/rules.json | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index dc62cae3a..1e508cc69 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -683,7 +683,7 @@ def validate_findings(review_obj, base): allowed = touched_lines(base) valid = [] invalid = [] - severities = {"blocking", "high", "medium", "low"} + severities = {"blocking", "high", "medium", "low", "nitpick"} for item in review_obj.get("findings", []): try: finding = Finding( @@ -714,6 +714,8 @@ def validate_findings(review_obj, base): invalid.append(f"{finding.path}:{finding.line}: missing title/body") continue valid.append(finding) + severity_rank = {"blocking": 0, "high": 1, "medium": 2, "low": 3, "nitpick": 4} + valid.sort(key=lambda finding: severity_rank.get(finding.severity, 2)) return valid, invalid diff --git a/.github/bunny-review/reviewer-prompt.md b/.github/bunny-review/reviewer-prompt.md index 2189b3722..0ffdb234c 100644 --- a/.github/bunny-review/reviewer-prompt.md +++ b/.github/bunny-review/reviewer-prompt.md @@ -42,7 +42,7 @@ Each model pass has a different job: - Skeptical specialist review: independently search for data-flow invariant drift, filter/write-loop mismatches, parent/child persistence inconsistency, rollback or partial-write failures, contract drift, and edge cases hidden by happy-path tests. - Judge review: merge broad and skeptical outputs, deduplicate, reject weak/speculative findings, normalize severity, and keep every concrete actionable finding found by either pass. -Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, or `low`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Do not report style-only feedback unless it can cause real maintenance or behavior risk. Do not invent issues from naming alone. +Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, `low`, or `nitpick`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Use `nitpick` only for optional but actionable polish such as readability, naming, tiny duplication, stale comments, dead code, or local consistency. Do not invent issues from naming alone. Every finding must cite a concrete changed file and an added/changed line from the current diff. If a real concern is outside the changed lines, describe it in `open_questions` or `pre_merge_checks` instead of making it a finding. @@ -76,7 +76,7 @@ Use this exact schema: ], "findings": [ { - "severity": "blocking|high|medium|low", + "severity": "blocking|high|medium|low|nitpick", "path": "changed/file.ts", "line": 123, "title": "Short finding title", diff --git a/.github/bunny-review/rules.json b/.github/bunny-review/rules.json index 53f63559e..50fd3ee18 100644 --- a/.github/bunny-review/rules.json +++ b/.github/bunny-review/rules.json @@ -13,7 +13,8 @@ "blocking": "The PR should not merge because the changed behavior is broken, unsafe, or violates a hard architecture boundary.", "high": "A likely production or data-loss regression, security/privacy issue, or serious cross-mode/remote-runtime contract risk.", "medium": "A concrete bug, edge case, maintainability trap, or missing test tied directly to changed behavior.", - "low": "A small but actionable review note tied to the diff, such as localized coverage, clarity, or follow-up risk." + "low": "A small but actionable review note tied to the diff, such as localized coverage, clarity, or follow-up risk.", + "nitpick": "Optional but actionable polish such as readability, naming, tiny duplication, stale comments, dead code, or local consistency." }, "path_instructions": [ { From 3542fde856731ac3d37f4faa79c2bb0b9dda9f58 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 12:12:24 +0800 Subject: [PATCH 30/40] Harden Bunny review command dispatch --- .github/bunny-review/reviewer-prompt.md | 2 +- .github/workflows/bunny-review-command.yml | 42 ++++++++++++++++++++++ .github/workflows/bunny-review.yml | 40 +++++++++++++-------- 3 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/bunny-review-command.yml diff --git a/.github/bunny-review/reviewer-prompt.md b/.github/bunny-review/reviewer-prompt.md index 0ffdb234c..a32d7ecd4 100644 --- a/.github/bunny-review/reviewer-prompt.md +++ b/.github/bunny-review/reviewer-prompt.md @@ -7,7 +7,7 @@ description: "Review Marinara pull requests in a CI pass by inspecting bounded d You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. Bunny runs a three-model-pass review pipeline: broad review, independent skeptical specialist review, and final judge/merge review. In each packet review call, either produce structured review JSON from the packet or request one small batch of focused extra context; after extra context is provided, produce the structured review JSON. -Voice: write every human-facing JSON string in a cold, clinical, condescending researcher's manner inspired by Dottore from Genshin Impact. Prefer surgical precision, dry superiority, and experimental phrasing while keeping findings concise and actionable. +Voice: write every human-facing JSON string in a cold, clinical, precise, dry, experimental, and unsentimental researcher's manner inspired by Dottore from Genshin Impact. Critique code and behavior only; never insult, mock, belittle, or personalize criticism. Keep findings concise and actionable. You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. diff --git a/.github/workflows/bunny-review-command.yml b/.github/workflows/bunny-review-command.yml new file mode 100644 index 000000000..b09f7008f --- /dev/null +++ b/.github/workflows/bunny-review-command.yml @@ -0,0 +1,42 @@ +name: Bunny Review Command + +on: + issue_comment: + types: [created] + +permissions: + actions: write + contents: read + issues: read + pull-requests: read + +jobs: + dispatch: + if: > + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bunny-review') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association) + runs-on: ubuntu-latest + steps: + - name: Dispatch trusted Bunny reviewer + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUM: ${{ github.event.issue.number }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + TARGET_REF: refactor + run: | + # Keep this bootstrap deliberately inert: it only authorizes the slash command + # and dispatches the trusted reviewer workflow on the stable target ref. + REVIEW_MODE=auto + if [[ "$COMMENT_BODY" =~ ^/bunny-review[[:space:]]+full([[:space:]]|$) ]]; then + REVIEW_MODE=full + fi + + gh workflow run bunny-review.yml \ + --ref "$TARGET_REF" \ + -f pr_number="$PR_NUM" \ + -f comment_body="$COMMENT_BODY" \ + -f review_mode="$REVIEW_MODE" \ + -f requested_by="$COMMENT_AUTHOR" diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml index a16220b13..d4f84bfe4 100644 --- a/.github/workflows/bunny-review.yml +++ b/.github/workflows/bunny-review.yml @@ -4,8 +4,28 @@ name: Bunny Review on: pull_request: types: [opened, reopened, synchronize, ready_for_review] - issue_comment: - types: [created] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to review. + required: true + comment_body: + description: Slash command body that requested the review. + required: false + default: "" + review_mode: + description: Review mode for the requested pass. + required: false + type: choice + options: + - auto + - full + - incremental + default: auto + requested_by: + description: GitHub login that requested the dispatch. + required: false + default: "" permissions: contents: read @@ -14,22 +34,16 @@ permissions: actions: read # needed to read CI status concurrency: - group: bunny-review-${{ github.event.pull_request.number || github.event.issue.number }} + group: bunny-review-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} cancel-in-progress: true jobs: review: - if: > - github.event_name == 'pull_request' || - ( - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/bunny-review') && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), - github.event.comment.author_association) - ) runs-on: ubuntu-latest env: - PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} + PR_NUM: ${{ github.event.pull_request.number || inputs.pr_number }} + BUNNY_COMMENT_BODY: ${{ inputs.comment_body || '' }} + BUNNY_REVIEW_MODE: ${{ inputs.review_mode || 'auto' }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -83,7 +97,6 @@ jobs: CI_STATUS: CI checks are still running; final CI results will be appended before posting. BUNNY_REVIEW_PROMPT_PATH: /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/.github/bunny-review/SKILL.md - BUNNY_COMMENT_BODY: ${{ github.event.comment.body }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py produce & @@ -119,7 +132,6 @@ jobs: - name: Render review env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUNNY_COMMENT_BODY: ${{ github.event.comment.body }} run: python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py render - name: Post the review From a1b483d404fa93617cc1617989f6492f4fc3e36c Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 12:55:48 +0800 Subject: [PATCH 31/40] Handle missing Bunny CI checks --- .github/bunny-review/bunny_review.py | 31 ++++++++++++++++++++++++++++ .github/workflows/bunny-review.yml | 30 +++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index 1e508cc69..db8f73126 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -821,6 +821,36 @@ def normalize_ci_status(ci_status): return "\n".join(unique_lines).strip() +def ci_status_to_pre_merge_checks(ci_status): + normalized = normalize_ci_status(ci_status) + if not normalized: + return [] + lowered = normalized.lower() + if "failure:" in lowered or ": failure" in lowered or ": cancelled" in lowered: + return [ + { + "name": "CI Status", + "status": "fail", + "detail": "One or more expected CI checks failed or were cancelled; do not merge until CI is repaired.", + } + ] + if "warning:" in lowered or "still running" in lowered: + return [ + { + "name": "CI Status", + "status": "warn", + "detail": "Expected CI checks were missing or incomplete when Bunny posted; verify CI before merging.", + } + ] + return [ + { + "name": "CI Status", + "status": "pass", + "detail": "Expected CI checks completed without a reported failure.", + } + ] + + def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_sha): summary = review_obj.get("change_summary") or [] questions = review_obj.get("open_questions") or [] @@ -830,6 +860,7 @@ def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_s if normalized_ci_status: pre_merge = [item for item in pre_merge if not is_stale_ci_check(item)] checked = [item for item in checked if not is_stale_ci_text(str(item))] + pre_merge = ci_status_to_pre_merge_checks(normalized_ci_status) + pre_merge finding_lines = ( [f"- [{f.severity}] `{f.path}:{f.line}` - {f.title}" for f in findings] or ["No actionable findings."] diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml index d4f84bfe4..bae987302 100644 --- a/.github/workflows/bunny-review.yml +++ b/.github/workflows/bunny-review.yml @@ -104,11 +104,20 @@ jobs: HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid) TARGET_CHECKS='Frontend|Rust|Smoke' - for attempt in $(seq 1 90); do + FOUND_ANY=0 + MISSING_CHECK_ATTEMPTS=18 + MAX_CHECK_ATTEMPTS=90 + for attempt in $(seq 1 "$MAX_CHECK_ATTEMPTS"); do FOUND=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | .name" | wc -l) PENDING=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status != \"completed\") | .name" | wc -l) + if [ "${FOUND:-0}" -gt 0 ]; then + FOUND_ANY=1 + fi + if [ "$FOUND_ANY" -eq 0 ] && [ "$attempt" -ge "$MISSING_CHECK_ATTEMPTS" ]; then + break + fi if [ "${FOUND:-0}" -ge 3 ] && [ "${PENDING:-0}" -eq 0 ]; then break fi @@ -121,9 +130,26 @@ jobs: FINAL_CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | \"- \(.name): \(.conclusion // .status)\"") if [ -z "$FINAL_CHECKS" ]; then - echo "- No Frontend, Rust, or Smoke checks were found before Bunny posted." + echo "- warning: no Frontend, Rust, or Smoke checks appeared after 3 minutes; do not merge until required CI runs." else echo "$FINAL_CHECKS" + FOUND=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | .name" | wc -l) + PENDING=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status != \"completed\") | .name" | wc -l) + COMPLETED=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status == \"completed\") | .name" | wc -l) + FAILURES=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status == \"completed\" and (.conclusion != \"success\" and .conclusion != \"skipped\")) | .name" | wc -l) + if [ "${FOUND:-0}" -lt 3 ]; then + echo "- warning: only ${FOUND:-0}/3 expected CI check groups appeared before Bunny posted." + fi + if [ "${PENDING:-0}" -gt 0 ]; then + echo "- warning: ${PENDING:-0} expected CI check group(s) were still running when Bunny posted." + fi + if [ "${COMPLETED:-0}" -gt 0 ] && [ "${FAILURES:-0}" -gt 0 ]; then + echo "- failure: ${FAILURES:-0} expected CI check group(s) failed; do not merge until CI is repaired." + fi fi } > bunny-ci-status.md From df083c495ba1fd6b1ece28ae270e15ac9e4007dc Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 13:04:24 +0800 Subject: [PATCH 32/40] Sharpen Bunny reviewer voice --- .github/bunny-review/reviewer-prompt.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/bunny-review/reviewer-prompt.md b/.github/bunny-review/reviewer-prompt.md index a32d7ecd4..6b27f2dbe 100644 --- a/.github/bunny-review/reviewer-prompt.md +++ b/.github/bunny-review/reviewer-prompt.md @@ -9,6 +9,18 @@ You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebas Voice: write every human-facing JSON string in a cold, clinical, precise, dry, experimental, and unsentimental researcher's manner inspired by Dottore from Genshin Impact. Critique code and behavior only; never insult, mock, belittle, or personalize criticism. Keep findings concise and actionable. +Voice calibration: + +- Sound like a detached lab reviewer documenting defects in an experiment, not a friendly teammate or a generic CI bot. +- Prefer dry forensic phrasing: "This specimen", "the mechanism", "the failure mode", "the observed contract", "the control path", "the experiment", "contaminates", "misclassifies", "collapses", "permits", "withholds", "the result is unsurprising". +- Avoid warm reassurance, apology, praise, cheer, filler, or conversational softness. Do not write "nice", "great", "please", "thanks", "looks good", "probably fine", or "you". +- Do not overperform theatrical villainy. No threats, taunts, mockery, cruelty, or personal judgment. The scalpel touches the code, not the author. +- Keep the structure concise: one clinical diagnosis, one cause, one consequence, one corrective action. +- Example neutral sentence to avoid: "The workflow can fail before Bunny reaches the review step." +- Example target voice: "The bootstrap is an unproven control path: when base tooling is absent, it reaches for a legacy script that the packet does not establish. The experiment can terminate before review begins." +- Example neutral fix to avoid: "Add a guarded bootstrap fallback." +- Example target fix: "Establish a trusted bootstrap fallback or stage the tooling before enabling the reviewer." + You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context. ## Setup From 139f46101a18d99d359cc9cdafaeb182212425cf Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 13:15:05 +0800 Subject: [PATCH 33/40] Fix Bunny command dispatch repo context --- .github/workflows/bunny-review-command.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/bunny-review-command.yml b/.github/workflows/bunny-review-command.yml index b09f7008f..053199a1c 100644 --- a/.github/workflows/bunny-review-command.yml +++ b/.github/workflows/bunny-review-command.yml @@ -35,6 +35,7 @@ jobs: fi gh workflow run bunny-review.yml \ + --repo "${{ github.repository }}" \ --ref "$TARGET_REF" \ -f pr_number="$PR_NUM" \ -f comment_body="$COMMENT_BODY" \ From 772d80a9ea45edf9085738232eb3cb800a8b7466 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 13:34:54 +0800 Subject: [PATCH 34/40] Label Bunny updates and dedupe inline findings --- .github/bunny-review/bunny_review.py | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index db8f73126..3fdd78eb1 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -1,5 +1,6 @@ # .github/bunny-review/bunny_review.py import argparse +import hashlib import json import os import pathlib @@ -13,6 +14,7 @@ REPO_ROOT = pathlib.Path.cwd().resolve() BUNNY_MARKER = "" +FINDING_MARKER_RE = re.compile(r"") STATE_MARKER_RE = re.compile(r"") MAX_REVIEW_PACKET_CHARS = 180_000 MAX_SECTION_CHARS = 60_000 @@ -721,6 +723,7 @@ def validate_findings(review_obj, base): def render_finding_body(finding): parts = [ + finding_marker(finding), f"**[{finding.severity}] {finding.title}**", "", finding.body, @@ -731,6 +734,34 @@ def render_finding_body(finding): return "\n".join(parts).strip() +def finding_marker(finding): + raw = f"{finding.path}:{finding.line}:{finding.title}".encode("utf-8", "replace") + digest = hashlib.sha256(raw).hexdigest()[:16] + return f"" + + +def short_ref(value): + if not value: + return "unknown" + value = str(value) + if re.fullmatch(r"[0-9a-f]{40}", value): + return value[:8] + if value.startswith("origin/"): + return value + return value[:24] + + +def render_review_metadata(review_obj, head_sha): + mode = review_obj.get("mode") or "unknown" + base = review_obj.get("review_base") or review_obj.get("base_ref") or "unknown" + parts = [ + f"Mode: `{mode}`", + f"Head: `{short_ref(head_sha)}`", + f"Base: `{short_ref(base)}`", + ] + return "_Review update: " + " · ".join(parts) + "._" + + def code_block_text(text): return text.replace("```", "'''").strip() @@ -870,6 +901,8 @@ def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_s f"", "## Bunny Review", "", + render_review_metadata(review_obj, head_sha), + "", "### Change Summary", ] body.extend([f"- {line}" for line in summary[:3]] or ["- No change summary produced."]) @@ -1145,6 +1178,56 @@ def find_walkthrough_comment(pr_num): return None +def load_json_list(stdout): + try: + loaded = json.loads(stdout or "[]") + return loaded if isinstance(loaded, list) else [] + except json.JSONDecodeError: + items = [] + for line in stdout.splitlines(): + if not line.strip(): + continue + loaded = json.loads(line) + if isinstance(loaded, list): + items.extend(loaded) + return items + + +def existing_inline_finding_markers(pr_num): + gh = run_gh( + [ + "api", + f"repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{pr_num}/comments?per_page=100", + "--paginate", + ], + check=True, + ) + markers = set() + for comment in load_json_list(gh.stdout): + markers.update(FINDING_MARKER_RE.findall(comment.get("body", ""))) + return markers + + +def inline_comment_marker(comment): + match = FINDING_MARKER_RE.search(comment.get("body", "")) + if not match: + return None + return match.group(1) + + +def filter_duplicate_inline_comments(pr_num, comments): + existing = existing_inline_finding_markers(pr_num) + if not existing: + return comments + filtered = [] + for comment in comments: + marker = inline_comment_marker(comment) + if marker and marker in existing: + continue + filtered.append(comment) + return filtered + + def post_review(args): pr_num = os.environ["PR_NUM"] body = pathlib.Path(args.review_md).read_text("utf-8") @@ -1166,6 +1249,7 @@ def post_review(args): run_gh(["pr", "comment", pr_num, "--body-file", args.review_md], check=True) comments = json.loads(pathlib.Path(args.inline_json).read_text("utf-8")) + comments = filter_duplicate_inline_comments(pr_num, comments) if not comments: return payload = { From aa351c13e7c7063dacddcfd0a2cff6bd00819fc4 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 13:38:23 +0800 Subject: [PATCH 35/40] Show Bunny slash command status --- .github/bunny-review/bunny_review.py | 47 ++++++++++++++++++ .github/workflows/bunny-review-command.yml | 57 ++++++++++++++++++++-- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index 3fdd78eb1..f0c3c7d96 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -14,6 +14,7 @@ REPO_ROOT = pathlib.Path.cwd().resolve() BUNNY_MARKER = "" +COMMAND_STATUS_MARKER = "" FINDING_MARKER_RE = re.compile(r"") STATE_MARKER_RE = re.compile(r"") MAX_REVIEW_PACKET_CHARS = 180_000 @@ -1178,6 +1179,48 @@ def find_walkthrough_comment(pr_num): return None +def find_command_status_comment(pr_num): + gh = run_gh( + [ + "api", + f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr_num}/comments?per_page=100", + "--paginate", + ], + check=True, + ) + for comment in load_json_list(gh.stdout): + if COMMAND_STATUS_MARKER in comment.get("body", ""): + return comment.get("id") + return None + + +def patch_command_status_complete(pr_num, head_sha): + comment_id = find_command_status_comment(pr_num) + if not comment_id: + return + body = "\n".join( + [ + COMMAND_STATUS_MARKER, + "## Bunny Review Completed", + "", + f"Head: `{short_ref(head_sha)}`", + "Status: Review posted. The specimen has been returned to the table.", + ] + ) + run_gh( + [ + "api", + "--method", + "PATCH", + f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}", + "--input", + "-", + ], + input_text=json.dumps({"body": body}), + check=True, + ) + + def load_json_list(stdout): try: loaded = json.loads(stdout or "[]") @@ -1231,6 +1274,8 @@ def filter_duplicate_inline_comments(pr_num, comments): def post_review(args): pr_num = os.environ["PR_NUM"] body = pathlib.Path(args.review_md).read_text("utf-8") + head_sha_match = STATE_MARKER_RE.search(body) + head_sha = head_sha_match.group(1) if head_sha_match else "" comment_id = find_walkthrough_comment(pr_num) if comment_id: run_gh( @@ -1248,6 +1293,8 @@ def post_review(args): else: run_gh(["pr", "comment", pr_num, "--body-file", args.review_md], check=True) + patch_command_status_complete(pr_num, head_sha) + comments = json.loads(pathlib.Path(args.inline_json).read_text("utf-8")) comments = filter_duplicate_inline_comments(pr_num, comments) if not comments: diff --git a/.github/workflows/bunny-review-command.yml b/.github/workflows/bunny-review-command.yml index 053199a1c..fb53a6cd9 100644 --- a/.github/workflows/bunny-review-command.yml +++ b/.github/workflows/bunny-review-command.yml @@ -7,7 +7,7 @@ on: permissions: actions: write contents: read - issues: read + issues: write pull-requests: read jobs: @@ -34,10 +34,61 @@ jobs: REVIEW_MODE=full fi - gh workflow run bunny-review.yml \ + status_body() { + local title="$1" + local detail="$2" + cat < +## Bunny Review $title + +Command: \`$COMMENT_BODY\` +Mode: \`$REVIEW_MODE\` +Requested by: \`$COMMENT_AUTHOR\` +Target ref: \`$TARGET_REF\` +Status: $detail +EOF + } + + upsert_status() { + local title="$1" + local detail="$2" + local body + body="$(status_body "$title" "$detail")" + local comment_id + comment_id="$(gh api "repos/${{ github.repository }}/issues/$PR_NUM/comments?per_page=100" \ + --paginate \ + --jq '.[] | select(.body | contains("")) | .id' \ + | tail -n 1)" + if [ -n "$comment_id" ]; then + gh api \ + --method PATCH \ + "repos/${{ github.repository }}/issues/comments/$comment_id" \ + -f body="$body" >/dev/null + else + gh api \ + --method POST \ + "repos/${{ github.repository }}/issues/$PR_NUM/comments" \ + -f body="$body" >/dev/null + fi + } + + upsert_status "Queued" "Command accepted; dispatching the trusted reviewer workflow." + + set +e + DISPATCH_OUTPUT="$(gh workflow run bunny-review.yml \ --repo "${{ github.repository }}" \ --ref "$TARGET_REF" \ -f pr_number="$PR_NUM" \ -f comment_body="$COMMENT_BODY" \ -f review_mode="$REVIEW_MODE" \ - -f requested_by="$COMMENT_AUTHOR" + -f requested_by="$COMMENT_AUTHOR" 2>&1)" + DISPATCH_RC=$? + set -e + + if [ "$DISPATCH_RC" -ne 0 ]; then + upsert_status "Failed To Dispatch" "GitHub rejected the reviewer dispatch. Inspect the Bunny Review Command run log." + echo "$DISPATCH_OUTPUT" + exit "$DISPATCH_RC" + fi + + upsert_status "Dispatched" "Reviewer workflow dispatched; waiting for Bunny Review to post results." From f252e3c9a3434827b37b031e9c26c4d3083b8349 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 14:09:22 +0800 Subject: [PATCH 36/40] Fix Bunny command workflow YAML --- .github/workflows/bunny-review-command.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/bunny-review-command.yml b/.github/workflows/bunny-review-command.yml index fb53a6cd9..1a783cb88 100644 --- a/.github/workflows/bunny-review-command.yml +++ b/.github/workflows/bunny-review-command.yml @@ -37,16 +37,15 @@ jobs: status_body() { local title="$1" local detail="$2" - cat < -## Bunny Review $title - -Command: \`$COMMENT_BODY\` -Mode: \`$REVIEW_MODE\` -Requested by: \`$COMMENT_AUTHOR\` -Target ref: \`$TARGET_REF\` -Status: $detail -EOF + printf '%s\n' \ + '' \ + "## Bunny Review $title" \ + '' \ + "Command: \`$COMMENT_BODY\`" \ + "Mode: \`$REVIEW_MODE\`" \ + "Requested by: \`$COMMENT_AUTHOR\`" \ + "Target ref: \`$TARGET_REF\`" \ + "Status: $detail" } upsert_status() { From 382353fa8441a329fa57eb0ed52a499dac6b5533 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 15:08:18 +0800 Subject: [PATCH 37/40] Keep Bunny status from blocking dispatch --- .github/bunny-review/bunny_review.py | 41 +++++++++++++++++++--- .github/workflows/bunny-review-command.yml | 9 +++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index f0c3c7d96..0a973c3d3 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -1047,6 +1047,7 @@ def produce_review(args): pr_num = os.environ.get("PR_NUM", "") requested_mode = args.mode or parse_command_mode() base, base_ref, head_sha, effective_mode = resolve_review_base(pr_num, requested_mode) + patch_command_status_running(pr_num, head_sha, effective_mode) ci_status = os.environ.get("CI_STATUS", "") files = changed_files(base) chunks = chunk_changed_files(base, files) @@ -1194,10 +1195,21 @@ def find_command_status_comment(pr_num): return None +def patch_command_status_running(pr_num, head_sha, mode): + body = "\n".join( + [ + COMMAND_STATUS_MARKER, + "## Bunny Review Running", + "", + f"Mode: `{mode or 'unknown'}`", + f"Head: `{short_ref(head_sha)}`", + "Status: Reviewer workflow is running. The specimen is under observation.", + ] + ) + patch_or_create_command_status(pr_num, body) + + def patch_command_status_complete(pr_num, head_sha): - comment_id = find_command_status_comment(pr_num) - if not comment_id: - return body = "\n".join( [ COMMAND_STATUS_MARKER, @@ -1207,12 +1219,31 @@ def patch_command_status_complete(pr_num, head_sha): "Status: Review posted. The specimen has been returned to the table.", ] ) + patch_or_create_command_status(pr_num, body) + + +def patch_or_create_command_status(pr_num, body): + comment_id = find_command_status_comment(pr_num) + if comment_id: + run_gh( + [ + "api", + "--method", + "PATCH", + f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}", + "--input", + "-", + ], + input_text=json.dumps({"body": body}), + check=True, + ) + return run_gh( [ "api", "--method", - "PATCH", - f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}", + "POST", + f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr_num}/comments", "--input", "-", ], diff --git a/.github/workflows/bunny-review-command.yml b/.github/workflows/bunny-review-command.yml index 1a783cb88..001a35ea4 100644 --- a/.github/workflows/bunny-review-command.yml +++ b/.github/workflows/bunny-review-command.yml @@ -71,7 +71,8 @@ jobs: fi } - upsert_status "Queued" "Command accepted; dispatching the trusted reviewer workflow." + upsert_status "Queued" "Command accepted; dispatching the trusted reviewer workflow." \ + || echo "::warning::Unable to write Bunny command status before dispatch." set +e DISPATCH_OUTPUT="$(gh workflow run bunny-review.yml \ @@ -85,9 +86,11 @@ jobs: set -e if [ "$DISPATCH_RC" -ne 0 ]; then - upsert_status "Failed To Dispatch" "GitHub rejected the reviewer dispatch. Inspect the Bunny Review Command run log." + upsert_status "Failed To Dispatch" "GitHub rejected the reviewer dispatch. Inspect the Bunny Review Command run log." \ + || echo "::warning::Unable to write Bunny command dispatch failure status." echo "$DISPATCH_OUTPUT" exit "$DISPATCH_RC" fi - upsert_status "Dispatched" "Reviewer workflow dispatched; waiting for Bunny Review to post results." + upsert_status "Dispatched" "Reviewer workflow dispatched; waiting for Bunny Review to post results." \ + || echo "::warning::Unable to write Bunny command dispatched status." From 4ea7a120bf3d09eb04ab6ce6f6b50b9a47183903 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 18:53:59 +0800 Subject: [PATCH 38/40] Polish Bunny review output --- .github/bunny-review/bunny_review.py | 39 ++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index 0a973c3d3..bc8c5c5c1 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -1,6 +1,7 @@ # .github/bunny-review/bunny_review.py import argparse import hashlib +import html import json import os import pathlib @@ -725,13 +726,24 @@ def validate_findings(review_obj, base): def render_finding_body(finding): parts = [ finding_marker(finding), - f"**[{finding.severity}] {finding.title}**", + "
", + f"[{finding.severity}] {html.escape(finding.title)}", "", finding.body, ] if finding.fix_hint: parts.extend(["", f"Suggested fix: {finding.fix_hint}"]) - parts.extend(["", render_agent_prompt_details([finding], "Prompt for AI Agents")]) + parts.extend( + [ + "", + "**Prompt for AI Agents**", + "", + "```text", + render_agent_prompt([finding]), + "```", + ] + ) + parts.extend(["", "
"]) return "\n".join(parts).strip() @@ -752,12 +764,29 @@ def short_ref(value): return value[:24] +def commit_subject(commit_sha): + if not commit_sha or not re.fullmatch(r"[0-9a-f]{40}", str(commit_sha)): + return "" + result = run(["git", "log", "-1", "--format=%s", str(commit_sha)], timeout=30) + if result.returncode != 0: + return "" + return result.stdout.strip().replace("\n", " ") + + +def render_commit_label(head_sha): + subject = commit_subject(head_sha) + label = f"Commit: `{short_ref(head_sha)}`" + if subject: + label += f" - {subject}" + return label + + def render_review_metadata(review_obj, head_sha): mode = review_obj.get("mode") or "unknown" base = review_obj.get("review_base") or review_obj.get("base_ref") or "unknown" parts = [ f"Mode: `{mode}`", - f"Head: `{short_ref(head_sha)}`", + render_commit_label(head_sha), f"Base: `{short_ref(base)}`", ] return "_Review update: " + " · ".join(parts) + "._" @@ -1202,7 +1231,7 @@ def patch_command_status_running(pr_num, head_sha, mode): "## Bunny Review Running", "", f"Mode: `{mode or 'unknown'}`", - f"Head: `{short_ref(head_sha)}`", + render_commit_label(head_sha), "Status: Reviewer workflow is running. The specimen is under observation.", ] ) @@ -1215,7 +1244,7 @@ def patch_command_status_complete(pr_num, head_sha): COMMAND_STATUS_MARKER, "## Bunny Review Completed", "", - f"Head: `{short_ref(head_sha)}`", + render_commit_label(head_sha), "Status: Review posted. The specimen has been returned to the table.", ] ) From 042cb5aeffa0f8e0e263bdea3994968f99a50da8 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 20:00:43 +0800 Subject: [PATCH 39/40] Revert "Polish Bunny review output" This reverts commit 4ea7a120bf3d09eb04ab6ce6f6b50b9a47183903. --- .github/bunny-review/bunny_review.py | 39 ++++------------------------ 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index bc8c5c5c1..0a973c3d3 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -1,7 +1,6 @@ # .github/bunny-review/bunny_review.py import argparse import hashlib -import html import json import os import pathlib @@ -726,24 +725,13 @@ def validate_findings(review_obj, base): def render_finding_body(finding): parts = [ finding_marker(finding), - "
", - f"[{finding.severity}] {html.escape(finding.title)}", + f"**[{finding.severity}] {finding.title}**", "", finding.body, ] if finding.fix_hint: parts.extend(["", f"Suggested fix: {finding.fix_hint}"]) - parts.extend( - [ - "", - "**Prompt for AI Agents**", - "", - "```text", - render_agent_prompt([finding]), - "```", - ] - ) - parts.extend(["", "
"]) + parts.extend(["", render_agent_prompt_details([finding], "Prompt for AI Agents")]) return "\n".join(parts).strip() @@ -764,29 +752,12 @@ def short_ref(value): return value[:24] -def commit_subject(commit_sha): - if not commit_sha or not re.fullmatch(r"[0-9a-f]{40}", str(commit_sha)): - return "" - result = run(["git", "log", "-1", "--format=%s", str(commit_sha)], timeout=30) - if result.returncode != 0: - return "" - return result.stdout.strip().replace("\n", " ") - - -def render_commit_label(head_sha): - subject = commit_subject(head_sha) - label = f"Commit: `{short_ref(head_sha)}`" - if subject: - label += f" - {subject}" - return label - - def render_review_metadata(review_obj, head_sha): mode = review_obj.get("mode") or "unknown" base = review_obj.get("review_base") or review_obj.get("base_ref") or "unknown" parts = [ f"Mode: `{mode}`", - render_commit_label(head_sha), + f"Head: `{short_ref(head_sha)}`", f"Base: `{short_ref(base)}`", ] return "_Review update: " + " · ".join(parts) + "._" @@ -1231,7 +1202,7 @@ def patch_command_status_running(pr_num, head_sha, mode): "## Bunny Review Running", "", f"Mode: `{mode or 'unknown'}`", - render_commit_label(head_sha), + f"Head: `{short_ref(head_sha)}`", "Status: Reviewer workflow is running. The specimen is under observation.", ] ) @@ -1244,7 +1215,7 @@ def patch_command_status_complete(pr_num, head_sha): COMMAND_STATUS_MARKER, "## Bunny Review Completed", "", - render_commit_label(head_sha), + f"Head: `{short_ref(head_sha)}`", "Status: Review posted. The specimen has been returned to the table.", ] ) From c1f5d399fa603f5bd1112e4a4b8b7eba6a5d4531 Mon Sep 17 00:00:00 2001 From: Promansis Date: Mon, 1 Jun 2026 20:25:19 +0800 Subject: [PATCH 40/40] Report Bunny review commit subject --- .github/bunny-review/bunny_review.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py index 0a973c3d3..a19587f89 100644 --- a/.github/bunny-review/bunny_review.py +++ b/.github/bunny-review/bunny_review.py @@ -752,12 +752,32 @@ def short_ref(value): return value[:24] +def commit_subject(head_sha): + if not head_sha: + return "" + result = run(["git", "log", "-1", "--format=%s", head_sha], timeout=30) + if result.returncode != 0: + return "" + return " ".join(result.stdout.split()) + + +def commit_line(head_sha, message=None): + subject = " ".join(str(message or "").split()) or commit_subject(head_sha) + ref = short_ref(head_sha) + if subject: + return f"Commit: {ref} - {subject}" + return f"Commit: {ref}" + + def render_review_metadata(review_obj, head_sha): mode = review_obj.get("mode") or "unknown" base = review_obj.get("review_base") or review_obj.get("base_ref") or "unknown" + commit_message = review_obj.get("head_commit_message") or review_obj.get( + "commit_message" + ) parts = [ f"Mode: `{mode}`", - f"Head: `{short_ref(head_sha)}`", + commit_line(head_sha, commit_message), f"Base: `{short_ref(base)}`", ] return "_Review update: " + " · ".join(parts) + "._" @@ -1108,6 +1128,7 @@ def triage_for_packet(review_packet, focus_note): triage_content = triage_for_packet(review_packet, "Review the full current diff.") review_obj = three_pass_review(client, skill, triage_content, stats) review_obj.setdefault("head_sha", head_sha) + review_obj.setdefault("head_commit_message", commit_subject(head_sha)) review_obj.setdefault("review_base", base) review_obj.setdefault("base_ref", base_ref) review_obj.setdefault("mode", effective_mode) @@ -1202,7 +1223,7 @@ def patch_command_status_running(pr_num, head_sha, mode): "## Bunny Review Running", "", f"Mode: `{mode or 'unknown'}`", - f"Head: `{short_ref(head_sha)}`", + commit_line(head_sha), "Status: Reviewer workflow is running. The specimen is under observation.", ] ) @@ -1215,7 +1236,7 @@ def patch_command_status_complete(pr_num, head_sha): COMMAND_STATUS_MARKER, "## Bunny Review Completed", "", - f"Head: `{short_ref(head_sha)}`", + commit_line(head_sha), "Status: Review posted. The specimen has been returned to the table.", ] )