Merge remote-tracking branch 'origin/main' into feat/query-filter-bar

This commit is contained in:
ktx-vaidehi
2026-11-03 21:34:10 +05:30
58 changed files with 4102 additions and 258 deletions
+4
View File
@@ -20,3 +20,7 @@ rustflags = ["-C", "target-feature=+aes,+sse2,+ssse3,+sse4.1,+sse4.2"]
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+aes,+sse2,+ssse3,+sse4.1,+sse4.2"]
[env]
# test threads get the 16 MB stack of the server's runtime threads (THREAD_STACK_SIZE); debug async code outgrows 2 MB
RUST_MIN_STACK = "16777216"
+42 -3
View File
@@ -68,7 +68,6 @@ jobs:
- name: anthropic
provider_id: anthropic-review
model_id: claude-sonnet-5-5
model_variant: high
api_key_env: ANTHROPIC_REVIEW_API_KEY
marker: "<!-- ai-code-review -->"
steps:
@@ -109,6 +108,11 @@ jobs:
fi
merge_base="$(gh api "repos/$REPO/compare/$base_sha...$head_sha" --jq '.merge_base_commit.sha' || true)"
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
{
echo "author=$(jq -r '.user.login // ""' <<<"$pr_json")"
echo "changed_files=$(jq -r '.changed_files // ""' <<<"$pr_json")"
echo "commit_count=$(jq -r '.commits // ""' <<<"$pr_json")"
} >> "$GITHUB_OUTPUT"
echo "REVIEW_RUN=true" >> "$GITHUB_ENV"
# If minting fails (app not installed, bad key) the steps below fall back to github.token.
@@ -149,6 +153,37 @@ jobs:
.claude/skills/ui-architect
.claude/skills/eslint-error-handling
# Bills nothing and posts nothing; a hand-started run, a failed listing or a failed check always reviews.
- name: Skip PRs with nothing to review
if: env.REVIEW_RUN == 'true' && github.event_name == 'pull_request_target'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
PR_AUTHOR: ${{ steps.pr.outputs.author }}
# The listings are checked against these, as both endpoints stop at a cap however far they are paged.
EXPECTED_FILES: ${{ steps.pr.outputs.changed_files }}
EXPECTED_COMMITS: ${{ steps.pr.outputs.commit_count }}
run: |
set -uo pipefail
list="$RUNNER_TEMP/pr-files.tsv"
commits="$RUNNER_TEMP/pr-commits.tsv"
# One line per file with its rename source, so code moved under docs/ is not taken for docs and the count can be checked.
if ! gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" --jq '.[] | [.filename, (.previous_filename // "")] | @tsv' > "$list"; then
echo "::warning::Could not list the PR's files; reviewing."
: > "$list"
fi
# One line per commit: author login, committer login, signature verified; a missing field lists as empty and reviews.
if ! gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits?per_page=100" --jq '.[] | [(.author.login // ""), (.committer.login // ""), ((.commit.verification.verified // "") | tostring)] | @tsv' > "$commits"; then
echo "::warning::Could not list the PR's commits; reviewing."
: > "$commits"
fi
reason="$(node engine/scripts/ai-review/skip-check.mjs "$list" "$commits")" || reason=""
if [ -n "$reason" ]; then
echo "::notice::PR #$PR_NUMBER needs no review ($reason); skipped."
echo "REVIEW_RUN=false" >> "$GITHUB_ENV"
fi
# Paired OSS+ENT changes are reviewed once, by the o2-enterprise workflow, which pulls this PR's diff in.
- name: Defer to a paired o2-enterprise PR
if: env.REVIEW_RUN == 'true' && !inputs.ignore_paired
@@ -291,10 +326,12 @@ jobs:
FORCE_FULL: ${{ inputs.force_full || 'false' }}
REVIEW_PROVIDER_ID: ${{ matrix.provider.provider_id }}
REVIEW_MODEL_ID: ${{ matrix.provider.model_id }}
REVIEW_MODEL_VARIANT: ${{ matrix.provider.model_variant }}
# The effort is set here only, so the variable is the single source; each value must be a variant in opencode.ci.jsonc.
REVIEW_MODEL_VARIANT: ${{ vars.AI_REVIEW_EFFORT || 'high' }}
REVIEW_COORDINATOR_VARIANT: ${{ vars.AI_REVIEW_COORDINATOR_EFFORT || vars.AI_REVIEW_EFFORT || 'high' }}
REVIEW_API_KEY_ENV: ${{ matrix.provider.api_key_env }}
REVIEW_MARKER: ${{ matrix.provider.marker }}
# opencode.ci.jsonc: every tool (shell, edit, webfetch) allowed with no prompts; "default" is the read-only opencode.jsonc.
# opencode.ci.jsonc: file tools plus a read-only shell allowlist, no web or sub-agent tools, no prompts; "default" is the read-only opencode.jsonc.
REVIEW_OPENCODE_CONFIG: ci-full
# Empty under the github.token fallback: any workflow a same-repo PR adds can also post as github-actions[bot].
REVIEW_BOT_LOGIN: ${{ steps.app_token.outputs.token && format('{0}[bot]', steps.app_token.outputs.app-slug) || '' }}
@@ -313,6 +350,8 @@ jobs:
# Optional ceiling on automatic reviews per PR; unset means no cap, so each new commit gets one review. The manual input bypasses it for one run.
REVIEW_MAX_PASSES: ${{ vars.AI_REVIEW_MAX_PASSES }}
REVIEW_IGNORE_PASS_LIMIT: ${{ inputs.ignore_pass_limit || 'false' }}
# A hand-started run reviews even a diff the last review already read.
REVIEW_MANUAL_RUN: ${{ github.event_name == 'workflow_dispatch' }}
# Independent reviewer sessions per area (1-3), merged by the coordinator; one cold pass misses findings a second catches.
REVIEW_COLD_PASSES: ${{ vars.AI_REVIEW_COLD_PASSES || '2' }}
# An unknown value means off, so a typo can never start approving.
+875 -16
View File
@@ -1,7 +1,5 @@
{
// Loaded from the trusted base-branch checkout via OPENCODE_CONFIG when REVIEW_OPENCODE_CONFIG=ci-full.
// Same provider, model and agents as opencode.jsonc, but every tool (shell, edit, webfetch, ...) is allowed
// and nothing asks for permission. Keep the two files in step apart from the agent permissions.
// ci-full, loaded from the trusted base checkout: keep in step with opencode.jsonc except agent permissions (file tools, shell allowlist, no prompts).
"$schema": "https://opencode.ai/config.json",
// Nothing here may reach into the PR tree: no `instructions`, no `skills`, no plugins.
@@ -28,8 +26,14 @@
"models": {
"claude-sonnet-5-5": {
"name": "Claude Sonnet 5.5",
// Selected with REVIEW_MODEL_VARIANT=high; opencode sends adaptive thinking and "output_config": { "effort": "high" }.
"variants": [{ "id": "high", "settings": { "thinking": { "type": "adaptive" }, "effort": "high" } }],
// REVIEW_MODEL_VARIANT and REVIEW_COORDINATOR_VARIANT pick one; opencode sends adaptive thinking and "output_config": { "effort": "<id>" }.
"variants": [
{ "id": "low", "settings": { "thinking": { "type": "adaptive" }, "effort": "low" } },
{ "id": "medium", "settings": { "thinking": { "type": "adaptive" }, "effort": "medium" } },
{ "id": "high", "settings": { "thinking": { "type": "adaptive" }, "effort": "high" } },
{ "id": "xhigh", "settings": { "thinking": { "type": "adaptive" }, "effort": "xhigh" } },
{ "id": "max", "settings": { "thinking": { "type": "adaptive" }, "effort": "max" } }
],
"limit": { "context": 1000000, "output": 128000 }
}
}
@@ -46,7 +50,7 @@
"models": {
"deepseek-flash": {
"name": "DeepSeek-V4.1-Flash",
// Selected with REVIEW_MODEL_VARIANT=max (the workflow matrix); opencode sends "reasoning_effort": "max".
// Selected with REVIEW_MODEL_VARIANT=max; opencode sends "reasoning_effort": "max".
"variants": [{ "id": "max", "settings": { "reasoningEffort": "max" } }],
"limit": { "context": 1048576, "output": 393216 }
}
@@ -56,52 +60,907 @@
"model": "anthropic-review/claude-sonnet-5-5",
// One allow-all rule: it comes after opencode's built-in defaults (last match wins), so no tool ever asks.
// Last match wins after opencode's built-in defaults, so nothing asks; the shell allowlist guards cost, not secrets (see README).
"agents": {
"ai-review-security": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-correctness": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-code-quality": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-performance": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-documentation": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-release": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-frontend": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-qa": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
},
"ai-review-coordinator": {
"mode": "primary",
"model": "anthropic-review/claude-sonnet-5-5",
"permissions": [{ "action": "*", "resource": "*", "effect": "allow" }]
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "webfetch", "resource": "*", "effect": "deny" },
{ "action": "websearch", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "question", "resource": "*", "effect": "deny" },
{ "action": "execute", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git log *", "effect": "allow" },
{ "action": "shell", "resource": "git show *", "effect": "allow" },
{ "action": "shell", "resource": "git blame *", "effect": "allow" },
{ "action": "shell", "resource": "git ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git rev-parse *", "effect": "allow" },
{ "action": "shell", "resource": "git merge-base *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -p *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -t *", "effect": "allow" },
{ "action": "shell", "resource": "git cat-file -s *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager diff *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager log *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager show *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager blame *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager ls-files *", "effect": "allow" },
{ "action": "shell", "resource": "git --no-pager status *", "effect": "allow" },
{ "action": "shell", "resource": "rg *", "effect": "allow" },
{ "action": "shell", "resource": "grep *", "effect": "allow" },
{ "action": "shell", "resource": "cat *", "effect": "allow" },
{ "action": "shell", "resource": "ls *", "effect": "allow" },
{ "action": "shell", "resource": "head *", "effect": "allow" },
{ "action": "shell", "resource": "tail *", "effect": "allow" },
{ "action": "shell", "resource": "find *", "effect": "allow" },
{ "action": "shell", "resource": "sed -n *", "effect": "allow" },
{ "action": "shell", "resource": "wc *", "effect": "allow" },
{ "action": "shell", "resource": "tail -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -f *", "effect": "deny" },
{ "action": "shell", "resource": "tail -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail * -F *", "effect": "deny" },
{ "action": "shell", "resource": "tail --follow*", "effect": "deny" },
{ "action": "shell", "resource": "tail * --follow*", "effect": "deny" },
{ "action": "shell", "resource": "find -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find * -exec *", "effect": "deny" },
{ "action": "shell", "resource": "find -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -execdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find * -ok *", "effect": "deny" },
{ "action": "shell", "resource": "find -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find * -okdir *", "effect": "deny" },
{ "action": "shell", "resource": "find -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find * -delete *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprint0 *", "effect": "deny" },
{ "action": "shell", "resource": "find -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fprintf *", "effect": "deny" },
{ "action": "shell", "resource": "find -fls *", "effect": "deny" },
{ "action": "shell", "resource": "find * -fls *", "effect": "deny" },
{ "action": "shell", "resource": "cat /*", "effect": "deny" },
{ "action": "shell", "resource": "cat * /*", "effect": "deny" },
{ "action": "shell", "resource": "cat *../*", "effect": "deny" },
{ "action": "shell", "resource": "cat *=/*", "effect": "deny" },
{ "action": "shell", "resource": "cat *environ*", "effect": "deny" },
{ "action": "shell", "resource": "head /*", "effect": "deny" },
{ "action": "shell", "resource": "head * /*", "effect": "deny" },
{ "action": "shell", "resource": "head *../*", "effect": "deny" },
{ "action": "shell", "resource": "head *=/*", "effect": "deny" },
{ "action": "shell", "resource": "head *environ*", "effect": "deny" },
{ "action": "shell", "resource": "tail /*", "effect": "deny" },
{ "action": "shell", "resource": "tail * /*", "effect": "deny" },
{ "action": "shell", "resource": "tail *../*", "effect": "deny" },
{ "action": "shell", "resource": "tail *=/*", "effect": "deny" },
{ "action": "shell", "resource": "tail *environ*", "effect": "deny" },
{ "action": "shell", "resource": "grep /*", "effect": "deny" },
{ "action": "shell", "resource": "grep * /*", "effect": "deny" },
{ "action": "shell", "resource": "grep *../*", "effect": "deny" },
{ "action": "shell", "resource": "grep *=/*", "effect": "deny" },
{ "action": "shell", "resource": "grep *environ*", "effect": "deny" },
{ "action": "shell", "resource": "rg /*", "effect": "deny" },
{ "action": "shell", "resource": "rg * /*", "effect": "deny" },
{ "action": "shell", "resource": "rg *../*", "effect": "deny" },
{ "action": "shell", "resource": "rg *=/*", "effect": "deny" },
{ "action": "shell", "resource": "rg *environ*", "effect": "deny" },
{ "action": "shell", "resource": "sed /*", "effect": "deny" },
{ "action": "shell", "resource": "sed * /*", "effect": "deny" },
{ "action": "shell", "resource": "sed *../*", "effect": "deny" },
{ "action": "shell", "resource": "sed *=/*", "effect": "deny" },
{ "action": "shell", "resource": "sed *environ*", "effect": "deny" },
{ "action": "shell", "resource": "wc /*", "effect": "deny" },
{ "action": "shell", "resource": "wc * /*", "effect": "deny" },
{ "action": "shell", "resource": "wc *../*", "effect": "deny" },
{ "action": "shell", "resource": "wc *=/*", "effect": "deny" },
{ "action": "shell", "resource": "wc *environ*", "effect": "deny" },
{ "action": "shell", "resource": "*>*", "effect": "deny" }
]
}
}
}
+9 -3
View File
@@ -27,8 +27,14 @@
"models": {
"claude-sonnet-5-5": {
"name": "Claude Sonnet 5.5",
// Selected with REVIEW_MODEL_VARIANT=high; opencode sends adaptive thinking and "output_config": { "effort": "high" }.
"variants": [{ "id": "high", "settings": { "thinking": { "type": "adaptive" }, "effort": "high" } }],
// REVIEW_MODEL_VARIANT and REVIEW_COORDINATOR_VARIANT pick one; opencode sends adaptive thinking and "output_config": { "effort": "<id>" }.
"variants": [
{ "id": "low", "settings": { "thinking": { "type": "adaptive" }, "effort": "low" } },
{ "id": "medium", "settings": { "thinking": { "type": "adaptive" }, "effort": "medium" } },
{ "id": "high", "settings": { "thinking": { "type": "adaptive" }, "effort": "high" } },
{ "id": "xhigh", "settings": { "thinking": { "type": "adaptive" }, "effort": "xhigh" } },
{ "id": "max", "settings": { "thinking": { "type": "adaptive" }, "effort": "max" } }
],
"limit": { "context": 1000000, "output": 128000 }
}
}
@@ -45,7 +51,7 @@
"models": {
"deepseek-flash": {
"name": "DeepSeek-V4.1-Flash",
// Selected with REVIEW_MODEL_VARIANT=max (the workflow matrix); opencode sends "reasoning_effort": "max".
// Selected with REVIEW_MODEL_VARIANT=max; opencode sends "reasoning_effort": "max".
"variants": [{ "id": "max", "settings": { "reasoningEffort": "max" } }],
"limit": { "context": 1048576, "output": 393216 }
}
+90 -10
View File
@@ -1,7 +1,7 @@
# AI Code Review (`scripts/ai-review`)
Automated PR review that posts a single consolidated comment on every pull request. Driven by
`.github/workflows/ai-code-review.yml` → `run-review.mjs`, using Claude Sonnet 5.5 (`anthropic-review/claude-sonnet-5-5`) at high effort with adaptive thinking via `opencode`.
`.github/workflows/ai-code-review.yml` → `run-review.mjs`, using Claude Sonnet 5.5 (`anthropic-review/claude-sonnet-5-5`) with adaptive thinking via `opencode`, at `high` effort unless the `AI_REVIEW_EFFORT` variable says otherwise (see Effort below).
The posted comment is branded **OpenObserve Code Reviewer**. Branding is *cosmetic + identity
only* — it does not change what the reviewer finds or decides.
@@ -18,12 +18,12 @@ whose marker still names the provider, such as o2-enterprise's older one, keeps
## How it works (one paragraph)
On each PR the workflow fetches the diff, filters noise, picks a risk tier (`trivial` / `lite` /
`full`), runs the tier's specialist agents (security, correctness, code-quality, performance,
`full` / `test-only` / `ci-only`), runs the tier's specialist agents (security, correctness, code-quality, performance,
documentation) in parallel — plus any *conditional* agent whose domain the diff touches (frontend,
qa, and release for migrations, `Cargo.toml`/`package.json`, `src/config/src/config.rs`, `.github/`,
`deploy/` and Docker files) — and a coordinator consolidates their findings into one comment with
a decision. Correctness owns the PR's behaviour end to end (callers, sibling
paths, state, the tests that prove the change); every tier runs it. Before the agents start, the
paths, state, the tests that prove the change); every tier but `ci-only` runs it. Before the agents start, the
runner greps the PR tree for where each symbol the diff changes is used outside the diff, and for
other definitions of the same name (`src/impact.mjs`), and gives that map to every reviewer.
Security and correctness also get `openobserve-checklist.md`, the defect classes that recur in
@@ -40,6 +40,50 @@ and folds findings at the same file and nearby line when one title's words conta
reviewed when any of its passes finished, and the comment notes a lost pass. Each pass is billed,
so the review costs about M times one pass; reviewer spans carry `review.pass`.
### Risk tiers
`assessRiskTier` in `run-review.mjs` picks the tier. More than 50 files, or any path that looks
security-sensitive (`auth`, `token`, `secret`, `crypto`, `password`, `oauth`, `unsafe`, `acme`,
`cert`), is `full`. Otherwise a PR whose every file is test code (`tests/`, `test/`, `e2e/`,
`fixtures/`, `*.spec.*`, `*.test.*`, `*_test.rs`; `isTestOnlyPath` in `src/selection.mjs`) is
`test-only` and runs correctness and code-quality, whatever its size (qa joins when Playwright files change); a rename counts by both its source and
its destination, so code moved from `src/` into `tests/` is not test-only. A PR whose every file is CI
definition (`.github/workflows/**`, `.github/actions/**` or a top-level `.github/*.yml`;
`isCiOnlyPath`) is `ci-only` and runs security and release. Scripts, including this engine and the
two opencode configs, are code and take the size tiers below. Everything else is `trivial` (at most 10
lines), `lite` (at most 100 lines) or `full`. Conditional agents are added on top as before, and
`force_full` still forces `full`.
### Effort
`AI_REVIEW_EFFORT` (`low`, `medium`, `high`, `xhigh` or `max`, default `high`) sets the reviewers' effort, and
`AI_REVIEW_COORDINATOR_EFFORT` the coordinator's (default: the reviewers' value). The workflow
passes them as `REVIEW_MODEL_VARIANT` and `REVIEW_COORDINATOR_VARIANT`, and the matrix carries no
variant of its own, so the variables are the only source. Each value is a variant of
`claude-sonnet-5-5` in both opencode configs, which opencode sends as adaptive thinking plus
`"output_config": { "effort": "<value>" }`. A value that is not a declared variant stops the run
before it claims a pass or calls the model, and the comment's earlier verdict and `Auto-approved`
line give way to a 🔴 Low line saying the review did not run. Spans carry the effort in
`gen_ai.request.reasoning_effort`.
### PRs that are never reviewed
The workflow's `Skip PRs with nothing to review` step runs right after the engine checkout and
before anything is installed. It lists the PR's files (a rename lists its old path too) and the
author login, committer login and signature state of each commit, and runs `skip-check.mjs` (`nothingToReviewReason` in
`src/selection.mjs`). It skips nothing unless both listings match the PR's `changed_files` and
`commits` counts and stay under the endpoints' caps (3000 files, 250 commits; `listingComplete`),
since a listing cut short could hide a code file or a human commit. A PR is skipped, with nothing posted and no model called, when its author is
`dependabot[bot]` or `renovate[bot]` and every commit is authored by that same bot, committed by
it or by `web-flow` (how GitHub commits a bot's API-made commits) and carries a signature GitHub
verified, when every file is docs (`*.md` or `docs/**`, but not markdown under `scripts/ai-review/` or
`.github/`, nor any instruction loader the sanitizer strips, at any depth and in any case: `AGENTS.md`, `CLAUDE.md`,
`CONTEXT.md` or a file under `.claude/`, `.agents/` or `.opencode/`; `isLoaderPath` in `src/sanitize.mjs`), or when every file is one the
translation workflow commits (`web/src/locales/languages/*.json` other than `en-US.json`, which a
person writes, and `scripts/translations/.translation_state.json`). Every other bot or App author
is reviewed, and so is a dependency-bot PR someone else pushed to. A manual dispatch always
reviews, and so does a run where the file or commit listing, or `skip-check.mjs` itself, fails.
The coordinator also lists the findings it kept in a hidden JSON block, which the runner strips
from the comment. Kept P0, P1 and P2 findings whose `file:line` is an added or context line of the
diff are posted as one `COMMENT` review with inline threads (at most 10, never approve or request
@@ -58,13 +102,34 @@ latest review is recorded, so a force-push back to an older reviewed head is rev
finished review records what it covered in a hidden `o2-review-sha` tag (the head SHA, plus the paired head in a
paired review, so a new commit on either side is new) and shows it in a visible `Reviewed commit`
line with the review number. A run whose head matches the tag skips before any model call, which
covers a queued run, a re-label and a plain dispatch, including the paired dispatch from
openobserve. The tag is only written when it is true: the diff the reviewers read is rebuilt locally
covers a queued run, a re-label and the paired dispatch from openobserve (o2-enterprise does not
set `REVIEW_MANUAL_RUN`); a manual dispatch here, `force_full` or `ignore_pass_limit` reviews it again. The tag is only written when it is true: the diff the reviewers read is rebuilt locally
from `MERGE_BASE_SHA` and `HEAD_SHA` (both sides of a paired review), because the API serves the
branch as it is now. If that cannot be done, or no reviewer finished (each one failed or returned
nothing parseable), the run writes no tag and names no commit, so that commit can be reviewed again.
A run where no reviewer finished also reports `Not reviewed` and fails the check.
A stamped review that was complete (every reviewer pass finished without a failure, timeout or
wrap-up, and the coordinator merged them without falling back; `reviewComplete` in
`src/passes.mjs`) also records a fingerprint of the unfiltered diff, in a hidden
`o2-review-diff` tag (`diffFingerprint` in `src/pr-diff.mjs`: a SHA-256 of the diff with blob ids
and hunk line numbers removed but each hunk's function context kept, so the same lines changed in another function differ, while a rebase or a merge from main that leaves the PR's own changed
and context lines alone keeps it). Lockfiles, assets and generated files the reviewers never see are
in it, so adding one reviews again instead of carrying the old merge confidence over. Equal text proves
the same change only where it sits once in the base, so both the write and the skip also need every
hunk's old lines (context and removed lines) to occur exactly once in its base file (`hunksLocatable`);
an edit to one of two identical blocks, or a base file that cannot be read, reviews. When a new head's pinned diff has the same fingerprint, the run
skips with `unchanged_diff` before any model call: it uses no pass, keeps the review and its merge
confidence, and moves the `o2-review-sha` tag to the new head; the `Reviewed commit` line keeps the
commit the findings' line numbers refer to. Binary files keep their blob ids and trailing spaces count, so
both still change the fingerprint. The
check runs ahead of the review cap, so such a push is skipped, not refused, after the cap. A manual
dispatch (`REVIEW_MANUAL_RUN`), `force_full` or `ignore_pass_limit` always reviews, and so does a
diff that is not pinned to the head. In `approve` mode the skip still dismisses the earlier
approval, and the kept review's `Auto-approved` line becomes `Needs human review`, because a
skip does not rerun the approval gate's checks. A partial review records no fingerprint, so the next push
reviews again even when its diff is the same.
There is no cap on reviews per PR unless the variable `AI_REVIEW_MAX_PASSES` is a positive integer;
unset, empty, `0` or an invalid value means no cap. Reviews are counted either way, in a hidden
`o2-review-pass` tag written before the reviewers start, so a run stopped by removing the label or
@@ -76,9 +141,9 @@ the list call fails) the run fails instead of reviewing, since neither the revie
count can be checked.
A push does not cancel a review in progress: the paid run finishes and only the newest push waits
in the concurrency queue. To review a commit again, dispatch the workflow with `ignore_pass_limit`
(it bypasses both the dedupe and the cap, and is forwarded to a paired o2-enterprise review) or with
`force_full` (it bypasses the dedupe only), or delete the review comment to reset the count.
in the concurrency queue. To review a commit again, dispatch the workflow (a plain dispatch or
`force_full` bypasses the dedupe only; `ignore_pass_limit` bypasses both the dedupe and the cap; the
two inputs are forwarded to a paired o2-enterprise review), or delete the review comment to reset the count.
The comment marker is matched as a pattern, so a retired provider leg's comment is adopted and
rewritten in place, including older comments whose marker was redacted to
@@ -204,8 +269,8 @@ A PR must never be able to run code next to the review secrets or weaken its own
| `src/opencode.mjs` env allowlist | `opencode serve` runs in `./pr` with only `PATH`, a fresh temp `HOME`/XDG dirs, `OPENCODE_CONFIG` (this repo's `opencode.jsonc` or `opencode.ci.jsonc` from `./engine`), the `OPENCODE_*` disable flags, a random server password and the one provider key. No `GH_*`, `GITHUB_*`, `ACTIONS_*`, `RUNNER_*` or `O2_*` variables. |
| Config selection | `REVIEW_OPENCODE_CONFIG` = `default` (`opencode.jsonc`) or `ci-full` (`opencode.ci.jsonc`); any other value stops the run. The workflow sets `ci-full`, which keeps `approve` ineligible (see Auto-approve). |
| `opencode.jsonc` permissions | Each `ai-review-*` agent: deny `*`, then allow `read`, `glob`, `grep`; `external_directory` denied. A `provider.use` policy allows only `anthropic-review`. LSP, formatters, snapshots and sharing are off. |
| `opencode.ci.jsonc` permissions | Same provider, model and agents, but each agent allows `*`: shell, edit, write and webfetch run with no prompt. The PR checkout is untrusted input to an agent that can run commands, and the provider key is in the opencode process environment, so a shell command can read it. |
| Startup self-check | Refuses to review if any agent is missing, any non-built-in plugin or MCP server loaded, or a granted tool's plugin is not active. Under `default` it also refuses any agent that resolves to allow a tool other than `read`/`glob`/`grep` or any outside directory; under `ci-full` it refuses an agent that does not allow every tool without asking. Permission prompts are rejected under `default` and approved once under `ci-full`. |
| `opencode.ci.jsonc` permissions | Same provider, model, variants and agents. Each agent allows `*` (read, glob, grep, edit, write run with no prompt), then denies `webfetch`, `websearch`, `subagent`, `question` (which would wait on an answer nobody in CI gives), `execute` (opencode 2.2.0's Code Mode: it runs JavaScript with `fetch`, so it would reach the network past the `webfetch` deny) and `skill` (reviewers read the skill copies with `read`). `shell` is kept to an allowlist: `git diff`, `git log`, `git show`, `git blame`, `git ls-files`, `git status` (each also after `git --no-pager`), `git rev-parse`, `git merge-base`, `git cat-file -p`/`-t`/`-s`, `rg`, `grep`, `cat`, `ls`, `head`, `tail`, `find`, `sed -n` and `wc`. Later deny rules refuse `find` with `-exec`, `-execdir`, `-ok`, `-okdir`, `-delete`, `-fprint`, `-fprint0`, `-fprintf` or `-fls` as a separate word; `tail -f`, `-F` and `--follow`; any absolute path, `../` path, `=/` argument or `environ` in `cat`, `head`, `tail`, `grep`, `rg`, `sed` and `wc` (so `/proc/*/environ` and `/dev/*` are not read through them); and any command containing `>` (an output redirect). `git grep` is not allowed (its `-O` runs a pager command). opencode checks every command in a pipeline, `&&` or `;` chain and inside `$(...)` or backticks, so the command runs only when every part is allowed, and a denied command fails outright, without a prompt. The allowlist stops builds, installs and long exploration. It is not a sandbox: `rg --pre` or a GNU `sed` `e` command can still run a program, a glob or a relative path inside the checkout is not inspected, and edit/write stay allowed in the disposable checkout. The PR checkout is untrusted input, and the provider key is in the opencode process environment, so a command that gets past these rules can read it. |
| Startup self-check | Refuses to review if any agent is missing, any non-built-in plugin or MCP server loaded, or a granted tool's plugin is not active. Under `default` it also refuses any agent that resolves to allow a tool other than `read`/`glob`/`grep` or any outside directory; under `ci-full` it refuses an agent that does not allow read, glob, grep, edit and write without asking, that leaves webfetch, websearch, subagent, question, execute or skill enabled, or whose shell is denied or not restricted to an allowlist. Permission prompts are rejected under `default` and approved once under `ci-full`. |
| `src/comments.mjs` | The previous review is only ever a marker comment by the reviewer app (`REVIEW_BOT_LOGIN` = `<app-slug>[bot]`), read across every page; anyone else's comment carrying the marker is never fed to the reviewers or edited. When the app token cannot be minted the review posts as `github-actions[bot]`, which any same-repo PR workflow can also post as, so that run uses no previous review and posts a fresh comment. |
| `src/scrub.mjs` | Before posting: redacts values of secret-named env vars and token-shaped strings (inside code too), breaks forged `<!-- o2-review` markers, and neutralises `@mentions` outside code. |
@@ -230,6 +295,21 @@ blocking request that waits for the run dies at undici's 300 s `headersTimeout`
A timed-out agent is interrupted server-side; transient provider errors are retried at most twice,
each in a fresh session.
A wrap-up keeps a long session's findings instead of discarding them at the 20-minute timeout. A
reviewer still running 3 minutes before its timeout is interrupted, and once opencode confirms the
interrupt the same session gets one more prompt asking for its verified findings in the required
format with no further tools. The interrupt keeps the steps already run, so the answer comes from
what the agent read, mostly from cache. If the interrupt is not confirmed, or the answer does not
arrive in time, the session ends as a timeout, as before; a run that finished on its own just
before the interrupt keeps its own answer. No agent sets opencode's `steps` cap: on 2.2.0 the capped
turn ends the request on an assistant message (its "maximum steps reached" text), a prefill the
model rejects with a 400 when thinking is on; `test/integration/opencode-sandbox.test.mjs` pins
that. Reviewer
spans carry `review.wrapped_up`. A wrapped-up answer counts as findings but not as a finished
reviewer, so an area with no uninterrupted pass lowers merge confidence like an unfinished one and
blocks auto-approval. Sessions shorter than 6 minutes, and the coordinator, get no
wrap-up.
The binary is pinned in `opencode-pin.env` and installed by `install-opencode.sh`, which verifies
the release asset's SHA-256. Bump both lines together, to a release at least 14 days old (waived
once, for 2.2.0).
+2 -2
View File
@@ -119,7 +119,7 @@ If findings exist:
| | |
|---|---|
| **Risk tier** | [trivial \| lite \| full] |
| **Risk tier** | [trivial \| lite \| full \| test-only \| ci-only] |
| **Reviewers** | [exactly the agents listed in `<reviewers_ran>`, each with its glyph] |
</details>
@@ -142,7 +142,7 @@ If NO issues across all reviewers:
| | |
|---|---|
| **Risk tier** | [trivial \| lite \| full] |
| **Risk tier** | [trivial \| lite \| full \| test-only \| ci-only] |
| **Reviewers** | [exactly the agents listed in `<reviewers_ran>`, each with its glyph] |
</details>
+118 -15
View File
@@ -26,14 +26,17 @@ import {
parseRawDiff,
reviewerFacts,
sizeReasons,
withoutCarriedApproval,
} from "./src/auto-approve.mjs";
import {
ANY_REVIEW_MARKER,
NOTICE_TAG,
REVIEW_HEADING,
REVIEW_TITLE,
bodyAtFailedRun,
bodyAtPassLimit,
bodyAtReviewedHead,
bodyAtUnchangedDiff,
bodyAtUnreviewedHead,
claimedReviewBody,
finishReviewBody,
@@ -44,8 +47,10 @@ import {
reviewKey,
reviewPassCount,
reviewSkipReason,
reviewedDiffFingerprint,
sanitizeReviewBody,
selectOwnReviewComment,
shouldSkipAsUnchangedDiff,
withPassTag,
} from "./src/comments.mjs";
import {
@@ -56,10 +61,11 @@ import {
selectInlineComments,
} from "./src/inline.mjs";
import { changedCodeEvidence, mergeConfidence, reviewerFinished, severityCounts, unfinishedReviewers } from "./src/confidence.mjs";
import { OpencodeServer, resolveOpencodeConfig, selfCheck, shouldRetry } from "./src/opencode.mjs";
import { lostPasses, lostPassesNote, mergePassResults, parseColdPasses, passLabel, passPlan, runPooled } from "./src/passes.mjs";
import { OpencodeServer, modelVariantIds, resolveOpencodeConfig, selfCheck, shouldRetry } from "./src/opencode.mjs";
import { stripJsonc } from "./src/jsonc.mjs";
import { lostPasses, lostPassesNote, mergePassResults, parseColdPasses, passLabel, passPlan, reviewComplete, runPooled } from "./src/passes.mjs";
import { MAX_USER_PROMPT_CHARS, fitCoordinatorPrompt } from "./src/coordinator-input.mjs";
import { fetchPinnedDiff, prefixDiffPaths, reviewerDiff } from "./src/pr-diff.mjs";
import { diffFingerprint, fetchPinnedDiff, hunksLocatable, prefixDiffPaths, reviewerDiff, runFile } from "./src/pr-diff.mjs";
import {
buildFallbackReview,
partialFindingsBlock,
@@ -73,7 +79,7 @@ import {
} from "./src/report.mjs";
import { sanitizePrTree } from "./src/sanitize.mjs";
import { scrubReviewBody, scrubSecrets } from "./src/scrub.mjs";
import { isReleaseFile, resolveRulesFiles } from "./src/selection.mjs";
import { isReleaseFile, narrowTier, resolveRulesFiles } from "./src/selection.mjs";
import { buildImpactContext } from "./src/impact.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -81,7 +87,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// ENGINE_DIR is the trusted base-branch checkout; PR_DIR is the PR head, which is data only and never executed.
const ENGINE_DIR = resolve(process.env.ENGINE_DIR || resolve(__dirname, "../.."));
const PR_DIR = process.env.PR_DIR ? resolve(process.env.PR_DIR) : "";
// REVIEW_OPENCODE_CONFIG: "default" (opencode.jsonc, read-only tools) or "ci-full" (opencode.ci.jsonc, every tool allowed).
// REVIEW_OPENCODE_CONFIG: "default" (opencode.jsonc, read-only tools) or "ci-full" (opencode.ci.jsonc, file tools and a shell allowlist).
const OPENCODE_CONFIG = resolveOpencodeConfig(process.env.REVIEW_OPENCODE_CONFIG);
const OPENCODE_CONFIG_PATH = resolve(ENGINE_DIR, OPENCODE_CONFIG.file);
// A same-named PR in another repo reviewed together with this one (the OSS side of an ENT pair).
@@ -102,13 +108,14 @@ const PAIRED = {
//
// - REVIEW_PROVIDER_ID / REVIEW_MODEL_ID: opencode provider+model IDs (must match a
// provider/model registered in opencode.jsonc).
// - REVIEW_MODEL_VARIANT: opencode `variant` on session.prompt (empty ⇒ omit).
// - REVIEW_MODEL_VARIANT / REVIEW_COORDINATOR_VARIANT: opencode `variant` (effort) of reviewers / coordinator (coordinator defaults to reviewers').
// - REVIEW_API_KEY_ENV: name of the env var holding the provider API key (checked for presence).
// - REVIEW_MARKER: HTML comment marker that identifies this provider's comment on the PR, so
// providers post/update independent comments and never clobber each other.
const PROVIDER_ID = process.env.REVIEW_PROVIDER_ID || "anthropic-review";
const MODEL_ID = process.env.REVIEW_MODEL_ID || "claude-sonnet-5-5";
const MODEL_VARIANT = process.env.REVIEW_MODEL_VARIANT ?? "";
const COORDINATOR_VARIANT = process.env.REVIEW_COORDINATOR_VARIANT || MODEL_VARIANT;
const GEN_AI_PROVIDER_NAME = process.env.REVIEW_GEN_AI_PROVIDER_NAME || genAiProviderName(PROVIDER_ID);
// REVIEW_API_KEY_ENV holds the NAME of the env var carrying the key (e.g. "ANTHROPIC_REVIEW_API_KEY"),
// never the key itself — the value is read only via apiKey() below and is never logged or
@@ -135,6 +142,8 @@ const REVIEW_INLINE = (process.env.REVIEW_INLINE || "false").toLowerCase() === "
// Optional ceiling on paid reviews per PR, 0 for none; the count lives in the review comment, so deleting that comment resets it.
const REVIEW_MAX_PASSES = parseMaxPasses(process.env.REVIEW_MAX_PASSES);
const REVIEW_IGNORE_PASS_LIMIT = process.env.REVIEW_IGNORE_PASS_LIMIT === "true";
// A run someone started by hand always reviews, even a diff the last review already read.
const REVIEW_MANUAL_RUN = process.env.REVIEW_MANUAL_RUN === "true";
// Anything but an exact known mode is "off": the gate must be opted into.
const AUTO_APPROVE_MODE = parseAutoApproveMode(process.env.REVIEW_AUTO_APPROVE);
// Only the workflow's "no open PR on this branch" path sets this; any failed or skipped pairing lookup leaves it unset.
@@ -151,6 +160,11 @@ const OVERALL_TIMEOUT_MS = 40 * 60 * 1000;
// Kept free at the end of the run for rendering and posting the comment.
const POST_RESERVE_MS = 2 * 60 * 1000;
const MIN_COORDINATOR_MS = 60 * 1000;
// A reviewer still running this long before its timeout is interrupted and asked for its findings, which a hard timeout throws away.
const REVIEWER_WRAP_UP = {
ms: 3 * 60 * 1000,
text: "Time is up for this review. Do not call any more tools. Reply now with your final output in the exact format your instructions require: a <findings> block with every issue you have already verified, or the no-issues marker if you verified none. Do not report anything you have not checked.",
};
// Covers every area of two passes at once (8 reviewers at most); a third pass queues instead of adding to provider rate limits.
const REVIEWER_CONCURRENCY = 16;
const TRACE_SERVICE_NAME = "github-actions.ai-code-review";
@@ -255,6 +269,15 @@ const RISK_TIERS = {
agents: ["security", "correctness", "code-quality", "performance", "documentation"],
coordinatorAgent: COORDINATOR_AGENT,
},
"test-only": {
// qa reviews Playwright only, and is added by CONDITIONAL_AGENTS when those files change.
agents: ["correctness", "code-quality"],
coordinatorAgent: COORDINATOR_AGENT,
},
"ci-only": {
agents: ["security", "release"],
coordinatorAgent: COORDINATOR_AGENT,
},
};
// Agents added on top of the tier's set when the diff touches their domain, regardless of size.
@@ -301,6 +324,20 @@ function execErrorText(err) {
return [err?.message, err?.stderr?.toString(), err?.stdout?.toString()].filter(Boolean).join(" ");
}
// cat-file reads the raw blob, so the PR's own attributes cannot reshape the base text a hunk is matched against.
function readBaseFile(path) {
const paired = Boolean(PAIRED.dir) && path.startsWith(`${PAIRED.dir}/`);
const rel = paired ? path.slice(PAIRED.dir.length + 1) : path;
const dir = paired ? resolve(PR_DIR, PAIRED.dir) : PR_DIR;
const base = paired ? PAIRED.mergeBase : process.env.MERGE_BASE_SHA;
if (!dir || !base) return null;
try {
return runFile("git", ["-C", dir, "cat-file", "blob", `${base}:${rel}`]);
} catch {
return null;
}
}
// The API refuses diffs over 20k lines; the workflow fetches the merge base into PR_DIR so the same three-dot diff can be built locally.
function fetchPrDiff(prNumber) {
const log = message => console.log(`[${isoNow()}] ${message}`);
@@ -739,9 +776,13 @@ function filterDiff(diff) {
function assessRiskTier(filteredDiff) {
const totalLines = filteredDiff.totalAdded + filteredDiff.totalRemoved;
const fileCount = filteredDiff.files.length;
const hasSecurityFiles = filteredDiff.files.some(f => isSecuritySensitiveFile(f.newPath));
// A rename's source counts too: moving code out of production or CI removes it from there.
const paths = filteredDiff.files.flatMap(f => [f.oldPath, f.newPath]);
const hasSecurityFiles = paths.some(isSecuritySensitiveFile);
if (fileCount > 50 || hasSecurityFiles) return "full";
const narrow = narrowTier(paths.map(logicalPath));
if (narrow) return narrow;
if (totalLines <= 10 && fileCount <= 20) return "trivial";
if (totalLines <= 100 && fileCount <= 20) return "lite";
return "full";
@@ -782,7 +823,7 @@ async function opencodeClient() {
const client = await OPENCODE.ensureStarted();
if (!selfChecks.has(client)) {
const check = selfCheck(client, reviewOpencodeAgents(), { readOnly: OPENCODE_CONFIG.readOnly }).then(result => {
const grant = OPENCODE_CONFIG.readOnly ? "only read/glob/grep allowed" : "every tool allowed (ci-full)";
const grant = OPENCODE_CONFIG.readOnly ? "only read/glob/grep allowed" : "file tools and a shell allowlist (ci-full)";
console.log(`[${isoNow()}] opencode self-check passed: ${result.plugins} built-in plugins, ${grant} for ${result.agents} agents`);
return result;
});
@@ -797,11 +838,12 @@ function remainingRunMs() {
}
async function callOpencode(agentName, systemPrompt, userPrompt, timeoutMs = AGENT_TIMEOUT_MS, traceOptions = {}) {
const variant = traceOptions.agentKey === "coordinator" ? COORDINATOR_VARIANT : MODEL_VARIANT;
const llmSpan = TRACE.startSpan("gen_ai.chat", {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": GEN_AI_PROVIDER_NAME,
"gen_ai.request.model": MODEL_ID,
"gen_ai.request.reasoning_effort": MODEL_VARIANT || "default",
"gen_ai.request.reasoning_effort": variant || "default",
"ai.review.operation.name": traceOptions.operationName || "chat",
"ai.agent.key": traceOptions.agentKey,
"ai.agent.name": traceOptions.agentName,
@@ -826,12 +868,14 @@ async function callOpencode(agentName, systemPrompt, userPrompt, timeoutMs = AGE
const result = await client.prompt({
agent: agentName,
model: { providerID: PROVIDER_ID, modelID: MODEL_ID },
variant: MODEL_VARIANT,
variant,
system: truncatedSystem,
text: truncatedUser,
title: `ai-review:${traceOptions.agentKey || agentName}${traceOptions.pass ? `:pass-${traceOptions.pass}` : ""}`,
timeoutMs,
wrapUp: traceOptions.wrapUp,
});
if (result.wrappedUp) console.warn(`[${isoNow()}] ${agentName}: interrupted near its timeout and asked for its findings`);
if (result.rejectedPermissions > 0) {
console.warn(`[${isoNow()}] ${agentName}: rejected ${result.rejectedPermissions} permission request(s)`);
}
@@ -844,8 +888,9 @@ async function callOpencode(agentName, systemPrompt, userPrompt, timeoutMs = AGE
"ai.review.output.chars": result.text.length,
"ai.review.attempts": attempt,
"opencode.permission_rejections": result.rejectedPermissions,
"ai.review.wrapped_up": Boolean(result.wrappedUp),
});
return { text: result.text, responseId: result.responseId };
return { text: result.text, responseId: result.responseId, wrappedUp: Boolean(result.wrappedUp) };
} catch (err) {
failedUsage = addUsage(failedUsage, err?.usage);
const retry = shouldRetry({
@@ -945,6 +990,7 @@ async function runReviewer(agentKey, agentDef, diff, prContext, existingReview,
agentName: agentDef.name,
pass,
operationName: "code_review",
wrapUp: REVIEWER_WRAP_UP,
},
);
const text = completion.text;
@@ -964,9 +1010,10 @@ async function runReviewer(agentKey, agentDef, diff, prContext, existingReview,
"review.elapsed_seconds": Number(elapsed),
"review.findings": findings.length,
"review.raw_text_length": text.length,
"review.wrapped_up": completion.wrappedUp,
"gen_ai.response.id": completion.responseId,
});
return { agentKey, agentName: agentDef.name, pass, findings, rawText: text, error: null, genAIResponseId: completion.responseId, fullDiff, parsed };
return { agentKey, agentName: agentDef.name, pass, findings, rawText: text, error: null, genAIResponseId: completion.responseId, fullDiff, parsed, wrappedUp: completion.wrappedUp };
} catch (err) {
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.error(`[${isoNow()}] ${label} FAILED after ${elapsed}s: ${err.message}`);
@@ -1146,6 +1193,10 @@ function noteReviewedHead(prNumber, comment) {
noteOnComment(prNumber, comment, bodyAtReviewedHead(comment.body, REVIEW_MAX_PASSES));
}
function noteFailedRun(prNumber, comment, key) {
if (comment) noteOnComment(prNumber, comment, bodyAtFailedRun(comment.body, key));
}
function noteSkippedPush(prNumber, comment, key) {
noteOnComment(prNumber, comment, bodyAtPassLimit(comment.body, REVIEW_MAX_PASSES, key));
}
@@ -1262,6 +1313,34 @@ function writeFindingsArtifact(prNumber, tier, results, coordinatorResult, inlin
// ─── Main ──────────────────────────────────────────────────────────────────
// A variant the config does not declare would fail every session, so the run stops before it claims a pass.
function variantProblem() {
let config;
try {
config = JSON.parse(stripJsonc(readFileSync(OPENCODE_CONFIG_PATH, "utf-8")));
} catch (err) {
return `cannot read ${OPENCODE_CONFIG.file}: ${err.message}`;
}
const known = modelVariantIds(config, PROVIDER_ID, MODEL_ID);
const unknown = [...new Set([MODEL_VARIANT, COORDINATOR_VARIANT])].filter(v => v && !known.includes(v));
return unknown.length > 0 ? `variant "${unknown.join('", "')}" is not declared for ${MODEL_SLUG} in ${OPENCODE_CONFIG.file} (declared: ${known.join(", ") || "none"})` : "";
}
// Fetched ahead of the cap check, and only when the comment holds a fingerprint, so a push that changed nothing is skipped even past the cap.
function unchangedDiffCheck(prNumber, comment) {
const force = REVIEW_MANUAL_RUN || REVIEW_IGNORE_PASS_LIMIT || process.env.FORCE_FULL === "true";
if (force || !reviewedDiffFingerprint(comment?.body)) return null;
try {
const fetched = fetchPrDiff(prNumber);
const fingerprint = diffFingerprint(fetched.diff);
const unchanged = shouldSkipAsUnchangedDiff(comment.body, fingerprint, { pinned: fetched.pinned, force }) && hunksLocatable(fetched.diff, readBaseFile);
return { fetched, unchanged };
} catch (err) {
console.warn(`[${isoNow()}] Could not fetch the diff to compare with the last review, so it is reviewed: ${execErrorText(err).slice(0, 300)}`);
return null;
}
}
function prepareReviewTree(rootSpan) {
if (!PR_DIR) {
if (process.env.GITHUB_ACTIONS === "true") throw new Error("PR_DIR is required in CI: the PR must be reviewed from its own sanitized checkout");
@@ -1323,6 +1402,7 @@ async function main() {
maxPasses: REVIEW_MAX_PASSES,
ignorePassLimit: REVIEW_IGNORE_PASS_LIMIT,
forceFull: process.env.FORCE_FULL === "true",
manual: REVIEW_MANUAL_RUN,
});
const gate = createAutoApproveGate({
mode: AUTO_APPROVE_MODE,
@@ -1333,6 +1413,15 @@ async function main() {
});
// Ahead of the cap skip too: a push the cap leaves unreviewed must still lose the earlier approval.
if (skipReason !== "already_reviewed") gate.start();
const early = skipReason === "already_reviewed" ? null : unchangedDiffCheck(prNumber, existingComment);
if (early?.unchanged) {
console.log(`[${isoNow()}] PR #${prNumber} skipped (unchanged_diff): ${headKey} changes the same lines the last review read. Dispatch the workflow to review it again.`);
const kept = bodyAtUnchangedDiff(existingComment.body, headKey, REVIEW_MAX_PASSES);
noteOnComment(prNumber, existingComment, AUTO_APPROVE_MODE === "approve" ? withoutCarriedApproval(kept) : kept);
outcome = "skipped";
TRACE.setSpanAttributes(rootSpan, { "review.skipped": true, "review.skip_reason": "unchanged_diff" });
return;
}
if (skipReason) {
console.log(`[${isoNow()}] PR #${prNumber} skipped (${skipReason}): ${headKey || "no head"}, ${passesDone} review(s) so far. Dispatch the workflow with ignore_pass_limit to review again.`);
if (skipReason === "pass_limit_reached") noteSkippedPush(prNumber, existingComment, headKey);
@@ -1366,7 +1455,14 @@ async function main() {
throw new Error(message);
}
console.log(`[${isoNow()}] ${MODEL_SLUG} (via opencode serve): configured`);
const variantError = variantProblem();
if (variantError) {
outcome = "misconfigured";
TRACE.setSpanAttributes(rootSpan, { "review.skipped": true, "review.skip_reason": "unknown_variant" });
noteFailedRun(prNumber, existingComment, headKey);
throw new Error(`${variantError}; fix AI_REVIEW_EFFORT or AI_REVIEW_COORDINATOR_EFFORT.`);
}
console.log(`[${isoNow()}] ${MODEL_SLUG} (via opencode serve): configured, reviewers at "${MODEL_VARIANT || "default"}", coordinator at "${COORDINATOR_VARIANT || "default"}"`);
// 1. Get PR diff
console.log(`[${isoNow()}] Fetching PR diff...`);
@@ -1376,7 +1472,7 @@ async function main() {
let diffPinned = false;
const diffSpan = TRACE.startSpan("github.pr.diff", { "github.pr.number": prNumber }, rootSpan);
try {
({ diff, own: ownDiff, pinned: diffPinned } = fetchPrDiff(prNumber));
({ diff, own: ownDiff, pinned: diffPinned } = early?.fetched ?? fetchPrDiff(prNumber));
const diffStats = diff.split("\n").length;
TRACE.endSpan(diffSpan, { "diff.raw_lines": diffStats });
console.log(`[${isoNow()}] Raw diff: ${diffStats} lines`);
@@ -1393,7 +1489,10 @@ async function main() {
const rawFiles = parseDiffFiles(diff);
const filtered = filterDiff(diff);
const changedLines = filtered.totalAdded + filtered.totalRemoved;
// The unfiltered diff: a lockfile or asset the filter hides still changes what the verdict covers.
const fingerprint = diffFingerprint(diff);
TRACE.endSpan(filterSpan, {
"diff.fingerprint": fingerprint,
"diff.changed_lines": changedLines,
"diff.filtered_files": filtered.files.length,
"diff.skipped_files": rawFiles.length - filtered.files.length,
@@ -1651,8 +1750,12 @@ async function main() {
// A run where no reviewer finished reviewed nothing, so it does not use up a pass.
const reviewed = completedAgents.length > 0;
const passes = reviewed ? passesDone + 1 : passesDone;
const complete = reviewComplete(passResults, coordinatorResult);
// A reviewer reads at most MAX_DIFF_TOKENS chars of diff, so a longer one was never fully reviewed.
const fullyRead = diff.length <= MAX_DIFF_TOKENS;
if (reviewed && !complete) console.warn(`[${isoNow()}] The review was partial, so the next push re-reviews even an unchanged diff`);
if (reviewed && !diffPinned) console.warn(`[${isoNow()}] The reviewed diff is not tied to ${headKey}, so the commit stays reviewable`);
finalReview = finishReviewBody(sanitizedReview, { key: headKey, passes, maxPasses: REVIEW_MAX_PASSES, reviewed: reviewed && diffPinned, confidence, gateLine: autoApproveLine(autoApprove) });
finalReview = finishReviewBody(sanitizedReview, { key: headKey, passes, maxPasses: REVIEW_MAX_PASSES, reviewed: reviewed && diffPinned, confidence, gateLine: autoApproveLine(autoApprove), diffFingerprint: complete && fullyRead && diffPinned && hunksLocatable(diff, readBaseFile) ? fingerprint : "" });
TRACE.setSpanAttributes(rootSpan, { "review.final_length": finalReview.length });
console.log(`[${isoNow()}] Final review length: ${finalReview.length} chars`);
+21
View File
@@ -0,0 +1,21 @@
// Runs before npm ci and opencode are installed, so it may import node built-ins and src/ modules that import only built-ins.
import { readFileSync } from "node:fs";
import { listingComplete, nothingToReviewReason } from "./src/selection.mjs";
const TSV_ESCAPES = { "\\t": "\t", "\\n": "\n", "\\r": "\r", "\\\\": "\\" };
const lines = file => {
const out = file ? readFileSync(file, "utf-8").split("\n").map(line => line.replace(/\r$/, "")) : [];
if (out.at(-1) === "") out.pop();
return out;
};
const unescapeTsv = field => field.replace(/\\[tnr\\]/g, seq => TSV_ESCAPES[seq]);
const [listFile, commitsFile] = process.argv.slice(2);
const fileLines = lines(listFile).filter(line => line.trim());
const paths = fileLines.flatMap(line => line.split("\t").map(unescapeTsv).filter(Boolean));
const commits = lines(commitsFile).map(line => {
const [author = "", committer = "", verified = ""] = line.split("\t");
return { author, committer, verified: verified === "true" };
});
const complete = listingComplete({ files: fileLines.length, commits: commits.length, expectedFiles: process.env.EXPECTED_FILES, expectedCommits: process.env.EXPECTED_COMMITS });
process.stdout.write(complete ? nothingToReviewReason({ author: process.env.PR_AUTHOR || "", commits, paths }) : "");
+8 -1
View File
@@ -1,4 +1,5 @@
import { removeRepeatedly } from "./comments.mjs";
import { reviewerCompleted } from "./confidence.mjs";
import { LOADER_DIRS, LOADER_FILES } from "./sanitize.mjs";
export const MAX_APPROVE_LINES = 100;
@@ -62,6 +63,7 @@ const SUBMODULE_MODE = "160000";
const KNOWN_HEADER_RE = /^(?:index |--- |\+\+\+ |similarity index |dissimilarity index |rename |copy |old mode |new mode |new file mode |deleted file mode |Binary files |GIT binary patch|\\ |$)/;
const HUNK_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/;
const APPROVED_LINE = "**Auto-approved** — small change, all gates passed.";
const UNCHANGED_DIFF_APPROVAL_LINE = "**Needs human review** — an approval does not carry over to a new commit, even one with the same reviewed diff";
const APPROVAL_BODY = "Auto-approved by the review gate: small change, all gates passed. CI must still pass before merge.";
const DISMISSAL_MESSAGE = "New commits arrived; an automatic approval does not carry over.";
@@ -234,7 +236,7 @@ function reviewerReasons(reviewers) {
// Parsed reviewer output is the only proof a reviewer looked; a clean exit with empty text is not.
export function reviewerFacts(results) {
return results.map(r => ({ key: r.agentKey, ran: !r.skipped, finished: !r.skipped && !r.error && r.parsed === true, fullDiff: r.fullDiff === true }));
return results.map(r => ({ key: r.agentKey, ran: !r.skipped, finished: reviewerCompleted(r), fullDiff: r.fullDiff === true }));
}
function pairingReasons({ paired, pairClear }) {
@@ -386,3 +388,8 @@ export function autoApproveLine(result) {
if (result?.mode !== "approve") return "";
return result.approved ? APPROVED_LINE : `**Needs human review** — ${result.reasons.join("; ")}`;
}
// The unchanged-diff skip still dismisses the approval: the fingerprint ignores lockfiles and other noise the approval gate checks.
export function withoutCarriedApproval(body) {
return String(body ?? "").split(APPROVED_LINE).join(UNCHANGED_DIFF_APPROVAL_LINE);
}
+39 -10
View File
@@ -1,4 +1,4 @@
import { isSettledConfidenceLine, mergeConfidenceLine, reviewingConfidenceLine, staleConfidenceLine } from "./confidence.mjs";
import { isSettledConfidenceLine, mergeConfidenceLine, notRunConfidenceLine, reviewingConfidenceLine, staleConfidenceLine } from "./confidence.mjs";
import { scrubReviewBody } from "./scrub.mjs";
export const REVIEW_PASS_TAG = "<!--\\s*o2-review-pass:(\\d+)\\s*-->";
@@ -6,6 +6,8 @@ export const REVIEW_PASS_TAG = "<!--\\s*o2-review-pass:(\\d+)\\s*-->";
export const REVIEW_SHA_TAG = "<!--\\s*o2-review-sha:([^\\s<>]+)\\s*-->";
// The verdict a later head's line replaced, kept in case the head returns to the reviewed commit.
export const REVIEW_HELD_TAG = "<!--\\s*o2-review-held:([^\\s<>]+)\\s*-->";
// Fingerprint of the diff the sha tag's review read, so a later head with the same changes (rebase, merge from main) needs no new review.
export const REVIEW_DIFF_TAG = "<!--\\s*o2-review-diff:([0-9a-f]{64})\\s*-->";
// Matched as a pattern so a retired leg's comment is still adopted; `[redacted]` is what redaction left in comments whose marker named the provider.
export const ANY_REVIEW_MARKER = "<!--\\s*ai-code-review[a-z0-9-]*(?:\\[redacted\\])?\\s*-->";
// Tags a comment that stands in for a review (post-paired-pointer.sh, the "key not set" notice, the in-progress claim); it is replaced in place but is not a previous review.
@@ -26,7 +28,7 @@ const AUTO_APPROVE_LINE_RE = /^(?:[^\w\n`]|<\/?[a-z]+>)*(?:auto[- ]?approved|nee
const FORGED_LINE_RE = /mergeconfidence|autoapproved|needshumanreview|reviewedcommit/;
// The fixed head of a finding line, glyph and location then category, which leaves no room for a status phrase ahead of the category.
const FINDING_LINE_RE = /^\s*- (?:[^\w\s`*<]+\s+)?(?:`[^`\n]+`\s+)?\*\*\[[^\]\n]+\]\*\*/;
const TRAILING_TAGS_RE = /(?:\s*<!--\s*o2-review-(?:(?:sha|held):[^\s<>]+|pass:\d+)\s*-->)+\s*$/i;
const TRAILING_TAGS_RE = /(?:\s*<!--\s*o2-review-(?:(?:sha|held|diff):[^\s<>]+|pass:\d+)\s*-->)+\s*$/i;
const HTML_COMMENT_RE = /<!--[\s\S]*?--!?>/g;
const MARKUP_RE = /<!--[\s\S]*?--!?>|<[^>]*>|&[a-z]+;/gi;
// "summary" stays out: it would strip the <summary> of the findings fold, and a leaked finding field sits inside a stripped <finding> block.
@@ -87,8 +89,30 @@ export function withReviewShaTag(body, key) {
return `${withoutTag(body, REVIEW_SHA_TAG)}\n${reviewShaTag(key)}`;
}
export function shouldSkipAsReviewed(body, key, { ignorePassLimit = false, forceFull = false } = {}) {
if (!key || ignorePassLimit || forceFull) return false;
// Only a body whose sha tag names a reviewed head can vouch for a diff.
export function reviewedDiffFingerprint(body) {
if (!reviewedKey(body)) return "";
return (body || "").match(new RegExp(REVIEW_DIFF_TAG, "i"))?.[1] ?? "";
}
export function withReviewDiffTag(body, fingerprint) {
const bare = withoutTag(body, REVIEW_DIFF_TAG);
return fingerprint ? `${bare}\n<!-- o2-review-diff:${fingerprint} -->` : bare;
}
// A forced run (dispatch, force_full, ignore_pass_limit) always reviews; otherwise a pinned diff equal to the reviewed one is skipped.
export function shouldSkipAsUnchangedDiff(body, fingerprint, { pinned = false, force = false } = {}) {
if (force || !pinned || !fingerprint) return false;
return reviewedDiffFingerprint(body) === fingerprint;
}
// Costs no pass; the visible commit stays the reviewed one because finding line numbers refer to it, while the tag moves so the next push compares against this head.
export function bodyAtUnchangedDiff(body, key, maxPasses) {
return withReviewShaTag(bodyAtReviewedHead(body, maxPasses), key);
}
export function shouldSkipAsReviewed(body, key, { ignorePassLimit = false, forceFull = false, manual = false } = {}) {
if (!key || ignorePassLimit || forceFull || manual) return false;
return reviewedKey(body) === key;
}
@@ -103,8 +127,8 @@ export function passLimitReached(passes, maxPasses) {
}
// Dedupe comes first so a queued run on a reviewed head stays silent instead of rewriting the limit note.
export function reviewSkipReason({ body, key, passesDone, maxPasses, ignorePassLimit = false, forceFull = false }) {
if (shouldSkipAsReviewed(body, key, { ignorePassLimit, forceFull })) return "already_reviewed";
export function reviewSkipReason({ body, key, passesDone, maxPasses, ignorePassLimit = false, forceFull = false, manual = false }) {
if (shouldSkipAsReviewed(body, key, { ignorePassLimit, forceFull, manual })) return "already_reviewed";
if (!ignorePassLimit && passLimitReached(passesDone, maxPasses)) return "pass_limit_reached";
return "";
}
@@ -207,20 +231,25 @@ export function bodyAtPassLimit(body, maxPasses, head) {
return bodyAtUnreviewedHead(withPassLimitNote(body, passLimitNote(maxPasses, reviewedKey(body), head)), head);
}
// Even on the covered commit: the run already dismissed the approval, so the old verdict and gate line must not read as current.
export function bodyAtFailedRun(body, head) {
return head ? withConfidenceLine(withHeldVerdict(body), notRunConfidenceLine(shortKey(head))) : body;
}
// The sha tag says that exact commit was read and stops it being reviewed again, so a run that cannot prove that leaves it off.
export function finishReviewBody(body, { key, passes, maxPasses, reviewed, confidence, gateLine = "" }) {
export function finishReviewBody(body, { key, passes, maxPasses, reviewed, confidence, gateLine = "", diffFingerprint = "" }) {
const stamped = reviewed && key;
const lines = [confidence ? mergeConfidenceLine(confidence) : "", gateLine].filter(Boolean);
let out = insertAfterCallout(body, lines.join("\n\n"));
if (stamped) out += `\n\n${reviewedCommitLine(key, passes, maxPasses)}`;
if (passLimitReached(passes, maxPasses)) out += `\n\n${passLimitNote(maxPasses)}`;
if (stamped) out = withReviewShaTag(out, key);
if (stamped) out = withReviewDiffTag(withReviewShaTag(out, key), diffFingerprint);
return withPassTag(out, passes);
}
// What the reviewers see as <previous_review>: the review alone, none of the runner's bookkeeping.
export function previousReviewText(body) {
return [REVIEW_SHA_TAG, REVIEW_PASS_TAG, REVIEW_HELD_TAG].reduce(withoutTag, stripRunnerLines(String(body ?? "")));
return [REVIEW_SHA_TAG, REVIEW_PASS_TAG, REVIEW_HELD_TAG, REVIEW_DIFF_TAG].reduce(withoutTag, stripRunnerLines(String(body ?? "")));
}
// Yields the text after each pass, last the first that changed nothing: a removal can splice a new match out of what surrounds it.
@@ -303,7 +332,7 @@ function unwrapJsonBody(body) {
export function sanitizeReviewBody(body, { marker, providerId, modelId, env }) {
if (!body) return { text: "", secretHits: 0 };
const anyMarker = new RegExp(ANY_REVIEW_MARKER, "gi");
const runnerTags = new RegExp(`${ANY_REVIEW_MARKER}|${REVIEW_PASS_TAG}|${REVIEW_SHA_TAG}|${REVIEW_HELD_TAG}`, "gi");
const runnerTags = new RegExp(`${ANY_REVIEW_MARKER}|${REVIEW_PASS_TAG}|${REVIEW_SHA_TAG}|${REVIEW_HELD_TAG}|${REVIEW_DIFF_TAG}`, "gi");
let cleaned = unwrapJsonBody(body)
.replace(/\\n/g, "\n")
+10 -1
View File
@@ -25,8 +25,13 @@ export function reviewerFinished(r) {
return !r.skipped && !r.error && r.parsed === true;
}
// A reviewer interrupted at its soft deadline answered in haste, so its output counts as findings but not as a finished review.
export function reviewerCompleted(r) {
return reviewerFinished(r) && !r.wrappedUp;
}
export function unfinishedReviewers(ranResults) {
return ranResults.filter(r => !reviewerFinished(r)).map(r => r.agentName);
return ranResults.filter(r => !reviewerCompleted(r)).map(r => r.agentName);
}
function plural(count, noun) {
@@ -78,6 +83,10 @@ export function staleConfidenceLine(shortHead) {
return `**Merge confidence: ${CONFIDENCE_LABEL.low}** — \`${shortHead}\` has not been reviewed`;
}
export function notRunConfidenceLine(shortHead) {
return `**Merge confidence: ${CONFIDENCE_LABEL.low}** — the review did not run because of a CI configuration error; \`${shortHead}\` has not been reviewed`;
}
export function reviewingConfidenceLine(shortHead) {
return `**Merge confidence: 🟡 Reviewing** — \`${shortHead}\` is being reviewed`;
}
+18
View File
@@ -0,0 +1,18 @@
// Comments and trailing commas out, so the opencode configs parse as JSON.
export function stripJsonc(text) {
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === '"') {
const start = i;
for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++;
out += text.slice(start, i + 1);
} else if (c === "/" && text[i + 1] === "/") {
while (i < text.length && text[i] !== "\n") i++;
out += "\n";
} else {
out += c;
}
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
+59 -10
View File
@@ -23,7 +23,11 @@ export const BUILTIN_TOOL_IDS = new Set([
const ALLOWED_TOOL_IDS = new Set(["read", "glob", "grep"]);
const EDIT_TOOL_IDS = new Set(["edit", "write"]);
// What ci-full must actually grant without asking; a config that fell back to the defaults fails here.
const CI_FULL_TOOL_IDS = ["read", "glob", "grep", "edit", "write", "shell", "webfetch"];
const CI_FULL_TOOL_IDS = ["read", "glob", "grep", "edit", "write"];
// question waits on an answer nobody in CI gives; execute runs JavaScript with fetch, so it would bypass the webfetch deny.
const CI_FULL_DENIED_TOOL_IDS = ["webfetch", "websearch", "subagent", "question", "execute", "skill"];
// Offered, but only for the commands the config allowlists.
const CI_FULL_RESTRICTED_TOOL_IDS = ["shell"];
// 2.x dropped OPENCODE_PURE and the plugin, skill and LSP-download flags; sanitizePrTree covers what they did.
const SANDBOX_FLAGS = {
@@ -42,6 +46,7 @@ const INTERRUPTED_ERROR = { type: "interrupted", message: "opencode interrupted
const DEFAULT_REQUEST_HEADERS_TIMEOUT_MS = 30_000;
const STATUS_POLL_MS = 15_000;
const STREAM_RETRY_MS = 1_000;
const INTERRUPT_SETTLE_MS = 20_000;
const TRANSIENT_ERROR_RE = /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|overloaded|rate limit|too many requests|temporarily unavailable|bad gateway|service unavailable|gateway timeout/i;
// opencode 2.x StructuredError types; the first set is worth a fresh session, the second never is, whatever the message says.
const TRANSIENT_ERROR_TYPES = new Set(["provider.rate-limit", "provider.transport", "provider.timeout", "provider.internal"]);
@@ -217,7 +222,7 @@ export class OpencodeClient {
}
// Sends the prompt (2.x admits it and returns at once) and waits on the event stream, so no request ever blocks for the length of an agent run (undici's 300 s headersTimeout killed every long review).
async prompt({ agent, model, variant, system, text, title, timeoutMs }) {
async prompt({ agent, model, variant, system, text, title, timeoutMs, wrapUp = null }) {
await this.ensureEventStream();
// 2.x binds agent, model and variant to the session; the prompt itself carries only text.
const session = (await this.json("POST", "/api/session", {
@@ -233,17 +238,20 @@ export class OpencodeClient {
if (system) {
await this.json("PUT", `/api/experimental/session/${session.id}/instructions/entries/${SYSTEM_ENTRY_KEY}`, { value: system });
}
const softMs = wrapUpStartMs(timeoutMs, wrapUp);
// Armed before the POST: the run can fail before the prompt call returns, and nothing would replay it.
const finished = waiter.wait(timeoutMs);
const finished = waiter.wait(softMs);
await this.json("POST", `/api/session/${session.id}/prompt`, { text });
const outcome = await finished;
let outcome = await finished;
let wrappedUp = false;
if (outcome.timedOut && softMs < timeoutMs) ({ outcome, wrappedUp } = await this.wrapUpSession(session.id, waiter, wrapUp.text, timeoutMs - softMs));
if (outcome.timedOut) {
await this.request("POST", `/api/session/${session.id}/interrupt`).catch(() => {});
throw new OpencodeError(`opencode agent ${agent} timed out after ${timeoutMs}ms`, { name: "TimeoutError" });
}
const messages = await this.json("GET", `/api/session/${session.id}/message?type=assistant&order=desc&limit=1`);
totals = await this.json("GET", `/api/session/${session.id}`).catch(() => null);
return finalAssistantResult(messages?.data, totals?.data, outcome.error, waiter.rejectedPermissions);
return { ...finalAssistantResult(messages?.data, totals?.data, outcome.error, waiter.rejectedPermissions), wrappedUp };
} catch (err) {
// A failed or timed-out session was still billed; its totals are gone once the session is deleted.
totals ??= await this.json("GET", `/api/session/${session.id}`).catch(() => null);
@@ -256,6 +264,23 @@ export class OpencodeClient {
}
}
// The interrupt keeps the finished steps in the session, so the follow-up prompt answers from everything the agent already read.
async wrapUpSession(sessionID, waiter, text, budgetMs) {
const deadline = Date.now() + budgetMs;
const stopped = waiter.wait(Math.min(INTERRUPT_SETTLE_MS, budgetMs));
await this.request("POST", `/api/session/${sessionID}/interrupt`).catch(() => {});
const stop = await stopped;
// An unconfirmed interrupt ends as the hard timeout; a run that ended on its own keeps its own outcome.
if (stop.timedOut || stop.error?.type !== "interrupted") return { outcome: stop, wrappedUp: false };
// Nothing interrupts the follow-up before its wait ends, so a late interrupted event or idle record is the first run's.
waiter.ignoreInterrupted = true;
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) return { outcome: { timedOut: true, error: null }, wrappedUp: true };
const finished = waiter.wait(remainingMs);
await this.json("POST", `/api/session/${sessionID}/prompt`, { text });
return { outcome: await finished, wrappedUp: true };
}
close() {
this.closed = true;
this.stream?.close();
@@ -270,6 +295,7 @@ class SessionWaiter {
this.sessionID = sessionID;
this.rejectedPermissions = 0;
this.settle = null;
this.ignoreInterrupted = false;
}
wait(timeoutMs) {
@@ -291,6 +317,7 @@ class SessionWaiter {
}
onError(error) {
if (this.ignoreInterrupted && error?.type === "interrupted") return;
this.settle?.({ timedOut: false, error: error || { type: "unknown", message: "opencode run failed" } });
}
@@ -353,11 +380,13 @@ function permissionAction(toolId) {
return EDIT_TOOL_IDS.has(toolId) ? "edit" : toolId;
}
// Same as opencode 2.2.0: a trailing " *" also matches the bare command, so "git diff *" covers "git diff".
export function wildcardMatch(input, pattern) {
const escaped = pattern
let escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
if (escaped.endsWith(" .*")) escaped = `${escaped.slice(0, -3)}( .*)?`;
return new RegExp(`^${escaped}$`, "s").test(input);
}
@@ -375,7 +404,7 @@ export async function selfCheck(client, expectedAgents, { readOnly = true } = {}
const broken = plugins.filter(p => p.state?.status !== "active");
if (broken.length > 0) throw new OpencodeError(`opencode plugins failed to load: ${broken.map(p => `${p.id}: ${p.state?.error || "unknown"}`).join("; ")}`);
const active = new Set(plugins.map(p => p.id));
const missing = (readOnly ? [...ALLOWED_TOOL_IDS] : CI_FULL_TOOL_IDS).filter(id => !active.has(`opencode.tool.${id}`));
const missing = (readOnly ? [...ALLOWED_TOOL_IDS] : [...CI_FULL_TOOL_IDS, ...CI_FULL_RESTRICTED_TOOL_IDS]).filter(id => !active.has(`opencode.tool.${id}`));
if (missing.length > 0) throw new OpencodeError(`opencode tools did not load: ${missing.join(", ")}`);
const mcp = (await client.json("GET", "/api/mcp"))?.data || [];
if (mcp.length > 0) throw new OpencodeError(`opencode loaded MCP servers; refusing to review`);
@@ -397,9 +426,7 @@ export async function selfCheck(client, expectedAgents, { readOnly = true } = {}
const problem = externalDirectoryProblem(ruleset);
if (problem) problems.push(`${name}: ${problem}`);
} else {
for (const toolId of CI_FULL_TOOL_IDS) {
if (!toolAllowedWithoutAsking(toolId, ruleset)) problems.push(`${name}: ${toolId} not fully allowed`);
}
problems.push(...ciFullProblems(ruleset).map(problem => `${name}: ${problem}`));
}
}
if (problems.length > 0) throw new OpencodeError(`opencode permission self-check failed: ${problems.join("; ")}`);
@@ -415,6 +442,28 @@ export function externalDirectoryProblem(ruleset) {
return extra.length > 0 ? `external_directory allows ${extra.map(r => r.resource).join(", ")}` : "";
}
// ci-full grants the file tools outright, denies the tools that only cost time, and keeps the shell on an allowlist.
export function ciFullProblems(ruleset) {
const problems = CI_FULL_TOOL_IDS.filter(id => !toolAllowedWithoutAsking(id, ruleset)).map(id => `${id} not fully allowed`);
problems.push(...CI_FULL_DENIED_TOOL_IDS.filter(id => toolEnabled(id, ruleset)).map(id => `${id} allowed`));
for (const id of CI_FULL_RESTRICTED_TOOL_IDS) {
if (!toolEnabled(id, ruleset)) problems.push(`${id} denied`);
else if (toolAllowedWithoutAsking(id, ruleset)) problems.push(`${id} allows every command`);
}
return problems;
}
// The follow-up only gets a slot when the first run keeps at least as long as the follow-up; shorter sessions run to the hard timeout.
export function wrapUpStartMs(timeoutMs, wrapUp) {
return wrapUp?.ms > 0 && wrapUp.text && timeoutMs >= 2 * wrapUp.ms ? timeoutMs - wrapUp.ms : timeoutMs;
}
// The variant ids a model declares in an opencode config; a variant opencode does not know must fail the run before any session starts.
export function modelVariantIds(config, providerId, modelId) {
const variants = config?.providers?.[providerId]?.models?.[modelId]?.variants;
return Array.isArray(variants) ? variants.map(v => v?.id).filter(Boolean) : [];
}
// A retry must leave room for its own full timeout plus whatever still has to run after it (the coordinator).
export function shouldRetry({ err, attempt, maxRetries, remainingMs, timeoutMs, reserveMs = 0 }) {
return attempt <= maxRetries && isTransientError(err) && remainingMs > timeoutMs + reserveMs;
+11 -2
View File
@@ -1,4 +1,4 @@
import { reviewerFinished, shortName } from "./confidence.mjs";
import { reviewerCompleted, reviewerFinished, shortName } from "./confidence.mjs";
export const DEFAULT_COLD_PASSES = 2;
export const MAX_COLD_PASSES = 3;
@@ -43,8 +43,9 @@ export async function runPooled(items, limit, worker) {
function mergeArea(group) {
const finished = group.filter(reviewerFinished);
const completed = finished.filter(reviewerCompleted);
const ran = group.filter(r => !r.skipped);
const base = finished[0] ?? ran[0] ?? group[0];
const base = completed[0] ?? finished[0] ?? ran[0] ?? group[0];
const covered = finished.length > 0;
return {
agentKey: base.agentKey,
@@ -58,6 +59,8 @@ function mergeArea(group) {
skipped: group.every(r => r.skipped),
fullDiff: base.fullDiff,
parsed: covered,
// Only an uninterrupted pass completes an area; wrapped-up output still counts as findings.
wrappedUp: covered && completed.length === 0,
passes: group.length,
passesFinished: finished.length,
passResults: group,
@@ -78,6 +81,12 @@ export function lostPasses(results) {
return results.filter(r => !r.skipped && r.passesFinished > 0 && r.passesFinished < r.passes);
}
// Only a review with every pass finished in time and a real coordinator merge may let the next push with the same diff skip.
export function reviewComplete(passResults, coordinatorResult) {
if (!coordinatorResult || coordinatorResult.fallback) return false;
return passResults.every(r => r.skipped || reviewerCompleted(r));
}
// Written by code like the failed-reviewers line, so a reader learns an area got less scrutiny than the rest.
export function lostPassesNote(results) {
const lost = lostPasses(results);
+43
View File
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
const MAX_BUFFER = 200 * 1024 * 1024;
const TOO_LARGE_RE = /exceeded the maximum number of lines|too_large|HTTP 406|HTTP 422/i;
@@ -55,6 +56,39 @@ export function fetchPinnedDiff(params) {
return pinDiff({ ...params, diff: fetchDiff(params) });
}
// Blob ids and hunk line numbers move with the base, so a rebase keeps the fingerprint; the function context after `@@` stays, as the same lines in another function are another change.
export function diffFingerprint(diff) {
const text = String(diff ?? "");
// A binary file has no hunks, so its blob ids are the only record of its content.
const keepBlobIds = /^(Binary files .* differ|GIT binary patch)$/m.test(text);
const normalized = text
.split("\n")
.map(line => (keepBlobIds ? line : line.replace(/^index [0-9a-f]+\.\.[0-9a-f]+/, "index")))
.map(line => line.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/, "@@"))
.join("\n")
.replace(/\n+$/, "");
return createHash("sha256").update(normalized).digest("hex");
}
// Equal text names one change only where each hunk's old lines sit once in the base, so an edit to one of two identical blocks cannot pass for the other.
export function hunksLocatable(diff, readBase) {
for (const section of String(diff ?? "").split(/^diff --git /m).slice(1)) {
const firstHunk = section.search(/^@@ /m);
if (firstHunk === -1) continue;
const header = section.slice(0, firstHunk);
if (/^--- \/dev\/null$/m.test(header)) continue;
const path = header.match(/^--- a\/(.*?)\t?$/m)?.[1];
const base = path ? readBase(path) : null;
if (typeof base !== "string") return false;
const baseLines = base.split("\n");
for (const hunk of section.slice(firstHunk).split(/^@@ [^\n]*(?:\n|$)/m).slice(1)) {
const old = hunk.split("\n").filter(line => line.startsWith(" ") || line.startsWith("-")).map(line => line.slice(1));
if (old.length === 0 ? base !== "" : occurrences(baseLines, old) !== 1) return false;
}
}
return true;
}
// `unfocused` keeps the whole diff for a gate candidate, whose approval must rest on reviewers that saw every file; requiresFocus agents still opt out.
export function reviewerDiff(diff, { fileFocus, requiresFocus }, { unfocused = false, pathOf = path => path } = {}) {
if (!fileFocus) return { diff, skipped: false, fullDiff: true };
@@ -107,3 +141,12 @@ function prefixGitHeader(line, p) {
function prefixBinaryLine(line, p) {
return line.replace(/(^Binary files |and )("?)([ab])\//g, `$1$2$3/${p}/`);
}
// Stops at two: the caller only needs to know whether a block is unique.
function occurrences(lines, block) {
let found = 0;
for (let i = 0; i + block.length <= lines.length && found < 2; i += 1) {
if (lines[i] === block[0] && block.every((line, j) => lines[i + j] === line)) found += 1;
}
return found;
}
+6
View File
@@ -38,6 +38,12 @@ export function sanitizePrTree(prDir, contextSourceDir) {
return { removed, contextFiles: copied };
}
// The same names the walk strips, so a PR touching only these still steers a reviewer and is never docs-only.
export function isLoaderPath(filePath) {
const parts = String(filePath ?? "").toLowerCase().split("/");
return LOADER_FILES.has(parts.at(-1)) || parts.some(part => LOADER_DIRS.has(part));
}
function walk(dir, root, removed) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
+61
View File
@@ -1,5 +1,12 @@
import { existsSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { isLoaderPath } from "./sanitize.mjs";
// Dependency bots whose PRs only bump versions; any other bot or App can write code and is reviewed.
const DEPENDENCY_BOTS = new Set(["dependabot[bot]", "renovate[bot]"]);
// GitHub's files and commits endpoints stop at these counts however far they are paged.
const MAX_LISTED_FILES = 3000;
const MAX_LISTED_COMMITS = 250;
// Diffs that can affect a release: migrations, dependency manifests, server config, CI and deployment files.
export function isReleaseFile(filePath) {
@@ -22,3 +29,57 @@ export function resolveRulesFiles(files, exists = existsSync) {
}
return out;
}
// Test code only: test trees, spec/test files and fixtures, in any language.
export function isTestOnlyPath(filePath) {
if (isCiOnlyPath(filePath)) return false;
if (/(^|\/)(tests?|__tests__|e2e|fixtures?|testdata)\//.test(filePath)) return true;
if (/\.(spec|test)\.[cm]?[jt]sx?$/.test(filePath)) return true;
return /(^|\/)[^/]+_test\.(rs|go|py)$/.test(filePath) || /(^|\/)test_[^/]+\.py$/.test(filePath);
}
// Workflows, actions and .github config only; scripts, the review engine and its opencode configs are code and take the normal tiers.
export function isCiOnlyPath(filePath) {
return /^\.github\/((workflows|actions)\/|[^/]+\.ya?ml$)/.test(filePath);
}
export function narrowTier(paths) {
if (paths.length === 0) return "";
if (paths.every(isTestOnlyPath)) return "test-only";
if (paths.every(isCiOnlyPath)) return "ci-only";
return "";
}
// Prompt, agent-rule and workflow markdown steers the AI or CI, so it is reviewed like code.
export function isDocsPath(filePath) {
if (/^(scripts\/ai-review\/|\.github\/)/.test(filePath) || isLoaderPath(filePath)) return false;
return /\.md$/i.test(filePath) || /^docs\//.test(filePath);
}
// What update-translations.yml commits; en-US.json is the English source a person writes, so it is still reviewed.
export function isTranslationPath(filePath) {
if (filePath === "scripts/translations/.translation_state.json") return true;
return /^web\/src\/locales\/languages\/[^/]+\.json$/.test(filePath) && !filePath.endsWith("/en-US.json");
}
// A listing is whole only when it matches the PR's own counts below each endpoint's cap; anything else may hide a file or commit.
export function listingComplete({ files, commits, expectedFiles, expectedCommits }) {
const count = value => (/^\d+$/.test(String(value ?? "").trim()) ? Number(value) : Number.NaN);
const wantFiles = count(expectedFiles);
const wantCommits = count(expectedCommits);
return files === wantFiles && commits === wantCommits && wantFiles < MAX_LISTED_FILES && wantCommits < MAX_LISTED_COMMITS;
}
// GitHub commits a bot's API-made commits as web-flow, so the committer is either the bot or web-flow, and GitHub must have verified the signature.
export function botCommit(commit, bot) {
return commit?.author === bot && (commit.committer === bot || commit.committer === "web-flow") && commit.verified === true;
}
// Why a PR needs no review at all, or "" when it does; a PR with no listed files is reviewed so a failed listing never skips one.
export function nothingToReviewReason({ author = "", commits = [], paths = [] }) {
if (DEPENDENCY_BOTS.has(author) && commits.length > 0 && commits.every(c => botCommit(c, author))) return "bot_author";
if (paths.length === 0) return "";
if (paths.every(isDocsPath)) return "docs_only";
if (paths.every(isTranslationPath)) return "translation_only";
return "";
}
+77 -1
View File
@@ -1,12 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import {
ANY_REVIEW_MARKER,
REVIEW_HEADING,
bodyAtPassLimit,
bodyAtReviewedHead,
bodyAtUnchangedDiff,
bodyAtUnreviewedHead,
claimedReviewBody,
finishReviewBody,
@@ -20,16 +22,18 @@ import {
reviewShaTag,
reviewSkipReason,
reviewedCommitLine,
reviewedDiffFingerprint,
reviewedKey,
sanitizeReviewBody,
selectOwnReviewComment,
shouldSkipAsReviewed,
shouldSkipAsUnchangedDiff,
withPassLimitNote,
withPassTag,
withReviewShaTag,
withoutAutoApproveLine,
} from "../src/comments.mjs";
import { autoApproveLine } from "../src/auto-approve.mjs";
import { autoApproveLine, withoutCarriedApproval } from "../src/auto-approve.mjs";
import { mergeConfidence } from "../src/confidence.mjs";
const MARKER = ANY_REVIEW_MARKER;
@@ -637,3 +641,75 @@ test("a reviewed head drops a limit note once the cap no longer applies", () =>
}
assert.equal(bodyAtReviewedHead(body, 2), body);
});
const FP_A = "a".repeat(64);
const FP_B = "b".repeat(64);
const HEAD_1 = "1".repeat(40);
const HEAD_2 = "2".repeat(40);
const stampedReview = (fingerprint = FP_A, extra = {}) => finishReviewBody("## 🔎 OpenObserve Code Review\n\n> [!TIP]\n> ### ✅ Approved", {
key: HEAD_1, passes: 1, maxPasses: 0, reviewed: true, confidence: { level: "high", reasons: ["no blockers"] }, diffFingerprint: fingerprint, ...extra,
});
test("a stamped review carries the fingerprint of the diff it read; an unstamped one vouches for none", () => {
assert.equal(reviewedDiffFingerprint(stampedReview()), FP_A);
assert.equal(reviewedDiffFingerprint(stampedReview(FP_A, { reviewed: false })), "");
assert.equal(reviewedDiffFingerprint(stampedReview("")), "");
assert.equal(reviewedDiffFingerprint(`plain text <!-- o2-review-diff:${FP_A} -->`), "", "without a sha tag no head was reviewed");
});
test("an unchanged diff is skipped unless the run is forced or the diff is not pinned to the head", () => {
const body = stampedReview();
assert.equal(shouldSkipAsUnchangedDiff(body, FP_A, { pinned: true }), true);
assert.equal(shouldSkipAsUnchangedDiff(body, FP_B, { pinned: true }), false);
assert.equal(shouldSkipAsUnchangedDiff(body, FP_A, { pinned: false }), false);
assert.equal(shouldSkipAsUnchangedDiff(body, FP_A, { pinned: true, force: true }), false);
assert.equal(shouldSkipAsUnchangedDiff(body, "", { pinned: true }), false);
});
test("an unchanged-diff skip in approve mode keeps the review but not the auto-approval it can no longer back", () => {
const approved = autoApproveLine({ mode: "approve", approved: true, reasons: [] });
const body = stampedReview(FP_A, { gateLine: approved });
assert.ok(body.includes(approved));
const next = withoutCarriedApproval(bodyAtUnchangedDiff(body, HEAD_2, 0));
assert.equal(next.includes("Auto-approved"), false);
assert.match(next, /Needs human review/);
assert.equal(reviewedKey(next), HEAD_2);
assert.equal(reviewedDiffFingerprint(next), FP_A);
assert.equal(withoutCarriedApproval(stampedReview()), stampedReview(), "a body with no approval line is unchanged");
const source = readFileSync(new URL("../run-review.mjs", import.meta.url), "utf-8");
assert.match(source, /AUTO_APPROVE_MODE === "approve" \? withoutCarriedApproval\(kept\) : kept/);
});
test("a partial review leaves no fingerprint, so the next push with the same diff is reviewed again", () => {
const partial = stampedReview("");
assert.equal(shouldSkipAsUnchangedDiff(partial, FP_A, { pinned: true }), false);
const overOld = finishReviewBody(`${stampedReview()}`, { key: HEAD_2, passes: 2, maxPasses: 0, reviewed: true, diffFingerprint: "" });
assert.equal(reviewedDiffFingerprint(overOld), "", "an earlier complete review's fingerprint does not survive a partial one");
});
test("skipping an unchanged diff moves the head marker, keeps the reviewed commit its line numbers refer to, and costs no pass", () => {
const body = stampedReview();
const next = bodyAtUnchangedDiff(body, HEAD_2, 0);
assert.equal(reviewedKey(next), HEAD_2);
assert.equal(reviewedDiffFingerprint(next), FP_A);
assert.equal(reviewPassCount(next), 1);
assert.match(next, new RegExp(`Reviewed commit \`${HEAD_1.slice(0, 7)}\``));
assert.match(next, /Merge confidence/);
assert.equal(shouldSkipAsReviewed(next, HEAD_2), true, "a queued run on the new head stays silent");
});
test("a run on another head that left its claim behind gets the held verdict back when the diff turns out unchanged", () => {
const claimed = claimedReviewBody(stampedReview(), 2, HEAD_2);
assert.doesNotMatch(claimed, /High/);
const next = bodyAtUnchangedDiff(claimed, HEAD_2, 0);
assert.match(next, /High/);
assert.equal(reviewPassCount(next), 2);
assert.equal(reviewedDiffFingerprint(next), FP_A);
});
test("a model cannot forge the diff fingerprint, and the reviewers never see it", () => {
const forged = `## 🔎 OpenObserve Code Review\n\nok\n<!-- o2-review-diff:${FP_B} -->`;
const { text } = sanitizeReviewBody(forged, { marker: "<!-- ai-code-review -->", providerId: "p", modelId: "m", env: {} });
assert.doesNotMatch(text, /o2-review-diff/);
assert.doesNotMatch(previousReviewText(stampedReview()), /o2-review-diff/);
});
@@ -18,8 +18,8 @@ const REVIEW_AGENTS = [
];
const skip = process.env.OPENCODE_BIN ? false : "set OPENCODE_BIN to the pinned opencode binary";
// The first request that offers tools gets one scripted tool call; every later request gets the final answer.
function stubProvider(toolCall) {
// By default the first request that offers tools gets one scripted tool call and every later request the final answer; `script` overrides that per request.
function stubProvider(toolCall, { script } = {}) {
const requests = [];
const event = (type, data) => `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
const server = http.createServer((req, res) => {
@@ -34,9 +34,15 @@ function stubProvider(toolCall) {
usage: { input_tokens: 1000, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
} }));
const hasToolResult = (parsed.messages || []).some(m => Array.isArray(m.content) && m.content.some(b => b.type === "tool_result"));
if (parsed.tools?.length && !hasToolResult) {
res.write(event("content_block_start", { index: 0, content_block: { type: "tool_use", id: "toolu_1", name: toolCall.name, input: {} } }));
res.write(event("content_block_delta", { index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(toolCall.args) } }));
const step = script ? script(parsed, requests.length) : { tool: parsed.tools?.length && !hasToolResult ? toolCall : null };
if (step.hangMs) {
const timer = setTimeout(() => res.end(), step.hangMs);
res.on("close", () => clearTimeout(timer));
return;
}
if (step.tool) {
res.write(event("content_block_start", { index: 0, content_block: { type: "tool_use", id: `toolu_${requests.length}`, name: step.tool.name, input: {} } }));
res.write(event("content_block_delta", { index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(step.tool.args) } }));
res.write(event("content_block_stop", { index: 0 }));
res.write(event("message_delta", { delta: { stop_reason: "tool_use" }, usage: { output_tokens: 100 } }));
} else {
@@ -102,9 +108,10 @@ async function withServer(prDir, configPath, run, { readOnly = true } = {}) {
}
}
function stubConfig(file, stub, configDir) {
function stubConfig(file, stub, configDir, edit = () => {}) {
const config = JSON.parse(stripJsonc(readFileSync(join(ENGINE_DIR, file), "utf-8")));
config.providers["anthropic-review"].settings = { baseURL: stub.url };
edit(config);
const configPath = join(configDir, "opencode.json");
writeFileSync(configPath, JSON.stringify(config));
return configPath;
@@ -189,12 +196,17 @@ test("without sanitize, the env flags still keep project config, tools and agent
assert.deepEqual(toolNames, ["glob", "grep", "read", "skill"]);
});
test("ci-full: every tool is offered and runs without a prompt, and the shell sees no GitHub token", { skip, timeout: 120_000 }, async t => {
const allowShell = (...commands) => config => {
config.agents["ai-review-correctness"].permissions.push(...commands.map(resource => ({ action: "shell", resource, effect: "allow" })));
};
test("ci-full: file tools and the shell are offered without a prompt, and the shell sees no GitHub token", { skip, timeout: 120_000 }, async t => {
const pr = plantedPr();
const stub = await stubProvider({ name: "shell", args: { command: "echo ran-$((6*7)); env", description: "probe" } });
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir);
// The allowlist refuses echo and env, so this probe of the shell's environment adds them for the test alone.
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir, allowShell("echo *", "env *"));
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, async client => {
@@ -206,8 +218,128 @@ test("ci-full: every tool is offered and runs without a prompt, and the shell se
}, { readOnly: false });
const toolNames = (stub.requests[0].body.tools || []).map(tool => tool.name);
for (const name of ["read", "glob", "grep", "edit", "write", "shell", "webfetch"]) assert.ok(toolNames.includes(name), name);
for (const name of ["read", "glob", "grep", "edit", "write", "shell"]) assert.ok(toolNames.includes(name), name);
for (const name of ["webfetch", "websearch", "subagent", "question", "execute"]) assert.equal(toolNames.includes(name), false, `${name} is offered`);
const content = toolResultOf(stub.requests.at(-1));
assert.match(content, /ran-42/);
assert.doesNotMatch(content, /ghs_must_not_leak/);
});
test("the real self-check passes the shipped ci-full config and refuses it with execute or the whole shell re-allowed", { skip, timeout: 120_000 }, async t => {
for (const [extra, problem] of [[null, null], [{ action: "execute", resource: "*", effect: "allow" }, /ai-review-qa: execute allowed/], [{ action: "shell", resource: "*", effect: "allow" }, /ai-review-qa: shell allows every command/]]) {
const pr = plantedPr();
const stub = await stubProvider(null);
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir, config => { if (extra) config.agents["ai-review-qa"].permissions.push(extra); });
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, async client => {
const check = selfCheck(client, REVIEW_AGENTS, { readOnly: false });
if (problem) await assert.rejects(check, problem);
else assert.equal((await check).agents, REVIEW_AGENTS.length);
}, { readOnly: false });
}
});
test("ci-full offers skill only because opencode appends a narrower rule, and refuses every skill", { skip, timeout: 120_000 }, async t => {
const pr = plantedPr();
const stub = await stubProvider({ name: "skill", args: { id: "browser" } });
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir);
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, async client => {
const result = await client.prompt({ ...PROMPT, title: "skill" });
assert.equal(result.rejectedPermissions, 0);
}, { readOnly: false });
assert.match(toolResultOf(stub.requests.at(-1)), /Permission denied: skill/);
});
test("ci-full shell runs an allowlisted command and refuses a build outright, without asking", { skip, timeout: 120_000 }, async t => {
// A RegExp is the output an allowed command must print; null means opencode must refuse it.
const ONE = /\b1\b/;
const cases = [
["cat src/main.rs | wc -l", ONE], ["grep -c main src/main.rs", ONE], ["find src -name '*.rs' | wc -l", ONE], ["tail -n 1 src/main.rs | wc -l", ONE],
["ls src && wc -l src/main.rs", ONE], ["find src -name '*-executor*' | wc -l", /\b0\b/], ["cargo build", null], ["ls && touch built.txt", null],
["find . -name main.rs -exec touch built.txt ;", null], ["find src -fprint built.txt", null], ["git grep -nOsh main", null],
["cat $(touch built.txt)", null], ["cat `touch built.txt`", null], ["cat src/main.rs > built.txt", null], ["cat /proc/self/environ", null],
];
for (const [command, output] of cases) {
const pr = plantedPr();
const stub = await stubProvider({ name: "shell", args: { command, description: "probe" } });
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir);
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, async client => {
const result = await client.prompt({ ...PROMPT, title: "shell" });
assert.equal(result.rejectedPermissions, 0, "a denied command must not reach a prompt that ci-full would approve");
}, { readOnly: false });
const content = toolResultOf(stub.requests.at(-1));
assert.match(content, output ?? /Permission denied: shell/, command);
assert.equal(existsSync(join(pr.root, "built.txt")), false, command);
}
});
// Why neither config sets `steps`: if opencode ever stops ending the capped turn on an assistant message, this fails and the cap can come back.
test("opencode 2.2.0's step cap ends the capped turn on an assistant prefill, which the model rejects with thinking on", { skip, timeout: 120_000 }, async t => {
const pr = plantedPr();
// Asks for another tool call on every turn that allows one, so only the cap can end the loop.
const stub = await stubProvider(null, { script: parsed => ({ tool: parsed.tool_choice?.type === "none" ? null : { name: "glob", args: { pattern: "*.rs" } } }) });
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir, config => { config.agents["ai-review-correctness"].steps = 3; });
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, client => client.prompt({ ...PROMPT, title: "steps" }), { readOnly: false });
assert.equal(stub.requests.length, 3);
const capped = stub.requests.at(-1).body;
assert.deepEqual(capped.tool_choice, { type: "none" });
assert.equal(capped.messages.at(-1).role, "assistant");
assert.match(JSON.stringify(capped.messages.at(-1).content), /MAXIMUM STEPS REACHED/);
for (const request of stub.requests.slice(0, -1)) assert.equal(request.body.messages.at(-1).role, "user");
});
test("each effort variant reaches the provider as its own effort level", { skip, timeout: 120_000 }, async t => {
for (const variant of ["low", "medium", "xhigh", "max"]) {
const pr = plantedPr();
const stub = await stubProvider({ name: "glob", args: { pattern: "*.rs" } });
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir);
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, client => client.prompt({ ...PROMPT, variant, title: variant }), { readOnly: false });
for (const request of stub.requests) {
assert.deepEqual(request.body.output_config, { effort: variant });
assert.equal(request.body.thinking?.type, "adaptive");
for (const field of ["temperature", "top_p", "top_k", "tool_choice"]) assert.equal(request.body[field], undefined, field);
}
}
});
test("a session still running at its soft deadline is interrupted and answers the wrap-up prompt from the same context", { skip, timeout: 120_000 }, async t => {
const pr = plantedPr();
const wrapText = "WRAP-UP-NOW-5d1e";
// A tool step, then a model turn that never returns; only the wrap-up prompt gets an answer.
const stub = await stubProvider(null, {
script: (parsed, n) => {
if (parsed.messages?.some(m => JSON.stringify(m.content).includes(wrapText))) return { tool: null };
return n === 1 ? { tool: { name: "glob", args: { pattern: "*.rs" } } } : { hangMs: 60_000 };
},
});
const configDir = mkdtempSync(join(tmpdir(), "ai-review-config-"));
cleanup(t, stub, pr, configDir);
const configPath = stubConfig(OPENCODE_CONFIGS["ci-full"].file, stub, configDir);
sanitizePrTree(pr.root, ENGINE_DIR);
await withServer(pr.root, configPath, async client => {
const result = await client.prompt({ ...PROMPT, title: "wrap-up", timeoutMs: 20_000, wrapUp: { ms: 10_000, text: wrapText } });
assert.equal(result.wrappedUp, true);
assert.equal(result.text, "<no-issues/>");
}, { readOnly: false });
const last = stub.requests.at(-1);
assert.match(last.raw, new RegExp(wrapText));
assert.ok(last.body.messages.some(m => Array.isArray(m.content) && m.content.some(b => b.type === "tool_result")), "the wrap-up lost the steps the agent had already run");
for (const request of stub.requests) {
for (const field of ["temperature", "top_p", "top_k", "tool_choice"]) assert.equal(request.body[field], undefined, field);
assert.equal(request.body.messages.at(-1).role, "user", "the wrap-up must not end on an assistant prefill");
}
});
+1 -18
View File
@@ -1,18 +1 @@
// Comments and trailing commas out, so the opencode configs parse as JSON in tests.
export function stripJsonc(text) {
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === '"') {
const start = i;
for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++;
out += text.slice(start, i + 1);
} else if (c === "/" && text[i + 1] === "/") {
while (i < text.length && text[i] !== "\n") i++;
out += "\n";
} else {
out += c;
}
}
return out.replace(/,(\s*[}\]])/g, "$1");
}
export { stripJsonc } from "../src/jsonc.mjs";
+132 -11
View File
@@ -11,15 +11,19 @@ import {
OPENCODE_CONFIGS,
OpencodeClient,
buildServerEnv,
ciFullProblems,
createSseParser,
externalDirectoryProblem,
httpRequest,
isTransientError,
modelVariantIds,
resolveOpencodeConfig,
selfCheck,
shouldRetry,
toolAllowedWithoutAsking,
toolEnabled,
wildcardMatch,
wrapUpStartMs,
} from "../src/opencode.mjs";
import { stripJsonc } from "./jsonc.mjs";
@@ -28,15 +32,16 @@ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const AGENT_RUN_MS = 1500;
const HEADERS_TIMEOUT_MS = 1000;
function fakeOpencode({ idleAfterMs = AGENT_RUN_MS, finalEvent = "session.execution.succeeded", error = null, errorBeforeAck = null, eventStream = true } = {}) {
function fakeOpencode({ idleAfterMs = AGENT_RUN_MS, finalEvent = "session.execution.succeeded", error = null, errorBeforeAck = null, eventStream = true, wrapUpAfterMs = null, interruptEventDelayMs = 50 } = {}) {
const calls = [];
let prompts = 0;
const streams = new Set();
let idle = null;
const emit = (type, data) => {
for (const res of streams) res.write(`data: ${JSON.stringify({ id: "evt", type, data })}\n\n`);
};
const finishRun = () => {
idle = { type: "idle", outcome: finalEvent === "session.execution.succeeded" ? "succeeded" : "failed", ...(error ? { error } : {}) };
idle = { id: `idle_${prompts}`, type: "idle", outcome: finalEvent === "session.execution.succeeded" ? "succeeded" : "failed", ...(error ? { error } : {}) };
if (eventStream) emit(finalEvent, { sessionID: "ses_1", ...(error ? { error } : {}) });
};
const server = http.createServer((req, res) => {
@@ -73,7 +78,13 @@ function fakeOpencode({ idleAfterMs = AGENT_RUN_MS, finalEvent = "session.execut
setTimeout(() => send(200, { data: { id: "msg_u1" } }), 200);
return;
}
send(200, { data: { id: "msg_u1" } });
prompts += 1;
send(200, { data: { id: `msg_u${prompts}` } });
// The second prompt is the wrap-up: it answers, where the first ran on until interrupted.
if (wrapUpAfterMs !== null && prompts > 1) {
setTimeout(finishRun, wrapUpAfterMs);
return;
}
setTimeout(() => emit("permission.asked", { id: "per_1", sessionID: "ses_1", action: "shell", resources: ["*"] }), 50);
if (idleAfterMs !== null) setTimeout(finishRun, idleAfterMs);
return;
@@ -103,7 +114,11 @@ function fakeOpencode({ idleAfterMs = AGENT_RUN_MS, finalEvent = "session.execut
res.end();
return;
}
if (req.method === "POST" && req.url === "/api/session/ses_1/interrupt") return send(200, { interrupted: true });
if (req.method === "POST" && req.url === "/api/session/ses_1/interrupt") {
idle = { id: `idle_${prompts}`, type: "idle", outcome: "interrupted" };
setTimeout(() => emit("session.execution.interrupted", { sessionID: "ses_1" }), interruptEventDelayMs);
return send(200, { interrupted: true });
}
if (req.method === "DELETE" && req.url === "/api/session/ses_1") {
res.writeHead(204);
res.end();
@@ -216,6 +231,61 @@ test("a timed-out agent is interrupted server-side and not reported as transient
}
});
test("a reviewer still running near its timeout is interrupted and asked for its findings in the same session", async () => {
const fake = await fakeOpencode({ idleAfterMs: null, wrapUpAfterMs: 100 });
try {
const client = new OpencodeClient({ baseUrl: fake.baseUrl, password: "pw", statusPollMs: 100 });
const started = Date.now();
const result = await client.prompt({ ...PROMPT, timeoutMs: 3000, wrapUp: { ms: 1000, text: "wrap up now" } });
client.close();
assert.ok(Date.now() - started < 3000, "waited for the hard timeout instead of the wrap-up answer");
assert.equal(result.wrappedUp, true);
assert.equal(result.text, "<no-issues/>");
assert.equal(result.info.tokens.input, 2000, "tokens are the whole session's, both prompts included");
const prompts = fake.calls.filter(c => c.url === "/api/session/ses_1/prompt");
assert.deepEqual(prompts.map(c => c.body.text), ["", "wrap up now"]);
assert.equal(fake.calls.filter(c => c.url === "/api/session/ses_1/interrupt").length, 1);
} finally {
await fake.close();
}
});
test("an interrupted event that arrives after the wrap-up prompt went out does not end the wrap-up", async () => {
// The poll confirms the interrupt first; the stream's interrupted event lands while the follow-up is running.
const fake = await fakeOpencode({ idleAfterMs: null, wrapUpAfterMs: 600, interruptEventDelayMs: 400 });
try {
const client = new OpencodeClient({ baseUrl: fake.baseUrl, password: "pw", statusPollMs: 100 });
const result = await client.prompt({ ...PROMPT, timeoutMs: 4000, wrapUp: { ms: 2000, text: "wrap up now" } });
client.close();
assert.equal(result.wrappedUp, true);
assert.equal(result.text, "<no-issues/>");
assert.equal(fake.calls.filter(c => c.url === "/api/session/ses_1/prompt").length, 2);
} finally {
await fake.close();
}
});
test("a wrap-up that does not answer in its slot still ends as a hard timeout", async () => {
const fake = await fakeOpencode({ idleAfterMs: null });
try {
const client = new OpencodeClient({ baseUrl: fake.baseUrl, password: "pw", statusPollMs: 100 });
await assert.rejects(client.prompt({ ...PROMPT, timeoutMs: 1600, wrapUp: { ms: 800, text: "wrap up now" } }), err => err.name === "TimeoutError");
client.close();
assert.equal(fake.calls.filter(c => c.url === "/api/session/ses_1/prompt").length, 2);
} finally {
await fake.close();
}
});
test("the wrap-up slot is taken only from a session at least twice its length", () => {
const wrapUp = { ms: 3 * 60_000, text: "wrap" };
assert.equal(wrapUpStartMs(20 * 60_000, wrapUp), 17 * 60_000);
assert.equal(wrapUpStartMs(6 * 60_000, wrapUp), 3 * 60_000);
assert.equal(wrapUpStartMs(5 * 60_000, wrapUp), 5 * 60_000);
assert.equal(wrapUpStartMs(20 * 60_000, null), 20 * 60_000);
assert.equal(wrapUpStartMs(20 * 60_000, { ms: 60_000, text: "" }), 20 * 60_000);
});
test("a provider rate limit surfaced as session.execution.failed is transient", async () => {
const error = { type: "provider.rate-limit", message: "Too Many Requests", status: 429 };
const fake = await fakeOpencode({ idleAfterMs: 100, finalEvent: "session.execution.failed", error });
@@ -354,6 +424,14 @@ const READ_ONLY = [
rule("skill", "browser", "deny"),
];
const FULL = [...BASE, rule("*", "*", "allow"), rule("browser", "*", "deny")];
const CI_FULL = [
...BASE,
rule("*", "*", "allow"),
...["webfetch", "websearch", "subagent", "question", "execute", "skill"].map(id => rule(id, "*", "deny")),
rule("shell", "*", "deny"),
rule("shell", "git diff *", "allow"),
rule("browser", "*", "deny"),
];
const agent = (permissions, id = "ai-review-security") => ({ id, name: id, mode: "primary", permissions });
test("self-check passes only for read-only agents and built-in plugins", async () => {
@@ -379,17 +457,21 @@ test("self-check passes only for read-only agents and built-in plugins", async (
await assert.rejects(selfCheck(fakeClient({ agents: [agent(FULL)] }), names), /shell allowed/, "the default config must refuse a full grant");
});
test("ci-full self-check wants every agent registered with the full grant, and still refuses foreign plugins", async () => {
test("ci-full self-check wants file tools granted, web and sub-agent tools denied and the shell allowlisted", async () => {
const names = ["ai-review-security", "ai-review-correctness"];
const agents = [agent(FULL), agent(FULL, "ai-review-correctness")];
const agents = [agent(CI_FULL), agent(CI_FULL, "ai-review-correctness")];
const result = await selfCheck(fakeClient({ agents }), names, { readOnly: false });
assert.equal(result.agents, 2);
await assert.rejects(selfCheck(fakeClient({ agents: [agent(FULL)] }), names, { readOnly: false }), /ai-review-correctness: not registered/);
await assert.rejects(selfCheck(fakeClient({ agents: [agent(CI_FULL)] }), names, { readOnly: false }), /ai-review-correctness: not registered/);
await assert.rejects(
selfCheck(fakeClient({ agents: [agent(READ_ONLY), agent(FULL, "ai-review-correctness")] }), names, { readOnly: false }),
selfCheck(fakeClient({ agents: [agent(READ_ONLY), agent(CI_FULL, "ai-review-correctness")] }), names, { readOnly: false }),
/ai-review-security: edit not fully allowed/,
);
assert.deepEqual(ciFullProblems(FULL), ["webfetch allowed", "websearch allowed", "subagent allowed", "question allowed", "execute allowed", "skill allowed", "shell allows every command"]);
assert.deepEqual(ciFullProblems([...CI_FULL, rule("execute", "*", "allow")]), ["execute allowed"]);
assert.deepEqual(ciFullProblems([...CI_FULL, rule("shell", "*", "deny")]), ["shell denied"]);
assert.deepEqual(ciFullProblems(CI_FULL), []);
await assert.rejects(
selfCheck(fakeClient({ agents, plugins: [...BUILTIN_PLUGINS, { id: "p", source: { type: "package", target: "evil" }, state: { status: "active" } }] }), names, { readOnly: false }),
/non-built-in plugins/,
@@ -427,10 +509,44 @@ test("both configs register the same agents, provider and model; only the defaul
for (const toolId of BUILTIN_TOOL_IDS) assert.equal(toolEnabled(toolId, readOnly), ["read", "glob", "grep"].includes(toolId), `${name} ${toolId}`);
assert.equal(externalDirectoryProblem(readOnly), "", name);
const full = [...BASE, ...ci.agents[name].permissions];
for (const toolId of BUILTIN_TOOL_IDS) assert.equal(toolAllowedWithoutAsking(toolId, full), true, `${name} ${toolId}`);
assert.deepEqual(ciFullProblems(full), [], name);
// opencode 2.2.0 ends the capped turn on an assistant message, a prefill the model rejects, so no agent sets a step cap.
for (const config of [base, ci]) for (const key of ["steps", "maxSteps"]) assert.equal(config.agents[name][key], undefined, `${name} ${key}`);
}
});
// Evaluated as opencode 2.2.0 does: the last rule whose action and resource both match decides.
function shellDecision(rules, command) {
return [...BASE, ...rules].findLast(r => wildcardMatch("shell", r.action) && wildcardMatch(command, r.resource))?.effect;
}
test("the ci-full shell runs the read-only allowlist and nothing that builds, installs or reaches the network", () => {
const ci = JSON.parse(stripJsonc(readFileSync(join(REPO_ROOT, OPENCODE_CONFIGS["ci-full"].file), "utf-8")));
for (const [name, agentConfig] of Object.entries(ci.agents)) {
for (const command of ["git diff", "git diff origin/main -- src", "git log -5 --oneline", "git show HEAD:src/a.rs", "git blame -L 1,5 a.rs", "rg -n foo src", "cat a.rs", "ls", "ls -la web", "head -50 a.rs", "sed -n 1,40p a.rs", "wc -l a.rs",
"grep -rn foo src", "tail -n 20 a.rs", "find src -name '*.rs'", "find . -type f -newer a.rs", "find src -name '*-executor*'", "find . -name ok-delete.rs",
"git ls-files src", "git status", "git status --short", "git rev-parse HEAD", "git merge-base HEAD origin/main", "git cat-file -p HEAD:a.rs", "git cat-file -t HEAD", "git cat-file -s HEAD",
"git --no-pager diff --stat", "git --no-pager log -3", "git --no-pager show HEAD", "rg -n foo src/env_util.rs"]) {
assert.equal(shellDecision(agentConfig.permissions, command), "allow", `${name}: ${command}`);
}
for (const command of ["cargo build", "cargo test -p config", "npm ci", "npx vitest", "curl https://example.com", "git push", "git fetch origin", "git difftool", "sed -i s/a/b/ a.rs", "make", "python3 x.py", "bash -c ls",
"find . -exec rm {} ;", "find -execdir sh -c x ;", "find . -name x -ok rm {} ;", "find . -delete", "find . -fprint out.txt", "find . -fls out.txt",
"tail -f a.log", "tail -n 5 -F a.log", "tail --follow=name a.log", "git grep foo", "git grep -nOsh foo", "git grep --open-files=sh foo", "git --no-pager grep foo",
"git --no-pager push", "git --no-pager -c core.pager=sh log", "git cat-file --batch", "git cat-file blob HEAD",
"cat /proc/self/environ", "head -c 100 /proc/1/environ", "tail /dev/stdin", "grep -a KEY /proc/self/environ", "rg KEY /proc", "sed -n p /proc/self/environ",
"cat ../../proc/self/environ", "cat src/environ", "rg --file=/proc/self/environ x", "wc -c /p*/self/env*", "cat /etc/passwd", "cat a.rs > b.rs", "rg foo src>>out.txt", "cat a.rs 2>/dev/null"]) {
assert.equal(shellDecision(agentConfig.permissions, command), "deny", `${name}: ${command}`);
}
}
});
test("a trailing ' *' also matches the bare command, as in opencode 2.2.0", () => {
assert.equal(wildcardMatch("git diff", "git diff *"), true);
assert.equal(wildcardMatch("git diff --stat", "git diff *"), true);
assert.equal(wildcardMatch("git difftool", "git diff *"), false);
assert.equal(wildcardMatch("read", "*"), true);
});
test("the review runs on the anthropic-review provider, the only one the policies allow, with the workflow's variant", () => {
const base = JSON.parse(stripJsonc(readFileSync(join(REPO_ROOT, OPENCODE_CONFIGS.default.file), "utf-8")));
const allowed = base.policies.filter(p => p.action === "provider.use" && p.effect === "allow").map(p => p.resource);
@@ -441,9 +557,14 @@ test("the review runs on the anthropic-review provider, the only one the policie
assert.equal(provider.package, "@opencode/ai/providers/anthropic");
assert.deepEqual(provider.env, ["ANTHROPIC_REVIEW_API_KEY"]);
const model = provider.models["claude-sonnet-5-5"];
assert.deepEqual(model.variants, [{ id: "high", settings: { thinking: { type: "adaptive" }, effort: "high" } }]);
assert.deepEqual(model.variants, ["low", "medium", "high", "xhigh", "max"].map(id => ({ id, settings: { thinking: { type: "adaptive" }, effort: id } })));
assert.deepEqual(modelVariantIds(base, "anthropic-review", "claude-sonnet-5-5"), ["low", "medium", "high", "xhigh", "max"]);
assert.deepEqual(modelVariantIds(base, "anthropic-review", "no-such-model"), []);
const workflow = readFileSync(join(REPO_ROOT, ".github/workflows/ai-code-review.yml"), "utf-8");
assert.match(workflow, /provider_id: anthropic-review\n\s+model_id: claude-sonnet-5-5\n\s+model_variant: high\n\s+api_key_env: ANTHROPIC_REVIEW_API_KEY\n/);
assert.match(workflow, /provider_id: anthropic-review\n\s+model_id: claude-sonnet-5-5\n\s+api_key_env: ANTHROPIC_REVIEW_API_KEY\n/);
assert.doesNotMatch(workflow, /model_variant/, "the effort variable is the only source of the variant");
assert.match(workflow, /REVIEW_MODEL_VARIANT: \$\{\{ vars\.AI_REVIEW_EFFORT \|\| 'high' \}\}/);
assert.match(workflow, /REVIEW_COORDINATOR_VARIANT: \$\{\{ vars\.AI_REVIEW_COORDINATOR_EFFORT \|\| vars\.AI_REVIEW_EFFORT \|\| 'high' \}\}/);
assert.match(workflow, /ANTHROPIC_REVIEW_API_KEY: \$\{\{ secrets\.USAGE_CREDIT_API_KEY \}\}/);
});
+21
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mergeConfidence, unfinishedReviewers } from "../src/confidence.mjs";
import { reviewerFacts } from "../src/auto-approve.mjs";
@@ -10,6 +11,7 @@ import {
parseColdPasses,
passLabel,
passPlan,
reviewComplete,
runPooled,
} from "../src/passes.mjs";
import { buildFallbackReview, partialFindingsBlock, selectFallbackFindings } from "../src/report.mjs";
@@ -137,3 +139,22 @@ test("the fallback never merges two different blockers that share most of their
]);
assert.equal(selectFallbackFindings(same).length, 1);
});
test("only a review whose every pass finished in time and whose coordinator merged may skip an unchanged diff next time", () => {
const coordinator = { fallback: false };
const skippedPass = { agentKey: "frontend", agentName: "frontend Reviewer", pass: 1, findings: [], error: null, skipped: true };
assert.equal(reviewComplete([done("security", 1), done("security", 2), skippedPass], coordinator), true);
assert.equal(reviewComplete([done("security", 1), failed("security", 2)], coordinator), false, "a lost cold pass is a partial review");
assert.equal(reviewComplete([done("security", 1), failed("security", 2, { timedOut: true })], coordinator), false);
assert.equal(reviewComplete([done("security", 1), { ...done("qa", 1), parsed: false }], coordinator), false, "unparseable output is unfinished");
assert.equal(reviewComplete([done("security", 1), { ...done("qa", 1), wrappedUp: true }], coordinator), false, "a reviewer cut short at its soft deadline");
assert.equal(reviewComplete([done("security", 1)], { fallback: true }), false, "the coordinator fell back");
assert.equal(reviewComplete([done("security", 1)], null), false, "the coordinator never ran");
});
test("run-review writes the diff fingerprint only for a complete review", () => {
const source = readFileSync(new URL("../run-review.mjs", import.meta.url), "utf-8");
assert.match(source, /const complete = reviewComplete\(passResults, coordinatorResult\);/);
assert.match(source, /diffFingerprint: complete && fullyRead && diffPinned && hunksLocatable\(diff, readBaseFile\) \? fingerprint : ""/);
assert.equal((source.match(/diffFingerprint:/g) || []).length, 1, "every finishReviewBody call goes through the completeness check");
});
+112 -1
View File
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseRawDiff, sizeReasons } from "../src/auto-approve.mjs";
import { fetchDiff, fetchPinnedDiff, localDiff, pinDiff, prefixDiffPaths } from "../src/pr-diff.mjs";
import { diffFingerprint, fetchDiff, fetchPinnedDiff, hunksLocatable, localDiff, pinDiff, prefixDiffPaths } from "../src/pr-diff.mjs";
const DIFF = [
"diff --git a/src/lib.rs b/src/lib.rs",
@@ -199,3 +199,114 @@ test("a renamed symlink or submodule whose header carries no mode cannot pass as
assert.match(edited, /^index \S+ 100644$/m);
assert.deepEqual(parseRawDiff(edited).problems, []);
});
test("a rebase that moves the PR's lines keeps its diff fingerprint, a changed line does not", () => {
const dir = mkdtempSync(join(tmpdir(), "ai-review-fingerprint-"));
const git = (...args) => execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { encoding: "utf-8" });
try {
git("init", "-q", "-b", "main");
const lines = Array.from({ length: 40 }, (_, i) => `line ${i}`);
writeFileSync(join(dir, "a.rs"), `${lines.join("\n")}\n`);
git("add", ".");
git("commit", "-qm", "base");
git("checkout", "-qb", "pr");
writeFileSync(join(dir, "a.rs"), `${lines.map((l, i) => (i === 30 ? "line 30 changed" : l)).join("\n")}\n`);
git("commit", "-qam", "pr change");
const before = localDiff(dir, "main", "pr");
git("checkout", "-q", "main");
writeFileSync(join(dir, "a.rs"), `head 1\nhead 2\nhead 3\n${lines.join("\n")}\n`);
git("commit", "-qam", "main moves on");
git("checkout", "-q", "pr");
git("rebase", "-q", "main");
const after = localDiff(dir, "main", "pr");
assert.notEqual(before, after, "the rebase must change hunk numbers and blob ids, or this test proves nothing");
assert.equal(diffFingerprint(after), diffFingerprint(before));
writeFileSync(join(dir, "a.rs"), `head 1\nhead 2\nhead 3\n${lines.map((l, i) => (i === 30 ? "line 30 changed again" : l)).join("\n")}\n`);
git("commit", "-qam", "another change");
assert.notEqual(diffFingerprint(localDiff(dir, "main", "pr")), diffFingerprint(before));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the fingerprint ignores only blob ids and hunk numbers", () => {
const base = "diff --git a/a b/a\nindex 1111111..2222222 100644\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@ fn x\n ctx\n-old\n+new\n";
assert.equal(diffFingerprint(base.replace("1111111..2222222", "3333333..4444444").replace("-1,2 +1,2 @@ fn x", "-9,2 +12,2 @@ fn x")), diffFingerprint(base));
assert.notEqual(diffFingerprint(base.replace("@@ fn x", "@@ fn y")), diffFingerprint(base), "the function a hunk sits in is part of what was reviewed");
assert.notEqual(diffFingerprint(base.replace("+new", "+newer")), diffFingerprint(base));
assert.notEqual(diffFingerprint(base.replace("diff --git a/a b/a", "diff --git a/b b/b")), diffFingerprint(base));
assert.match(diffFingerprint(base), /^[0-9a-f]{64}$/);
});
test("the same lines changed inside a different function get a different fingerprint", () => {
const dir = mkdtempSync(join(tmpdir(), "ai-review-fingerprint-"));
const git = (...args) => execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { encoding: "utf-8" });
const fn = (name, allowed = "user.active") => `function ${name}(user) {\n const id = user.id;\n const name = user.name;\n const role = user.role;\n const group = user.group;\n const allowed = ${allowed};\n const details = { id, name, role, group };\n const present = Boolean(details);\n const result = Boolean(allowed);\n return present && result;\n}\n`;
try {
git("init", "-q", "-b", "main");
writeFileSync(join(dir, "rules.js"), `${fn("preview")}\n${fn("authorize")}`);
git("add", ".");
git("commit", "-qm", "base");
git("checkout", "-qb", "safe");
writeFileSync(join(dir, "rules.js"), `${fn("preview", "true")}\n${fn("authorize")}`);
git("commit", "-qam", "safe");
git("checkout", "-qb", "unsafe", "main");
writeFileSync(join(dir, "rules.js"), `${fn("preview")}\n${fn("authorize", "true")}`);
git("commit", "-qam", "unsafe");
const safe = localDiff(dir, "main", "safe");
const unsafe = localDiff(dir, "main", "unsafe");
assert.match(safe, /^@@ .* @@ function preview\(user\) \{$/m);
assert.match(unsafe, /^@@ .* @@ function authorize\(user\) \{$/m);
assert.notEqual(diffFingerprint(unsafe), diffFingerprint(safe));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an edit to one of two identical blocks in a function cannot be located, so it is never taken for the reviewed one", () => {
const dir = mkdtempSync(join(tmpdir(), "ai-review-fingerprint-"));
const git = (...args) => execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { encoding: "utf-8" });
const block = (flag, allowed) => [` if (${flag}) {`, " const a = 1;", " const b = 2;", " const c = 3;", ` ok = ${allowed};`, " const d = 4;", " const e = 5;", " const f = 6;", " }"];
const source = (first, second) => ["function check(user, internal) {", " let ok = false;", ...block("internal", first), ...block("!internal", second), " return ok;", "}", ""].join("\n");
const readBase = ref => path => { try { return execFileSync("git", ["-C", dir, "cat-file", "blob", `${ref}:${path}`], { encoding: "utf-8" }); } catch { return null; } };
try {
git("init", "-q", "-b", "main");
writeFileSync(join(dir, "check.js"), source("user.admin", "user.admin"));
git("add", ".");
git("commit", "-qm", "base");
git("checkout", "-qb", "internal");
writeFileSync(join(dir, "check.js"), source("true", "user.admin"));
git("commit", "-qam", "internal");
git("checkout", "-qb", "external", "main");
writeFileSync(join(dir, "check.js"), source("user.admin", "true"));
git("commit", "-qam", "external");
const internal = localDiff(dir, "main", "internal");
const external = localDiff(dir, "main", "external");
assert.notEqual(internal, external);
assert.equal(hunksLocatable(external, readBase("main")), false, "the edited block's old text sits twice in the base");
assert.equal(hunksLocatable(internal, readBase("main")), false);
writeFileSync(join(dir, "other.js"), "a\nb\nc\nd\ne\nf\ng\n");
git("add", "other.js");
git("commit", "-qm", "unique");
writeFileSync(join(dir, "other.js"), "a\nb\nc\nD\ne\nf\ng\n");
git("commit", "-qam", "edit unique");
const unique = localDiff(dir, "HEAD~1", "HEAD");
assert.equal(hunksLocatable(unique, readBase("HEAD~1")), true);
assert.equal(hunksLocatable(unique, () => null), false, "an unreadable base file cannot locate the edit");
assert.equal(hunksLocatable("diff --git a/n b/n\nnew file mode 100644\n--- /dev/null\n+++ b/n\n@@ -0,0 +1 @@\n+x\n", () => null), true, "a new file has nothing to locate");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a changed binary file or trailing space on the last line changes the fingerprint", () => {
const bin = id => `diff --git a/m.wasm b/m.wasm\nindex 1111111..${id} 100644\nBinary files a/m.wasm and b/m.wasm differ\n`;
assert.notEqual(diffFingerprint(bin("2222222")), diffFingerprint(bin("3333333")));
const csv = tail => `diff --git a/a.csv b/a.csv\nindex 1111111..2222222 100644\n--- a/a.csv\n+++ b/a.csv\n@@ -1 +1 @@\n-x\n+secret${tail}\n`;
assert.notEqual(diffFingerprint(csv(" ")), diffFingerprint(csv(" ")));
});
test("a file mode change keeps a different fingerprint", () => {
const d = mode => `diff --git a/l b/l\nindex 1111111..2222222 ${mode}\n--- a/l\n+++ b/l\n@@ -1 +1 @@\n-old-target\n\\ No newline at end of file\n+new-target\n\\ No newline at end of file\n`;
assert.notEqual(diffFingerprint(d("100644")), diffFingerprint(d("120000")));
});
+153 -6
View File
@@ -37,7 +37,8 @@ import {
} from "../src/report.mjs";
import { extractInlineBlock } from "../src/inline.mjs";
import { LOADER_DIRS, LOADER_FILES } from "../src/sanitize.mjs";
import { reviewerDiff } from "../src/pr-diff.mjs";
import { diffFingerprint, hunksLocatable, reviewerDiff } from "../src/pr-diff.mjs";
import { narrowTier } from "../src/selection.mjs";
import * as approvalModule from "../src/auto-approve.mjs";
import * as commentsModule from "../src/comments.mjs";
import * as confidenceModule from "../src/confidence.mjs";
@@ -779,7 +780,8 @@ const mainSource = () => {
const source = readFileSync(new URL("../run-review.mjs", import.meta.url), "utf-8");
const fetcher = source.slice(source.indexOf("function fetchPrDiff("), source.indexOf("function pairedPrContext("));
const notes = source.slice(source.indexOf("function noteOnComment("), source.indexOf("function noteSkippedPush("));
return `${fetcher}\n${notes}\n${source.slice(source.indexOf("async function main() {"), source.indexOf("// ─── Entry point"))}\nreturn main;`;
const unchanged = source.slice(source.indexOf("function unchangedDiffCheck("), source.indexOf("function prepareReviewTree("));
return `${fetcher}\n${notes}\n${unchanged}\n${source.slice(source.indexOf("async function main() {"), source.indexOf("// ─── Entry point"))}\nreturn main;`;
};
const cleanCoordinatorOutput = `${commentsModule.REVIEW_HEADING}\n\n> [!TIP]\n> ### ✅ Approved\n> Looks right.\n\n<!-- ai-review-inline [] -->`;
const finishedReviewer = key => ({ agentKey: key, agentName: `${key} Reviewer`, findings: [], parsed: true, fullDiff: true, error: null });
@@ -787,19 +789,19 @@ const silentReviewer = key => ({ ...finishedReviewer(key), parsed: false, rawTex
const smallRawDiff = "diff --git a/docs/a.md b/docs/a.md\n--- a/docs/a.md\n+++ b/docs/a.md\n@@ -1 +1 @@\n-old\n+new\n";
// Every default makes the run an approvable one, so a test changes the single fact it is about.
async function runMain({ results = [finishedReviewer("security"), finishedReviewer("correctness")], byPass = null, pinned = {}, mode = "approve", existing = null, reviewedBody = "plain words", prNow = {}, paired = {}, contextFails = false, noiseOnly = false, diffFails = false, coordinatorOutput = cleanCoordinatorOutput, rawDiff = smallRawDiff, keptDiff = null } = {}) {
async function runMain({ results = [finishedReviewer("security"), finishedReviewer("correctness")], byPass = null, pinned = {}, mode = "approve", existing = null, reviewedBody = "plain words", prNow = {}, paired = {}, contextFails = false, noiseOnly = false, diffFails = false, manual = false, head = MAIN_HEAD, coordinatorOutput = cleanCoordinatorOutput, rawDiff = smallRawDiff, keptDiff = null, variantError = "", ignorePassLimit = false, forceFull = false, baseFile = "old\n" } = {}) {
const run = { posted: "", approvals: [], logs: [], coordinatorCalls: 0, coordinatorArgs: null, reviewerCalls: [], artifact: null, updates: 0, creates: 0, claims: 0, sanitized: 0 };
const pr = { ...eligibleInput().pr, title: "fix a bug", body: "plain words", head: { sha: MAIN_HEAD }, changed_files: 1, additions: 1, deletions: 1, ...prNow };
const pr = { ...eligibleInput().pr, title: "fix a bug", body: "plain words", head: { sha: head }, changed_files: 1, additions: 1, deletions: 1, ...prNow };
const noop = () => {};
const scope = {
...commentsModule, ...confidenceModule, ...approvalModule, ...reportModule, ...passesModule,
REVIEW_COLD_PASSES: 2, REVIEWER_CONCURRENCY: 16,
process: { env: { PR_NUMBER: "1", GITHUB_REPOSITORY: "test/repo", HEAD_SHA: MAIN_HEAD, MERGE_BASE_SHA: "c".repeat(40) } },
process: { env: { PR_NUMBER: "1", GITHUB_REPOSITORY: "test/repo", HEAD_SHA: head, MERGE_BASE_SHA: "c".repeat(40), ...(forceFull ? { FORCE_FULL: "true" } : {}) } },
console: { log: (...args) => run.logs.push(args.join(" ")), warn: noop, error: noop },
TRACE: { startSpan: () => ({}), endSpan: noop, setSpanAttributes: noop }, activeRootSpan: null, isoNow: () => "now", prepareReviewTree: noop,
findExistingReviewComment: () => ({ comment: existing, ok: true }),
passesDoneIn: comment => (comment ? commentsModule.reviewPassCount(comment.body) : 0),
REVIEW_IGNORE_PASS_LIMIT: false, REVIEW_MAX_PASSES: 0, REVIEW_BOT_LOGIN: "review[bot]", REVIEW_PAIR_CLEAR: true, AUTO_APPROVE_MODE: mode,
REVIEW_IGNORE_PASS_LIMIT: ignorePassLimit, REVIEW_MANUAL_RUN: manual, MAX_DIFF_TOKENS: 150_000, diffFingerprint, hunksLocatable, readBaseFile: () => baseFile, variantProblem: () => variantError, MODEL_VARIANT: "high", COORDINATOR_VARIANT: "high", REVIEW_MAX_PASSES: 0, REVIEW_BOT_LOGIN: "review[bot]", REVIEW_PAIR_CLEAR: true, AUTO_APPROVE_MODE: mode,
REVIEW_MARKER: "<!-- ai-code-review -->", PAIRED: { repo: "", prNumber: "", dir: "", mergeBase: "", head: "", ...paired }, PR_DIR: "/pr", resolve: (...parts) => parts.join("/"),
githubReviewApi: () => ({ readPr: () => pr, listReviews: () => [], dismissReview: noop, approve: payload => run.approvals.push(payload) }),
noteSkippedPush: noop, apiKey: () => "configured", API_KEY_VAR_NAME: "TEST_KEY", MODEL_SLUG: "test/model",
@@ -922,6 +924,109 @@ test("a push the run stops on before claiming a pass loses the old verdict and k
}
});
test("a new head whose diff matches the reviewed one is skipped without a pass, a model call or a claim", async () => {
const first = await runMain({ mode: "off" });
assert.equal(commentsModule.reviewedDiffFingerprint(first.posted), diffFingerprint(smallRawDiff));
const rebased = await runMain({ mode: "off", existing: { id: 1, body: first.posted }, head: PAIRED_MAIN_HEAD });
assert.equal(rebased.error, undefined);
assert.ok(rebased.logs.some(line => line.includes("unchanged_diff")));
assert.deepEqual(rebased.reviewerCalls, []);
assert.equal(rebased.coordinatorCalls, 0);
assert.equal(rebased.claims, 0);
assert.equal(rebased.updates, 1);
assert.equal(commentsModule.reviewedKey(rebased.posted), PAIRED_MAIN_HEAD);
assert.equal(commentsModule.reviewPassCount(rebased.posted), 1);
assert.match(rebased.posted, /Merge confidence: 🟢 High/);
});
test("a hand-started run, a changed diff or an unpinned diff is reviewed even after a matching review", async () => {
const first = await runMain({ mode: "off" });
const existing = { id: 1, body: first.posted };
const changed = `${smallRawDiff}+more\n`;
for (const run of [
await runMain({ mode: "off", existing, head: PAIRED_MAIN_HEAD, manual: true }),
await runMain({ mode: "off", existing, head: PAIRED_MAIN_HEAD, rawDiff: changed }),
await runMain({ mode: "off", existing, head: PAIRED_MAIN_HEAD, pinned: { "test/repo": false } }),
]) {
assert.equal(run.logs.some(line => line.includes("unchanged_diff")), false);
assert.equal(run.claims, 1);
assert.ok(run.reviewerCalls.length > 0);
assert.equal(commentsModule.reviewPassCount(run.posted), 2);
}
});
test("a hand-started, force-full or pass-limit-ignoring run reviews the head the last review already covered", async () => {
const first = await runMain({ mode: "off" });
const existing = { id: 1, body: first.posted };
for (const bypass of [{ manual: true }, { forceFull: true }, { ignorePassLimit: true }]) {
const run = await runMain({ mode: "off", existing, ...bypass });
assert.equal(run.error, undefined, JSON.stringify(bypass));
assert.equal(run.logs.some(line => /already_reviewed|unchanged_diff/.test(line)), false, JSON.stringify(bypass));
assert.ok(run.reviewerCalls.length > 0, JSON.stringify(bypass));
assert.equal(run.claims, 1, JSON.stringify(bypass));
}
const automatic = await runMain({ mode: "off", existing });
assert.ok(automatic.logs.some(line => line.includes("already_reviewed")));
assert.deepEqual(automatic.reviewerCalls, []);
});
test("reviewers cut short at their soft deadline are not finished, so the verdict is not High and the gate does not approve", async () => {
const wrapped = key => ({ ...finishedReviewer(key), wrappedUp: true });
for (const results of [[wrapped("security"), wrapped("correctness")], [finishedReviewer("security"), wrapped("correctness")]]) {
const run = await runMain({ results });
assert.equal(run.error, undefined);
assert.equal(run.approvals.length, 0);
assert.doesNotMatch(run.posted, /Merge confidence: 🟢 High/);
assert.doesNotMatch(run.posted, /all reviewers finished/);
assert.doesNotMatch(run.posted, /Auto-approved/);
assert.equal(commentsModule.reviewedDiffFingerprint(run.posted), "");
}
const byPass = (key, pass) => ({ ...finishedReviewer(key), wrappedUp: pass === 2 });
const oneCleanPass = await runMain({ byPass });
assert.equal(oneCleanPass.approvals.length, 1, "an area with one uninterrupted pass is still reviewed");
});
test("an invalid effort setting retracts the old verdict and approval line without claiming a pass", async () => {
const first = await runMain();
assert.match(first.posted, /Merge confidence: 🟢 High/);
assert.match(first.posted, /Auto-approved/);
const variantError = 'variant "typo" is not declared for test/model in opencode.ci.jsonc (declared: high)';
for (const stop of [{ head: PAIRED_MAIN_HEAD, rawDiff: `${smallRawDiff}+more\n` }, { manual: true }]) {
const run = await runMain({ existing: { id: 1, body: first.posted }, variantError, ...stop });
assert.match(run.error, /variant "typo" is not declared/);
assert.equal(run.claims, 0);
assert.deepEqual(run.reviewerCalls, []);
assert.equal(run.updates, 1);
assert.doesNotMatch(run.posted, /High/);
assert.doesNotMatch(run.posted, /Auto-approved/);
assert.match(run.posted, /the review did not run/);
assert.doesNotMatch(run.posted, /typo|test\/model/, "the configuration detail stays in the log");
assert.equal(commentsModule.reviewPassCount(run.posted), 1);
assert.equal(commentsModule.reviewedKey(run.posted), MAIN_HEAD);
}
});
test("an unchanged diff whose hunk text appears twice in the base is reviewed, as the edit cannot be located", async () => {
const first = await runMain({ mode: "off" });
const twice = await runMain({ mode: "off", existing: { id: 1, body: first.posted }, head: PAIRED_MAIN_HEAD, baseFile: "old\nx\nold\n" });
assert.equal(twice.logs.some(line => line.includes("unchanged_diff")), false);
assert.equal(twice.claims, 1);
assert.equal(commentsModule.reviewedDiffFingerprint(twice.posted), "", "a review whose edit cannot be located records no fingerprint");
const unreadable = await runMain({ mode: "off", existing: { id: 1, body: first.posted }, head: PAIRED_MAIN_HEAD, baseFile: null });
assert.equal(unreadable.claims, 1);
assert.ok((await runMain({ mode: "off", existing: { id: 1, body: first.posted }, head: PAIRED_MAIN_HEAD })).logs.some(line => line.includes("unchanged_diff")));
});
test("a push that only adds a production asset the noise filter hides is reviewed, not skipped with the old High", async () => {
const first = await runMain({ mode: "off" });
assert.match(first.posted, /Merge confidence: 🟢 High/);
const svg = "diff --git a/web/src/assets/chart.svg b/web/src/assets/chart.svg\nnew file mode 100644\n--- /dev/null\n+++ b/web/src/assets/chart.svg\n@@ -0,0 +1 @@\n+<svg/>\n";
const next = await runMain({ mode: "off", existing: { id: 1, body: first.posted }, head: PAIRED_MAIN_HEAD, rawDiff: `${smallRawDiff}${svg}`, keptDiff: smallRawDiff });
assert.equal(next.logs.some(line => line.includes("unchanged_diff")), false);
assert.equal(next.claims, 1);
assert.doesNotMatch(next.posted, /Merge confidence: 🟢 High/);
});
test("a dangling second block cannot read as a clean count, so the gate refuses", () => {
const output = `${cleanCoordinatorOutput}\n<!-- ai-review-inline [{"file":"docs/a.md","line":1,"severity":"p1","title":"broken"}]`;
const inline = extractInlineBlock(output);
@@ -1343,3 +1448,45 @@ test("a tilde-fenced or blockquoted report example is not a finished review", ()
];
for (const [text, parsed] of table) assert.equal(reviewerReported(text, parseFindings(text)), parsed, text);
});
// The tier table and its chooser are lifted from the runner, which starts a review on import.
function riskTierFns() {
const source = readFileSync(new URL("../run-review.mjs", import.meta.url), "utf-8");
const tiers = source.slice(source.indexOf("const COORDINATOR_AGENT"), source.indexOf("// Agents added on top"));
const security = source.slice(source.indexOf("function isSecuritySensitiveFile("), source.indexOf("// Scoped to web/src on purpose"));
const assess = source.slice(source.indexOf("function assessRiskTier("), source.indexOf("function selectAgents("));
return new Function("narrowTier", "logicalPath", `${tiers}\n${security}\n${assess}\nreturn { RISK_TIERS, assessRiskTier };`)(narrowTier, p => p);
}
test("test-only and CI-only PRs of any size get their narrow reviewer set, unless a path or the file count escalates to full", () => {
const { RISK_TIERS, assessRiskTier } = riskTierFns();
assert.deepEqual(RISK_TIERS["test-only"].agents, ["correctness", "code-quality"]);
assert.deepEqual(RISK_TIERS["ci-only"].agents, ["security", "release"]);
const diff = (paths, lines = 500) => ({ files: paths.map(newPath => ({ oldPath: newPath, newPath })), totalAdded: lines, totalRemoved: 0 });
assert.equal(assessRiskTier(diff(["tests/ui-testing/playwright-tests/a.spec.js"])), "test-only");
assert.equal(assessRiskTier(diff([".github/workflows/ci.yml"], 3)), "ci-only");
assert.equal(assessRiskTier(diff(["src/handler/http/auth/tests/login.rs"])), "full", "a security-looking path still escalates");
assert.equal(assessRiskTier(diff(Array.from({ length: 51 }, (_, i) => `tests/api-testing/test_${i}.py`))), "full");
assert.equal(assessRiskTier(diff(["src/service/search.rs"], 5)), "trivial");
assert.equal(assessRiskTier(diff(["src/service/search.rs"], 50)), "lite");
});
test("an action or workflow under a test-named directory stays ci-only, not test-only", () => {
assert.equal(narrowTier([".github/actions/test/action.yml"]), "ci-only");
assert.equal(narrowTier([".github/workflows/tests.yml"]), "ci-only");
});
test("a narrow tier needs both a rename's source and its destination to qualify", () => {
const { assessRiskTier } = riskTierFns();
const moved = (oldPath, newPath) => ({ files: [{ oldPath, newPath }], totalAdded: 120, totalRemoved: 120 });
assert.equal(assessRiskTier(moved("src/service/search.rs", "tests/search.rs")), "full", "moving production code into tests removes it from production");
assert.equal(assessRiskTier(moved("scripts/deploy.sh", ".github/workflows/deploy.yml")), "full");
assert.equal(assessRiskTier(moved("src/handler/http/auth/login.rs", "src/service/login.rs")), "full", "a security-looking source still escalates");
assert.equal(assessRiskTier(moved("tests/a_test.py", "tests/b_test.py")), "test-only");
});
test("a diff longer than a reviewer reads records no fingerprint, so the next push reviews it", async () => {
const big = `diff --git a/docs/a.md b/docs/a.md\n--- a/docs/a.md\n+++ b/docs/a.md\n@@ -1 +1,2 @@\n-old\n+new\n+${"x".repeat(160_000)}\n`;
const run = await runMain({ mode: "off", rawDiff: big });
assert.equal(commentsModule.reviewedDiffFingerprint(run.posted), "");
});
+156 -1
View File
@@ -1,7 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isReleaseFile, resolveRulesFiles } from "../src/selection.mjs";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isCiOnlyPath, isDocsPath, isReleaseFile, isTestOnlyPath, listingComplete, narrowTier, nothingToReviewReason, resolveRulesFiles } from "../src/selection.mjs";
import { isSensitivePath } from "../src/auto-approve.mjs";
import { LOADER_DIRS, LOADER_FILES } from "../src/sanitize.mjs";
test("release review runs only for release-relevant files", () => {
for (const path of [
@@ -40,3 +47,151 @@ test("rules fall back to CLAUDE.md only where AGENTS.md is absent", () => {
);
assert.deepEqual(resolveRulesFiles(["/x/AGENTS.md"], p => p === "/x/CLAUDE.md"), ["/x/CLAUDE.md"]);
});
test("a PR of test code only gets the test-only tier, of CI files only the ci-only tier, and anything else neither", () => {
assert.equal(narrowTier(["tests/ui-testing/playwright-tests/a.spec.js", "src/service/tests/query.rs", "web/src/utils/date.spec.ts"]), "test-only");
assert.equal(narrowTier(["src/config/src/lib_test.rs", "tests/api-testing/test_streams.py", "web/src/test/fixtures/x.json"]), "test-only");
assert.equal(narrowTier(["scripts/ai-review/test/comments.test.mjs"]), "test-only", "the engine's own tests want a correctness review");
assert.equal(narrowTier([".github/workflows/ai-code-review.yml", ".github/actions/setup/action.yml", ".github/dependabot.yml"]), "ci-only");
for (const path of ["scripts/ai-review/run-review.mjs", "scripts/ai-review/agents/qa.md", "opencode.jsonc", "opencode.ci.jsonc", "scripts/test-assist-ci-batch.sh", "scripts/ci-telemetry/o2_llm_trace.py", "scripts/x.js", ".github/CODEOWNERS"]) {
assert.equal(isCiOnlyPath(path), false, `${path} is code, not CI config`);
}
assert.equal(narrowTier([".github/workflows/ai-code-review.yml", "scripts/ai-review/run-review.mjs"]), "", "the review engine takes the normal tiers");
assert.equal(narrowTier(["tests/ui-testing/a.spec.js", "src/service/search.rs"]), "");
assert.equal(narrowTier([".github/workflows/x.yml", "web/src/App.vue"]), "");
assert.equal(narrowTier([".github/workflows/x.yml", "tests/api-testing/test_x.py"]), "", "a mix is neither narrow set");
assert.equal(narrowTier([]), "");
for (const path of ["src/service/search.rs", "web/src/App.vue", "scripts/translations/main.py", "contest/a.rs", "latest/x.ts"]) {
assert.equal(isTestOnlyPath(path), false, path);
assert.equal(isCiOnlyPath(path), false, path);
}
});
test("dependency-bot, docs-only and translation-only PRs need no review; anything else, or an empty file list, does", () => {
const bot = (login, extra = {}) => ({ author: login, committer: "web-flow", verified: true, ...extra });
assert.equal(nothingToReviewReason({ author: "dependabot[bot]", commits: [bot("dependabot[bot]"), bot("dependabot[bot]")], paths: ["Cargo.toml"] }), "bot_author");
assert.equal(nothingToReviewReason({ author: "renovate[bot]", commits: [bot("renovate[bot]", { committer: "renovate[bot]" })], paths: ["web/package.json"] }), "bot_author");
const reviewed = {
"a person's commit": bot("alice"),
"an unlinked commit author": bot(""),
"a person as committer": bot("dependabot[bot]", { committer: "alice" }),
"a missing committer": bot("dependabot[bot]", { committer: "" }),
"an unverified signature": bot("dependabot[bot]", { verified: false }),
"another bot's commit": bot("renovate[bot]"),
};
for (const [why, commit] of Object.entries(reviewed)) {
assert.equal(nothingToReviewReason({ author: "dependabot[bot]", commits: [bot("dependabot[bot]"), commit], paths: ["Cargo.toml"] }), "", why);
}
assert.equal(nothingToReviewReason({ author: "dependabot[bot]", commits: [], paths: ["Cargo.toml"] }), "", "a failed commit listing reviews");
for (const author of ["github-actions[bot]", "Copilot", "o2-ai-bot[bot]", "Dependabot[bot]"]) {
assert.equal(nothingToReviewReason({ author, commits: [bot(author)], paths: ["src/a.rs"] }), "", `${author} is reviewed`);
}
assert.equal(nothingToReviewReason({ author: "alice", paths: ["README.md", "docs/setup.txt", "web/README.MD"] }), "docs_only");
assert.equal(nothingToReviewReason({ author: "alice", paths: ["web/src/locales/languages/fr.json", "web/src/locales/languages/de.json", "scripts/translations/.translation_state.json"] }), "translation_only");
assert.equal(nothingToReviewReason({ author: "alice", paths: ["web/src/locales/languages/en-US.json"] }), "", "the English source is written by a person");
assert.equal(nothingToReviewReason({ author: "alice", paths: ["web/src/locales/languages/fr.json", "web/src/App.vue"] }), "");
assert.equal(nothingToReviewReason({ author: "alice", paths: ["README.md", "src/main.rs"] }), "");
assert.equal(nothingToReviewReason({ author: "alice", paths: ["web/src/locales/dbmMessages.spec.ts"] }), "");
assert.equal(nothingToReviewReason({ author: "alice", paths: [] }), "", "a failed file listing must never skip a review");
});
test("skip-check.mjs prints the reason the workflow step reads, and nothing when the PR is reviewed", () => {
const dir = mkdtempSync(join(tmpdir(), "ai-review-skip-"));
try {
const list = join(dir, "files.txt");
const commits = join(dir, "commits.txt");
const run = (files, env = {}, authors = null) => {
writeFileSync(list, files.join("\n"));
env = { EXPECTED_FILES: String(files.length), EXPECTED_COMMITS: String(authors?.length ?? 0), ...env };
// The workflow's jq output: one login per line, newline-terminated.
if (authors) writeFileSync(commits, authors.map(a => `${a}\n`).join(""));
const args = authors ? [list, commits] : [list];
const out = spawnSync(process.execPath, [new URL("../skip-check.mjs", import.meta.url).pathname, ...args], { encoding: "utf-8", env: { PATH: process.env.PATH, ...env } });
assert.equal(out.status, 0, out.stderr);
return out.stdout;
};
assert.equal(run(["docs/a.md"], { PR_AUTHOR: "alice" }), "docs_only");
const ok = "renovate[bot]\tweb-flow\ttrue";
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }, [ok, "renovate[bot]\trenovate[bot]\ttrue"]), "bot_author");
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }, [ok, "\tweb-flow\ttrue"]), "", "an unlinked commit author is not the bot");
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }, [ok, "renovate[bot]\tweb-flow\tfalse"]), "", "an unverified commit reviews");
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }, [ok, "renovate[bot]\tweb-flow\t"]), "", "a missing verification reviews");
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }, [ok, "renovate[bot]"]), "", "a short line reviews");
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }, []), "", "a failed commit listing reviews");
assert.equal(run(["src/a.rs"], { PR_AUTHOR: "renovate[bot]" }), "", "no commit file reviews");
assert.equal(run(["src/a.rs", "docs/a.md"], { PR_AUTHOR: "alice" }), "");
assert.equal(run([], { PR_AUTHOR: "alice" }), "");
assert.equal(run(["docs/new.md\tdocs/old.md"], { PR_AUTHOR: "alice" }), "docs_only", "a rename is one listed file");
assert.equal(run(["docs/new.md\tsrc/old.rs"], { PR_AUTHOR: "alice" }), "", "a rename source outside docs reviews");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a file or commit listing that may be cut short never skips a review", () => {
const dir = mkdtempSync(join(tmpdir(), "ai-review-skip-"));
try {
const list = join(dir, "files.tsv");
const commits = join(dir, "commits.tsv");
const run = (fileCount, commitCount, env) => {
writeFileSync(list, Array.from({ length: fileCount }, (_, i) => `docs/a${i}.md`).join("\n"));
writeFileSync(commits, Array.from({ length: commitCount }, () => "renovate[bot]\tweb-flow\ttrue\n").join(""));
const out = spawnSync(process.execPath, [new URL("../skip-check.mjs", import.meta.url).pathname, list, commits], { encoding: "utf-8", env: { PATH: process.env.PATH, PR_AUTHOR: "alice", ...env } });
assert.equal(out.status, 0, out.stderr);
return out.stdout;
};
assert.equal(run(2, 1, { EXPECTED_FILES: "2", EXPECTED_COMMITS: "1" }), "docs_only");
assert.equal(run(3000, 1, { EXPECTED_FILES: "3000", EXPECTED_COMMITS: "1" }), "", "the files endpoint stops at 3000");
assert.equal(run(3000, 1, { EXPECTED_FILES: "3200", EXPECTED_COMMITS: "1" }), "");
assert.equal(run(2, 1, { EXPECTED_FILES: "3", EXPECTED_COMMITS: "1" }), "", "a listing shorter than the PR's own count");
assert.equal(run(2, 1, { EXPECTED_FILES: "2", EXPECTED_COMMITS: "2" }), "");
assert.equal(run(2, 250, { EXPECTED_FILES: "2", EXPECTED_COMMITS: "250" }), "", "the commits endpoint stops at 250");
assert.equal(run(2, 1, {}), "", "unknown PR counts review");
assert.equal(run(2, 1, { EXPECTED_FILES: "two", EXPECTED_COMMITS: "1" }), "");
assert.equal(listingComplete({ files: 2, commits: 1, expectedFiles: "2", expectedCommits: "1" }), true);
assert.equal(listingComplete({ files: 2999, commits: 249, expectedFiles: "2999", expectedCommits: "249" }), true);
assert.equal(listingComplete({ files: 250, commits: 250, expectedFiles: "250", expectedCommits: "250" }), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the workflow skips nothing-to-review PRs before installing anything, and lets a hand-started run force a review", () => {
const workflow = readFileSync(new URL("../../../.github/workflows/ai-code-review.yml", import.meta.url), "utf-8");
const skipAt = workflow.indexOf("- name: Skip PRs with nothing to review");
assert.ok(skipAt > workflow.indexOf("- name: Check out review engine"), "the check runs from the engine checkout");
for (const later of ["- name: Defer to a paired o2-enterprise PR", "- name: Install engine dependencies", "- name: Install opencode", "- name: Run AI Code Review"]) {
assert.ok(skipAt < workflow.indexOf(later), later);
}
const step = workflow.slice(skipAt, workflow.indexOf("\n\n", skipAt));
assert.match(step, /if: env\.REVIEW_RUN == 'true' && github\.event_name == 'pull_request_target'/);
assert.match(step, /reason="\$\(node engine\/scripts\/ai-review\/skip-check\.mjs "\$list" "\$commits"\)" \|\| reason=""/, "a failed check must review, not skip");
assert.match(step, /pulls\/\$PR_NUMBER\/commits/);
assert.match(step, /\.committer\.login \/\/ ""/);
assert.match(step, /\.commit\.verification\.verified \/\/ ""/);
assert.match(step, /EXPECTED_FILES: \$\{\{ steps\.pr\.outputs\.changed_files \}\}/);
assert.match(step, /EXPECTED_COMMITS: \$\{\{ steps\.pr\.outputs\.commit_count \}\}/);
assert.match(step, /\[\.filename, \(\.previous_filename \/\/ ""\)\] \| @tsv/, "one line per listed file, so the count can be checked");
assert.match(workflow, /echo "changed_files=\$\(jq -r '\.changed_files \/\/ ""'/);
assert.match(workflow, /echo "commit_count=\$\(jq -r '\.commits \/\/ ""'/);
assert.doesNotMatch(step, /set -[a-z]*e/, "set -e would end the step on a failed listing instead of reviewing");
assert.match(step, /REVIEW_RUN=false/);
assert.match(workflow, /REVIEW_MANUAL_RUN: \$\{\{ github\.event_name == 'workflow_dispatch' \}\}/);
});
test("markdown that steers the AI or CI is not docs-only", () => {
for (const p of ["README.md", "docs/guide/a.md"]) assert.equal(isDocsPath(p), true, p);
for (const p of ["scripts/ai-review/agents/coordinator.md", "AGENTS.md", "web/CLAUDE.md", ".claude/skills/x/SKILL.md", ".agents/skills/x/SKILL.md", ".agents/README.md", ".github/PULL_REQUEST_TEMPLATE.md"]) assert.equal(isDocsPath(p), false, p);
});
test("every instruction loader the sanitizer removes, at any depth and case, is reviewed rather than skipped as docs", () => {
const paths = ["CONTEXT.md", "src/Context.md", "web/agents.md", "web/.claude/skills/x/SKILL.md", "web/.agents/skills/x/SKILL.md", "web/.opencode/agents/reviewer.md", "docs/.Claude/notes.md"];
for (const name of LOADER_FILES) if (name.endsWith(".md")) paths.push(`docs/deep/${name.toUpperCase()}`);
for (const dir of LOADER_DIRS) paths.push(`docs/${dir}/guide.md`);
for (const p of paths) {
assert.equal(isSensitivePath(p), true, `the approval gate already treats ${p} as an instruction loader`);
assert.equal(isDocsPath(p), false, p);
assert.equal(nothingToReviewReason({ author: "alice", paths: [p] }), "", p);
}
for (const p of ["docs/context-menu.md", "web/agents-guide.md", "docs/claude/notes.md"]) assert.equal(isDocsPath(p), true, p);
});
+2
View File
@@ -150,6 +150,7 @@ const ASSISTANT_SCOPE: &[ScopeRoute] = &[
v1(&[Param, Lit("oncall"), Lit("incidents"), Param, Lit("responses")], GET),
v1(&[Param, Lit("oncall"), Lit("responses"), Param, Lit("acknowledge")], POST),
v1(&[Param, Lit("oncall"), Lit("responses"), Param, Lit("snooze")], POST),
v1(&[Param, Lit("oncall"), Lit("responses"), Param, Lit("unsnooze")], POST),
v1(&[Param, Lit("oncall"), Lit("responses"), Param, Lit("resolve")], POST),
v1(&[Param, Lit("ai"), Lit("chat")], POST),
v1(&[Param, Lit("ai"), Lit("chat_stream")], POST),
@@ -1001,6 +1002,7 @@ mod tests {
(Method::GET, "acme/oncall/teams/t1/on-call"),
(Method::POST, "acme/oncall/responses/r1/acknowledge"),
(Method::POST, "acme/oncall/responses/r1/snooze"),
(Method::POST, "acme/oncall/responses/r1/unsnooze"),
(Method::POST, "acme/oncall/responses/r1/resolve"),
(Method::POST, "acme/ai/chat_stream"),
(Method::POST, "acme/ai/confirm/s1"),
@@ -2416,6 +2416,10 @@ fn oncall_routes(router: Router) -> Router {
"/{org_id}/oncall/responses/{response_id}/snooze",
post(oncall::snooze_response),
)
.route(
"/{org_id}/oncall/responses/{response_id}/unsnooze",
post(oncall::unsnooze_response),
)
.route(
"/{org_id}/oncall/responses/{response_id}/handoff",
post(oncall::handoff_response),
@@ -5213,6 +5217,7 @@ mod tests {
"POST /{org_id}/oncall/responses/{response_id}/acknowledge",
"POST /{org_id}/oncall/responses/{response_id}/resolve",
"POST /{org_id}/oncall/responses/{response_id}/snooze",
"POST /{org_id}/oncall/responses/{response_id}/unsnooze",
"POST /{org_id}/prometheus/api/v1/query",
"POST /{org_id}/prometheus/api/v1/query_range",
"POST /{org_id}/{stream_name}/_around",
@@ -1328,6 +1328,7 @@ pub struct ApiDoc;
openobserve_api_management::request::oncall::snooze_response,
openobserve_api_management::request::oncall::test_team_slack_channel,
openobserve_api_management::request::oncall::unacknowledge_response,
openobserve_api_management::request::oncall::unsnooze_response,
openobserve_api_management::request::oncall::update_ownership_rule,
openobserve_api_management::request::oncall::update_team,
openobserve_api_management::request::oncall::who_is_on_call,
@@ -3081,6 +3081,56 @@ pub async fn snooze_response(
}
}
#[utoipa::path(
post,
path = "/{org_id}/oncall/responses/{response_id}/unsnooze",
context_path = "/api",
tag = "OnCall",
operation_id = "UnsnoozeOnCallResponse",
summary = "End a snooze now, so paging continues from the current step",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("response_id" = String, Path, description = "Response record ID"),
),
responses((status = 200, description = "Success", content_type = "application/json", body = Object)),
extensions(
("x-o2-ratelimit" = json!({"module": "OnCall", "operation": "create"})),
("x-o2-mcp" = json!({"enabled": false}))
)
)]
pub async fn unsnooze_response(
Path((org_id, response_id)): Path<(String, String)>,
#[cfg(feature = "enterprise")] Headers(user_email): Headers<UserEmail>,
) -> Response {
#[cfg(feature = "enterprise")]
{
if !allowed(&org_id, &user_email.user_id, RESPONSES, "POST").await {
return MetaHttpResponse::forbidden("Forbidden");
}
if let Err(resp) = page_problem(&org_id, &response_id, &user_email.user_id).await {
return resp;
}
match o2_enterprise::enterprise::oncall::escalation::unsnooze(
&org_id,
&response_id,
&user_email.user_id,
config::utils::time::now_micros(),
)
.await
{
Ok(Some(r)) => MetaHttpResponse::json(r),
Ok(None) => MetaHttpResponse::not_found("Response record not found"),
Err(e) => to_response(e),
}
}
#[cfg(not(feature = "enterprise"))]
{
let _ = (org_id, response_id);
MetaHttpResponse::forbidden("Not Supported")
}
}
#[utoipa::path(
post,
path = "/{org_id}/oncall/responses/{response_id}/handoff",
+32
View File
@@ -78,6 +78,17 @@ pub fn snoozed_ladder_anchor(
existing_anchor.unwrap_or(opened_at) + (until - quiet_reaches).max(0)
}
/// Where the ladder's clock sits when a snooze ends early at `now`: as if it had been that short.
pub fn unsnoozed_ladder_anchor(
existing_anchor: Option<i64>,
opened_at: i64,
snoozed_until: Option<i64>,
now: i64,
) -> i64 {
let unused = snoozed_until.map_or(0, |until| (until - now).max(0));
existing_anchor.unwrap_or(opened_at) - unused
}
/// Lifecycle of a response record: `triggered → triaged → acknowledged →
/// resolved`. `triaged` comes from the L0 agent and is skipped entirely when
/// the agent is absent or disabled.
@@ -1711,6 +1722,27 @@ mod tests {
17 * MIN
);
}
/// Ending a snooze early gives back the unused quiet, so the rungs keep their gaps from
/// where the ladder paused instead of waiting out the rest of the snooze.
#[test]
fn test_unsnoozing_leaves_the_clock_where_a_snooze_that_short_would() {
let snoozed = snoozed_ladder_anchor(None, 0, None, MIN, 61 * MIN);
assert_eq!(
unsnoozed_ladder_anchor(Some(snoozed), 0, Some(61 * MIN), 3 * MIN),
snoozed_ladder_anchor(None, 0, None, MIN, 3 * MIN),
);
}
/// A snooze that has already lapsed has no quiet left to give back.
#[test]
fn test_unsnoozing_a_lapsed_snooze_leaves_the_clock() {
let snoozed = snoozed_ladder_anchor(None, 0, None, MIN, 61 * MIN);
assert_eq!(
unsnoozed_ladder_anchor(Some(snoozed), 0, Some(61 * MIN), 90 * MIN),
snoozed
);
}
}
#[test]
+4 -5
View File
@@ -3362,12 +3362,12 @@ mod tests {
}
}
/// Paused time that moves 100x faster than the wall clock.
/// 10x time: auto-advance fires the DB acquire timeout; faster makes CI stalls false backoffs.
fn fast_clock() -> tokio::task::JoinHandle<()> {
tokio::spawn(async {
loop {
std::thread::sleep(Duration::from_millis(1));
tokio::time::advance(Duration::from_millis(100)).await;
tokio::time::advance(Duration::from_millis(10)).await;
}
})
}
@@ -3491,9 +3491,9 @@ mod tests {
clock.abort();
}
#[tokio::test(start_paused = true)]
// Real clock: even the 10x fast_clock turns a 500 ms CI stall into a false 5 s backoff.
#[tokio::test]
async fn other_start_errors_fail_the_continuation_at_once() {
let clock = fast_clock();
let _db = db().await;
for (error, can_retry, code) in [
(RcaTurnError::Busy, true, "busy"),
@@ -3543,7 +3543,6 @@ mod tests {
"{stored_error:?}"
);
}
clock.abort();
}
#[tokio::test]
+30
View File
@@ -664,6 +664,36 @@ pub async fn snooze(
.await
}
/// Ends a snooze at `now`, giving the ladder's clock back the quiet that was not used.
pub async fn unsnooze(org_id: &str, id: &str, now: i64) -> Result<Option<Response>, errors::Error> {
let client = get_orm_client_rw().await;
let Some(existing) = oncall_responses::Entity::find_by_id(id)
.filter(oncall_responses::Column::OrgId.eq(org_id))
.one(client)
.await?
else {
return Ok(None);
};
let anchor = config::meta::oncall::response::unsnoozed_ladder_anchor(
existing.ladder_anchor,
existing.opened_at,
existing.snoozed_until,
now,
);
save(
org_id,
id,
vec![
(
oncall_responses::Column::SnoozedUntil,
Expr::value(Option::<i64>::None),
),
(oncall_responses::Column::LadderAnchor, Expr::value(anchor)),
],
)
.await
}
/// Writes a new severity onto an open record.
///
/// The severity is what SLO reporting, digests and the post-incident review
@@ -6,6 +6,7 @@ import { confirmDraftSignOut } from '../generalPages/draftSignOut.js';
import { dateTimeButtonLocator, relative30SecondsButtonLocator, absoluteTabLocator, Past30SecondsValue } from '../commonActions.js';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { isCloudEnvironment } = require('../cloudPages/cloud-env.js');
const { ingestTraces } = require('../../playwright-tests/utils/trace-ingestion.js');
// Rate and Errors come from web/src/plugins/traces/metrics/metrics.json; Duration is the latency heatmap.
const TRACES_METRICS_PANELS = ['Rate', 'Errors', 'Duration'];
@@ -13,8 +14,50 @@ const CHART_ZOOM_START_RATIO = 0.35;
const CHART_ZOOM_END_RATIO = 0.75;
const CHART_DRAG_STEPS = 20;
const QUERY_CLEAR_ATTEMPTS = 10;
const DURATION_UNIT_US = { us: 1, ms: 1000, s: 1000000 };
export function decodedSqlOf(request) {
if (request.method() !== 'POST' || !/_search/.test(request.url())) return null;
const body = request.postDataJSON?.() ?? null;
const query = body?.query ?? {};
if (typeof query.sql !== 'string') return null;
const base64 = body.encoding === 'base64' || query.encoding === 'base64';
return {
sql: base64 ? Buffer.from(query.sql, 'base64').toString('utf8') : query.sql,
start: query.start_time,
end: query.end_time,
};
}
// The comparison sends its own counts and LIMIT 4000 samples; nothing else does.
export const isComparison = (q) => /^SELECT count\(\*\) AS n\b/.test(q.sql) || /LIMIT 4000$/.test(q.sql);
export const isCount = (q) => /^SELECT count\(\*\) AS n\b/.test(q.sql);
// The table search: spans mode posts a base64 `SELECT *` SQL.
export const isTableSearch = (q) => /^SELECT \* FROM/.test(q.sql);
// The band's bounds in µs as the SQL writes them; a missing side is absent.
export function bandSql(editorBand) {
const toUs = (m) => (m ? Number(m[1]) * DURATION_UNIT_US[m[2]] : null);
const lo = toUs(editorBand.match(/duration >= '(\d+)(us|ms|s)'/));
const hi = toUs(editorBand.match(/duration < '(\d+)(us|ms|s)'/));
return [lo ? `duration >= ${lo}` : '', hi !== null ? `duration < ${hi}` : ''].filter(Boolean).join(' AND ');
}
export class TracesPage {
// Static so a beforeAll, which has no page fixture, can seed through its own context.
static async seedTraces(browser, ageMs = 0) {
const context = await browser.newContext({ storageState: 'playwright-tests/utils/auth/user.json' });
const page = await context.newPage();
try {
for (const scenario of [{ count: 6, forceScenario: 'error' }, { count: 10, forceScenario: 'success' }]) {
const seeded = await ingestTraces(page, scenario.count, { forceScenario: scenario.forceScenario, ageMs });
expect(seeded.failed, `Seeding ${scenario.forceScenario} traces must succeed`).toBe(0);
}
} finally {
await context.close();
}
}
constructor(page) {
this.page = page;
@@ -3,39 +3,21 @@
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const { ingestTraces } = require('../utils/trace-ingestion.js');
const {
TracesPage,
decodedSqlOf,
isComparison,
isCount,
isTableSearch,
bandSql,
} = require('../../pages/tracesPages/tracesPage.js');
const USER_FILTER = "duration >= '1ms'";
const DURATION_UNIT_US = { us: 1, ms: 1000, s: 1000000 };
const MINUTE_US = 60 * 1000000;
const HOUR_US = 60 * MINUTE_US;
function decodedSqlOf(request) {
if (request.method() !== 'POST' || !/_search/.test(request.url())) return null;
const body = request.postDataJSON?.() ?? null;
const query = body?.query ?? {};
if (typeof query.sql !== 'string') return null;
const base64 = body.encoding === 'base64' || query.encoding === 'base64';
return {
sql: base64 ? Buffer.from(query.sql, 'base64').toString('utf8') : query.sql,
start: query.start_time,
end: query.end_time,
};
}
// The comparison sends its own counts and LIMIT 4000 samples; nothing else does.
const isComparison = (q) => /^SELECT count\(\*\) AS n\b/.test(q.sql) || /LIMIT 4000$/.test(q.sql);
const isCount = (q) => /^SELECT count\(\*\) AS n\b/.test(q.sql);
// The table search: spans mode posts a base64 `SELECT *` SQL.
const isTableSearch = (q) => /^SELECT \* FROM/.test(q.sql);
// The band's bounds in µs as the SQL writes them; a missing side is absent.
function bandSql(editorBand) {
const toUs = (m) => (m ? Number(m[1]) * DURATION_UNIT_US[m[2]] : null);
const lo = toUs(editorBand.match(/duration >= '(\d+)(us|ms|s)'/));
const hi = toUs(editorBand.match(/duration < '(\d+)(us|ms|s)'/));
return [lo ? `duration >= ${lo}` : '', hi !== null ? `duration < ${hi}` : ''].filter(Boolean).join(' AND ');
}
// The default brush spans 35-75% of a 15m range (~10-4 min ago); spans stamped now sit right of it.
const BRUSH_WINDOW_AGE_MS = 6 * 60 * 1000;
test.describe('Traces triage loop', () => {
let pm;
@@ -43,16 +25,7 @@ test.describe('Traces triage loop', () => {
test.beforeAll(async ({ browser }) => {
test.setTimeout(120000);
const context = await browser.newContext({ storageState: 'playwright-tests/utils/auth/user.json' });
const page = await context.newPage();
try {
for (const scenario of [{ count: 6, forceScenario: 'error' }, { count: 10, forceScenario: 'success' }]) {
const seeded = await ingestTraces(page, scenario.count, { forceScenario: scenario.forceScenario });
expect(seeded.failed, `Seeding ${scenario.forceScenario} traces must succeed`).toBe(0);
}
} finally {
await context.close();
}
await TracesPage.seedTraces(browser);
});
test.beforeEach(async ({ page }, testInfo) => {
@@ -246,8 +219,9 @@ test.describe('Traces triage loop', () => {
test('P1: an Errors brush compares error spans with the rest of its window', {
tag: ['@tracesTriageLoop', '@traces', '@functional', '@P1', '@all'],
}, async ({ page }) => {
}, async ({ page, browser }) => {
test.setTimeout(240000);
await TracesPage.seedTraces(browser, BRUSH_WINDOW_AGE_MS);
await searchWith('');
const preBox = queries.filter(isTableSearch).at(-1);
expect(await pm.tracesPage.zoomOnMetricsPanel('Errors')).toBeTruthy();
@@ -267,8 +241,9 @@ test.describe('Traces triage loop', () => {
test('P1: a Rate brush compares its window with the rest of the range', {
tag: ['@tracesTriageLoop', '@traces', '@functional', '@P1', '@all'],
}, async ({ page }) => {
}, async ({ page, browser }) => {
test.setTimeout(240000);
await TracesPage.seedTraces(browser, BRUSH_WINDOW_AGE_MS);
await searchWith('');
expect(await pm.tracesPage.zoomOnMetricsPanel('Rate')).toBeTruthy();
await page.waitForTimeout(2000);
@@ -296,8 +271,9 @@ test.describe('Traces triage loop across time zones', () => {
test('P2: an Errors brush under an Asia/Kolkata app zone keeps the brushed window', {
tag: ['@tracesTriageLoop', '@traces', '@functional', '@P2', '@all'],
}, async ({ page }) => {
}, async ({ page, browser }) => {
test.setTimeout(240000);
await TracesPage.seedTraces(browser, BRUSH_WINDOW_AGE_MS);
const queries = [];
page.on('request', (r) => {
const q = decodedSqlOf(r);
@@ -62,6 +62,22 @@ describe("DbmCoverageLine", () => {
expect(lineText(wrapper)).toContain("they're exact");
});
it("says a several-schema database shows its slowest schema's speeds", () => {
const wrapper = mountLine({ exactPercentiles: true, slowestSchemaSpeeds: true });
expect(lineText(wrapper)).toBe(
"All databases reporting. Speeds are exact per schema; a database with several schemas shows its slowest one.",
);
});
it("keeps the estimated copy when the server fused windows, several schemas or not", () => {
const wrapper = mountLine({
exactPercentiles: true,
slowestSchemaSpeeds: true,
freshness: freshness({ percentiles_estimated: true }),
});
expect(lineText(wrapper)).toContain("close but not exact");
});
it("never claims exactness when the server fused windows", () => {
const wrapper = mountLine({
exactPercentiles: false,
+6 -2
View File
@@ -155,6 +155,8 @@ const props = withDefaults(
* separate question the response answers via `percentiles_estimated`.
*/
exactPercentiles?: boolean;
/** Some row folds several schemas, so its speeds are its slowest schema's, not its own. */
slowestSchemaSpeeds?: boolean;
/**
* What these rows ARE. `list` (default) is a ranked table, where the share
* of total time is the headline claim. `query` is one statement, where that
@@ -172,6 +174,7 @@ const props = withDefaults(
other: () => [],
topNSubset: false,
exactPercentiles: false,
slowestSchemaSpeeds: false,
filterLabel: null,
subject: "list",
inline: false,
@@ -411,8 +414,9 @@ const summary = computed<I18nText>(() => {
// The Overview grain measures every call, so it states exactness plainly
// rather than borrowing the per-query "heaviest N" sentence.
if (props.exactPercentiles) {
return estimatedSpeeds.value
? t("dbm.coverage.lineExactEstimated")
if (estimatedSpeeds.value) return t("dbm.coverage.lineExactEstimated");
return props.slowestSchemaSpeeds
? t("dbm.coverage.lineExactSlowestSchema")
: t("dbm.coverage.lineExact");
}
if (otherTime.value <= 0) {
@@ -271,7 +271,7 @@ describe("every list page re-reads its scope when the reader returns", () => {
const source = read(file);
// Prettier splits the call across lines once the model map grows, so
// match the call and its live-query argument rather than one spelling.
expect(source).toMatch(/useDbmOwnFilterSync\(\s*\(\) => route\.query/);
expect(source).toMatch(/useDbmOwnFilterSync\(\s*\(\) =>[^;]*?route\.query/);
// ...and it must RELOAD, or the chip moves while the rows do not.
expect(source, `${file} must refetch when it adopts a new scope`).toContain(
"() => void load()",
@@ -219,6 +219,58 @@ describe("the databases fallback counts the same refs the fleet union renders",
// The client vantage answered 1; the fallback must not override it.
expect(counts.databaseCount).toBe(1);
});
it("counts servers, not schemas, and honours the Schema filter", async () => {
const databases = {
hits: [
{ db_system: "postgresql", db_instance: "o2dev", db_namespace: "o2dev" },
{ db_system: "postgresql", db_instance: "o2dev", db_namespace: "postgres" },
{ db_system: "postgresql", db_instance: "o2dev", db_namespace: "o2alpha" },
{ db_system: "postgresql", db_instance: "o2-introspection", db_namespace: "postgres" },
],
};
const data = {
databases,
queries: { total: 0, hits: [] },
activity: null,
deadlocks: null,
blocking: null,
table_health: null,
};
service.getBadges.mockResolvedValueOnce({ data });
expect((await fetchDbmTabCounts("acme", WINDOW)).databaseCount).toBe(2);
queryClient.clear();
service.getBadges.mockResolvedValueOnce({ data });
expect((await fetchDbmTabCounts("acme", WINDOW, { namespace: "o2alpha" })).databaseCount).toBe(
1,
);
});
it("does not badge server-known instances under a Schema filter no traffic matched", async () => {
service.getBadges.mockResolvedValueOnce({
data: {
databases: {
hits: [{ db_system: "postgresql", db_instance: "o2dev", db_namespace: "o2dev" }],
},
queries: { total: 0, hits: [] },
activity: {
by_state: [],
hits: [{ db_system: "postgresql", db_instance: "o2-introspection" }],
},
deadlocks: null,
blocking: null,
table_health: null,
server_queries: {
hits: [{ db_system: "postgresql", db_instance: "o2dev" }],
truncated: false,
},
server_samples: { hits: [], truncated: false },
},
});
const counts = await fetchDbmTabCounts("acme", WINDOW, { namespace: "stale-schema" });
expect(counts.databaseCount).toBe(0);
});
});
describe("one dead slice must not blank a badge the last window answered", () => {
@@ -331,6 +383,40 @@ describe("what one page learns is visible from every tab", () => {
expect(badgeCount(counts.value.databaseCount)).toBe("6");
});
it("keeps a count the page publishes once the cached unfiltered scope is back", async () => {
const twoTraced = {
...envelope(),
databases: {
hits: [
{ db_system: "postgresql", db_instance: "pg-orders", db_namespace: "orders" },
{ db_system: "postgresql", db_instance: "pg-billing", db_namespace: "billing" },
],
},
};
service.getBadges.mockResolvedValue({ data: twoTraced });
const { counts, load, publishOwnCount } = useDbmTabCounts();
await load("acme", RANGE, WINDOW, {});
await load("acme", RANGE, WINDOW, { namespace: "orders" });
publishOwnCount("databaseCount", 1);
// Schema chip cleared: the URL settles first, so the shell re-keys before the page republishes.
await load("acme", RANGE, WINDOW, {});
publishOwnCount("databaseCount", 3);
expect(service.getBadges).toHaveBeenCalledTimes(2);
expect(badgeCount(counts.value.databaseCount)).toBe("3");
});
it("still drops a count published under the scope the strip is leaving", async () => {
service.getBadges.mockResolvedValue({ data: envelope() });
const { counts, load, publishOwnCount } = useDbmTabCounts();
await load("acme", RANGE, WINDOW, { namespace: "orders" });
publishOwnCount("databaseCount", 6);
await load("acme", RANGE, WINDOW, {});
expect(badgeCount(counts.value.databaseCount)).toBe("1");
});
it("treats an empty breakdown beside total:0 as a MEASURED zero, not an unknown", async () => {
// THE REPORTED BUG, at its source. Scoping Activity to `mssql-prod-1` —
// an engine with no session sampler at all — returns
+11 -2
View File
@@ -128,6 +128,7 @@ import { queryClient } from "@/composables/query/queryClient";
import { activitySampleTotal } from "@/utils/dbm/activity";
import { dedupeServerInstanceRefs, type DbmServerInstanceRef } from "@/utils/dbm/fleetRows";
import { countClaim, overlapClaim, type DbmCountClaim } from "@/utils/dbm/format";
import { inSchema, instanceKey } from "@/utils/dbm/instanceRows";
/**
* Everything the tab strips need, from one request.
@@ -300,7 +301,14 @@ export const fetchDbmTabCounts = async (
const { databases, queries, activity, deadlocks, blocking, table_health: tableHealth } = badges;
const counts: DbmTabCounts = {
databaseCount: databases ? (databases.hits?.length ?? 0) : null,
// The endpoint ignores `namespace`, so the Overview's client-side Schema filter applies here.
databaseCount: databases
? new Set(
(databases.hits ?? [])
.filter((row) => inSchema(row, filters.namespace))
.map((row) => instanceKey(row)),
).size
: null,
// The same databases member, summed instead of counted: its rows carry
// the rollup's EXACT per-instance `calls` totals, and instances partition
// calls, so the sum is the window's finished-call population. A row without
@@ -414,7 +422,8 @@ export const fetchDbmTabCounts = async (
// list the fleet union renders, so this badge counts the rows the tile
// counts. It can undercount an instance known only to the metric streams;
// the Overview page's own exact union overrides this the moment it loads.
if (counts.databaseCount === 0 && counts.serverInstanceRefs.length) {
// Server-known refs name no schema, so under a Schema filter they prove nothing.
if (counts.databaseCount === 0 && counts.serverInstanceRefs.length && !filters.namespace) {
counts.databaseCount = counts.serverInstanceRefs.length;
}
}
+6
View File
@@ -24285,6 +24285,7 @@
"databases": {
"searchPlaceholder": "Search database or service…",
"unnamedInstance": "Instance not named",
"schemaCount": "{count} schema | {count} schemas",
"unnamedInstanceHint": "The database's own statement reports prove this engine is running, but they don't name a host. The row will show an address once a feed that carries one — session sampling or instance metrics — is set up.",
"moreServices": "+{count}",
"columns": {
@@ -24472,6 +24473,7 @@
"lineCountedTo": "Counted up to {time}; the last half-minute is still coming in.",
"lineExact": "All databases reporting. Speeds here are measured across every call, so they're exact.",
"lineExactEstimated": "All databases reporting. Speeds here are blended from several stretches of time, so they're close but not exact.",
"lineExactSlowestSchema": "All databases reporting. Speeds are exact per schema; a database with several schemas shows its slowest one.",
"lineSubsetUnnamed": "Your filters narrow this below the level these totals add up at, so the percentages describe the rows shown rather than the whole database.",
"lineQuery": "Timings are measured from your application, so they include network time and any wait for a free connection.",
"lineUncodedErrors": "{percent} of the failures here came back without a reason code, so they can be counted but not explained.",
@@ -25779,6 +25781,10 @@
"snoozed": "Snoozed",
"snoozedUntil": "Quiet until {time} — still unassigned",
"snoozeFailed": "Could not snooze the page",
"unsnooze": "Unsnooze",
"unsnoozeHint": "Ends the snooze now. Paging continues from the step it paused on.",
"unsnoozed": "Snooze ended · paging continues",
"unsnoozeFailed": "Could not end the snooze",
"note": "Note",
"notePlaceholder": "What did you find? What did you try?",
"addNote": "Add note",
+62 -2
View File
@@ -243,8 +243,12 @@ describe("Logs Index — first landing and URL loads (item 2)", { timeout: 30000
});
describe("re-applied query links (AI chat, search history)", () => {
const reApply = async (query: Record<string, string>) => {
store.state.zoConfig = { ...originalZoConfig, auto_query_enabled: true };
const reApply = async (
query: Record<string, string>,
zoConfig: Record<string, unknown> = {},
logs?: Mock,
) => {
store.state.zoConfig = { ...originalZoConfig, auto_query_enabled: true, ...zoConfig };
(store.state as any).logs = { isInitialized: false, logs: {} };
vi.spyOn(store, "dispatch").mockResolvedValue(undefined as any);
(getFieldsFromQuery as Mock).mockResolvedValue({ fields: [], filters: [], streamName: "" });
@@ -253,6 +257,7 @@ describe("Logs Index — first landing and URL loads (item 2)", { timeout: 30000
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
if (logs) wrapper.vm.autoRun.setExecutors({ logs });
await vi.waitFor(
() => expect(wrapper.vm.searchObj.data.stream.streamLists.length).toBeGreaterThan(0),
{ timeout: 10000 },
@@ -283,6 +288,61 @@ describe("Logs Index — first landing and URL loads (item 2)", { timeout: 30000
},
);
it.each(["ai_chat_query", "search_history_re_apply"])(
"%s loaded without running shows the run-query prompt, not an empty result grid",
async (type) => {
await reApply(
{ type, stream: "k8s_json", stream_type: "logs", period: "15m" },
{ auto_query_enabled: false, query_on_stream_selection: true },
);
await vi.waitFor(() =>
expect(wrapper.vm.searchObj.data.stream.selectedStreamFields.length).toBeGreaterThan(0),
);
await flushPromises();
expect(wrapper.vm.searchObj.meta.searchApplied).toBe(false);
expect(wrapper.find('[data-test="logs-search-apply-search-text"]').exists()).toBe(true);
expect(wrapper.find('[data-test="logs-search-search-result"]').exists()).toBe(false);
},
);
it("a search Auto Run starts during a re-apply keeps its hits", async () => {
const logs = vi.fn(async () => {
wrapper.vm.searchObj.data.queryResults = { hits: [{ _timestamp: 1, body: "row" }] };
wrapper.vm.searchObj.meta.searchApplied = true;
});
await reApply(
{ type: "search_history_re_apply", stream: "k8s_json", stream_type: "logs", period: "15m" },
{ auto_query_enabled: true, query_on_stream_selection: false },
logs,
);
await vi.waitFor(() =>
expect(wrapper.vm.searchObj.data.stream.selectedStreamFields.length).toBeGreaterThan(0),
);
await vi.waitFor(() => expect(logs).toHaveBeenCalled());
await flushPromises();
expect(wrapper.vm.searchObj.data.queryResults.hits).toHaveLength(1);
expect(wrapper.find('[data-test="logs-search-apply-search-text"]').exists()).toBe(false);
});
it("a search Auto Run starts during a re-apply keeps loading, not the run-query prompt, while it is pending", async () => {
const logs = vi.fn(() => {
wrapper.vm.searchObj.loading = true;
return new Promise(() => {});
});
await reApply(
{ type: "search_history_re_apply", stream: "k8s_json", stream_type: "logs", period: "15m" },
{ auto_query_enabled: true, query_on_stream_selection: false },
logs,
);
await vi.waitFor(() =>
expect(wrapper.vm.searchObj.data.stream.selectedStreamFields.length).toBeGreaterThan(0),
);
await vi.waitFor(() => expect(logs).toHaveBeenCalled());
await flushPromises();
expect(wrapper.vm.searchObj.loading).toBe(true);
expect(wrapper.find('[data-test="logs-search-apply-search-text"]').exists()).toBe(false);
});
it("a re-applied query loaded without running lets the user's edited run push history", async () => {
await router.replace({ name: "home" });
await reApply({
+8 -1
View File
@@ -1749,12 +1749,19 @@ export default defineComponent({
// loadLogsData() minus getQueryData(): a re-applied query is loaded for the user to run, not run for them.
async function applyReAppliedQuery() {
resetRunStateForReapply();
const gridAtStart = autoRun.engine.currentGeneration("grid")?.id;
searchObj.meta.searchApplied = false;
// The run-query prompt needs a `hits` array; set before the awaits so an Auto Run search that lands meanwhile keeps its hits.
searchObj.data.queryResults.hits = [];
await getStreamList(true, { fromUrl: true });
await getFunctions();
await extractFields();
refreshData();
searchObj.loading = false;
const latest = autoRun.engine.currentGeneration("grid");
// A search started during the load owns the loading state; its completion or cancel clears it.
if (!latest || latest.id === gridAtStart || latest.settled || latest.cancelled) {
searchObj.loading = false;
}
}
// Helper function for handling the stream explorer
+7
View File
@@ -721,6 +721,13 @@ export const snoozeResponseMutation = (org: string) =>
meta: { invalidates: responseWriteScopes(org), silentError: true },
});
export const unsnoozeResponseMutation = (org: string) =>
mutationOptions({
mutationFn: (responseId: string) =>
oncallService.unsnoozeResponse({ org_identifier: org, response_id: responseId }),
meta: { invalidates: responseWriteScopes(org), silentError: true },
});
export const resolveResponseMutation = (org: string) =>
mutationOptions({
mutationFn: ({
+12
View File
@@ -511,6 +511,18 @@ const oncall = {
{ minutes },
),
/// Ends a snooze now; the ladder continues from the step it paused on.
unsnoozeResponse: ({
org_identifier,
response_id,
}: {
org_identifier: string;
response_id: string;
}) =>
http().post<OnCallResponse>(
`/api/${org_identifier}/oncall/responses/${encodeURIComponent(response_id)}/unsnooze`,
),
addNote: ({
org_identifier,
response_id,
+26 -1
View File
@@ -17,7 +17,13 @@ import { describe, expect, it } from "vitest";
import type { QueryStatsRow } from "@/services/db_monitoring";
import { buildDatabaseBreakdown, rowNamespace, rowService, SHORTFALL_FLOOR } from "./breakdown";
import {
buildDatabaseBreakdown,
namesNoSchema,
rowNamespace,
rowService,
SHORTFALL_FLOOR,
} from "./breakdown";
/**
* The lab shape, verbatim from the live endpoint: the merged rows carry
@@ -55,6 +61,25 @@ describe("rowNamespace", () => {
});
});
describe("namesNoSchema", () => {
it("tells a row with no schema from one spanning several", () => {
expect(namesNoSchema(row())).toBe(true);
expect(namesNoSchema(row({ namespaces: ["", ""] }))).toBe(true);
expect(namesNoSchema(row({ namespaces: ["a", "b"] }))).toBe(false);
expect(namesNoSchema(row({ namespaces: ["a"] }))).toBe(false);
});
it("keeps a multi-schema row out of the unnamed services", () => {
const result = buildDatabaseBreakdown([
row({ fingerprint: "m", namespaces: ["a", "b"], services: ["multi"] }),
row({ fingerprint: "n", services: ["noschema"], total_time_ns: 400 }),
]);
expect(result.unnamed.map((node) => [node.name, node.totalTimeNs, node.share])).toEqual([
["noschema", 400, 1],
]);
});
});
describe("rowService", () => {
it("prefers service_name", () => {
expect(rowService(row({ service_name: "checkout", services: ["cart"] }))).toBe("checkout");
+14 -1
View File
@@ -85,6 +85,8 @@ export interface DbmBreakdown {
levels: DbmBreakdownNode[];
/** No row named a schema, so the schema tier was dropped as empty. */
collapsed: boolean;
/** Services of the rows that named no schema at all; a row spanning several schemas is in none. */
unnamed: DbmBreakdownNode[];
/** Time the fingerprint rows account for, nanoseconds. */
attributedNs: number;
/** The database row's own exact total, nanoseconds — `null` when unknown. */
@@ -113,6 +115,10 @@ export const rowNamespace = (row: QueryStatsRow): string | null => {
return list.length === 1 ? list[0] : null;
};
/** Whether a row named no schema at all, as distinct from naming several. */
export const namesNoSchema = (row: QueryStatsRow): boolean =>
!row.db_namespace && !(row.namespaces ?? []).some(Boolean);
/**
* The service that issued a row.
*
@@ -245,6 +251,13 @@ export const buildDatabaseBreakdown = (
return node;
});
const schemaless = rows.filter(namesNoSchema);
const unnamed = serviceNodes(
schemaless,
schemaless.reduce((acc, row) => acc + num(row.total_time_ns), 0),
"service::",
);
// Only a gap big enough to change how a share reads is worth a sentence; the
// two grains are computed by different code paths and drift by rounding.
const missing =
@@ -253,5 +266,5 @@ export const buildDatabaseBreakdown = (
: null;
const shortfall = missing !== null && missing >= SHORTFALL_FLOOR ? missing : null;
return { levels, collapsed, attributedNs, databaseTotalNs, shortfall };
return { levels, collapsed, unnamed, attributedNs, databaseTotalNs, shortfall };
};
+91 -1
View File
@@ -36,8 +36,10 @@
* row would be a nesting level whose only content is a missing fact.
*/
import type { DbmBreakdown, DbmBreakdownNode } from "@/utils/dbm/breakdown";
import { SHORTFALL_FLOOR, type DbmBreakdown, type DbmBreakdownNode } from "@/utils/dbm/breakdown";
import type { DbTotalsRow } from "@/services/db_monitoring";
import { errorRate } from "@/utils/dbm/format";
import { worstPercentile } from "@/utils/dbm/instanceRows";
/**
* Which tier of the split a child row came from.
@@ -190,6 +192,94 @@ export const toBreakdownRows = (
});
};
/** The exact per-schema totals a schema row is built from. */
export type DbmSchemaTotals = Pick<
DbTotalsRow,
"db_namespace" | "calls" | "errors" | "total_time_ns" | "p50_ns" | "p95_ns" | "p99_ns"
>;
const UNATTRIBUTED_KEY = "schema:\u0000none";
const schemaRow = (
name: string | null,
rows: DbmSchemaTotals[],
services: DbmBreakdownNode[],
serverTotal: number,
parentKey: string,
): DbmBreakdownRow => {
const key = `${parentKey}/${name === null ? UNATTRIBUTED_KEY : `schema:${name}`}`;
const calls = rows.reduce((acc, row) => acc + (row.calls ?? 0), 0);
const errors = rows.reduce((acc, row) => acc + (row.errors ?? 0), 0);
const time = rows.reduce((acc, row) => acc + (row.total_time_ns ?? 0), 0);
return {
rowKey: key,
kind: "schema",
name,
namespace: name,
service: null,
calls,
errors,
errorRate: errorRate(errors, calls),
total_time_ns: time,
share: serverTotal > 0 ? time / serverTotal : 0,
p50_ns: worstPercentile(rows, (row) => row.p50_ns) ?? null,
p95_ns: worstPercentile(rows, (row) => row.p95_ns) ?? null,
p99_ns: worstPercentile(rows, (row) => row.p99_ns) ?? null,
queryCount: services.reduce((acc, service) => acc + service.queryCount, 0),
status: null,
children: services.map((child) => serviceRow(child, name, key)),
};
};
/** A server's children from its exact schema totals, plus one row for its unnamed totals. */
export const toSchemaRows = (
state: DbmBreakdownState | undefined,
schemas: DbmSchemaTotals[],
parentKey: string,
): DbmBreakdownRow[] => {
const named = schemas.filter((schema) => !!schema.db_namespace);
if (!named.length) return toBreakdownRows(state, parentKey);
if (!state || state.loading) return [statusRow("loading", parentKey)];
const nodes = new Map<string, DbmBreakdownNode>();
if (!state.failed) {
for (const node of state.breakdown.collapsed ? [] : state.breakdown.levels) {
if (node.name) nodes.set(node.name, node);
}
}
const unnamed = schemas.filter((schema) => !schema.db_namespace);
const serverTotal = schemas.reduce((acc, schema) => acc + (schema.total_time_ns ?? 0), 0);
const rows = named.map((schema) => {
const name = schema.db_namespace as string;
return schemaRow(name, [schema], nodes.get(name)?.children ?? [], serverTotal, parentKey);
});
// A multi-schema statement's time sits in its schemas' totals, so listing it here would count it twice.
if (unnamed.length) {
const services = state.failed ? [] : state.breakdown.unnamed;
rows.push(schemaRow(null, unnamed, services, serverTotal, parentKey));
}
if (state.failed) rows.push(statusRow("error", parentKey));
return rows;
};
/** The share of the parent's time its children leave unaccounted, above the rounding floor. */
export const childShortfall = (
children: DbmBreakdownRow[],
parentTotalNs: number | null | undefined,
): number | null => {
if (!parentTotalNs || parentTotalNs <= 0) return null;
// A status row has no figures, so a split that is not in yet has no gap to state.
if (!children.some((child) => child.kind !== "status")) return null;
const covered = children.reduce((acc, child) => acc + child.total_time_ns, 0);
const missing = (parentTotalNs - covered) / parentTotalNs;
return missing >= SHORTFALL_FLOOR ? missing : null;
};
/** A schema row's own uncovered share; `null` on any other row and when nothing was attributed. */
export const schemaShortfall = (row: DbmBreakdownRow): number | null =>
row.kind === "schema" ? childShortfall(row.children, row.total_time_ns) : null;
/**
* Whether this database's split may carry the coverage caveat.
*
+27
View File
@@ -475,6 +475,33 @@ describe("unionFleetRows with server-vantage instances", () => {
);
expect(withOption).toEqual(without);
});
it("adds no trafficless or engine-only row under a Schema filter", () => {
const rows = unionFleetRows(
[totalsRow({ db_instance: "pgprod-1", db_namespace: "orders" })],
new Map([
["postgresql|pgprod-1", metrics()],
["postgresql|idle-replica", metrics()],
]),
{
namespace: "orders",
serverInstances: [
{ db_system: "postgresql", db_instance: "pg-reporting" },
{ db_system: "mysql", db_instance: null },
],
},
);
expect(rows.map((r) => [r.db_instance, r.trafficless])).toEqual([["pgprod-1", false]]);
expect(rows[0].metrics?.state).toBe("matched");
});
it("leaves nothing to render when the Schema filter matched no traffic", () => {
const rows = unionFleetRows([], new Map([["postgresql|idle-replica", metrics()]]), {
namespace: "stale-schema",
serverInstances: [{ db_system: "postgresql", db_instance: "pg-reporting" }],
});
expect(rows).toEqual([]);
});
});
// ── the server-vantage ref dedupe ────────────────────────────────────────────
+5 -4
View File
@@ -175,6 +175,8 @@ export const unionFleetRows = (
metricsByKey: Map<string, DbmInstanceMetricSet>,
context: DbmMergeContext & {
system?: string | null;
/** The Schema filter; only query traffic can show a server holds that schema. */
namespace?: string | null;
/** Instances `_o2_dbm_server` rows name — the second discovery source. */
serverInstances?: readonly DbmServerInstanceRef[];
} = {},
@@ -194,16 +196,15 @@ export const unionFleetRows = (
if (aliasKey) claimed.add(aliasKey);
return {
...row,
// The client grain includes the NAMESPACE — one host serving two
// databases is two rows — while an instance identity does not. Keying a
// client row on the identity would collapse those two onto one id and
// render one of them twice.
// Namespace included, so per-schema input rows on one instance never share an id.
rowKey: clientRowKey(row),
trafficless: false,
metrics: resolveRowMetrics(row.db_system, row.db_instance, metricsByKey, context),
};
});
if (context.namespace) return clientRows;
// Both non-client vantages, folded onto one identity key so an instance
// they both report appears exactly once — with the metric set when the
// metrics read has one, and `null` when only a `_o2_dbm_server` row names it.
+461
View File
@@ -0,0 +1,461 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import type { DbTotalsRow, QueryStatsRow } from "@/services/db_monitoring";
import { buildDatabaseBreakdown } from "@/utils/dbm/breakdown";
import { childShortfall, schemaShortfall, toSchemaRows } from "@/utils/dbm/breakdownRows";
import {
drowningInstanceKeys,
foldByInstance,
hasWorstOfPercentiles,
instanceKey,
isCriticalServer,
serverRowKey,
} from "@/utils/dbm/instanceRows";
const totals = (over: Partial<DbTotalsRow> = {}): DbTotalsRow => ({
db_system: "postgresql",
db_instance: "o2dev",
...over,
});
/** The shape the live O2-introspection org returned: one host, many databases. */
const o2dev = [
totals({
db_namespace: "o2dev",
calls: 300,
errors: 3,
total_time_ns: 9_000,
p95_ns: 30,
max_ns: 50,
qps: 0.3,
calling_services: ["o2-dbm-agent"],
}),
totals({
db_namespace: "postgres",
calls: 100,
total_time_ns: 1_000,
p95_ns: 10,
max_ns: 90,
qps: 0.1,
calling_services: ["o2-dbm-agent", "pgbouncer"],
}),
totals({ db_instance: "valkey-cart", db_system: "redis", db_namespace: "", calls: 5 }),
totals({ db_namespace: "o2alpha", calls: 0, total_time_ns: 5_000 }),
];
describe("foldByInstance", () => {
it("returns one row per (system, instance), whatever the schema count", () => {
const folded = foldByInstance(o2dev);
expect(folded.map((row) => row.db_instance)).toEqual(["o2dev", "valkey-cart"]);
});
it("sums the additive figures exactly and keeps the worst max", () => {
const [server] = foldByInstance(o2dev);
expect(server.calls).toBe(400);
expect(server.errors).toBe(3);
expect(server.total_time_ns).toBe(15_000);
expect(server.qps).toBeCloseTo(0.4);
expect(server.max_ns).toBe(90);
});
it("takes the worst schema's percentile, never a mean of them", () => {
const [server] = foldByInstance(o2dev);
expect(server.p95_ns).toBe(30);
expect(server.p50_ns).toBeUndefined();
});
it("unions the calling services", () => {
const [server] = foldByInstance(o2dev);
expect(server.calling_services).toEqual(["o2-dbm-agent", "pgbouncer"]);
});
it("names no namespace for a multi-schema server, and keeps the only one otherwise", () => {
const [server, redis] = foldByInstance(o2dev);
expect(server.db_namespace).toBeUndefined();
expect(redis.db_namespace).toBe("");
});
it("lists the named schemas, so one named schema beside unnamed traffic is still named", () => {
const [server] = foldByInstance(o2dev);
expect(server.namedSchemas).toEqual(["o2dev", "o2alpha", "postgres"]);
const [mixed] = foldByInstance([
totals({ db_namespace: "orders", calls: 10, total_time_ns: 100 }),
totals({ db_namespace: "", calls: 4, total_time_ns: 40 }),
]);
expect(mixed.db_namespace).toBeUndefined();
expect(mixed.namedSchemas).toEqual(["orders"]);
});
it("keys a server row on its identity alone, whatever schemas fold into it", () => {
const [all] = foldByInstance(o2dev);
const [one] = foldByInstance(o2dev, "postgres");
expect(serverRowKey(all)).toBe(serverRowKey(one));
expect(serverRowKey(all)).not.toBe(serverRowKey(foldByInstance(o2dev)[1]));
});
it("keeps the schemas heaviest first", () => {
const [server] = foldByInstance(o2dev);
expect(server.schemas.map((row) => row.db_namespace)).toEqual(["o2dev", "o2alpha", "postgres"]);
});
it("narrows to one schema under the Schema filter and drops servers without it", () => {
const folded = foldByInstance(o2dev, "postgres");
expect(folded).toHaveLength(1);
expect(folded[0].db_namespace).toBe("postgres");
expect(folded[0].calls).toBe(100);
expect(folded[0].p95_ns).toBe(10);
});
});
describe("toSchemaRows", () => {
const split: QueryStatsRow[] = [
{
fingerprint: "a",
db_system: "postgresql",
db_instance: "o2dev",
db_namespace: "o2dev",
service_name: "o2-dbm-agent",
calls: 10,
total_time_ns: 4_000,
},
];
const state = (rows: QueryStatsRow[]) => ({
breakdown: buildDatabaseBreakdown(rows),
loading: false,
failed: false,
});
it("builds one schema child per schema from the exact totals", () => {
const [server] = foldByInstance(o2dev);
const children = toSchemaRows(state(split), server.schemas, "p");
expect(children.map((child) => [child.kind, child.name, child.calls])).toEqual([
["schema", "o2dev", 300],
["schema", "o2alpha", 0],
["schema", "postgres", 100],
]);
expect(children[0].share).toBeCloseTo(9_000 / 15_000);
});
it("hangs the split's services under their own schema only", () => {
const [server] = foldByInstance(o2dev);
const [o2devRow, o2alpha] = toSchemaRows(state(split), server.schemas, "p");
expect(o2devRow.children.map((child) => child.name)).toEqual(["o2-dbm-agent"]);
expect(o2devRow.queryCount).toBe(1);
expect(o2alpha.children).toEqual([]);
});
it("keeps the schemas and says the split failed when it did", () => {
const [server] = foldByInstance(o2dev);
const children = toSchemaRows({ ...state([]), failed: true }, server.schemas, "p");
expect(children.map((child) => [child.kind, child.status])).toEqual([
["schema", null],
["schema", null],
["schema", null],
["status", "error"],
]);
expect(children[3].rowKey).toBe("p/status");
});
it("adds one trailing child for traffic no single schema owns, so the children add up", () => {
const schemas = [
totals({ db_namespace: "orders", calls: 600, total_time_ns: 6_000, p95_ns: 40 }),
totals({ db_namespace: "", calls: 200, errors: 2, total_time_ns: 3_000, p95_ns: 90 }),
totals({ db_namespace: "billing", calls: 100, total_time_ns: 1_000, p95_ns: 20 }),
];
const [server] = foldByInstance(schemas);
const splitRows: QueryStatsRow[] = [
{
fingerprint: "f1",
db_system: "postgresql",
db_instance: "o2dev",
db_namespace: "orders",
service_name: "checkout",
calls: 500,
total_time_ns: 5_000,
},
{
fingerprint: "f2",
db_system: "postgresql",
db_instance: "o2dev",
namespaces: ["orders", "billing"],
service_name: "healthcheck",
calls: 50,
total_time_ns: 500,
},
{
fingerprint: "f3",
db_system: "postgresql",
db_instance: "o2dev",
service_name: "migrator",
calls: 150,
total_time_ns: 2_500,
},
];
const children = toSchemaRows(state(splitRows), server.schemas, "p");
expect(children.map((child) => [child.name, child.calls])).toEqual([
["orders", 600],
["billing", 100],
[null, 200],
]);
const unattributed = children[2];
expect(unattributed.kind).toBe("schema");
expect(unattributed.errors).toBe(2);
expect(unattributed.p95_ns).toBe(90);
expect(unattributed.children.map((child) => child.name)).toEqual(["migrator"]);
const time = children.reduce((acc, child) => acc + child.total_time_ns, 0);
expect(time).toBe(server.total_time_ns);
expect(children.reduce((acc, child) => acc + child.share, 0)).toBeCloseTo(1);
expect(childShortfall(children, server.total_time_ns)).toBeNull();
});
it("counts a multi-schema statement once: no unattributed child without unnamed totals", () => {
const [server] = foldByInstance([
totals({ db_namespace: "orders", calls: 600, total_time_ns: 6_000, p95_ns: 40 }),
totals({ db_namespace: "billing", calls: 100, total_time_ns: 1_000, p95_ns: 20 }),
]);
const children = toSchemaRows(
state([
{
fingerprint: "f1",
db_system: "postgresql",
db_instance: "o2dev",
db_namespace: "orders",
service_name: "checkout",
calls: 500,
total_time_ns: 5_000,
},
{
fingerprint: "f2",
db_system: "postgresql",
db_instance: "o2dev",
namespaces: ["orders", "billing"],
service_name: "healthcheck",
calls: 50,
total_time_ns: 500,
},
]),
server.schemas,
"p",
);
expect(children.map((child) => child.name)).toEqual(["orders", "billing"]);
expect(children.reduce((acc, child) => acc + child.total_time_ns, 0)).toBe(
server.total_time_ns,
);
expect(children.reduce((acc, child) => acc + child.calls, 0)).toBe(server.calls);
});
it("takes the unattributed figures from unnamed totals, never a multi-schema statement's", () => {
const [server] = foldByInstance([
totals({ db_namespace: "orders", calls: 600, total_time_ns: 6_000 }),
totals({ db_namespace: "", calls: 40, errors: 1, total_time_ns: 400, p99_ns: 70 }),
]);
const children = toSchemaRows(
state([
{
fingerprint: "f9",
db_system: "postgresql",
db_instance: "o2dev",
namespaces: ["orders", "reports"],
service_name: "healthcheck",
calls: 900,
total_time_ns: 9_000,
p99_ns: 999,
},
]),
server.schemas,
"p",
);
const unattributed = children[1];
expect([unattributed.name, unattributed.calls, unattributed.errors]).toEqual([null, 40, 1]);
expect(unattributed.total_time_ns).toBe(400);
expect(unattributed.p99_ns).toBe(70);
expect(unattributed.children).toEqual([]);
expect(children.reduce((acc, child) => acc + child.total_time_ns, 0)).toBe(
server.total_time_ns,
);
});
it("states the shortfall when the children do not cover the parent", () => {
const children = toSchemaRows(
state(split),
[totals({ db_namespace: "o2dev", calls: 300, total_time_ns: 9_000 })],
"p",
);
expect(childShortfall(children, 9_000)).toBeNull();
expect(childShortfall(children, 12_000)).toBeCloseTo(0.25);
});
describe("a server with one named schema beside unnamed traffic", () => {
const [server] = foldByInstance([
totals({ db_namespace: "app", calls: 100, total_time_ns: 100, p95_ns: 5 }),
totals({ db_namespace: "", calls: 10, total_time_ns: 10, p95_ns: 50 }),
]);
const split: QueryStatsRow[] = [
{
fingerprint: "a",
db_system: "postgresql",
db_instance: "o2dev",
namespaces: ["app", "billing"],
service_name: "multi",
calls: 60,
total_time_ns: 60,
},
{
fingerprint: "b",
db_system: "postgresql",
db_instance: "o2dev",
service_name: "noschema",
calls: 10,
total_time_ns: 10,
},
{
fingerprint: "c",
db_system: "postgresql",
db_instance: "o2dev",
db_namespace: "app",
service_name: "appsvc",
calls: 30,
total_time_ns: 30,
},
];
const children = toSchemaRows(
{
breakdown: buildDatabaseBreakdown(split, server.total_time_ns),
loading: false,
failed: false,
},
server.schemas,
"k",
);
it("lists only schema-less statements under the unnamed child", () => {
const unnamed = children[1];
expect([unnamed.name, unnamed.total_time_ns]).toEqual([null, 10]);
expect(unnamed.children.map((child) => [child.name, child.total_time_ns])).toEqual([
["noschema", 10],
]);
});
it("states a schema's own shortfall when its services cover less than its total", () => {
const [app, unnamed] = children;
expect(app.children.map((child) => child.name)).toEqual(["appsvc"]);
expect(schemaShortfall(app)).toBeCloseTo(0.7);
expect(schemaShortfall(unnamed)).toBeNull();
expect(schemaShortfall(app.children[0])).toBeNull();
});
it("states no shortfall for a schema nothing was attributed to", () => {
const [, empty] = toSchemaRows(
{ breakdown: buildDatabaseBreakdown(split), loading: false, failed: false },
[...server.schemas, totals({ db_namespace: "reports", calls: 4, total_time_ns: 40 })],
"k",
);
expect([empty.name, empty.children]).toEqual(["reports", []]);
expect(schemaShortfall(empty)).toBeNull();
});
it("states no shortfall while the split is still loading", () => {
const loading = toSchemaRows(
{ breakdown: buildDatabaseBreakdown(split), loading: true, failed: false },
server.schemas,
"k",
);
expect(childShortfall(loading, server.total_time_ns)).toBeNull();
expect(childShortfall(toSchemaRows(undefined, server.schemas, "k"), 110)).toBeNull();
});
it("counts the server's speeds as a worst-of over its rows", () => {
expect(hasWorstOfPercentiles([server])).toBe(true);
expect(
hasWorstOfPercentiles(foldByInstance([totals({ db_namespace: "app", calls: 1 })])),
).toBe(false);
});
});
it("shows a loading placeholder until the split is filed", () => {
const [server] = foldByInstance(o2dev);
expect(toSchemaRows(undefined, server.schemas, "p")[0].kind).toBe("status");
});
it("drops straight to services when no schema was named", () => {
const redis = foldByInstance(o2dev)[1];
const children = toSchemaRows(
state([
{
fingerprint: "r",
db_system: "redis",
db_instance: "valkey-cart",
service_name: "cart",
calls: 5,
total_time_ns: 10,
},
]),
redis.schemas,
"r",
);
expect(children.map((child) => [child.kind, child.name])).toEqual([["service", "cart"]]);
});
});
describe("drowningInstanceKeys", () => {
const slow = 400_000_000;
const normal = 100_000_000;
const now = [
totals({ db_namespace: "orders", calls: 5_000, total_time_ns: 9e12, p95_ns: 500_000_000 }),
totals({ db_namespace: "reports", calls: 200, total_time_ns: 1e11, p95_ns: slow }),
totals({ db_instance: "pg-billing", db_namespace: "billing", calls: 900, p95_ns: normal }),
];
const before = [
totals({ db_namespace: "orders", calls: 5_000, total_time_ns: 9e12, p95_ns: 500_000_000 }),
totals({ db_namespace: "reports", calls: 200, total_time_ns: 1e10, p95_ns: normal }),
totals({ db_instance: "pg-billing", db_namespace: "billing", calls: 900, p95_ns: normal }),
];
it("flags the server when one schema slows down, though its worst p95 did not move", () => {
expect([...drowningInstanceKeys(now, before)]).toEqual([instanceKey(now[0])]);
});
it("compares schema to schema even when the server's schema count changed", () => {
expect([...drowningInstanceKeys(now, before.slice(1))]).toEqual([instanceKey(now[0])]);
});
it("honours the Schema filter", () => {
expect(drowningInstanceKeys(now, before, "orders").size).toBe(0);
expect(drowningInstanceKeys(now, before, "reports").size).toBe(1);
});
});
describe("isCriticalServer", () => {
it("is critical when one schema fails critically, even if the server's rate is diluted", () => {
const [server] = foldByInstance([
totals({ db_namespace: "orders", calls: 1_000, errors: 5 }),
totals({ db_namespace: "reports", calls: 30, errors: 3 }),
]);
expect(isCriticalServer(server)).toBe(true);
});
it("keeps the server rule", () => {
const [server] = foldByInstance([
totals({ db_namespace: "orders", calls: 15, errors: 1 }),
totals({ db_namespace: "reports", calls: 15, errors: 1 }),
]);
expect(isCriticalServer(server)).toBe(true);
const [calm] = foldByInstance([totals({ db_namespace: "orders", calls: 1_000, errors: 1 })]);
expect(isCriticalServer(calm)).toBe(false);
});
});
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// db_totals arrives per schema, but the reader's unit is the server, so rows fold here.
import type { DbTotalsRow } from "@/services/db_monitoring";
import { detectDrowningDatabases, isCriticalErrorRate } from "@/utils/dbm/insights";
/** A server row, with the per-schema rows it was folded from. */
export interface DbmInstanceTotals extends DbTotalsRow {
/** The per-namespace rows, heaviest first. Always at least one. */
schemas: DbTotalsRow[];
/** The schemas that carry a name, heaviest first. */
namedSchemas: string[];
}
const num = (value: number | undefined | null): number =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
/** NUL-separated, like `totalsKey`: no host or engine name can contain it. */
export const instanceKey = (row: Pick<DbTotalsRow, "db_system" | "db_instance">): string =>
`${row.db_system}\u0000${row.db_instance}`;
/** The table identity of a server row; it must not move when the schema count or filter does. */
export const serverRowKey = (row: Pick<DbTotalsRow, "db_system" | "db_instance">): string =>
`db\u0000${instanceKey(row)}`;
/** Whether a namespace-grain row falls inside the Schema filter. */
export const inSchema = (row: Pick<DbTotalsRow, "db_namespace">, namespace?: string | null) =>
!namespace || (row.db_namespace ?? "") === namespace;
/** Percentiles cannot be pooled, so a group reports its worst; `undefined` when none reported. */
export const worstPercentile = <T>(
rows: T[],
pick: (row: T) => number | null | undefined,
): number | undefined => {
const values = rows
.map(pick)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
return values.length ? Math.max(...values) : undefined;
};
const optionalSum = (
rows: DbTotalsRow[],
pick: (row: DbTotalsRow) => number | undefined,
): number | undefined =>
rows.some((row) => typeof pick(row) === "number")
? rows.reduce((acc, row) => acc + num(pick(row)), 0)
: undefined;
const foldOne = (rows: DbTotalsRow[]): DbmInstanceTotals => {
const [first] = rows;
const schemas = [...rows].sort((a, b) => num(b.total_time_ns) - num(a.total_time_ns));
const names = [...new Set(rows.map((row) => row.db_namespace ?? ""))];
return {
db_system: first.db_system,
db_instance: first.db_instance,
// A server row stands for all its schemas, so it names one only when it holds exactly one.
db_namespace: names.length === 1 ? first.db_namespace : undefined,
statements: optionalSum(rows, (row) => row.statements),
calls: optionalSum(rows, (row) => row.calls),
errors: optionalSum(rows, (row) => row.errors),
total_time_ns: optionalSum(rows, (row) => row.total_time_ns),
traces: optionalSum(rows, (row) => row.traces),
qps: optionalSum(rows, (row) => row.qps),
p50_ns: worstPercentile(rows, (row) => row.p50_ns),
p95_ns: worstPercentile(rows, (row) => row.p95_ns),
p99_ns: worstPercentile(rows, (row) => row.p99_ns),
max_ns: worstPercentile(rows, (row) => row.max_ns),
trace_stream_name: first.trace_stream_name,
calling_services: [
...new Set(rows.flatMap((row) => row.calling_services ?? []).filter(Boolean)),
],
schemas,
namedSchemas: [
...new Set(schemas.map((row) => row.db_namespace).filter((name): name is string => !!name)),
],
};
};
/** One row per (system, instance); under the Schema filter a server without it drops out. */
export const foldByInstance = (
rows: DbTotalsRow[],
namespace?: string | null,
): DbmInstanceTotals[] => {
const groups = new Map<string, DbTotalsRow[]>();
for (const row of rows) {
if (!inSchema(row, namespace)) continue;
const key = instanceKey(row);
const group = groups.get(key);
if (group) group.push(row);
else groups.set(key, [row]);
}
return [...groups.values()].map(foldOne);
};
/** Whether any server's speeds are the worst of several db_totals rows rather than its own. */
export const hasWorstOfPercentiles = (rows: { schemas?: DbTotalsRow[] }[]): boolean =>
rows.some((row) => (row.schemas?.length ?? 0) > 1);
/** The servers with a schema slowing against its own baseline, by `instanceKey`. */
export const drowningInstanceKeys = (
rows: DbTotalsRow[],
previousRows: DbTotalsRow[],
namespace?: string | null,
): Set<string> =>
new Set(
detectDrowningDatabases(
rows.filter((row) => inSchema(row, namespace)),
previousRows.filter((row) => inSchema(row, namespace)),
).map((found) => instanceKey(found.row)),
);
/** A server is critical on its own rate, or when any one schema's rate is critical. */
export const isCriticalServer = (
row: Pick<DbTotalsRow, "errors" | "calls"> & { schemas?: DbTotalsRow[] },
): boolean =>
isCriticalErrorRate(row.errors, row.calls) ||
(row.schemas ?? []).some((schema) => isCriticalErrorRate(schema.errors, schema.calls));
@@ -109,6 +109,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:top-n-subset="topNSubset"
:error-count="errorCount"
exact-percentiles
:slowest-schema-speeds="showsSlowestSchemaSpeeds"
data-test="dbm-databases-coverage"
/>
</DbmTableToolbar>
@@ -205,9 +206,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</span>
<div class="text-text-secondary text-3xs flex min-w-0 items-center gap-1">
<OTag type="dbSystem" :value="row.db_system" size="xs" />
<template v-if="row.db_namespace">
<template v-if="namedSchemaCount(row) === 1">
<span class="opacity-45">·</span>
<OTruncatedText>{{ row.db_namespace }}</OTruncatedText>
<OTruncatedText>{{ row.namedSchemas?.[0] }}</OTruncatedText>
</template>
<template v-else-if="namedSchemaCount(row) > 1">
<span class="opacity-45">·</span>
<span class="shrink-0" data-test="dbm-databases-schema-count">
{{
t(
"dbm.databases.schemaCount",
{ count: namedSchemaCount(row) },
namedSchemaCount(row),
)
}}
</span>
</template>
<!-- The receiver can reach it, no application asked it anything.
That is a finding, not an absence — an idle replica is what
@@ -441,7 +454,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// silently drop the page from the cache and bring back the refetch-on-return.
defineOptions({ name: "DbmDatabasesPage" });
import { computed, ref, watch, shallowRef } from "vue";
import { computed, onActivated, ref, watch, shallowRef } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "vuex";
@@ -478,13 +491,22 @@ import { tabCountProps } from "@/composables/dbm/useDbmTabCounts";
import { useDbmListPage } from "@/composables/dbm/useDbmListPage";
import { useDbmOwnFilterSync } from "@/composables/dbm/useDbmScopeFilters";
import { createDbmContextProvider } from "@/composables/contextProviders";
import { buildDatabaseBreakdown, type DbmBreakdown } from "@/utils/dbm/breakdown";
import { buildDatabaseBreakdown, rowNamespace, type DbmBreakdown } from "@/utils/dbm/breakdown";
import {
isBreakdownRow,
schemaShortfall,
showsShortfall,
toBreakdownRows,
toSchemaRows,
type DbmBreakdownRow,
} from "@/utils/dbm/breakdownRows";
import {
drowningInstanceKeys,
foldByInstance,
hasWorstOfPercentiles,
instanceKey,
isCriticalServer,
serverRowKey,
} from "@/utils/dbm/instanceRows";
import {
computeQps,
errorRate,
@@ -501,7 +523,6 @@ import type { DbmInstanceMetricSet, DbmRowMetrics } from "@/utils/dbm/instanceMe
import { unionFleetRows, type DbmServerInstanceRef } from "@/utils/dbm/fleetRows";
import { healthScalar, healthSortValue } from "@/utils/dbm/healthScalar";
import { APP_SOURCE_QUALIFIER } from "@/utils/dbm/overlapMetrics";
import { detectDrowningDatabases, isCriticalErrorRate, totalsKey } from "@/utils/dbm/insights";
import DbmOverlapValue from "@/components/dbm/DbmOverlapValue.vue";
import { hasDbmTraceVantage } from "@/composables/dbm/useDbmTraceVantage";
import { buildDbmPrefill } from "@/utils/alerts/prefill/fromDbm";
@@ -553,16 +574,32 @@ const permissionOk = ref(true);
// still return staging merged with prod — a filter that lies is worse than no
// filter. Top queries is the grain that genuinely accepts `env`.
const systemFilter = ref<string | null>((route.query.system as string) ?? null);
// Client-side: the endpoint takes no namespace, and every schema is already in the response.
const namespaceFilter = ref<string | null>((route.query.namespace as string) ?? null);
// A schema row's hand-off scopes the page it opened; the tabs carrying it back must not filter this one.
let handedOffSchema: string | null = null;
const returnsWithHandedOffSchema = () =>
handedOffSchema !== null && route.query.namespace === handedOffSchema;
// This page is kept alive, so the seeds above run ONCE per session — a filter
// set (or cleared) on a sibling tab would otherwise never reach these refs,
// leaving the chips and the next read disagreeing with the URL. Re-read on
// activation, the same moment the range sync uses.
useDbmOwnFilterSync(
() => route.query,
{ system: systemFilter },
() =>
returnsWithHandedOffSchema()
? { ...route.query, namespace: namespaceFilter.value ?? undefined }
: route.query,
{ system: systemFilter, namespace: namespaceFilter },
() => void load(),
);
// Registered after the re-seed above, which still needs the hand-off to tell it apart.
onActivated(() => {
const returned = returnsWithHandedOffSchema();
handedOffSchema = null;
if (returned) void syncUrl();
});
/** Kept so the empty state can say "we haven't finished counting" rather than "no data". */
const neverAggregated = ref(false);
/**
@@ -586,6 +623,10 @@ const failedMetricStreams = ref<string[]>([]);
* when it lands without refetching them.
*/
const clientHits = shallowRef<DbTotalsRow[]>([]);
/** The previous window's rows, so a Schema filter change re-derives drowning without a refetch. */
const baselineHits = shallowRef<DbTotalsRow[]>([]);
const baselineReadFailed = ref(false);
/** Drowning servers, by `instanceKey`. */
const drowningKeys = ref<Set<string>>(new Set());
/** At most this many service chips per row before the cell collapses the rest. */
@@ -594,8 +635,12 @@ const MAX_VISIBLE_SERVICES = 3;
interface DatabaseRow extends Partial<DbTotalsRow> {
db_system: string;
db_instance: string;
/** OTable needs a stable identity; the grain is (system, instance, namespace). */
/** OTable needs a stable identity; the grain is (system, instance). */
rowKey: string;
/** The per-schema rows this server row was folded from — its schema children. */
schemas?: DbTotalsRow[];
/** The named schemas among them, heaviest first. */
namedSchemas?: string[];
share: number;
errorRate: number | null;
/**
@@ -700,7 +745,8 @@ const summaryStats = computed<StatItem[]>(() => {
});
const isFiltered = computed(
() => !!search.value || !!systemFilter.value || statFilter.value !== null,
() =>
!!search.value || !!systemFilter.value || !!namespaceFilter.value || statFilter.value !== null,
);
// Every filter change publishes the scope to the URL BEFORE reloading — the
@@ -710,6 +756,20 @@ const filterEntry = createDbmFilterEntry(() => {
load();
});
// The Schema filter narrows rows already in memory, so a refetch would only reset the open rows.
const schemaFilterEntry = createDbmFilterEntry(async () => {
// The shell re-keys the badges on the URL, and drops a count published before it did.
await syncUrl();
if (error.value) {
load();
return;
}
if (loading.value) return;
breakdowns.value = {};
deriveRows();
fillOpenBreakdowns();
});
/**
* The same popover-and-chips control Top queries uses. Only the dimensions this
* endpoint actually accepts are offered — a select that silently did nothing
@@ -723,6 +783,14 @@ const dimensionFilters = computed<DbmScopeFilter[]>(() => [
options: optionsFrom(rows.value.map((r) => r.db_system)),
model: systemFilter,
}),
// From the unfolded response, so the selected schema stays among the options.
schemaFilterEntry({
key: "namespace",
dimension: t("dbm.filters.dimension.namespace"),
placeholder: t("dbm.filters.allNamespaces"),
options: optionsFrom(clientHits.value.map((r) => r.db_namespace)),
model: namespaceFilter,
}),
]);
/**
@@ -736,12 +804,19 @@ const visibleRows = computed(() => {
statFilter.value === "failed" ? rows.value.filter((row) => (row.errors ?? 0) > 0) : rows.value;
if (!needle) return scoped;
return scoped.filter((row) =>
[row.db_instance, row.db_namespace, row.db_system, ...(row.calling_services ?? [])]
[
row.db_instance,
row.db_system,
...(row.schemas ?? []).map((schema) => schema.db_namespace),
...(row.calling_services ?? []),
]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(needle)),
);
});
const showsSlowestSchemaSpeeds = computed(() => hasWorstOfPercentiles(visibleRows.value));
// ── the in-place breakdown ──────────────────────────────────────────────────
//
// The split renders as TREE CHILD ROWS of this table, not as a panel with a
@@ -800,8 +875,9 @@ const treeRows = computed<TableRow[]>(() =>
// chevron onto a fetch that can only ever come back empty.
children: row.trafficless
? []
: toBreakdownRows(
: toSchemaRows(
state && { breakdown: state.breakdown, loading: state.loading, failed: !!state.error },
row.schemas ?? [],
row.rowKey,
),
};
@@ -921,35 +997,28 @@ const onSortChange = ({ column, order }: { column: string; order: "asc" | "desc"
sortOrder.value = order;
};
/**
* Whether this database's split falls short of its own total. Only an open
* database can have one — OTable renders the warning row between a parent and
* its children, which is exactly where the gap is readable.
*
* `showsShortfall` owns the rule, and the case it excludes is the one that made
* this caveat repeat verbatim under all four databases: nothing attributed puts
* every row's shortfall at exactly 1, and the `empty` placeholder child already
* says that better.
*/
const hasShortfall = (row: TableRow): boolean => {
if (isBreakdownRow(row)) return false;
// Zero attribution is left to the `empty` placeholder, so one 100% caveat never repeats under every row.
const shortfallOf = (row: TableRow): number | null => {
if (isBreakdownRow(row)) return schemaShortfall(row);
// Schema children are exact totals that add up to the server, so its gap shows on each schema row.
if (namedSchemaCount(row) > 0) return null;
const state = breakdowns.value[row.rowKey];
return showsShortfall(
state && { breakdown: state.breakdown, loading: state.loading, failed: !!state.error },
);
)
? breakdownFor(row).breakdown.shortfall
: null;
};
const hasShortfall = (row: TableRow): boolean => shortfallOf(row) !== null;
/**
* What the shortfall means for the rows below it, with the number attached. A
* caveat carrying a figure is a disclosure; one without is a disclaimer nobody
* reads — the rule the coverage line above the table already follows.
*/
const shortfallLine = (row: TableRow): I18nText =>
isBreakdownRow(row)
? raw("")
: t("dbm.breakdown.shortfall", {
percent: formatPercent(breakdownFor(row).breakdown.shortfall ?? 0, 0),
});
t("dbm.breakdown.shortfall", { percent: formatPercent(shortfallOf(row) ?? 0, 0) });
/**
* What the placeholder child says. The failure keeps the server's own wording
@@ -979,15 +1048,7 @@ const statusLine = (row: DbmBreakdownRow): I18nText => {
const instanceBreakdowns = shallowRef<Record<string, QueryStatsRow[]> | null>(null);
const breakdownFailed = ref(false);
/**
* File one database's split from what the last load returned.
*
* The row's own exact total rides along so the aggregation can report its
* shortfall rather than presenting a sum that silently does not reconcile. The
* split is keyed by INSTANCE, and that is the grain it always had: the
* per-row request scoped on `instance` alone, so two namespace rows on one
* instance received the same response then and read the same entry now.
*/
// The row's exact total rides along so the split reports its shortfall instead of a sum that does not add up.
const fileBreakdown = (row: DatabaseRow) => {
const section = instanceBreakdowns.value;
if (!section || breakdownFailed.value) {
@@ -1007,7 +1068,12 @@ const fileBreakdown = (row: DatabaseRow) => {
// An instance with no rows in the split is an EMPTY breakdown, not a
// failure: nothing was attributable, which the `empty` placeholder
// already says better than an error would.
breakdown: buildDatabaseBreakdown(section[row.db_instance] ?? [], row.total_time_ns),
breakdown: buildDatabaseBreakdown(
(section[row.db_instance] ?? []).filter(
(entry) => !namespaceFilter.value || rowNamespace(entry) === namespaceFilter.value,
),
row.total_time_ns,
),
loading: false,
error: null,
},
@@ -1073,13 +1139,14 @@ watch(serverKnownInstances, () => {
* an idle replica.
*/
const applyInstanceMetrics = () => {
const hits = clientHits.value;
const hits = foldByInstance(clientHits.value, namespaceFilter.value);
const total = hits.reduce((acc, row) => acc + (row.total_time_ns ?? 0), 0);
// With no metrics the union is the identity over the client rows, which is
// exactly the page as it shipped.
rows.value = unionFleetRows(hits, instanceMetrics.value, {
failedStreams: failedMetricStreams.value,
system: systemFilter.value,
namespace: namespaceFilter.value,
// The instances the SERVER vantage knows, from data the shell already
// fetched for the tab badges. This is what keeps the overview honest for
// the user who wired up collector recipes but has no APM: their fleet is
@@ -1090,15 +1157,23 @@ const applyInstanceMetrics = () => {
const idle = row.trafficless;
return {
...row,
rowKey: idle ? row.rowKey : serverRowKey(row),
share: !idle && total > 0 ? (row.total_time_ns ?? 0) / total : 0,
errorRate: idle ? null : errorRate(row.errors, row.calls),
drowning: !idle && drowningKeys.value.has(totalsKey(row as DbTotalsRow)),
critical: !idle && isCriticalErrorRate(row.errors, row.calls),
drowning: !idle && drowningKeys.value.has(instanceKey(row)),
critical: !idle && isCriticalServer(row),
chips: idle ? [] : databaseChips(row as DbTotalsRow),
};
});
};
const deriveRows = () => {
drowningKeys.value = baselineReadFailed.value
? new Set()
: drowningInstanceKeys(clientHits.value, baselineHits.value, namespaceFilter.value);
applyInstanceMetrics();
};
const { getStreams } = useStreams(t);
/**
@@ -1193,17 +1268,12 @@ const load = () =>
topNSubset.value = data.top_n_subset;
neverAggregated.value = data.freshness?.data_through === 0;
const drowning = data.baseline_read_failed
? new Set<string>()
: new Set(
detectDrowningDatabases(hits, data.baseline_hits ?? []).map((d) => totalsKey(d.row)),
);
clientHits.value = hits;
drowningKeys.value = drowning;
baselineHits.value = data.baseline_hits ?? [];
baselineReadFailed.value = !!data.baseline_read_failed;
instanceBreakdowns.value = data.breakdown ?? null;
breakdownFailed.value = !!data.breakdown_read_failed;
applyInstanceMetrics();
deriveRows();
// Rows that were open before the reload need their split re-filed for
// the new window; the watcher only fires when the open SET changes.
fillOpenBreakdowns();
@@ -1282,6 +1352,10 @@ const databaseChips = (row: DbTotalsRow): DbmRowChip[] =>
const missing = (value: number | null | undefined): boolean =>
value === null || value === undefined;
/** How many named schemas a server row was folded from; 0 on a child row. */
const namedSchemaCount = (row: TableRow): number =>
isBreakdownRow(row) ? 0 : (row.namedSchemas?.length ?? 0);
/** The placeholder standing in for a split that has not arrived — no figures. */
const isStatusRow = (row: TableRow): boolean => isBreakdownRow(row) && row.kind === "status";
@@ -1413,14 +1487,7 @@ const onRowAction = (id: string, row: DatabaseRow) => {
);
};
/**
* Columns. Sorting is client-side over the complete set, so every numeric
* column can be sorted honestly — unlike the Top queries table, where the
* server owns the order and only whitelisted keys may claim to be sortable.
*
* The percentile headers say what they mean. These are exact per-database
* figures, so unlike the query grain they need no qualifier.
*/
// Percentiles cannot be pooled, so a server spanning several schemas shows its worst schema's.
const allColumns = computed<OTableColumnDef<TableRow>[]>(() => [
{
id: "instance",
@@ -1615,6 +1682,7 @@ const defaultColumnVisibility = {};
const clearScope = () => {
systemFilter.value = null;
namespaceFilter.value = null;
statFilter.value = null;
// Clear the SEARCH too, the way every sibling tab's clear does
// (TableHealthPage, SamplesPage, QueriesPage). Leaving it set makes "clear"
@@ -1642,7 +1710,7 @@ const clearScope = () => {
* from `route.query`, leaving them in the URL means a "cleared" section is
* still filtered the moment the reader switches tabs.
*/
const syncUrl = ({ clearAllScope = false }: { clearAllScope?: boolean } = {}) => {
const syncUrl = ({ clearAllScope = false }: { clearAllScope?: boolean } = {}) =>
router
.replace({
name: route.name as string,
@@ -1650,6 +1718,7 @@ const syncUrl = ({ clearAllScope = false }: { clearAllScope?: boolean } = {}) =>
...route.query,
...queryParams.value,
system: systemFilter.value ?? undefined,
namespace: namespaceFilter.value ?? undefined,
...(clearAllScope
? {
instance: undefined,
@@ -1662,7 +1731,6 @@ const syncUrl = ({ clearAllScope = false }: { clearAllScope?: boolean } = {}) =>
},
})
.catch(() => {});
};
/**
* Every cause resolves to an action, including `not-instrumented` — the default
@@ -1688,6 +1756,13 @@ const onEmptyAction = (cause: DbmEmptyCauseId) => {
}
};
/** A server row hands off the overview's Schema filter, never the one schema it happens to hold. */
const handOffSchema = (schema?: string | null): string | null => {
const namespace = schema ?? namespaceFilter.value;
handedOffSchema = namespace !== namespaceFilter.value ? namespace : null;
return namespace;
};
/**
* The overview's job is to hand off — a row click carries its scope along, so
* the Top queries tab opens already filtered to the database the user clicked
@@ -1695,12 +1770,12 @@ const onEmptyAction = (cause: DbmEmptyCauseId) => {
*
* `target` is what a breakdown leaf adds: the schema and service the reader
* picked, so every node of the split is a filter into the query grain rather
* than a dead end. Its schema wins over the row's, because the leaf is the
* than a dead end. Its schema wins over the Schema filter's, because the leaf is the
* narrower and more recent statement of what the reader wants.
*/
/** The Metrics tab, scoped to this database — window and filters carry. */
const openMetrics = (row: DatabaseRow, namespaceOverride?: string | null) => {
const namespace = namespaceOverride ?? row.db_namespace;
const namespace = handOffSchema(namespaceOverride);
router
.push({
name: "dbmMetrics",
@@ -1710,7 +1785,7 @@ const openMetrics = (row: DatabaseRow, namespaceOverride?: string | null) => {
...queryParams.value,
system: row.db_system,
instance: row.db_instance,
...(namespace ? { namespace } : {}),
namespace: namespace ?? undefined,
},
})
.catch(() => {});
@@ -1720,7 +1795,7 @@ const openQueries = (
row: DatabaseRow,
target?: { namespace: string | null; service: string | null },
) => {
const namespace = target?.namespace ?? row.db_namespace;
const namespace = handOffSchema(target?.namespace);
router
.push({
name: "dbmQueries",
@@ -1730,7 +1805,7 @@ const openQueries = (
...queryParams.value,
system: row.db_system,
instance: row.db_instance,
...(namespace ? { namespace } : {}),
namespace: namespace ?? undefined,
...(target?.service ? { service: target.service } : {}),
},
})
@@ -1775,6 +1850,7 @@ const dbmContext = createDbmContextProvider(
endTime: current.value.endTime,
period: range.value.relativeTimePeriod,
system: systemFilter.value,
namespace: namespaceFilter.value,
},
}),
store,
@@ -0,0 +1,439 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { defineComponent, h, type PropType } from "vue";
import { createMemoryHistory, createRouter, RouterView, type Router } from "vue-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabasesResponse, QueryStatsRow } from "@/services/db_monitoring";
import type { DbmScopeFilter } from "@/components/dbm/DbmScopeFilters.vue";
import type { DbmRowAction } from "@/components/dbm/DbmRowActions.vue";
vi.mock("vuex", () => ({
useStore: () => ({
state: {
selectedOrganization: { identifier: "acme" },
zoConfig: { database_monitoring_enabled: true },
theme: "light",
},
dispatch: vi.fn(),
}),
}));
vi.mock("@/services/db_monitoring", () => ({
default: {
getBadges: vi.fn(),
getDatabases: vi.fn(),
getInstanceMetrics: vi.fn(),
},
}));
vi.mock("@/composables/useStreams", () => ({
default: () => ({ getStreams: vi.fn().mockResolvedValue({ list: [] }) }),
}));
const { default: dbMonitoringService } = await import("@/services/db_monitoring");
const { queryClient } = await import("@/composables/query/queryClient");
const { contextRegistry } = await import("@/composables/contextProviders");
const { default: DbmShell } = await import("./DbmShell.vue");
const { default: DatabasesPage } = await import("./DatabasesPage.vue");
const service = dbMonitoringService as unknown as Record<string, ReturnType<typeof vi.fn>>;
const SECOND = 1_000_000_000;
/** `/databases` as the server answers it: one row per (system, instance, namespace). */
const databasesResponse = (): DatabasesResponse => ({
hits: [
{
db_system: "postgresql",
db_instance: "pg-orders",
db_namespace: "orders",
calls: 800,
errors: 0,
total_time_ns: 8 * SECOND,
p95_ns: 4_000_000,
calling_services: ["checkout"],
},
{
db_system: "postgresql",
db_instance: "pg-billing",
db_namespace: "billing",
calls: 400,
errors: 0,
total_time_ns: 2 * SECOND,
p95_ns: 9_000_000,
calling_services: ["invoice"],
},
{
db_system: "postgresql",
db_instance: "pg-billing",
db_namespace: "ledger",
calls: 100,
errors: 0,
total_time_ns: 1 * SECOND,
p95_ns: 3_000_000,
calling_services: ["ledger-svc"],
},
{
db_system: "mysql",
db_instance: "my-cart",
db_namespace: "",
calls: 50,
errors: 0,
total_time_ns: 1 * SECOND,
p95_ns: 2_000_000,
calling_services: ["cart"],
},
{
db_system: "redis",
db_instance: "valkey-cart",
db_namespace: "",
calls: 30,
errors: 0,
total_time_ns: 1 * SECOND,
p95_ns: 1_000_000,
calling_services: ["cart"],
},
],
baseline_hits: [],
breakdown: {
// `invoice` covers half of `billing`; `ledger-svc` covers all of `ledger`.
"pg-billing": [
stat("pg-billing", "billing", "invoice", SECOND),
stat("pg-billing", "ledger", "ledger-svc", SECOND),
],
"pg-orders": [stat("pg-orders", "orders", "checkout", 8 * SECOND)],
// Schemaless engine: 60% of its time attributed to a service.
"my-cart": [stat("my-cart", null, "cart", 0.6 * SECOND, "mysql")],
// Nothing attributed at all: the `empty` placeholder speaks, not a caveat.
"valkey-cart": [],
},
freshness: { data_through: 1_700_000_000_000_000 },
});
function stat(
instance: string,
namespace: string | null,
serviceName: string,
totalTimeNs: number,
system = "postgresql",
): QueryStatsRow {
return {
fingerprint: `${instance}-${serviceName}`,
db_system: system,
db_instance: instance,
...(namespace ? { db_namespace: namespace } : {}),
service_name: serviceName,
calls: 10,
errors: 0,
total_time_ns: totalTimeNs,
p95_ns: 1_000_000,
} as QueryStatsRow;
}
/** `/badges`: the client vantage names two postgres servers; activity knows a replica too. */
const badgesEnvelope = () => ({
databases: databasesResponse(),
queries: { total: 12, hits: [] },
activity: {
by_state: [{ state: "active", sessions: 1 }],
hits: [{ db_system: "postgresql", db_instance: "pg-replica" }],
},
deadlocks: { total: 0 },
blocking: { total: 0 },
table_health: { total: 0 },
});
interface TreeRow {
rowKey: string;
db_instance?: string;
namespace?: string | null;
children?: TreeRow[];
}
const OTableStub = defineComponent({
name: "OTable",
props: {
data: { type: Array as PropType<TreeRow[]>, default: () => [] },
getRowWarning: { type: Function as PropType<(row: TreeRow) => boolean>, default: null },
expandedIds: { type: Array as PropType<string[]>, default: () => [] },
},
emits: ["row-click", "update:expandedIds", "sort-change"],
setup(props, { slots }) {
const walk = (rows: TreeRow[]): TreeRow[] =>
rows.flatMap((row) => [
row,
...(props.expandedIds.includes(row.rowKey) ? walk(row.children ?? []) : []),
]);
return () =>
h("div", [
slots.toolbar?.(),
...walk(props.data).map((row) =>
h("div", { key: row.rowKey }, [
props.getRowWarning?.(row) ? slots["tree-warning"]?.({ row }) : null,
slots["cell-actions"]?.({ row }),
]),
),
]);
},
});
const DbmPageChromeStub = defineComponent({
name: "DbmPageChrome",
props: { tabCounts: { type: Object, default: () => ({}) } },
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.()),
});
const DbmTableToolbarStub = defineComponent({
name: "DbmTableToolbar",
setup:
(_, { slots }) =>
() =>
h("div", slots.filters?.()),
});
const DbmScopeFiltersStub = defineComponent({
name: "DbmScopeFilters",
props: { filters: { type: Array as PropType<DbmScopeFilter[]>, default: () => [] } },
setup: () => () => h("div"),
});
const DbmRowActionsStub = defineComponent({
name: "DbmRowActions",
props: { actions: { type: Array as PropType<DbmRowAction[]>, default: () => [] } },
emits: ["action"],
setup: () => () => h("div"),
});
const stubs = {
OTable: OTableStub,
DbmPageChrome: DbmPageChromeStub,
DbmTableToolbar: DbmTableToolbarStub,
DbmScopeFilters: DbmScopeFiltersStub,
DbmRowActions: DbmRowActionsStub,
DbmCoverageLine: true,
DbmEmptyState: true,
DbmInstanceHealthCell: true,
DbmRowChips: true,
DbmServiceList: true,
DbmSubheaderBand: true,
DbmOverlapValue: true,
DateTime: true,
ORefreshButton: true,
OEmptyState: true,
OTag: true,
OTruncatedText: true,
OIcon: true,
OStatStrip: true,
OTooltip: true,
};
const Elsewhere = defineComponent({ name: "DbmElsewhere", render: () => h("div") });
let wrapper: VueWrapper | null = null;
const settle = async () => {
for (let i = 0; i < 5; i += 1) await flushPromises();
};
const mountOverview = async (query: Record<string, string> = {}) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: "/dbm",
component: DbmShell,
children: [
{ path: "databases", name: "dbmDatabases", component: DatabasesPage },
{ path: "metrics", name: "dbmMetrics", component: Elsewhere },
{ path: "queries", name: "dbmQueries", component: Elsewhere },
],
},
],
});
await router.push({ name: "dbmDatabases", query: { org_identifier: "acme", ...query } });
await router.isReady();
wrapper = mount(defineComponent({ render: () => h(RouterView) }), {
global: { plugins: [router], stubs },
});
await settle();
return { router, wrapper };
};
const chrome = () => wrapper!.findComponent(DbmPageChromeStub);
const table = () => wrapper!.findComponent(OTableStub);
const badge = () => (chrome().props("tabCounts") as { databaseCount: unknown }).databaseCount;
const topRows = () => table().props("data") as TreeRow[];
const serverRow = (instance: string) => topRows().find((row) => row.db_instance === instance)!;
const setSchema = async (value: string | null) => {
const filters = wrapper!.findComponent(DbmScopeFiltersStub).props("filters") as DbmScopeFilter[];
filters.find((filter) => filter.key === "namespace")!.onChange(value as string);
await settle();
};
const expand = async (instance: string) => {
table().vm.$emit("update:expandedIds", [serverRow(instance).rowKey]);
await settle();
};
beforeEach(() => {
vi.clearAllMocks();
queryClient.clear();
service.getDatabases.mockImplementation(() => Promise.resolve({ data: databasesResponse() }));
service.getBadges.mockImplementation(() => Promise.resolve({ data: badgesEnvelope() }));
service.getInstanceMetrics.mockResolvedValue({ data: { hits: [], streams: [] } });
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
describe("the Schema filter republishes the fleet count under the scope it narrowed", () => {
it("publishes the server count, trafficless replica included", async () => {
await mountOverview();
expect(topRows().map((row) => row.db_instance)).toHaveLength(5);
expect(badge()).toBe(5);
});
it("narrows the badge to the servers holding the schema", async () => {
await mountOverview();
await setSchema("ledger");
expect(topRows().map((row) => row.db_instance)).toEqual(["pg-billing"]);
expect(badge()).toBe(1);
});
// The cached unfiltered fan-out replaces the snapshot on re-key, so a count published first is lost.
it("keeps the page's own count once the cleared filter's URL has settled", async () => {
const { router } = await mountOverview();
await setSchema("ledger");
await setSchema(null);
expect(router.currentRoute.value.query.namespace).toBeUndefined();
expect(topRows()).toHaveLength(5);
expect(badge(), "the fan-out's 4 must not stand over the page's exact union").toBe(5);
});
});
describe("the coverage caveat follows the shared shortfall rule at runtime", () => {
const caveats = () =>
wrapper!
.findAll('[data-test^="dbm-databases-shortfall-"]')
.map((node) => [node.attributes("data-test"), node.text()]);
it("states a schema row's own gap, not its server's", async () => {
await mountOverview();
await expand("pg-billing");
const parent = serverRow("pg-billing").rowKey;
expect(caveats()).toEqual([
[`dbm-databases-shortfall-${parent}/schema:billing`, expect.stringContaining("50%")],
]);
});
it("states a schemaless server's gap against its total", async () => {
await mountOverview();
await expand("my-cart");
expect(caveats()).toEqual([
[`dbm-databases-shortfall-${serverRow("my-cart").rowKey}`, expect.stringContaining("40%")],
]);
});
it("stays silent when nothing was attributed", async () => {
await mountOverview();
await expand("valkey-cart");
expect(caveats()).toEqual([]);
});
});
describe("a hand-off from the overview scopes the target, not the overview", () => {
const pushed = (router: Router) => router.currentRoute.value;
it("opens Metrics for a single-schema server without naming its schema", async () => {
const { router } = await mountOverview();
table().vm.$emit("row-click", serverRow("pg-orders"));
await settle();
expect(pushed(router).name).toBe("dbmMetrics");
expect(pushed(router).query.instance).toBe("pg-orders");
expect(pushed(router).query.namespace).toBeUndefined();
});
it("opens Top queries for a single-schema server without naming its schema", async () => {
const { router } = await mountOverview();
const index = topRows().findIndex((row) => row.db_instance === "pg-orders");
wrapper!.findAllComponents(DbmRowActionsStub)[index].vm.$emit("action", "open");
await settle();
expect(pushed(router).name).toBe("dbmQueries");
expect(pushed(router).query.instance).toBe("pg-orders");
expect(pushed(router).query.namespace).toBeUndefined();
});
it("carries the active Schema filter on a server hand-off", async () => {
const { router } = await mountOverview();
await setSchema("ledger");
table().vm.$emit("row-click", serverRow("pg-billing"));
await settle();
expect(pushed(router).name).toBe("dbmMetrics");
expect(pushed(router).query.namespace).toBe("ledger");
});
it("keeps the overview unfiltered when the Overview tab carries a child's schema back", async () => {
const { router } = await mountOverview();
await expand("pg-billing");
const ledger = serverRow("pg-billing").children!.find((row) => row.namespace === "ledger")!;
table().vm.$emit("row-click", ledger);
await settle();
expect(pushed(router).name).toBe("dbmMetrics");
expect(pushed(router).query.namespace).toBe("ledger");
// What DbmSectionTabs' Overview link carries: the current query, unchanged.
await router.push({ name: "dbmDatabases", query: { ...pushed(router).query } });
await settle();
expect(topRows()).toHaveLength(5);
expect(pushed(router).query.namespace).toBeUndefined();
});
it("adopts a different schema the reader picked on the target page", async () => {
const { router } = await mountOverview();
await expand("pg-billing");
const ledger = serverRow("pg-billing").children!.find((row) => row.namespace === "ledger")!;
table().vm.$emit("row-click", ledger);
await settle();
await router.push({
name: "dbmDatabases",
query: { ...pushed(router).query, namespace: "orders" },
});
await settle();
expect(topRows().map((row) => row.db_instance)).toEqual(["pg-orders"]);
});
});
describe("the AI assistant sees the Schema filter", () => {
it("puts the active schema in the page context", async () => {
await mountOverview({ namespace: "ledger" });
const context = await contextRegistry.getActiveContext();
expect(context?.db_namespace).toBe("ledger");
});
it("names no schema when the filter is off", async () => {
await mountOverview();
const context = await contextRegistry.getActiveContext();
expect(context?.db_namespace).toBeUndefined();
});
});
@@ -73,7 +73,13 @@ const ok = (data: unknown) => Promise.resolve({ data });
const fullEnvelope = () => ({
// One row deliberately carries no `calls` — legal per the response contract
// (an idle instance's row omits it); the fold treats it as 0.
databases: { hits: [{ calls: 800 }, { calls: 400 }, {}] },
databases: {
hits: [
{ db_system: "postgresql", db_instance: "pg-orders", db_namespace: "orders", calls: 800 },
{ db_system: "postgresql", db_instance: "pg-billing", db_namespace: "billing", calls: 400 },
{ db_system: "redis", db_instance: "valkey-cart", db_namespace: "" },
],
},
queries: { total: 42 },
activity: { by_state: [{ state: "active", sessions: 7 }], hits: [{ session_pid: 1 }] },
deadlocks: { total: 90, truncated: true },
@@ -581,8 +587,8 @@ describe("no page re-fetches the badges the shell already owns", () => {
/**
* The coverage caveat under each open database.
*
* `showsShortfall` is unit-tested in breakdownRows.spec.ts; what is pinned here
* is that the page ASKS it. The defect was never in the arithmetic — the
* `showsShortfall` is unit-tested in breakdownRows.spec.ts; dbmDatabasesSchemaScope.spec.ts
* pins that the page ASKS it. The defect was never in the arithmetic — the
* per-row percentages were always distinct — it was that the page gated the
* caveat on `shortfall !== null` directly, which is true even when nothing was
* attributed and every row therefore reported the identical 100%.
@@ -590,11 +596,6 @@ describe("no page re-fetches the badges the shell already owns", () => {
describe("DatabasesPage gates the coverage caveat on the shared rule", () => {
const source = read("DatabasesPage.vue");
it("asks showsShortfall rather than reading shortfall itself", () => {
expect(source).toContain('from "@/utils/dbm/breakdownRows"');
expect(source).toMatch(/const hasShortfall[\s\S]{0,400}showsShortfall\(/);
});
it("no longer gates the caveat on a bare non-null shortfall", () => {
expect(source).not.toMatch(/hasShortfall[\s\S]{0,200}breakdown\.shortfall !== null/);
});
@@ -39,6 +39,7 @@ vi.mock("@/services/oncall", () => ({
unacknowledgeResponse: vi.fn(),
confirmRecovery: vi.fn(),
snoozeResponse: vi.fn(),
unsnoozeResponse: vi.fn(),
addNote: vi.fn(),
handoffResponse: vi.fn(),
resolveResponse: vi.fn(),
@@ -661,6 +662,29 @@ describe("OnCallResponseDetail", () => {
expect(lapsed.find('[data-test="oncall-response-snoozed-banner"]').exists()).toBe(false);
});
/// Snooze means paused, then continues; while it is paused the same control continues it now.
it("swaps Snooze for Unsnooze while snoozed, and the banner offers it too", async () => {
const wrapper = await renderWith({ snoozed_until: (Date.now() + 30 * 60_000) * 1000 });
service.unsnoozeResponse.mockResolvedValue({ data: {} } as any);
expect(wrapper.find('[data-test="oncall-response-snooze-btn"]').exists()).toBe(false);
for (const button of [
"oncall-response-unsnooze-btn",
"oncall-response-snoozed-banner-unsnooze",
]) {
service.unsnoozeResponse.mockClear();
toastSpy.mockClear();
await wrapper.find(`[data-test="${button}"]`).trigger("click");
await flushPromises();
expect(service.unsnoozeResponse).toHaveBeenCalledWith(
expect.objectContaining({ response_id: "resp_1" }),
);
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ message: "Snooze ended · paging continues" }),
);
}
});
it("adds a note and clears the box", async () => {
const wrapper = await renderWith();
service.addNote.mockResolvedValue({ data: {} } as any);
@@ -1586,6 +1610,19 @@ describe("OnCallResponseDetail — what the payload already knew", () => {
);
});
it("unsnoozes from the menu while snoozed", async () => {
const wrapper = await renderWith({ snoozed_until: (Date.now() + 30 * 60_000) * 1000 });
service.unsnoozeResponse.mockResolvedValue({ data: {} } as any);
expect(wrapper.find('[data-test="oncall-response-snooze-30-menu"]').exists()).toBe(false);
await wrapper.find('[data-test="oncall-response-unsnooze-btn-menu"]').trigger("click");
await flushPromises();
expect(service.unsnoozeResponse).toHaveBeenCalledWith(
expect.objectContaining({ response_id: "resp_1" }),
);
});
it("opens the hand-off drawer from the menu", async () => {
const wrapper = await renderWith();
+50 -2
View File
@@ -146,7 +146,18 @@
</template>
<!-- A menu of durations, not a panel that pushes the page down. -->
<ODropdown v-if="canSnooze && lgUp">
<OButton
v-if="canUnsnooze && lgUp"
variant="outline"
size="sm-action"
:loading="snoozing"
data-test="oncall-response-unsnooze-btn"
@click="unsnoozeRecord"
>
{{ t("oncall.unsnooze") }}
<OTooltip :content="t('oncall.unsnoozeHint')" />
</OButton>
<ODropdown v-else-if="canSnooze && lgUp">
<template #trigger>
<OButton
variant="outline"
@@ -253,7 +264,14 @@
data-test="oncall-response-more-btn"
/>
</template>
<ODropdownGroup v-if="canSnooze" :label="t('oncall.snoozeFor')">
<ODropdownItem
v-if="canUnsnooze"
data-test="oncall-response-unsnooze-btn-menu"
@select="unsnoozeRecord"
>
{{ t("oncall.unsnooze") }}
</ODropdownItem>
<ODropdownGroup v-else-if="canSnooze" :label="t('oncall.snoozeFor')">
<ODropdownItem
v-for="opt in snoozeOptions"
:key="opt.minutes"
@@ -364,6 +382,17 @@
data-test="oncall-response-snoozed-banner"
>
{{ t("oncall.snoozedUntil", { time: snoozedUntilLabel }) }}
<template v-if="canUnsnooze" #actions>
<OButton
variant="outline"
size="sm"
:loading="snoozing"
data-test="oncall-response-snoozed-banner-unsnooze"
@click="unsnoozeRecord"
>
{{ t("oncall.unsnooze") }}
</OButton>
</template>
</OBanner>
<!-- D-21. This record exists because somebody else's failure reaches
@@ -847,6 +876,7 @@ import {
responseQuery,
responseReportQuery,
snoozeResponseMutation,
unsnoozeResponseMutation,
teamMembersQuery,
teamPolicyQuery,
teamReachabilityQuery,
@@ -1088,6 +1118,7 @@ const ackWords = computed(() =>
),
);
const snoozeWrite = useMutation(() => snoozeResponseMutation(orgId.value));
const unsnoozeWrite = useMutation(() => unsnoozeResponseMutation(orgId.value));
const noteWrite = useMutation(() => addResponseNoteMutation(orgId.value));
const handoffWrite = useMutation(() => handoffResponseMutation(orgId.value));
const recoveryWrite = useMutation(() => confirmRecoveryMutation(orgId.value));
@@ -1299,6 +1330,7 @@ const snoozedUntilLabel = computed(() => {
if (!r || !isSnoozed(r)) return "";
return formatOnCallInstant(r.snoozed_until as number, nowMicros.value, store.state.timezone);
});
const canUnsnooze = computed(() => canSnooze.value && !!snoozedUntilLabel.value);
const timeToAck = computed(() => {
const r = response.value;
@@ -1519,6 +1551,22 @@ async function snoozeRecord(minutes: number) {
}
}
async function unsnoozeRecord() {
snoozing.value = true;
try {
await unsnoozeWrite.mutateAsync(responseId.value);
toast({ variant: "success", message: t("oncall.unsnoozed") });
await fetchResponse();
} catch (err: any) {
toast({
variant: "error",
message: raw(err?.response?.data?.message) || t("oncall.unsnoozeFailed"),
});
} finally {
snoozing.value = false;
}
}
async function addNote() {
const body = noteBody.value.trim();
if (!body) return;
@@ -43,6 +43,7 @@ vi.mock("@/services/oncall", () => ({
acknowledgeResponse: vi.fn(),
resolveResponse: vi.fn(),
snoozeResponse: vi.fn(),
unsnoozeResponse: vi.fn(),
getPolicy: vi.fn(),
getSchedule: vi.fn(),
escalationProgress: vi.fn(),
@@ -860,6 +861,29 @@ describe("OnCallResponses", () => {
expect(handled.findAll('[data-test^="oncall-row-resolve-"]')).toHaveLength(1);
});
it("offers unsnooze instead of snooze on a snoozed row", async () => {
const wrapper = await withPages([page({ snoozed_until: (Date.now() + 60_000) * 1000 })]);
service.unsnoozeResponse.mockResolvedValue({ data: {} } as any);
expect(wrapper.find('[data-test^="oncall-row-snooze-"]').exists()).toBe(false);
await wrapper.find('[data-test^="oncall-row-unsnooze-"]').trigger("click");
await flushPromises();
expect(service.unsnoozeResponse).toHaveBeenCalledWith(
expect.objectContaining({ response_id: "resp_1" }),
);
});
it("keeps snooze for the awake firing of a row whose other firing is snoozed", async () => {
const wrapper = await withPages([
page({ id: "a", snoozed_until: (Date.now() + 60_000) * 1000 }),
page({ id: "b" }),
]);
expect(wrapper.find('[data-test^="oncall-row-unsnooze-"]').exists()).toBe(true);
expect(wrapper.find('[data-test^="oncall-row-snooze-"]').exists()).toBe(true);
});
/// Snoozing only makes sense while the ladder is still climbing, so a
/// handled row's menu must not offer it.
it("offers no snooze once nothing is escalating", async () => {
+38 -1
View File
@@ -815,7 +815,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Grouped rather than four loose items: they are one decision
("quiet it") asked at four lengths. -->
<ODropdownGroup v-if="canAcknowledge(row)" :label="t('oncall.snoozeFor')">
<ODropdownItem
v-if="canAcknowledge(row) && snoozedFirings(row).length"
:data-test="`oncall-row-unsnooze-${row.rowKey}`"
@select="unsnoozeRow(row)"
>
<template #icon-left>
<OIcon name="play-circle-filled" size="sm" />
</template>
{{ t("oncall.unsnooze") }}
</ODropdownItem>
<ODropdownGroup
v-if="canAcknowledge(row) && snoozedFirings(row).length < row.escalating.length"
:label="t('oncall.snoozeFor')"
>
<ODropdownItem
v-for="option in snoozeOptions"
:key="option.minutes"
@@ -971,6 +984,7 @@ import {
responseProgressQuery,
responseQuery,
snoozeResponseMutation,
unsnoozeResponseMutation,
teamPolicyQuery,
teamScheduleQuery,
whoIsOnCallQuery,
@@ -1624,6 +1638,7 @@ function menuActions(row: PageRow): RowAction[] {
/// One list writes to many rows, so the id rides in the variables, not the setup.
const ackWrite = useMutation(() => acknowledgeResponseMutation(orgId.value));
const snoozeWrite = useMutation(() => snoozeResponseMutation(orgId.value));
const unsnoozeWrite = useMutation(() => unsnoozeResponseMutation(orgId.value));
const resolveWrite = useMutation(() => resolveResponseMutation(orgId.value));
const handoffWrite = useMutation(() => handoffResponseMutation(orgId.value));
const noteWrite = useMutation(() => addResponseNoteMutation(orgId.value));
@@ -1657,6 +1672,28 @@ async function snoozeRow(row: PageRow, minutes: number) {
}
}
function snoozedFirings(row: PageRow): OnCallResponse[] {
return row.escalating.filter((r) => isSnoozed(r));
}
/// Ends the snooze on every quiet firing the row stands for, so they all continue now.
async function unsnoozeRow(row: PageRow) {
busyId.value = row.rowKey;
try {
const results = await Promise.allSettled(
snoozedFirings(row).map((r) => unsnoozeWrite.mutateAsync(r.id)),
);
if (results.some((r) => r.status === "rejected")) {
toast({ variant: "error", message: t("oncall.unsnoozeFailed") });
} else {
toast({ variant: "success", message: t("oncall.unsnoozed") });
}
await fetchResponses();
} finally {
busyId.value = "";
}
}
async function resolveRow(row: PageRow) {
busyId.value = row.rowKey;
try {