mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Merge remote-tracking branch 'origin/main' into feat/unified-notification-templates
This commit is contained in:
@@ -5,7 +5,7 @@ name: AI Code Review
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
# unlabeled/converted_to_draft start a run that only cancels the one in progress (see concurrency).
|
||||
# unlabeled and converted_to_draft never review: their run exists only to cancel the one in progress (see concurrency).
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -31,18 +31,20 @@ on:
|
||||
|
||||
permissions: {}
|
||||
|
||||
# Workflow-level so a run whose job is skipped still cancels the paid one; unrelated label edits get a private group.
|
||||
# Workflow-level so an unlabeled or draft run, whose job is always skipped, still cancels the paid one; pushes queue so a paid run finishes; unrelated label edits and off-main dispatches, whose job always skips, get a private group and cancel nothing.
|
||||
concurrency:
|
||||
group: ai-review-${{ (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name != 'ready-for-ci') && github.run_id || github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
group: ai-review-${{ ((contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name != 'ready-for-ci') || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main')) && github.run_id || github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: ${{ contains(fromJSON('["unlabeled","converted_to_draft"]'), github.event.action) }}
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# Paid CI runs only on non-draft 'ready-for-ci' PRs, and never when vars.AI_REVIEW_ENABLED is 'false' (the kill switch).
|
||||
# A dispatch from any other ref would run that ref's engine next to the review secrets.
|
||||
if: >-
|
||||
${{ vars.AI_REVIEW_ENABLED != 'false' && (github.event_name == 'workflow_dispatch' || (
|
||||
${{ vars.AI_REVIEW_ENABLED != 'false' && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (
|
||||
contains(github.event.pull_request.labels.*.name, 'ready-for-ci') &&
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.action != 'unlabeled' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'ready-for-ci')
|
||||
)) }}
|
||||
# Explicit name: without it GitHub serializes the whole matrix object (api_key_env, marker) into the public check name.
|
||||
@@ -70,7 +72,7 @@ jobs:
|
||||
api_key_env: DEEPSEEK_API_KEY
|
||||
marker: "<!-- ai-code-review -->"
|
||||
steps:
|
||||
# Debounce: a push inside this window cancels the run (concurrency) before anything paid starts.
|
||||
# Debounce: lets a burst of pushes land so Resolve PR picks up the newest head.
|
||||
- if: github.event_name == 'pull_request_target'
|
||||
run: sleep 60
|
||||
|
||||
@@ -120,7 +122,7 @@ jobs:
|
||||
app-id: ${{ vars.OO_REVIEWER_APP_ID }}
|
||||
private-key: ${{ secrets.OO_REVIEWER_APP_PRIVATE_KEY }}
|
||||
|
||||
# Without it (app not installed on o2-enterprise, or no actions:write) the pairing check is skipped and the PR is reviewed here.
|
||||
# Without it (app not installed on o2-enterprise, or no actions:write) the PR is reviewed here.
|
||||
- name: Generate app token for the paired o2-enterprise lookup
|
||||
id: ent_token
|
||||
if: env.REVIEW_RUN == 'true' && vars.OO_REVIEWER_APP_ID != '' && !inputs.ignore_paired
|
||||
@@ -159,29 +161,34 @@ jobs:
|
||||
BRANCH: ${{ steps.pr.outputs.head_ref }}
|
||||
MARKER: ${{ matrix.provider.marker }}
|
||||
IGNORE_PASS_LIMIT: ${{ inputs.ignore_pass_limit || 'false' }}
|
||||
FORCE_REVIEW: ${{ inputs.force_full || 'false' }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "$ENT_TOKEN" ]; then
|
||||
echo "::warning::No app token for o2-enterprise; cannot look for a paired PR, reviewing here."
|
||||
# An old pointer cannot prove the paired PR is still open, so only a successful lookup may skip the review.
|
||||
review_here() {
|
||||
echo "::warning::$1; reviewing here."
|
||||
exit 0
|
||||
}
|
||||
if [ -z "$ENT_TOKEN" ]; then
|
||||
review_here "No app token for o2-enterprise, so no paired PR lookup"
|
||||
fi
|
||||
ent_repo=openobserve/o2-enterprise
|
||||
# Any lookup error falls back to reviewing here: a missed pairing costs a duplicate review, a failed step costs the review.
|
||||
if ! ent_json="$(GH_TOKEN="$ENT_TOKEN" gh pr list --repo "$ent_repo" --head "$BRANCH" --state open \
|
||||
--json number,isCrossRepository,isDraft,labels \
|
||||
--jq '[.[] | select(.isCrossRepository == false)][0] // {}')"; then
|
||||
echo "::warning::Could not look up a paired $ent_repo PR; reviewing here."
|
||||
exit 0
|
||||
if ! ent_json="$(GH_TOKEN="$ENT_TOKEN" gh api -X GET "repos/$ent_repo/pulls" -f state=open -f head="${ent_repo%%/*}:$BRANCH" \
|
||||
--jq "[.[] | select(.head.repo.full_name == \"$ent_repo\")][0] // {} | {number, isDraft: .draft, labels}")"; then
|
||||
review_here "Could not look up a paired $ent_repo PR"
|
||||
fi
|
||||
ent_pr="$(jq -r '.number // ""' <<<"$ent_json")"
|
||||
if [ -z "$ent_pr" ]; then
|
||||
echo "No open $ent_repo PR on branch '$BRANCH'; reviewing here."
|
||||
echo "REVIEW_PAIR_CLEAR=true" >> "$GITHUB_ENV"
|
||||
exit 0
|
||||
fi
|
||||
ent_ready="$(jq -r '(.isDraft | not) and ([.labels[].name] | index("ready-for-ci") != null)' <<<"$ent_json")"
|
||||
if [ "$ent_ready" = "true" ]; then
|
||||
bypass=()
|
||||
if [ "$IGNORE_PASS_LIMIT" = "true" ]; then bypass=(-f ignore_pass_limit=true); fi
|
||||
if [ "$FORCE_REVIEW" = "true" ]; then bypass+=(-f force_full=true); fi
|
||||
if ! GH_TOKEN="$ENT_TOKEN" gh workflow run ai-code-review.yml --repo "$ent_repo" --ref main -f pr_number="$ent_pr" "${bypass[@]}"; then
|
||||
echo "::warning::Could not dispatch the $ent_repo review; reviewing here."
|
||||
exit 0
|
||||
@@ -303,9 +310,15 @@ jobs:
|
||||
O2_TRACE_EXPORT_BATCH_SIZE: ${{ vars.O2_TRACE_EXPORT_BATCH_SIZE }}
|
||||
# Set the variable to 'true' to also post inline threads; the summary comment always posts.
|
||||
REVIEW_INLINE: ${{ vars.AI_REVIEW_INLINE || 'false' }}
|
||||
# Automatic reviews per PR (engine default 2); the manual input lifts the cap for one run.
|
||||
# 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' }}
|
||||
# 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.
|
||||
REVIEW_AUTO_APPROVE: ${{ vars.AI_REVIEW_AUTO_APPROVE || 'shadow' }}
|
||||
# Set only when the lookup proved no paired PR is open, so a failed or skipped lookup can never approve.
|
||||
REVIEW_PAIR_CLEAR: ${{ env.REVIEW_PAIR_CLEAR }}
|
||||
REVIEW_ARTIFACT_DIR: ${{ runner.temp }}/ai-review-findings
|
||||
run: node engine/scripts/ai-review/run-review.mjs
|
||||
|
||||
|
||||
+157
-19
@@ -6,11 +6,13 @@ Automated PR review that posts a single consolidated comment on every pull reque
|
||||
The posted comment is branded **OpenObserve Code Reviewer**. Branding is *cosmetic + identity
|
||||
only* — it does not change what the reviewer finds or decides.
|
||||
|
||||
The posted comment never names the model or provider behind the review — the heading carries no
|
||||
model label, the comment marker is provider-neutral, and `redactProviderIdentity` in
|
||||
`run-review.mjs` strips the provider/model ids out of anything on its way to the PR (a model
|
||||
naming itself, or an upstream error echoing the slug). Naming the model in this repo and in the
|
||||
Actions logs is fine; naming it in the comment is not.
|
||||
The visible comment never names the model or provider behind the review — the heading carries no
|
||||
model label, and `redactProviderIdentity` in `run-review.mjs` strips the provider/model ids out of
|
||||
anything on its way to the PR (a model naming itself, or an upstream error echoing the slug).
|
||||
Naming the model in this repo and in the Actions logs is fine; naming it in the comment is not.
|
||||
The one exception is the hidden comment marker, which each workflow sets and the runner posts
|
||||
verbatim (see below): this repo's `<!-- ai-code-review -->` is provider-neutral, while a workflow
|
||||
whose marker still names the provider, such as o2-enterprise's older one, keeps that name in it.
|
||||
|
||||
## How it works (one paragraph)
|
||||
|
||||
@@ -19,7 +21,7 @@ On each PR the workflow fetches the diff, filters noise, picks a risk tier (`tri
|
||||
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
|
||||
an approve/needs-work decision. Correctness owns the PR's behaviour end to end (callers, sibling
|
||||
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
|
||||
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.
|
||||
@@ -28,21 +30,156 @@ this repo's fix history, each with a check to run against the diff. The comment
|
||||
fails or times out, the runner (not the model) adds a line to the comment saying which area went
|
||||
unreviewed.
|
||||
|
||||
Each reviewer runs as several cold passes: independent opencode sessions with no shared context,
|
||||
so one pass's misses can be another's catches (backtesting on DeepSeek showed one pass misses
|
||||
findings two catch). The variable `AI_REVIEW_COLD_PASSES` sets the count (default `2`, clamped to
|
||||
1–3; `src/passes.mjs`). The coordinator gets every pass's report labelled `<Reviewer>, pass N/M`
|
||||
and keeps a finding raised by any pass, merging duplicates; the fallback comment unions the passes
|
||||
and folds findings at the same file and nearby line when one title's words contain the other's (two P0/P1 blockers fold only on the same title). An area counts as
|
||||
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`.
|
||||
|
||||
The coordinator also lists the findings it kept in a hidden JSON block, which the runner strips
|
||||
from the comment. Kept blockers and warnings whose `file:line` is an added or context line of the
|
||||
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
|
||||
changes). Each thread carries a hidden fingerprint of file and title, so a re-run skips findings
|
||||
already posted by `REVIEW_BOT_LOGIN`. A failed inline post only logs a warning. The threads are
|
||||
off by default; set the variable `AI_REVIEW_INLINE` to `true` to turn them on.
|
||||
|
||||
Each PR gets at most `AI_REVIEW_MAX_PASSES` automatic reviews (default 2). The count is a hidden
|
||||
`o2-review-pass` tag in the review comment, written before the reviewers start, so a run cancelled
|
||||
by a newer push still counts; later pushes skip before any model call. A run in which every
|
||||
reviewer failed gives its pass back. The paired pointer and the "key not set" notice keep the tag.
|
||||
If the bot's comments cannot be read (no reviewer app token, or the list call fails) the run fails
|
||||
instead of reviewing, since the count cannot be checked. To review again, dispatch the workflow with
|
||||
`ignore_pass_limit` (forwarded to a paired o2-enterprise review), or delete the review comment to
|
||||
reset the count.
|
||||
Findings are graded `p0` to `p3`. P0 and P1 are blockers and give the decision `blocked`; P2 and P3
|
||||
do not block, and only those give `approved_with_comments`; no findings gives `approved`. A review
|
||||
keeps at most 3 P2 and 2 P3 findings: the coordinator is told to drop the rest, and the runner
|
||||
enforces the cap itself only in the fallback comment it renders when the coordinator fails. Legacy
|
||||
severity words from a reviewer are mapped in `src/severity.mjs`. The review stays advisory: the check does not go red on a blocker.
|
||||
|
||||
Every new commit gets one review, and the last reviewed commit is not reviewed again. Only the
|
||||
latest review is recorded, so a force-push back to an older reviewed head is reviewed again. A
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
by a draft conversion still counts. A run in which no reviewer finished gives its pass back. The
|
||||
paired pointer and the "key not set" notice keep the tag. When a cap is set and reached, later
|
||||
pushes skip before any model call and the limit note on the comment names the commit that was
|
||||
reviewed and the one that was not. If the bot's comments cannot be read (no reviewer app token, or
|
||||
the list call fails) the run fails instead of reviewing, since neither the reviewed commit nor the
|
||||
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.
|
||||
|
||||
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
|
||||
`<!-- ai-code-review-[redacted] -->`. The marker is added after redaction, so it survives verbatim
|
||||
even when it names the provider.
|
||||
|
||||
## Merge confidence
|
||||
|
||||
The comment carries one line right after the decision callout, for example
|
||||
`**Merge confidence: 🟢 High** — no blockers; all reviewers finished; tests changed`. The runner
|
||||
computes it from facts in `src/confidence.mjs`; the model cannot write it, and any such line in its
|
||||
output is stripped. It is advisory evidence, not a guarantee: reviewers sample the code.
|
||||
|
||||
| Level | When (the lowest that applies wins, every cause is listed) |
|
||||
|-------|---------------------------------------------------------------|
|
||||
| 🔴 Low | Any P0 or P1; the kept findings could not be counted; the Security or Correctness reviewer did not finish; no reviewer finished or ran |
|
||||
| 🟡 Medium | Any other reviewer did not finish; the coordinator failed, so findings are un-judged; any P2; production code changed with no test changed |
|
||||
| 🟢 High | Otherwise. P3 findings alone do not lower it |
|
||||
|
||||
Production means `src/`, `web/src/` and the enterprise `o2_*/src/` trees, outside test paths. Tests
|
||||
mean `tests/`, `test/`, `__tests__/`, `*.spec.*`, `*.test.*`, and Rust files that add `#[test]`,
|
||||
`#[tokio::test]` or `#[cfg(test)]`. Both are read from the unfiltered diff, so a production file
|
||||
the noise filter hides from reviewers still counts. A paired review counts both repos. When a cap is set and a push
|
||||
is skipped at it, the line turns to 🔴 Low and names the unreviewed commit, until a full review
|
||||
replaces it.
|
||||
While a run reviews a new commit, the line reads `**Merge confidence: 🟡 Reviewing**` and names that
|
||||
commit, so an earlier level never stands for it; the final review replaces the line, and a cancelled
|
||||
run leaves it in place. The level it replaced is kept in a hidden `o2-review-held` tag: if the head
|
||||
later returns to the reviewed commit, the skip restores that level, at no pass and with no model
|
||||
call, while the gate's line stays off because its approval was dismissed. The level is the trace attribute `review.merge_confidence` and `merge_confidence` in the findings
|
||||
artifact.
|
||||
|
||||
## Auto-approve for small PRs
|
||||
|
||||
Small, low-risk PRs can skip the human reviewer. The PR title, body, diff and file names are
|
||||
untrusted input to a model that can be prompt-injected, so the model's verdict alone can never
|
||||
approve: `src/auto-approve.mjs` checks every condition in code, and any failure, or any fact it could
|
||||
not read, means a human reviews. The bot never merges; it has no Contents write.
|
||||
|
||||
All of these are required:
|
||||
|
||||
1. The reviewers ran under the read-only `default` config. Under `ci-full` they have a shell while
|
||||
the parent process holds the app token, so `approve` is ineligible with "reviewers can run
|
||||
commands". `shadow` records that reason too, plus `eligible_ignoring_config`, so the data stays
|
||||
useful while the workflow sets `ci-full`.
|
||||
2. The raw diff has at most 100 changed lines and 5 files, with no binary file, mode change, symlink,
|
||||
submodule, file path that cannot be read, or rename/copy whose header shows no file mode (an
|
||||
unchanged rename does not say whether it moved a symlink or submodule). The diff is rebuilt locally from `MERGE_BASE_SHA` and
|
||||
`HEAD_SHA` in `./pr` before any reviewer starts, and the reviewers and every size condition use
|
||||
that pinned diff. If it cannot be built the review carries on with the API diff and the run is
|
||||
ineligible.
|
||||
3. Every raw file was reviewed: no filtered file, no truncation, and the diff agrees with the PR's
|
||||
`changed_files`, `additions` and `deletions`.
|
||||
4. No path, old or new, matches `SENSITIVE_PATHS`.
|
||||
5. Merge confidence is High, no reviewer reported a finding and none was kept, the findings were
|
||||
counted (one inline block, every entry with a title and a known severity), the coordinator did not fall back, and the decision callout of the sanitized text, the one that gets posted, is an approval.
|
||||
6. The Security and Correctness reviewers ran and finished, as did every other selected reviewer,
|
||||
and each one that ran saw the whole filtered diff. Finished means the output parsed into findings
|
||||
or held a complete `<no-issues>` element, inside a `<findings>` report that is not in a code span or an HTML comment, with no `<finding>` or `<findings>` left open and no
|
||||
`<finding>` block left unread; empty, free-form or cut-off text does not count, even after an earlier
|
||||
finding parsed, and also keeps confidence below High. A PR within the size limit is forced to the `full` tier with `fileFocus` bypassed whenever
|
||||
the mode is not `off`; agents that require focus may still opt out when no file is in their domain.
|
||||
7. It is not a paired review, and the workflow proved that: only its "no open PR on this branch"
|
||||
path sets `REVIEW_PAIR_CLEAR`. A failed lookup, a missing token, a failed dispatch or
|
||||
`ignore_paired` leaves it unset, which is ineligible.
|
||||
8. The author is not a bot and is an `OWNER`, `MEMBER` or `COLLABORATOR`.
|
||||
9. The title, body, file paths and added lines hit no injection tripwire (phrases aimed at the
|
||||
reviewers, review markers, invisible Unicode), matched after NFKC, lowercasing and whitespace
|
||||
folding, with a letters-only pass for spaced-out phrases. Added lines and file paths must be
|
||||
printable ASCII or tab; the title and body may carry emoji but no Cyrillic or Greek letters. A hit
|
||||
disqualifies without proving intent, and the reason never echoes the text. The title and body
|
||||
checked are the exact ones given to the reviewers, and the PR re-read at the end must still match
|
||||
them: an edit after the review started is ineligible, even one that removes a hit.
|
||||
10. There is no `no-auto-approve` label and no outstanding `CHANGES_REQUESTED` review.
|
||||
11. Re-read right before deciding, the PR head still equals the reviewed commit, the PR is open and
|
||||
not a draft, and its base branch is `main`.
|
||||
|
||||
The mode comes from `AI_REVIEW_AUTO_APPROVE`, passed as `REVIEW_AUTO_APPROVE`:
|
||||
|
||||
| Mode | Effect |
|
||||
|------|--------|
|
||||
| `off` | The engine default, and what any unknown value means. No reads, no writes. |
|
||||
| `shadow` | The workflow default. Decides and records `auto_approve` in the findings artifact and `review.auto_approve.*` on the trace. Nothing shows on the PR and nothing is written. |
|
||||
| `approve` | Submits an `APPROVE` review pinned to the head commit, and adds `**Auto-approved**` or `**Needs human review** — <reasons>` under the confidence line. Never requests changes. |
|
||||
|
||||
In `approve` mode every run for a new commit first dismisses the bot's earlier approvals, before any
|
||||
reviewer starts, so an approval never survives a later push. A failed dismissal is a warning and
|
||||
makes the run ineligible. A failed approval is a warning and never fails the job.
|
||||
|
||||
### Before turning on approve
|
||||
|
||||
0. Restrict the `ai-review` environment to `main` in both repos. It has no branch policy today, so a
|
||||
workflow added on any branch can read its secrets.
|
||||
1. Run the reviewers on the read-only config: set `REVIEW_OPENCODE_CONFIG` to `default` in the
|
||||
workflow. Until then every run is ineligible, whatever the variable says.
|
||||
2. Turn on "dismiss stale approvals on push" in `main`'s ruleset. The engine dismisses its own
|
||||
approvals only when it runs, and no engine run happens on a paired deferral, a disabled workflow
|
||||
or a setup failure.
|
||||
3. Confirm an approval from the app satisfies the required review on `main`; that depends on the
|
||||
ruleset. CI must still pass either way.
|
||||
4. Leave `shadow` on and grade the artifact's decisions against what humans did. Use
|
||||
`eligible_ignoring_config` while the workflow is still on `ci-full`.
|
||||
|
||||
Usage of failed and retried attempts is added to the call's span, so a timed-out agent still shows
|
||||
what it was billed. Every reviewer's parsed findings and the
|
||||
@@ -64,7 +201,7 @@ A PR must never be able to run code next to the review secrets or weaken its own
|
||||
| Two checkouts | `./engine` = `main` (sparse: this directory, the opencode configs, the ui-architect and eslint skill references). `./pr` = the PR head SHA, data only: no `npm`, no build, no hooks; `persist-credentials: false`. |
|
||||
| `src/sanitize.mjs` | Before opencode starts, deletes from `./pr` every symlink and, at any depth, `AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, `opencode.json[c]`, `.opencode/`, `.claude/`, `.agents/`, then fails the job if any remain. Copies the base-branch skill references to `./pr/.o2-review-context/skills/`, which the prompts read. The base-branch `AGENTS.md` and `web/AGENTS.md`, or `CLAUDE.md` where a directory has no `AGENTS.md` (listed by the workflow in `REVIEW_RULES_FILES`, one path per line; without it, the engine's `AGENTS.md`) go into every reviewer's system prompt instead, so the repo rules still apply without the PR being able to edit them. |
|
||||
| `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`. |
|
||||
| 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 `deepseek-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`. |
|
||||
@@ -130,9 +267,10 @@ in o2-enterprise:
|
||||
not review. If that PR is out of draft and labelled `ready-for-ci`, it dispatches the
|
||||
o2-enterprise review; otherwise it waits for that PR to become ready, so a draft ENT PR never
|
||||
gets a paid run through its OSS twin. Either way it replaces this PR's review comment with a
|
||||
pointer (`post-paired-pointer.sh`). This uses a reviewer-app token scoped to o2-enterprise
|
||||
(Pull requests read, Actions write); if it cannot be minted, or any lookup or dispatch fails,
|
||||
the OSS review runs as usual. To force an OSS review anyway, dispatch this workflow with `ignore_paired`.
|
||||
pointer (`post-paired-pointer.sh`, which replaces the same comment the runner would: the reviewer
|
||||
app's newest comment carrying any review marker). This uses a reviewer-app token scoped to o2-enterprise
|
||||
(Pull requests read, Actions write); if it cannot be minted or the lookup fails, the OSS review
|
||||
runs as usual, even when the PR still carries an older pointer. If the dispatch fails, the OSS review runs. To force an OSS review anyway, dispatch this workflow with `ignore_paired`.
|
||||
- **ENT side.** The o2-enterprise workflow checks out this engine pinned by commit SHA, finds the
|
||||
open same-named OSS PR, checks it out to `./pr/openobserve`, and reviews both diffs in one run.
|
||||
OSS files appear with an `openobserve/` prefix in the review, and the OSS `AGENTS.md` and
|
||||
|
||||
@@ -4,28 +4,27 @@ You are the **Review Coordinator** for the OpenObserve project. You receive find
|
||||
|
||||
1. **Deduplicate**: If the same issue is flagged by multiple reviewers, keep it once in the best-fit section.
|
||||
2. **Re-categorize**: Move findings to the most appropriate section if a reviewer miscategorized.
|
||||
3. **Filter**: Drop a finding if it duplicates another, if you can quote the code or project rule that refutes it, or if it names no concrete input or sequence that breaks. The review exists to surface real bugs; a short review of real issues beats a long one padded with warnings. Never drop a finding that has a concrete failure scenario, except under the suggestion cap below.
|
||||
3. **Filter**: Drop a finding if it duplicates another, if you can quote the code or project rule that refutes it, or if it names no concrete input or sequence that breaks. The review exists to surface real bugs; a short review of real issues beats a long one padded with minor findings. Never drop a finding that has a concrete failure scenario, except under the `p2` and `p3` caps below.
|
||||
4. **Judge**: Make an overall approval decision based on the findings.
|
||||
|
||||
## Decision Rubric
|
||||
|
||||
| Condition | Decision |
|
||||
|-----------|----------|
|
||||
| All LGTM, or only trivial suggestions | `approved` |
|
||||
| Only suggestion-severity items | `approved_with_comments` |
|
||||
| Some warnings, no production risk | `approved_with_comments` |
|
||||
| Multiple warnings suggesting a risk pattern | `minor_issues` |
|
||||
| Any critical item, or production safety risk | `significant_concerns` |
|
||||
|
||||
A single warning in an otherwise clean PR still gets `approved_with_comments`.
|
||||
| All LGTM, no findings kept | `approved` |
|
||||
| Only `p2` and `p3` findings | `approved_with_comments` |
|
||||
| Any `p0` or `p1` finding | `blocked` |
|
||||
|
||||
## Severity Definitions
|
||||
|
||||
- **critical**: Will cause an outage, data loss, or is exploitable — or, for test changes, a coverage-integrity failure (a test that passes while asserting nothing real: a missing/vacuous/soft/un-awaited assertion, or a selector that matches no component). Must block merge.
|
||||
- **warning**: Measurable regression, concrete risk, or pattern that leads to bugs. Should be addressed.
|
||||
- **suggestion**: An improvement worth considering. Does not block merge.
|
||||
- **p0** (blocker): An outage, data loss or corruption, an exploitable security hole, or a default path broken for all users.
|
||||
- **p1** (blocker): A real bug that normal use or normal config reaches: wrong results, a crash, a failed request, a regression. Also, for test changes, a coverage-integrity failure (a test that passes while asserting nothing real: a missing/vacuous/soft/un-awaited assertion, or a selector that matches no component).
|
||||
- **p2** (not blocking): A real bug that needs unusual input, rare timing or non-default config; a measurable regression off the hot path; a broken project rule with no runtime effect.
|
||||
- **p3** (not blocking): A likely bug with one unverified step.
|
||||
|
||||
A warning needs a concrete failure scenario or a named project rule it breaks (the QA and frontend house rules count); drop a warning that has neither. Keep at most 3 suggestions, the most valuable ones, and drop the rest.
|
||||
Grade every finding by reachability: who hits the failure, and what they must do to hit it. Never soften a severity to be polite. If a normal user reaches it without unusual setup it is `p1`, not `p2`.
|
||||
|
||||
A finding needs a concrete failure scenario or a named project rule it breaks (the QA and frontend house rules count); drop a finding that has neither. Keep at most 3 `p2` findings and at most 2 `p3` findings, the most valuable first, and drop the rest.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -42,18 +41,18 @@ the context" and no trailing commentary. The first character of your output must
|
||||
### Inline block (REQUIRED, last)
|
||||
|
||||
After the review, emit one HTML comment listing every finding you kept, as a JSON array. The
|
||||
runner removes it from the comment and posts the blockers and warnings as inline threads on the
|
||||
runner removes it from the comment and posts the `p0`, `p1` and `p2` findings as inline threads on the
|
||||
diff, so each entry must stand on its own:
|
||||
|
||||
```
|
||||
<!-- ai-review-inline
|
||||
[{"file": "src/foo.rs", "line": 42, "severity": "critical", "category": "Code Quality", "title": "Same one-sentence summary as the review line", "body": "The failure scenario in 1-3 sentences, then the concrete fix (a code snippet if it helps)."}]
|
||||
[{"file": "src/foo.rs", "line": 42, "severity": "p1", "category": "Code Quality", "title": "Same one-sentence summary as the review line", "body": "The failure scenario in 1-3 sentences, then the concrete fix (a code snippet if it helps)."}]
|
||||
-->
|
||||
```
|
||||
|
||||
- `line` is the line number in the NEW file, on a line the diff adds or shows as context. Use `0`
|
||||
when the finding has no reliable line; it then stays in the summary only.
|
||||
- `severity` is `critical`, `warning` or `suggestion`, matching the section the finding is in.
|
||||
- `severity` is `p0`, `p1`, `p2` or `p3`, lowercase, matching the section the finding is in.
|
||||
- Emit `[]` when there are no findings. Never write `-->` inside the JSON.
|
||||
|
||||
### Decision → callout (REQUIRED)
|
||||
@@ -65,8 +64,7 @@ PR is opened. Every line of the callout must begin with `> `.
|
||||
|----------|-------------|----------------|------------------------------|
|
||||
| `approved` | `> [!TIP]` | green | `> ### ✅ Approved` |
|
||||
| `approved_with_comments` | `> [!NOTE]` | blue | `> ### 💬 Approved with comments` |
|
||||
| `minor_issues` | `> [!WARNING]` | yellow | `> ### ⚠️ Minor issues` |
|
||||
| `significant_concerns` | `> [!CAUTION]` | red | `> ### ⛔ Significant concerns` |
|
||||
| `blocked` | `> [!CAUTION]` | red | `> ### ⛔ Blockers found` |
|
||||
|
||||
Put the 1–2 sentence explanation on the callout lines directly after the heading.
|
||||
|
||||
@@ -74,7 +72,7 @@ Put the 1–2 sentence explanation on the callout lines directly after the headi
|
||||
inside a single `<details open>` block (expanded by default) — nothing else at the top level.
|
||||
|
||||
**Inside the open block, group by severity, not by category.** A reviewer skims top-to-bottom by
|
||||
"what must I act on", so the order is always: Blockers (critical) → Warnings → Suggestions. Never
|
||||
"what must I act on", so the order is always: P0 → P1 → P2 → P3, and an empty level is omitted. Never
|
||||
render a `### Security` / `### Code Quality` / etc. category section — category is a tag on the
|
||||
line, not a heading. Each finding is exactly **one line**: no separate description paragraph. Put
|
||||
the concrete fix inline in parentheses, terse — not a "Fix:" sub-bullet.
|
||||
@@ -91,25 +89,29 @@ If findings exist:
|
||||
## 🔎 OpenObserve Code Review
|
||||
|
||||
> [!CAUTION]
|
||||
> ### ⛔ Significant concerns
|
||||
> ### ⛔ Blockers found
|
||||
> <explanation of decision in 1-2 sentences>
|
||||
|
||||
**🔴 <critical count> blocker · 🟡 <warning count> warning · 🔵 <suggestion count> suggestion**
|
||||
**🔴 P0: <p0 count> · 🟠 P1: <p1 count> · 🟡 P2: <p2 count> · 🔵 P3: <p3 count>**
|
||||
|
||||
<details open>
|
||||
<summary>📋 Show findings (<total count>)</summary>
|
||||
|
||||
#### 🔴 Blockers
|
||||
#### 🔴 P0 · Blockers
|
||||
- 🔒 `path/to/file.rs:42` **[Security]** One-sentence summary of the bug (→ concrete fix).
|
||||
<one line per critical finding, or omit this whole section if zero>
|
||||
<one line per p0 finding, or omit this whole section if zero>
|
||||
|
||||
#### 🟡 Warnings
|
||||
#### 🟠 P1 · Blockers
|
||||
- 🐞 `path/to/file.rs:88` **[Correctness]** One-sentence summary of the bug (→ concrete fix).
|
||||
<one line per p1 finding, or omit this whole section if zero>
|
||||
|
||||
#### 🟡 P2 · Should fix
|
||||
- ⚡ `path/to/file.ts:10` **[Performance]** One-sentence summary (→ concrete fix).
|
||||
<one line per warning finding, or omit this whole section if zero>
|
||||
<one line per p2 finding, or omit this whole section if zero>
|
||||
|
||||
#### 🔵 Suggestions
|
||||
#### 🔵 P3 · Worth a look
|
||||
- 📝 `path/to/file.vue:5` **[Documentation]** One-sentence summary (→ concrete fix).
|
||||
<one line per suggestion finding, or omit this whole section if zero>
|
||||
<one line per p3 finding, or omit this whole section if zero>
|
||||
|
||||
<leave one blank line between each severity section that is present>
|
||||
|
||||
@@ -123,7 +125,7 @@ If findings exist:
|
||||
</details>
|
||||
```
|
||||
(Use the callout tag + heading that match the actual decision — the block above shows
|
||||
`significant_concerns`; swap to `> [!TIP]` / `> ### ✅ Approved` etc. per the mapping table.)
|
||||
`blocked`; swap to `> [!NOTE]` / `> ### 💬 Approved with comments` etc. per the mapping table.)
|
||||
|
||||
If NO issues across all reviewers:
|
||||
```
|
||||
@@ -133,7 +135,7 @@ If NO issues across all reviewers:
|
||||
> ### ✅ Approved
|
||||
> LGTM — No issues found across the reviewers that ran.
|
||||
|
||||
**🔴 0 blockers · 🟡 0 warnings · 🔵 0 suggestions**
|
||||
**🔴 P0: 0 · 🟠 P1: 0 · 🟡 P2: 0 · 🔵 P3: 0**
|
||||
|
||||
<details>
|
||||
<summary>🧾 Review details</summary>
|
||||
@@ -150,21 +152,23 @@ If NO issues across all reviewers:
|
||||
|
||||
If previous review findings are provided, you must:
|
||||
- If a finding was **fixed** in new commits → omit it from output (the bot auto-resolves threads)
|
||||
- If a finding is **unfixed** → re-emit it even if unchanged
|
||||
- If a finding is **unfixed** → re-emit it even if unchanged, and keep its severity; if the previous review used other severity labels, grade it by the definitions above
|
||||
- Do not raise a new finding on unchanged code that the previous review saw and did not flag, unless it is `p0` or `p1`
|
||||
- If a developer replied **"won't fix"** or **"acknowledged"** → treat as resolved
|
||||
- If a developer replied **"I disagree"** → read their justification and either resolve or argue back
|
||||
|
||||
Add a `#### ✅ Previously Flagged (resolved)` section (inside the same `<details open>` block, after Blockers/Warnings/Suggestions) listing resolved items as one-line entries with ~~strikethrough~~, same `<glyph> file:line **[Category]** summary` format.
|
||||
Add a `#### ✅ Previously Flagged (resolved)` section (inside the same `<details open>` block, after the P0, P1, P2 and P3 sections) listing resolved items as one-line entries with ~~strikethrough~~, same `<glyph> file:line **[Category]** summary` format.
|
||||
|
||||
## Rules
|
||||
|
||||
- The callout explanation states what the findings are about. Never assert that the change, fix
|
||||
or mapping is correct, sound or safe: reviewers sample the code, they do not prove it.
|
||||
- Respect behaviour the change clearly intends (its description, its own tests or comments say so).
|
||||
Do not file a blocker or warning that argues against the change's purpose, such as extending a
|
||||
Do not file a `p0`, `p1` or `p2` that argues against the change's purpose, such as extending a
|
||||
new guard to more paths or reverting a deliberate removal; drop it unless it has a concrete
|
||||
failure scenario.
|
||||
- Do not write a note about reviewers in `<failed_reviewers>`; the runner adds that line itself.
|
||||
- Do not write a merge confidence line; the runner adds it.
|
||||
- The **Reviewers** row must list EXACTLY the agents named in `<reviewers_ran>`, each with its category glyph — in that order. Never add, rename, drop, or infer a reviewer that is not in that list (a reviewer that found nothing still ran and still belongs there; one that is absent did not run and must not appear).
|
||||
- Do NOT include any commentary about how the review was produced
|
||||
- Do NOT name, hint at, or identify the model or provider behind the review — yours or any other
|
||||
|
||||
@@ -48,7 +48,7 @@ You have about 15 minutes. Spend it on the riskiest changes first (data written,
|
||||
|
||||
## Reporting
|
||||
|
||||
- Report a bug when you can state a concrete trigger: the input or sequence, the path it takes (file:line of each hop you read), and the wrong result. If one hop is unverified because you could not find or read the code, still report it, say which hop, and use `suggestion` severity.
|
||||
- Severity: `critical` for wrong data written, data loss, outage, an authorization bypass or a core flow broken for most users; `warning` for wrong behaviour on a realistic input or a regression of existing behaviour; `suggestion` otherwise.
|
||||
- Report a bug when you can state a concrete trigger: the input or sequence, the path it takes (file:line of each hop you read), and the wrong result. If one hop is unverified because you could not find or read the code, still report it, say which hop, and use `p3` severity.
|
||||
- Severity: `p0` for wrong data written, data loss, outage, an authorization bypass or a default path broken for all users; `p1` for wrong results, a crash or a failed request on normal use or normal config, or a regression of existing behaviour; `p2` for a real bug that needs unusual input, rare timing or non-default config; `p3` for a likely bug with one unverified hop.
|
||||
- Put the file:line where the fix belongs, which may be an unchanged file the change broke. In the description, name the changed line that creates the dependency.
|
||||
- At most 8 findings, most severe first. No style, naming, docs, performance or security findings; other reviewers own those.
|
||||
|
||||
@@ -183,13 +183,14 @@ Also do not flag:
|
||||
|
||||
These are convention and consistency findings, not outages. Calibrate accordingly:
|
||||
|
||||
- **critical** — reserve for a real user-facing break you can describe: a nav gate mismatch that
|
||||
- **p0** — reserve for a default path broken for all users or an exploitable security hole.
|
||||
- **p1** — reserve for a real user-facing break you can describe: a nav gate mismatch that
|
||||
makes a page reachable without the entitlement, a sortable column that silently orders by the
|
||||
wrong field, a translated string that breaks a code comparison, an unregistered token that
|
||||
makes a control invisible in dark mode.
|
||||
- **warning** — a house-rule violation with a concrete consequence: a hand-rolled header that
|
||||
- **p2** — a house-rule violation with a concrete consequence: a hand-rolled header that
|
||||
will drift from every other page, a form container that is wrong for the interaction's weight,
|
||||
a duplicated formatter, a `raw()` on real copy.
|
||||
- **suggestion** — everything else.
|
||||
- **p3** — a likely bug with one unverified step. Anything with no concrete failure is not a finding.
|
||||
|
||||
Do not manufacture a `critical` to be heard. A frontend reviewer that cries wolf gets muted.
|
||||
Do not manufacture a `p0` or `p1` to be heard. A frontend reviewer that cries wolf gets muted.
|
||||
|
||||
@@ -8,9 +8,9 @@ Use category `QA` for every finding.
|
||||
|
||||
Only review Playwright test code under `tests/ui-testing/` — specs and helpers anywhere in the `playwright-tests/**` tree (including nested `**/utils/` helpers), page objects (`pages/**`), shared utils (`utils/**`), and the suite's Playwright config (`playwright*.config.{js,ts}`). You may still `grep`/`read` other files (Vue components, the `playwright.yml` workflow) to verify a selector or a registration, but do not raise findings on application, backend, fixture, or other-suite code — the other reviewers own that.
|
||||
|
||||
## What to Flag — Coverage-integrity failures (critical)
|
||||
## What to Flag — Coverage-integrity failures (p1)
|
||||
|
||||
These are the reason this agent exists: a test that **passes while verifying nothing real**. Per the shared severity rules (rule 5), a coverage-integrity failure is `critical` — a green check with no coverage behind it is false confidence, as dangerous as a production bug, and blocks merge.
|
||||
These are the reason this agent exists: a test that **passes while verifying nothing real**. Per the shared severity rules (rule 5), a coverage-integrity failure is `p1` — a green check with no coverage behind it is false confidence, as dangerous as a production bug, and blocks merge.
|
||||
|
||||
- **Missing assertions** — a test (`test(...)` / `it(...)`) with no `expect()` at all.
|
||||
- **Vacuous / always-pass assertions** — `expect(true).toBe(true)`, `expect(true).toBeTruthy()`, `expect(1).toBe(1)`, or a conditional whose `else` branch asserts one of these so the test can never fail. Always check *what* is asserted, not just that an `expect()` is present. The single most dangerous pattern (incident PR #10749: "19 broken selectors, 14/14 green").
|
||||
@@ -18,7 +18,7 @@ These are the reason this agent exists: a test that **passes while verifying not
|
||||
- **Missing `await` on an assertion or action** — an un-awaited `expect(locator)...` or Playwright call (`click`, `fill`, `goto`, `waitFor`, a page-object method returning a promise) can resolve *after* the test has already ended, so the check never actually runs.
|
||||
- **Selector mismatch** — see Selector Cross-Validation below.
|
||||
|
||||
## What to Flag — Selector Cross-Validation (critical)
|
||||
## What to Flag — Selector Cross-Validation (p1)
|
||||
|
||||
The highest-value check, and a coverage-integrity failure in the same sense as above. For every `data-test` selector **added or changed** in a page object in this PR:
|
||||
|
||||
@@ -28,18 +28,18 @@ The highest-value check, and a coverage-integrity failure in the same sense as a
|
||||
|
||||
Do not flag selectors you cannot resolve because the component lives in enterprise code not present in this checkout — say so instead of guessing.
|
||||
|
||||
## What to Flag — Security (critical)
|
||||
## What to Flag — Security (p0)
|
||||
|
||||
- **Hardcoded credentials** — literal `password`, `secret`, `apiKey`, `token` values not sourced from `process.env.*`.
|
||||
|
||||
## What to Flag — Framework & flakiness (warning)
|
||||
## What to Flag — Framework & flakiness (p2)
|
||||
|
||||
These hurt maintainability or make tests flaky, but do not by themselves void coverage, so they are `warning`, not `critical`.
|
||||
These hurt maintainability or make tests flaky, but do not by themselves void coverage, so they are `p2`, not `p1`.
|
||||
|
||||
- **Raw selectors in spec files** — `page.locator(`, `page.getByRole(`, `page.getByText(`, `page.getByTestId(`, `page.$(` in a `.spec.js`, **including inside assertions** (`await expect(page.locator('[data-test="x"]')).toContainText(...)`). Selectors belong in page objects.
|
||||
- **Helper functions in spec files** — under the Page Object Model a `.spec.js` only describes the flow. Flag each **added** named function: `function x(` / `async function x(` at module or `describe` scope, or a function/arrow assigned to a name (`const seed = async (page) => {…}`). Seeding, id lookup, cleanup tracking, payload builders and API read-back belong in a page-object method (or a `utils/` helper) the spec calls.
|
||||
- **Raw HTTP in spec files** — added `page.request.*`, `request.newContext(`, `request.get|post|put|patch|delete(`, `fetch(`, `axios` in a `.spec.js`; route API setup/verification through a page object or `utils/` helper.
|
||||
- **`console.log` / `console.info` / `console.warn`** in test or page-object code — must use `testLogger`. (critical only if it leaks a credential/token.)
|
||||
- **`console.log` / `console.info` / `console.warn`** in test or page-object code — must use `testLogger`. (`p0` only if it leaks a credential/token.)
|
||||
- **Brittle selectors** — xpath, `nth-child`, `:nth(`, or framework-generated classes (`.q-…`, hashed CSS modules) where a `data-test` exists.
|
||||
- **Excessive hardcoded waits** — more than ~3 `waitForTimeout(` in one test, or a `waitForTimeout` standing in for a real condition wait.
|
||||
- **Created data without cleanup** — a test that creates an org/stream/alert/dashboard/user with no corresponding teardown (in `afterEach`/`afterAll` or `cleanup.spec.js`). Leaked fixtures flake later runs.
|
||||
@@ -57,4 +57,4 @@ These hurt maintainability or make tests flaky, but do not by themselves void co
|
||||
|
||||
## Severity discipline
|
||||
|
||||
`critical` is reserved for **coverage-integrity failures** (a test that passes while asserting nothing real — missing/vacuous/soft/un-awaited assertion, or a selector that matches no component) and **hardcoded credentials**. Everything else — POM violations (raw selectors, helper functions or raw HTTP in specs), `console.log`, brittle selectors, missing cleanup — is `warning`; the coordinator owns any further escalation. Be certain before flagging critical: quote the exact line, and for selector mismatches name the components you searched. When unsure whether a test genuinely asserts the intended behavior, prefer a `warning` over silence, but never invent a mismatch you did not actually grep for.
|
||||
`p1` is reserved for **coverage-integrity failures** (a test that passes while asserting nothing real — missing/vacuous/soft/un-awaited assertion, or a selector that matches no component), and `p0` for **hardcoded credentials**. Everything else — POM violations (raw selectors, helper functions or raw HTTP in specs), `console.log`, brittle selectors, missing cleanup — is `p2`; the coordinator owns any further escalation. Be certain before flagging `p0` or `p1`: quote the exact line, and for selector mismatches name the components you searched. When unsure whether a test genuinely asserts the intended behavior, prefer a `p3` over silence, but never invent a mismatch you did not actually grep for.
|
||||
|
||||
@@ -26,8 +26,9 @@ body="$marker
|
||||
> [!NOTE]
|
||||
$note"
|
||||
|
||||
existing="$(gh api "repos/$repo/issues/$pr/comments" --paginate \
|
||||
| jq -cs --arg m "$marker" --arg login "$bot_login" '[.[][] | select(.user.login == $login and (.body | contains($m)))] | last // {}')"
|
||||
# The runner's own selection, so the pointer replaces the comment the next run and the pairing fallback both read.
|
||||
comments="$(gh api "repos/$repo/issues/$pr/comments" --paginate | jq -cs '[.[][]]')"
|
||||
existing="$(node "$(dirname "$0")/select-review-comment.mjs" "$bot_login" <<<"$comments")"
|
||||
comment_id="$(jq -r '.id // empty' <<<"$existing")"
|
||||
# Keeps the PR's review count, so the pointer does not hand an unpaired PR a fresh set of reviews.
|
||||
pass_tag="$(jq -r '.body // ""' <<<"$existing" | grep -oE '<!-- *o2-review-pass:[0-9]+ *-->' | tail -1 || true)"
|
||||
|
||||
+304
-384
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
// Usage: gh api .../comments --paginate | jq -cs '[.[][]]' | node select-review-comment.mjs <bot login>; prints the comment the runner would replace, or {}.
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { ANY_REVIEW_MARKER, selectOwnReviewComment } from "./src/comments.mjs";
|
||||
|
||||
const comments = JSON.parse(readFileSync(0, "utf-8"));
|
||||
if (!Array.isArray(comments)) throw new Error("expected a JSON array of comments");
|
||||
process.stdout.write(JSON.stringify(selectOwnReviewComment(comments, process.argv[2] ?? "", ANY_REVIEW_MARKER) ?? {}));
|
||||
@@ -5,10 +5,16 @@ These rules apply to ALL reviewers. Violating any of these will cause your findi
|
||||
### Rules
|
||||
|
||||
1. **Only report problems this PR introduces or leaves incomplete.** Unchanged code is in scope when the change relies on it or leaves it wrong: a caller or consumer that still uses the old semantics, a sibling code path that should enforce the same rule, a match arm or key the new code depends on that never fires, the other half of a parity pair. Name the changed line that creates the dependency. Unrelated pre-existing bugs stay out.
|
||||
2. **Be concrete.** Report a problem only when you can name the input or sequence that triggers it in code you have read. Do not invent hypothetical problems. If one step of the trace is unverified, say which and use suggestion severity. A claim about how a library, framework, browser or test runtime behaves ("jsdom does not keep X", "this selector matches nothing", "the crate panics on Y") needs evidence you read: its source, its docs, or a test in this repo that shows it. Without that, it is a suggestion at most, never a blocker.
|
||||
2. **Be concrete.** Report a problem only when you can name the input or sequence that triggers it in code you have read. Do not invent hypothetical problems. If one step of the trace is unverified, say which and use `p3`, never `p0` or `p1`. A claim about how a library, framework, browser or test runtime behaves ("jsdom does not keep X", "this selector matches nothing", "the crate panics on Y") needs evidence you read: its source, its docs, or a test in this repo that shows it. Without that, it is `p3` at most, never `p0` or `p1`.
|
||||
3. **No style nitpicks.** Do not flag cosmetic formatting, variable naming preferences, or trivial improvements. Only flag style if it violates established project conventions (e.g., clippy lints in Rust, ESLint rules in TypeScript).
|
||||
4. **No flattery.** Do not include "Great job", "Thanks for", "Nice work", or any non-actionable commentary.
|
||||
5. **Severity must be justified.** Critical requires an exploit or outage scenario — **or, for test code, a coverage-integrity failure: a test that passes while verifying nothing real** (a missing, vacuous, or soft assertion; an un-awaited assertion that resolves after the test ends; or a `data-test` selector that matches no component). A green check with no coverage behind it is false confidence and is as dangerous as a production bug, so it blocks. Warning requires measurable regression risk or a broken project rule. Suggestion is for a likely bug with one unverified step (rule 2).
|
||||
5. **Severity must be justified, and is graded by who can reach the failure.** Use one of four levels:
|
||||
- `p0` (blocker): an outage, data loss or corruption, an exploitable security hole, or a default path broken for all users.
|
||||
- `p1` (blocker): a real bug that normal use or normal config reaches: wrong results, a crash, a failed request, a regression. Also, for test code, **a coverage-integrity failure: a test that passes while verifying nothing real** (a missing, vacuous, or soft assertion; an un-awaited assertion that resolves after the test ends; or a `data-test` selector that matches no component). A green check with no coverage behind it is false confidence, so it blocks.
|
||||
- `p2` (not blocking): a real bug that needs unusual input, rare timing or non-default config; a measurable regression off the hot path; a broken project rule with no runtime effect.
|
||||
- `p3` (not blocking): a likely bug with one unverified step (rule 2).
|
||||
|
||||
If a normal user reaches it without unusual setup, it is `p1`, not `p2`. A finding with neither a concrete failure nor a named project rule it breaks is not a finding: drop it.
|
||||
6. **Real bugs only.** Report a finding only when it breaks something: wrong output, a crash, data loss, a security hole, or a measurable regression. Breaking a project rule (AGENTS.md, the house rules) counts. Hardening ideas, extra defensive checks, "consider" items and best-practice notes are not findings. If you cannot name what breaks, leave it out.
|
||||
7. **One finding per issue.** Do not combine multiple unrelated issues into one finding.
|
||||
8. **Be specific.** Include exact file paths and line numbers. Reference the relevant code snippet in your description.
|
||||
@@ -42,7 +48,7 @@ You MUST output your findings as a single block of structured XML. Do not includ
|
||||
<findings>
|
||||
<finding>
|
||||
<category>YOUR_CATEGORY</category>
|
||||
<severity>critical|warning|suggestion</severity>
|
||||
<severity>p0|p1|p2|p3</severity>
|
||||
<file>path/to/file.rs</file>
|
||||
<line>NUMBER</line>
|
||||
<summary>One-line summary of the issue</summary>
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { removeRepeatedly } from "./comments.mjs";
|
||||
import { LOADER_DIRS, LOADER_FILES } from "./sanitize.mjs";
|
||||
|
||||
export const MAX_APPROVE_LINES = 100;
|
||||
export const MAX_APPROVE_FILES = 5;
|
||||
// Reviewer prompts are cut at 150k characters with the PR text ahead of the diff, so a long body could push the diff out of view.
|
||||
export const MAX_REVIEWED_CHARS = 100_000;
|
||||
export const OPT_OUT_LABEL = "no-auto-approve";
|
||||
export const SENSITIVE_PATHS = [
|
||||
/(?:^|\/)\.github\//i,
|
||||
/(?:^|\/)scripts(?:\/|$)/i,
|
||||
/^deploy\//i,
|
||||
/(?:^|\/)Dockerfile[^/]*$/i,
|
||||
/(?:^|\/)migration[^/]*\//i,
|
||||
/(?:^|\/)Cargo\.(?:toml|lock)$/i,
|
||||
/(?:^|\/)build\.rs$/i,
|
||||
/(?:^|\/)\.cargo\//i,
|
||||
/(?:^|\/)rust-toolchain[^/]*$/i,
|
||||
/(?:^|\/)\.?clippy\.toml$/i,
|
||||
/(?:^|\/)\.gitattributes$/i,
|
||||
/(?:^|\/)\.npmrc$/i,
|
||||
/(?:^|\/)\.husky\//i,
|
||||
/(?:^|\/)Makefile$/i,
|
||||
/\.config\.(?:ts|js|mjs|cjs)$/i,
|
||||
/(?:^|\/)package[^/]*\.json$/i,
|
||||
/(?:^|\/)(?:[^/]*\.lock|[^/]*-lock\.(?:json|ya?ml)|npm-shrinkwrap\.json|go\.sum)$/i,
|
||||
/^src\/config\/src\/config\.rs$/i,
|
||||
// Built from the sanitizer's own names, so whatever it treats as an instruction loader also needs a human.
|
||||
new RegExp(`(?:^|/)(?:${[...LOADER_FILES, ...LOADER_DIRS].map(name => name.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")).join("|")})(?:/|$)`, "i"),
|
||||
/(?:^|\/)\.?opencode[^/]*(?:\/|$)/i,
|
||||
/(?:^|\/)\.env[^/]*(?:\/|$)/i,
|
||||
/\.proto$/i,
|
||||
/auth|rbac|openfga|token|secret|crypt|password|session|permission|license|sso|oauth|saml/i,
|
||||
];
|
||||
// Run over normalizedText, so every space is a single one and every dash a plain hyphen.
|
||||
const TRIPWIRE_RE = new RegExp([
|
||||
"ignore (?:(?:the|all|any|previous|prior) )+instructions",
|
||||
"system prompt",
|
||||
"approve this",
|
||||
"auto[- ]?approve",
|
||||
"do not flag",
|
||||
"no issues found",
|
||||
"lgtm",
|
||||
"<findings?",
|
||||
"no-issues",
|
||||
"o2-review",
|
||||
"ai-code-review",
|
||||
"ai-review-inline",
|
||||
].join("|"));
|
||||
// Run over letters alone, so "l.g.t.m" or a phrase split across two added lines still reads as written.
|
||||
const SQUEEZED_TRIPWIRE_RE = /lgtm|systemprompt|ignore(?:all)?(?:the)?previous|noissuesfound|donotflag/;
|
||||
// Each of these renders as nothing or as blank space, so text can hide behind or between them.
|
||||
const INVISIBLE_RE = /[\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\u2800\u3164\uFE00-\uFE0F\uFEFF\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u;
|
||||
// Code needs none of it, and a homoglyph reads as the word it copies to a human and past a phrase match.
|
||||
const NON_ASCII_RE = /[^\t\x20-\x7E]/;
|
||||
const LOOKALIKE_LETTER_RE = /[\p{Script=Cyrillic}\p{Script=Greek}]/u;
|
||||
const COMMANDS_REASON = "reviewers can run commands";
|
||||
const TRUSTED_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
const REGULAR_FILE_MODE = "100644";
|
||||
const SYMLINK_MODE = "120000";
|
||||
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 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.";
|
||||
|
||||
export function parseAutoApproveMode(value) {
|
||||
return value === "shadow" || value === "approve" ? value : "off";
|
||||
}
|
||||
|
||||
function headerPath(line) {
|
||||
const rest = line.slice("diff --git ".length);
|
||||
const size = (rest.length - 5) / 2;
|
||||
const path = rest.slice(2, 2 + size);
|
||||
return Number.isInteger(size) && rest === `a/${path} b/${path}` ? path : null;
|
||||
}
|
||||
|
||||
function cleanPath(raw) {
|
||||
return raw.replace(/\t$/, "");
|
||||
}
|
||||
|
||||
function readMode(mode, problems) {
|
||||
if (mode === SYMLINK_MODE) problems.add("a symlink changed");
|
||||
else if (mode === SUBMODULE_MODE) problems.add("a submodule changed");
|
||||
else if (mode !== REGULAR_FILE_MODE) problems.add("a file mode changed");
|
||||
}
|
||||
|
||||
function readFileHeader(line, file, problems) {
|
||||
if (/^(?:rename|copy) (?:from|to) /.test(line)) file.moved = true;
|
||||
if (/^(?:old mode|new mode|new file mode|deleted file mode) |^index \S+ \d+/.test(line)) file.modeKnown = true;
|
||||
const path = /^(?:--- a\/|\+\+\+ b\/|rename from |rename to |copy from |copy to )(.*)$/.exec(line)?.[1];
|
||||
if (path !== undefined) {
|
||||
if (path.startsWith('"')) problems.add("a file path could not be read");
|
||||
file.paths.add(cleanPath(path));
|
||||
return;
|
||||
}
|
||||
if (/^(?:--- |\+\+\+ )/.test(line) && !/ \/dev\/null$/.test(line)) problems.add("a file path could not be read");
|
||||
if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) problems.add("a binary file changed");
|
||||
if (line.startsWith("old mode ") || line.startsWith("new mode ")) problems.add("a file mode changed");
|
||||
const created = /^(?:new file|deleted file) mode (\d+)/.exec(line);
|
||||
if (created) readMode(created[1], problems);
|
||||
const indexMode = /^index \S+ (\d+)/.exec(line)?.[1];
|
||||
if (indexMode === SYMLINK_MODE || indexMode === SUBMODULE_MODE) readMode(indexMode, problems);
|
||||
}
|
||||
|
||||
// Hunk-aware on purpose: an added line "++ x" reads as "+++ x", and a path-looking line inside a hunk must never count as a header.
|
||||
export function parseRawDiff(diff) {
|
||||
const text = String(diff ?? "");
|
||||
const files = [];
|
||||
const addedLines = [];
|
||||
const problems = new Set(text.includes("\0") ? ["a binary file changed"] : []);
|
||||
let file = null;
|
||||
let changedLines = 0;
|
||||
let oldLeft = 0;
|
||||
let newLeft = 0;
|
||||
for (const line of text.split("\n")) {
|
||||
if (oldLeft > 0 || newLeft > 0) {
|
||||
if (line.startsWith("\\")) continue;
|
||||
if (line.startsWith("-")) { oldLeft -= 1; changedLines += 1; continue; }
|
||||
if (line.startsWith("+")) { newLeft -= 1; changedLines += 1; addedLines.push(line.slice(1)); continue; }
|
||||
oldLeft -= 1;
|
||||
newLeft -= 1;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("diff --git ")) {
|
||||
const path = headerPath(line);
|
||||
file = { paths: new Set(path === null ? [] : [path]) };
|
||||
files.push(file);
|
||||
continue;
|
||||
}
|
||||
const hunk = HUNK_RE.exec(line);
|
||||
if (hunk && file) {
|
||||
oldLeft = hunk[1] === undefined ? 1 : Number(hunk[1]);
|
||||
newLeft = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
||||
continue;
|
||||
}
|
||||
if (!KNOWN_HEADER_RE.test(line) || (!file && line)) { problems.add("the diff could not be parsed"); continue; }
|
||||
if (file) readFileHeader(line, file, problems);
|
||||
}
|
||||
if (oldLeft > 0 || newLeft > 0) problems.add("the diff is cut short");
|
||||
if (files.some(f => f.paths.size === 0)) problems.add("a file path could not be read");
|
||||
// An unchanged rename prints no mode, so a symlink or submodule would otherwise pass as a plain file.
|
||||
if (files.some(f => f.moved && !f.modeKnown)) problems.add("a renamed file's type could not be read");
|
||||
return { fileCount: files.length, changedLines, paths: files.flatMap(f => [...f.paths]), addedLines, problems: [...problems] };
|
||||
}
|
||||
|
||||
export function sizeReasons(parsed) {
|
||||
const reasons = [...(parsed?.problems ?? [])];
|
||||
if (!(parsed?.fileCount > 0)) reasons.push("no files to judge");
|
||||
if (!(parsed?.fileCount <= MAX_APPROVE_FILES)) reasons.push(`more than ${MAX_APPROVE_FILES} files`);
|
||||
if (!(parsed?.changedLines <= MAX_APPROVE_LINES)) reasons.push(`more than ${MAX_APPROVE_LINES} changed lines`);
|
||||
return reasons;
|
||||
}
|
||||
|
||||
export function isSensitivePath(path) {
|
||||
return SENSITIVE_PATHS.some(re => re.test(path));
|
||||
}
|
||||
|
||||
function normalizedText(text) {
|
||||
return text.normalize("NFKC").toLowerCase().replace(/\p{Pd}/gu, "-").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function hasTripwire(texts) {
|
||||
const text = texts.map(raw => String(raw ?? "")).join("\n");
|
||||
const normalized = normalizedText(text);
|
||||
return INVISIBLE_RE.test(text) || TRIPWIRE_RE.test(normalized) || SQUEEZED_TRIPWIRE_RE.test(normalized.replace(/[^a-z]/g, ""));
|
||||
}
|
||||
|
||||
export function hasNonAscii(texts) {
|
||||
return texts.some(raw => NON_ASCII_RE.test(String(raw ?? "")));
|
||||
}
|
||||
|
||||
// NFKC first: a mathematical Greek letter is a Common-script symbol until it is normalized.
|
||||
export function hasLookalikeLetters(texts) {
|
||||
return texts.some(raw => LOOKALIKE_LETTER_RE.test(String(raw ?? "").normalize("NFKC")));
|
||||
}
|
||||
|
||||
// Only the exact approved block may render, alone, so a body that disagrees with an empty findings block cannot approve; a fence or HTML block ahead of it would turn it into quoted text.
|
||||
export function isApprovedReview(text) {
|
||||
const visible = removeRepeatedly(String(text ?? ""), /<!--[\s\S]*?--!?>/g, "");
|
||||
const callouts = [...visible.matchAll(/^ {0,3}> ?\[![A-Za-z]+\]/gm)];
|
||||
const decision = /^> \[!TIP\]\n> ### ✅ Approved[ \t]*$/m.exec(visible);
|
||||
if (callouts.length !== 1 || decision?.index !== callouts[0].index) return false;
|
||||
if (showsBlocker(visible)) return false;
|
||||
return !/^[ >]*(?:`{3,}|~{3,}|<)/m.test(visible.slice(0, decision.index));
|
||||
}
|
||||
|
||||
// The callout is model-written, so a P0/P1 section or a non-zero blocker count anywhere below it vetoes the approval.
|
||||
function showsBlocker(visible) {
|
||||
const text = visible
|
||||
.replace(/&#x([0-9a-f]{1,6});?|&#(\d{1,7});?/gi, (ref, hex, dec) => {
|
||||
const code = hex ? Number.parseInt(hex, 16) : Number(dec);
|
||||
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ref;
|
||||
})
|
||||
.normalize("NFKC");
|
||||
const withoutZeroCounts = text.replace(/\bp[01][ \t]*:[ \t]*0(?![\d.,])/gi, "").replace(/\b(?:0|no)[ \t]+blockers?\b/gi, "");
|
||||
if (/\b(?:p[01]|blockers?)\b/i.test(withoutZeroCounts)) return true;
|
||||
return /^[ \t>]*(?:#{1,6}[ \t]|\*\*|<summary>)[^\n]*\bcritical\b/im.test(text);
|
||||
}
|
||||
|
||||
function coverageReasons({ diff, diffPinned, filteredFileCount, reviewedChars, pr }) {
|
||||
const reasons = [];
|
||||
if (diffPinned !== true) reasons.push("the reviewed diff is not pinned to the head commit");
|
||||
if (filteredFileCount !== diff?.fileCount) reasons.push("not every file was reviewed");
|
||||
if (!(reviewedChars <= MAX_REVIEWED_CHARS)) reasons.push("the diff was truncated for the reviewers");
|
||||
if (pr?.changed_files !== diff?.fileCount || (pr?.additions ?? NaN) + (pr?.deletions ?? NaN) !== diff?.changedLines) {
|
||||
reasons.push("the diff does not match the PR's size");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function verdictReasons({ confidence, counts, reviewerFindingCount, findingsKnown, coordinatorFallback, decisionApproved }) {
|
||||
const reasons = [];
|
||||
if (confidence?.level !== "high") reasons.push("merge confidence is not high");
|
||||
const kept = Object.values(counts ?? { unknown: 1 }).reduce((sum, n) => sum + n, 0);
|
||||
if (kept !== 0 || reviewerFindingCount !== 0) reasons.push("there are findings");
|
||||
if (findingsKnown !== true) reasons.push("findings could not be counted");
|
||||
if (coordinatorFallback !== false) reasons.push("findings are un-judged");
|
||||
if (decisionApproved !== true) reasons.push("the review did not approve");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function reviewerReasons(reviewers) {
|
||||
const list = Array.isArray(reviewers) ? reviewers : [];
|
||||
const done = key => list.some(r => r.key === key && r.ran === true && r.finished === true);
|
||||
const reasons = [];
|
||||
if (!done("security")) reasons.push("the Security reviewer did not finish");
|
||||
if (!done("correctness")) reasons.push("the Correctness reviewer did not finish");
|
||||
if (list.some(r => r.ran !== false && r.finished !== true)) reasons.push("a reviewer did not finish");
|
||||
if (list.length === 0 || list.some(r => r.ran !== false && r.fullDiff !== true)) reasons.push("a reviewer did not see the whole diff");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
// 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 }));
|
||||
}
|
||||
|
||||
function pairingReasons({ paired, pairClear }) {
|
||||
const reasons = [];
|
||||
if (paired !== false) reasons.push("paired with another PR");
|
||||
if (pairClear !== true) reasons.push("the pairing check did not clear");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function authorReasons(pr) {
|
||||
const login = String(pr?.user?.login ?? "");
|
||||
const reasons = [];
|
||||
if (!login || pr?.user?.type !== "User" || login.endsWith("[bot]")) reasons.push("the author is a bot");
|
||||
if (!TRUSTED_ASSOCIATIONS.has(pr?.author_association)) reasons.push("the author is not a project member");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function hasOutstandingChangeRequest(reviews) {
|
||||
const latest = new Map();
|
||||
for (const r of reviews) {
|
||||
if (["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(r.state)) latest.set(r.user?.login ?? "", r.state);
|
||||
}
|
||||
return [...latest.values()].includes("CHANGES_REQUESTED");
|
||||
}
|
||||
|
||||
function samePrText(reviewed, pr) {
|
||||
return ["title", "body"].every(field => String(reviewed[field] ?? "") === String(pr[field] ?? ""));
|
||||
}
|
||||
|
||||
// What the reviewers read is what counts: an injection edited out before this check has still reached them.
|
||||
function textReasons({ pr, diff, reviewedText }) {
|
||||
if (!pr || !diff) return ["suspicious text in the PR"];
|
||||
const changed = [...diff.paths, ...diff.addedLines];
|
||||
const prose = [reviewedText?.title, reviewedText?.body, pr.title, pr.body];
|
||||
const reasons = [];
|
||||
if (hasTripwire([...prose, ...changed])) reasons.push("suspicious text in the PR");
|
||||
if (hasNonAscii(changed)) reasons.push("non-ASCII text in the change");
|
||||
if (hasLookalikeLetters(prose)) reasons.push("lookalike letters in the title or body");
|
||||
if (!reviewedText) reasons.push("the text given to the reviewers is unknown");
|
||||
else if (!samePrText(reviewedText, pr)) reasons.push("the title or body changed after the review started");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function blockReasons({ pr, reviews }) {
|
||||
const reasons = [];
|
||||
const labels = pr?.labels;
|
||||
if (!Array.isArray(labels) || labels.some(l => String(l?.name).toLowerCase() === OPT_OUT_LABEL)) reasons.push("opted out of auto-approve");
|
||||
if (!Array.isArray(reviews) || hasOutstandingChangeRequest(reviews)) reasons.push("changes were requested");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function headReasons({ pr, headSha }) {
|
||||
const reasons = [];
|
||||
if (!headSha || pr?.head?.sha !== headSha) reasons.push("the head moved");
|
||||
if (pr?.state !== "open" || pr?.draft !== false) reasons.push("the PR is not open and ready");
|
||||
if (pr?.base?.ref !== "main") reasons.push("the PR does not target main");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
// Every value is read as "not proven" when missing, so an incomplete input can never come out eligible.
|
||||
export function autoApproveDecision(input = {}) {
|
||||
const { diff, reviewConfig, staleCleared } = input;
|
||||
const reasons = [
|
||||
...(reviewConfig === "default" ? [] : [COMMANDS_REASON]),
|
||||
...sizeReasons(diff),
|
||||
...coverageReasons(input),
|
||||
...(diff?.paths?.length > 0 && !diff.paths.some(isSensitivePath) ? [] : ["touches a sensitive path"]),
|
||||
...verdictReasons(input),
|
||||
...reviewerReasons(input.reviewers),
|
||||
...pairingReasons(input),
|
||||
...authorReasons(input.pr),
|
||||
...textReasons(input),
|
||||
...blockReasons(input),
|
||||
...headReasons(input),
|
||||
...(staleCleared === true ? [] : ["an earlier approval could not be dismissed"]),
|
||||
];
|
||||
return { eligible: reasons.length === 0, eligibleIgnoringConfig: reasons.every(r => r === COMMANDS_REASON), reasons: [...new Set(reasons)] };
|
||||
}
|
||||
|
||||
// `api` is { readPr, listReviews, dismissReview, approve }, injected so the no-write modes can be proven.
|
||||
export function createAutoApproveGate({ mode, api, botLogin, headSha, warn = () => {} }) {
|
||||
let staleCleared = false;
|
||||
const ownApprovals = reviews => reviews.filter(r => r.user?.login === botLogin && r.state === "APPROVED");
|
||||
|
||||
function dismissOwnApprovals() {
|
||||
if (!botLogin) {
|
||||
warn("no reviewer app identity, so earlier approvals cannot be found");
|
||||
return false;
|
||||
}
|
||||
let cleared = true;
|
||||
for (const review of ownApprovals(api.listReviews())) {
|
||||
try {
|
||||
api.dismissReview(review.id, DISMISSAL_MESSAGE);
|
||||
} catch (err) {
|
||||
cleared = false;
|
||||
warn(`could not dismiss review ${review.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function submitApproval(reviews) {
|
||||
if (ownApprovals(reviews).some(r => r.commit_id === headSha)) return true;
|
||||
try {
|
||||
api.approve({ event: "APPROVE", commit_id: headSha, body: APPROVAL_BODY });
|
||||
return true;
|
||||
} catch (err) {
|
||||
warn(`could not submit the approval: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
active: mode !== "off",
|
||||
// Must run before the reviewers start: a small approval must not outlive the commit it covered, even if this run dies.
|
||||
start() {
|
||||
if (mode === "off") return;
|
||||
if (mode === "shadow") {
|
||||
staleCleared = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
staleCleared = dismissOwnApprovals();
|
||||
} catch (err) {
|
||||
warn(`could not list earlier approvals: ${err.message}`);
|
||||
}
|
||||
},
|
||||
conclude(facts) {
|
||||
if (mode === "off") return null;
|
||||
let pr;
|
||||
let reviews;
|
||||
try {
|
||||
pr = api.readPr();
|
||||
reviews = api.listReviews();
|
||||
} catch (err) {
|
||||
warn(`could not re-read the PR: ${err.message}`);
|
||||
}
|
||||
const decision = autoApproveDecision({ ...facts, pr, reviews, headSha, staleCleared });
|
||||
const result = { mode, eligible: decision.eligible, eligible_ignoring_config: decision.eligibleIgnoringConfig, reasons: decision.reasons, approved: false };
|
||||
if (mode !== "approve" || !decision.eligible) return result;
|
||||
result.approved = submitApproval(reviews);
|
||||
if (!result.approved) result.reasons = ["the approval could not be submitted"];
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function autoApproveLine(result) {
|
||||
if (result?.mode !== "approve") return "";
|
||||
return result.approved ? APPROVED_LINE : `**Needs human review** — ${result.reasons.join("; ")}`;
|
||||
}
|
||||
@@ -1,4 +1,44 @@
|
||||
import { isSettledConfidenceLine, mergeConfidenceLine, reviewingConfidenceLine, staleConfidenceLine } from "./confidence.mjs";
|
||||
import { scrubReviewBody } from "./scrub.mjs";
|
||||
|
||||
export const REVIEW_PASS_TAG = "<!--\\s*o2-review-pass:(\\d+)\\s*-->";
|
||||
// The value class excludes `<` and `>` so a tag can neither cross into the next one nor backtrack across it.
|
||||
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*-->";
|
||||
// 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.
|
||||
export const PAIRED_POINTER_TAG = "<!-- o2-paired-pointer -->";
|
||||
export const NOTICE_TAG = "<!-- o2-review-notice -->";
|
||||
export const REVIEW_TITLE = "OpenObserve Code Review";
|
||||
export const REVIEW_HEADING = `## 🔎 ${REVIEW_TITLE}`;
|
||||
// Tolerates a missing glyph and the old "AI Code Review" heading so prompt drift still normalizes.
|
||||
const REVIEW_HEADING_RE = /^#{1,3}[ \t]*(?:🔎[ \t]*)?(?:OpenObserve Code Review|AI Code Review)\b/mi;
|
||||
const REVIEW_HEADING_LINE_RE = /^#{1,3}[ \t]*(?:🔎[ \t]*)?(?:OpenObserve Code Review|AI Code Review)\b[ \t]*[^\n]*$/mi;
|
||||
// The runner writes the note, the commit line and the confidence line itself, so a copy echoed from the previous review is stripped.
|
||||
const PASS_LIMIT_NOTE_RE = /\n*> \[!NOTE\]\n> This PR has used its \d+ automatic reviews[^\n]*/g;
|
||||
const REVIEWED_COMMIT_LINE_RE = /\n*<sub>Reviewed commits? [^\n]*<\/sub>[ \t]*/g;
|
||||
// Anchored to the line start so a finding that merely mentions the phrase survives; any leading punctuation or tag still counts as a forgery, but a backtick opens a finding's file path.
|
||||
const MERGE_CONFIDENCE_LINE_RE = /^(?:[^\w\n`]|<\/?[a-z]+>)*merge[ \t]+confidence\b[^\n]*(?:\n|$)/gim;
|
||||
const AUTO_APPROVE_LINE_RE = /^(?:[^\w\n`]|<\/?[a-z]+>)*(?:auto[- ]?approved|needs[ \t]+human[ \t]+review)\b[^\n]*(?:\n|$)/gim;
|
||||
// Matched on letters alone, after tags and comments, so spacing, markup and lookalike punctuation cannot disguise a runner line.
|
||||
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 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.
|
||||
const BOUNDARY_TAGS = ["mr_input", "mr_body", "mr_comments", "mr_details",
|
||||
"changed_files", "existing_inline_findings", "previous_review",
|
||||
"custom_review_instructions", "review_task", "pr_context", "diff",
|
||||
"all_findings", "coordinator_task", "overall_pr_context", "risk_tier",
|
||||
"reviewers_ran", "failed_reviewers", "reviewer", "findings", "finding", "no-issues",
|
||||
"category", "severity", "file", "line", "description", "suggestion"];
|
||||
const BOUNDARY_TAG_RE = new RegExp(`</?(${BOUNDARY_TAGS.join("|")})[^>]*>`, "gi");
|
||||
// Cyrillic and Greek letters that render like Latin ones; NFKC leaves them alone.
|
||||
const LOOKALIKES = new Map([..."аеорсухіјѕԁһӏԛԝɡαονριкАВЕКМНОРСТХІЈЅΑΒΕΖΗΙΚΜΝΟΡΤΥΧ"].map((ch, i) => [ch, "aeopcyxijsdhlqwgaovpikABEKMHOPCTXIJSABEZHIKMNOPTYX"[i]]));
|
||||
|
||||
export function selectOwnReviewComment(comments, botLogin, markerPattern) {
|
||||
if (!botLogin) return null;
|
||||
@@ -8,6 +48,10 @@ export function selectOwnReviewComment(comments, botLogin, markerPattern) {
|
||||
}
|
||||
|
||||
// untagged is what a comment from before the counter is worth: a review is one pass, a pointer or notice none.
|
||||
export function isPlaceholderComment(body) {
|
||||
return carriesRunnerTag(body, PAIRED_POINTER_TAG) || carriesRunnerTag(body, NOTICE_TAG);
|
||||
}
|
||||
|
||||
export function reviewPassCount(body, untagged = 1) {
|
||||
const m = (body || "").match(new RegExp(REVIEW_PASS_TAG, "i"));
|
||||
return m ? Number(m[1]) : untagged;
|
||||
@@ -16,3 +60,267 @@ export function reviewPassCount(body, untagged = 1) {
|
||||
export function reviewPassTag(count) {
|
||||
return `<!-- o2-review-pass:${count} -->`;
|
||||
}
|
||||
|
||||
export function withPassTag(body, count) {
|
||||
return `${withoutTag(body, REVIEW_PASS_TAG)}\n${reviewPassTag(count)}`;
|
||||
}
|
||||
|
||||
// A new commit on either side of a paired review is a new key.
|
||||
export function reviewKey(head, pairedHead) {
|
||||
if (!head) return "";
|
||||
return pairedHead ? `${head}+${pairedHead}` : head;
|
||||
}
|
||||
|
||||
export function reviewedKey(body) {
|
||||
return (body || "").match(new RegExp(REVIEW_SHA_TAG, "i"))?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function reviewShaTag(key) {
|
||||
return `<!-- o2-review-sha:${key} -->`;
|
||||
}
|
||||
|
||||
function withoutTag(body, tag) {
|
||||
return body.replace(new RegExp(`\\n?${tag}`, "gi"), "");
|
||||
}
|
||||
|
||||
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;
|
||||
return reviewedKey(body) === key;
|
||||
}
|
||||
|
||||
// 0 means no cap: anything but a positive integer leaves every new commit reviewable.
|
||||
export function parseMaxPasses(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^\d+$/.test(text) ? Number(text) : 0;
|
||||
}
|
||||
|
||||
export function passLimitReached(passes, maxPasses) {
|
||||
return maxPasses > 0 && 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";
|
||||
if (!ignorePassLimit && passLimitReached(passesDone, maxPasses)) return "pass_limit_reached";
|
||||
return "";
|
||||
}
|
||||
|
||||
function shortKey(key) {
|
||||
return key.split("+").map(sha => sha.slice(0, 7)).join("+");
|
||||
}
|
||||
|
||||
// With a pair, HEAD_SHA is the enterprise PR and PAIRED_HEAD_SHA the OSS one.
|
||||
export function reviewedCommitLine(key, review, maxPasses) {
|
||||
const [head, paired] = key.split("+").map(shortKey);
|
||||
const commits = paired
|
||||
? `Reviewed commits \`${head}\` and \`${paired}\` (paired OSS PR)`
|
||||
: `Reviewed commit \`${head}\``;
|
||||
return `<sub>${commits} · review ${review}${maxPasses > 0 ? ` of ${maxPasses}` : ""}</sub>`;
|
||||
}
|
||||
|
||||
export function passLimitNote(maxPasses, reviewed = "", head = "") {
|
||||
const scope = reviewed && head && reviewed !== head
|
||||
? `. This review covers \`${shortKey(reviewed)}\`; \`${shortKey(head)}\` and later pushes are not reviewed.`
|
||||
: "; later pushes are not reviewed.";
|
||||
return `> [!NOTE]\n> This PR has used its ${maxPasses} automatic reviews${scope} Run the AI Code Review workflow by hand with \`ignore_pass_limit\` to review it again.`;
|
||||
}
|
||||
|
||||
// Replaces any earlier note and stays above the hidden tags, so repeat skips neither stack notes nor lose tags.
|
||||
export function withPassLimitNote(body, note) {
|
||||
const bare = body.replace(PASS_LIMIT_NOTE_RE, "");
|
||||
const tags = bare.match(TRAILING_TAGS_RE)?.[0] ?? "";
|
||||
return `${bare.slice(0, bare.length - tags.length).trimEnd()}\n\n${note}${tags}`;
|
||||
}
|
||||
|
||||
// The blank lines stop Markdown pulling the note into the callout's blockquote.
|
||||
export function insertAfterCallout(body, note) {
|
||||
if (!note) return body;
|
||||
const lines = body.split("\n");
|
||||
const heading = lines.findIndex(l => /^#{1,3}\s/.test(l));
|
||||
let at = heading + 1;
|
||||
const callout = lines.findIndex((l, i) => i > heading && /^>\s*\[!(?:TIP|NOTE|WARNING|CAUTION|IMPORTANT)\]/i.test(l));
|
||||
if (callout !== -1) {
|
||||
at = callout;
|
||||
while (at < lines.length && lines[at].startsWith(">")) at += 1;
|
||||
}
|
||||
lines.splice(at, 0, "", note, "");
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
// A gate verdict covers one commit, so it comes off as soon as a run for a later one claims the comment.
|
||||
export function withoutAutoApproveLine(body) {
|
||||
return body.replace(AUTO_APPROVE_LINE_RE, "");
|
||||
}
|
||||
|
||||
function withConfidenceLine(body, line) {
|
||||
return insertAfterCallout(withoutAutoApproveLine(body).replace(MERGE_CONFIDENCE_LINE_RE, ""), line);
|
||||
}
|
||||
|
||||
function confidenceLineOf(body) {
|
||||
return (body.match(MERGE_CONFIDENCE_LINE_RE)?.[0] ?? "").trim();
|
||||
}
|
||||
|
||||
function heldLine(body) {
|
||||
const encoded = body.match(new RegExp(REVIEW_HELD_TAG, "i"))?.[1];
|
||||
const line = encoded ? Buffer.from(encoded, "base64url").toString("utf-8") : "";
|
||||
return isSettledConfidenceLine(line) ? line : "";
|
||||
}
|
||||
|
||||
// Base64 because a verdict's text is free to hold `-->`; only the first line replaced is the reviewed commit's verdict.
|
||||
function withHeldVerdict(body) {
|
||||
const line = confidenceLineOf(body);
|
||||
if (!reviewedKey(body) || !isSettledConfidenceLine(line) || body.search(new RegExp(REVIEW_HELD_TAG, "i")) !== -1) return body;
|
||||
const tags = body.match(TRAILING_TAGS_RE)?.[0] ?? "";
|
||||
return `${body.slice(0, body.length - tags.length)}\n<!-- o2-review-held:${Buffer.from(line, "utf-8").toString("base64url")} -->${tags}`;
|
||||
}
|
||||
|
||||
// The old verdict must not read as the new head's while the run works, legacy reviews with no confidence line included; a cancelled run leaving "Reviewing" behind is still true.
|
||||
export function claimedReviewBody(body, count, key) {
|
||||
const bare = withoutAutoApproveLine(body);
|
||||
const newHead = key && reviewedKey(body) !== key && (bare.search(MERGE_CONFIDENCE_LINE_RE) !== -1 || !isPlaceholderComment(bare));
|
||||
return withPassTag(newHead ? withConfidenceLine(withHeldVerdict(bare), reviewingConfidenceLine(shortKey(key))) : bare, count);
|
||||
}
|
||||
|
||||
// A head the review did not cover must not keep showing that review's confidence; an untagged review covers no head, only a placeholder claims none.
|
||||
export function bodyAtUnreviewedHead(body, head) {
|
||||
const reviewed = reviewedKey(body);
|
||||
const covered = reviewed ? reviewed === head : isPlaceholderComment(body);
|
||||
return head && !covered ? withConfidenceLine(withHeldVerdict(body), staleConfidenceLine(shortKey(head))) : body;
|
||||
}
|
||||
|
||||
// Only for a head the sha tag covers. No held verdict means no level is claimed, and the gate's line stays off because its approval was dismissed.
|
||||
export function bodyAtReviewedHead(body, maxPasses) {
|
||||
const line = confidenceLineOf(body);
|
||||
const held = body.search(new RegExp(REVIEW_HELD_TAG, "i")) !== -1;
|
||||
const settled = !held && (!line || isSettledConfidenceLine(line));
|
||||
const restored = settled ? body : withConfidenceLine(withoutTag(body, REVIEW_HELD_TAG), heldLine(body));
|
||||
if (body.search(PASS_LIMIT_NOTE_RE) === -1) return restored;
|
||||
if (!passLimitReached(reviewPassCount(body), maxPasses)) return restored.replace(PASS_LIMIT_NOTE_RE, "");
|
||||
return settled ? body : withPassLimitNote(restored, passLimitNote(maxPasses));
|
||||
}
|
||||
|
||||
export function bodyAtPassLimit(body, maxPasses, head) {
|
||||
return bodyAtUnreviewedHead(withPassLimitNote(body, passLimitNote(maxPasses, reviewedKey(body), head)), head);
|
||||
}
|
||||
|
||||
// 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 = "" }) {
|
||||
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);
|
||||
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 ?? "")));
|
||||
}
|
||||
|
||||
// Yields the text after each pass, last the first that changed nothing: a removal can splice a new match out of what surrounds it.
|
||||
function* removalPasses(text, pattern, replacement = "") {
|
||||
let out = text;
|
||||
let previous;
|
||||
do {
|
||||
previous = out;
|
||||
out = out.replace(pattern, replacement);
|
||||
yield out;
|
||||
} while (out !== previous);
|
||||
}
|
||||
|
||||
export function removeRepeatedly(text, pattern, replacement) {
|
||||
let last = text;
|
||||
for (const pass of removalPasses(text, pattern, replacement)) last = pass;
|
||||
return last;
|
||||
}
|
||||
|
||||
// Markdown renders a numeric reference as its character, so "Merge" reads as "Merge"; named ones are already stripped by MARKUP_RE and none spells a Latin letter.
|
||||
function decodeCharRefs(text) {
|
||||
return text.replace(/&#x([0-9a-f]{1,6});?|&#(\d{1,7});?/gi, (ref, hex, dec) => {
|
||||
const code = hex ? Number.parseInt(hex, 16) : Number(dec);
|
||||
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ref;
|
||||
});
|
||||
}
|
||||
|
||||
function lettersOf(text) {
|
||||
const canonical = [...decodeCharRefs(text).normalize("NFKC")].map(ch => LOOKALIKES.get(ch) ?? ch).join("");
|
||||
return canonical.toLowerCase().replace(/[^a-z]/g, "");
|
||||
}
|
||||
|
||||
// Every pass is judged, not only the last: a renderer does not rescan, so a comment rebuilt by one pass, which the next would swallow, is visible text.
|
||||
function isForgedLine(line) {
|
||||
for (const pass of removalPasses(line, MARKUP_RE)) {
|
||||
if (FORGED_LINE_RE.test(lettersOf(pass))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripForgedLines(text) {
|
||||
const lines = removeRepeatedly(text, HTML_COMMENT_RE, comment => (comment.includes("\n") ? "" : comment)).split("\n");
|
||||
// A hidden real finding costs more than a cosmetic forgery, and the gate never reads these lines, so a finding's text is not judged.
|
||||
return lines.filter(line => !isForgedLine(line) || FINDING_LINE_RE.test(line)).join("\n");
|
||||
}
|
||||
|
||||
function stripRunnerLines(text) {
|
||||
return stripForgedLines(text.replace(PASS_LIMIT_NOTE_RE, "").replace(REVIEWED_COMMIT_LINE_RE, "").replace(MERGE_CONFIDENCE_LINE_RE, "").replace(AUTO_APPROVE_LINE_RE, ""));
|
||||
}
|
||||
|
||||
// The vendor prefix goes too, so "deepseek-v4-pro" and a bare "DeepSeek" are both caught; longest first because alternation is first-match.
|
||||
export function redactProviderIdentity(text, { providerId, modelId }) {
|
||||
const vendor = String(modelId ?? "").split(/[-/_]/)[0];
|
||||
const terms = [providerId, modelId, vendor.length >= 4 ? vendor : null]
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
if (terms.length === 0) return text;
|
||||
return text.replace(new RegExp(terms.join("|"), "gi"), "[redacted]");
|
||||
}
|
||||
|
||||
function unwrapJsonBody(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return body;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed.body && typeof parsed.body === "string" ? parsed.body : body;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
// The marker is added last: it may name the provider, and a redacted marker no longer matches, so every run would post a new comment.
|
||||
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");
|
||||
|
||||
let cleaned = unwrapJsonBody(body)
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(BOUNDARY_TAG_RE, "");
|
||||
cleaned = stripRunnerLines(removeRepeatedly(cleaned, runnerTags));
|
||||
|
||||
// Runs after marker stripping so a leading marker cannot anchor the search at index 0 and hide a preamble behind it.
|
||||
const headingAt = cleaned.search(REVIEW_HEADING_RE);
|
||||
if (headingAt > 0) cleaned = cleaned.slice(headingAt);
|
||||
cleaned = cleaned.trimStart().replace(REVIEW_HEADING_LINE_RE, REVIEW_HEADING);
|
||||
|
||||
const scrubbed = scrubReviewBody(redactProviderIdentity(cleaned.trim(), { providerId, modelId }), env);
|
||||
// Stripped again because redaction can complete a marker the first pass did not recognise.
|
||||
return { text: `${marker}\n${scrubbed.text.replace(anyMarker, "").trimStart()}`, secretHits: scrubbed.secretHits };
|
||||
}
|
||||
|
||||
// Only the runner writes a tag on its own line above the heading, and a reviewed head means a finished review, so quoted text never counts.
|
||||
function carriesRunnerTag(body, tag) {
|
||||
const text = String(body ?? "");
|
||||
if (new RegExp(REVIEW_SHA_TAG, "i").test(text)) return false;
|
||||
const lines = text.split("\n");
|
||||
const heading = lines.findIndex(line => REVIEW_HEADING_LINE_RE.test(line));
|
||||
return heading > 0 && lines.slice(0, heading).some(line => line.trim() === tag);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { SEVERITIES, normalizeSeverity } from "./severity.mjs";
|
||||
|
||||
const CONFIDENCE_LABEL = { high: "🟢 High", medium: "🟡 Medium", low: "🔴 Low" };
|
||||
// "unknown" is a reviewer that rejected before reporting its name; it may have been either of these.
|
||||
const CORE_REVIEWER_RE = /^(?:security|correctness|unknown)\b/i;
|
||||
const TEST_PATH_RE = /(?:^|\/)(?:tests|__tests__|test)\/|\.(?:spec|test)\.[^/]*$/;
|
||||
// o2_* are the enterprise crates, whose sources do not sit under a top-level src/.
|
||||
const PROD_PATH_RE = /^(?:src|web\/src|o2_[^/]+\/src)\//;
|
||||
const RUST_TEST_ADDED_RE = /^\+\s*#\[(?:test\]|tokio::test\b|cfg\(test\)\])/m;
|
||||
// What a run writes over a verdict: the two lines below, which judge a head and not the review.
|
||||
const SETTLED_LINE_RE = /^\*\*Merge confidence: (?:🟢 High|🟡 Medium|🔴 Low)\*\* — (?!.* (?:is being|has not been) reviewed$)/;
|
||||
|
||||
export function shortName(agentName) {
|
||||
return String(agentName || "unknown").replace(/\s+Reviewer$/i, "");
|
||||
}
|
||||
|
||||
export function severityCounts(findings) {
|
||||
const counts = Object.fromEntries(SEVERITIES.map(s => [s, 0]));
|
||||
for (const f of findings) counts[normalizeSeverity(f.severity)] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
// A reviewer that returned nothing parseable counts as unfinished, or its silence would read as a clean pass.
|
||||
export function reviewerFinished(r) {
|
||||
return !r.skipped && !r.error && r.parsed === true;
|
||||
}
|
||||
|
||||
export function unfinishedReviewers(ranResults) {
|
||||
return ranResults.filter(r => !reviewerFinished(r)).map(r => r.agentName);
|
||||
}
|
||||
|
||||
function plural(count, noun) {
|
||||
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function highReason(prodChanged) {
|
||||
return `no blockers; all reviewers finished${prodChanged ? "; tests changed" : ""}`;
|
||||
}
|
||||
|
||||
// Computed from facts only, so neither a PR nor the model can talk the level up.
|
||||
export function mergeConfidence({
|
||||
counts = {},
|
||||
findingsKnown = true,
|
||||
failedReviewers = [],
|
||||
ranCount,
|
||||
coordinatorFallback = false,
|
||||
prodChanged = false,
|
||||
testsChanged = false,
|
||||
}) {
|
||||
if (ranCount === 0) return { level: "low", reasons: ["no reviewer ran"] };
|
||||
if (ranCount > 0 && failedReviewers.length >= ranCount) return { level: "low", reasons: ["no reviewer finished"] };
|
||||
|
||||
const blockers = (counts.p0 ?? 0) + (counts.p1 ?? 0);
|
||||
const core = failedReviewers.filter(name => CORE_REVIEWER_RE.test(shortName(name)));
|
||||
const other = failedReviewers.filter(name => !core.includes(name));
|
||||
const unfinished = name => `${shortName(name)} reviewer did not finish`;
|
||||
const low = [
|
||||
...(blockers > 0 ? [`${plural(blockers, "blocker")} (P0/P1)`] : []),
|
||||
...(findingsKnown ? [] : ["findings could not be counted"]),
|
||||
...core.map(unfinished),
|
||||
];
|
||||
const medium = [
|
||||
...other.map(unfinished),
|
||||
...(coordinatorFallback ? ["findings are un-judged"] : []),
|
||||
...((counts.p2 ?? 0) > 0 ? [plural(counts.p2, "P2 finding")] : []),
|
||||
...(prodChanged && !testsChanged ? ["no tests changed"] : []),
|
||||
];
|
||||
if (low.length > 0) return { level: "low", reasons: [...low, ...medium] };
|
||||
if (medium.length > 0) return { level: "medium", reasons: medium };
|
||||
return { level: "high", reasons: [highReason(prodChanged)] };
|
||||
}
|
||||
|
||||
export function mergeConfidenceLine({ level, reasons }) {
|
||||
return `**Merge confidence: ${CONFIDENCE_LABEL[level]}** — ${reasons.join("; ")}`;
|
||||
}
|
||||
|
||||
export function staleConfidenceLine(shortHead) {
|
||||
return `**Merge confidence: ${CONFIDENCE_LABEL.low}** — \`${shortHead}\` has not been reviewed`;
|
||||
}
|
||||
|
||||
export function reviewingConfidenceLine(shortHead) {
|
||||
return `**Merge confidence: 🟡 Reviewing** — \`${shortHead}\` is being reviewed`;
|
||||
}
|
||||
|
||||
export function isSettledConfidenceLine(line) {
|
||||
return SETTLED_LINE_RE.test(line);
|
||||
}
|
||||
|
||||
// A Rust test added beside the code counts as test evidence without making that file any less production code.
|
||||
export function changedCodeEvidence(diff, pairedDir = "") {
|
||||
const prefix = pairedDir ? `${pairedDir.replace(/\/+$/, "")}/` : "";
|
||||
let prodChanged = false;
|
||||
let testsChanged = false;
|
||||
for (const section of String(diff ?? "").split(/^diff --git /m)) {
|
||||
const raw = /^a\/.*? b\/(.*?)\n/.exec(section)?.[1];
|
||||
if (!raw) continue;
|
||||
const renamedFrom = /^rename from "?(.*?)"?$/m.exec(section)?.[1];
|
||||
// Moving code out of a production path removes it from there, so the source counts as much as the destination.
|
||||
const [path, source = path] = [raw, renamedFrom].filter(Boolean).map(p => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p));
|
||||
if (TEST_PATH_RE.test(path) || (path.endsWith(".rs") && RUST_TEST_ADDED_RE.test(section))) testsChanged = true;
|
||||
if ([path, source].some(p => !TEST_PATH_RE.test(p) && PROD_PATH_RE.test(p))) prodChanged = true;
|
||||
}
|
||||
return { prodChanged, testsChanged };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { passLabel } from "./passes.mjs";
|
||||
|
||||
// The transport cuts a user prompt here, so anything past it never reaches the model.
|
||||
export const MAX_USER_PROMPT_CHARS = 150_000;
|
||||
|
||||
// Tried in order once the full prompt is too big; every finding keeps its severity, place, summary and fix at each step.
|
||||
const DESCRIPTION_LIMITS = [Infinity, 1000, 500, 200, 80, 0];
|
||||
|
||||
function shortened(text, limit) {
|
||||
const value = String(text ?? "");
|
||||
if (value.length <= limit) return value;
|
||||
return limit === 0 ? "[omitted to fit]" : `${value.slice(0, limit)}… [truncated]`;
|
||||
}
|
||||
|
||||
function findingXml(f, descriptionLimit) {
|
||||
return ` <finding>
|
||||
<category>${f.category}</category>
|
||||
<severity>${f.severity}</severity>
|
||||
<file>${f.file}</file>
|
||||
<line>${f.line}</line>
|
||||
<summary>${f.summary}</summary>
|
||||
<description>${shortened(f.description, descriptionLimit)}</description>
|
||||
<suggestion>${f.suggestion}</suggestion>
|
||||
</finding>`;
|
||||
}
|
||||
|
||||
export function coordinatorFindingsXml(passResults, passes, { descriptionLimit = Infinity, dedupe = false } = {}) {
|
||||
const seen = new Set();
|
||||
return passResults
|
||||
.map(r => {
|
||||
const findings = (r.findings || []).filter(f => {
|
||||
if (!dedupe) return true;
|
||||
const key = JSON.stringify([r.agentKey, f.severity, f.file, f.line, f.summary, f.description, f.suggestion]);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
if (findings.length === 0) return "";
|
||||
const xml = findings.map(f => findingXml(f, descriptionLimit)).join("\n");
|
||||
return `<reviewer name="${passLabel(r.agentName, r.pass, passes)}">\n${xml}\n</reviewer>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Never hands back a prompt the transport would cut: a cut prompt silently loses the later passes' findings.
|
||||
export function fitCoordinatorPrompt(passResults, passes, build, limit = MAX_USER_PROMPT_CHARS) {
|
||||
const full = build(coordinatorFindingsXml(passResults, passes));
|
||||
if (full.length <= limit) return { prompt: full, mode: "full", chars: full.length };
|
||||
for (const descriptionLimit of DESCRIPTION_LIMITS) {
|
||||
const prompt = build(coordinatorFindingsXml(passResults, passes, { descriptionLimit, dedupe: true }));
|
||||
if (prompt.length <= limit) return { prompt, mode: "compacted", chars: prompt.length, fullChars: full.length };
|
||||
}
|
||||
return { prompt: null, mode: "too_large", chars: full.length, fullChars: full.length };
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const INLINE_SEVERITIES = new Set(["critical", "warning"]);
|
||||
import { isKnownSeverity, normalizeSeverity, severityRank } from "./severity.mjs";
|
||||
|
||||
export const INLINE_SEVERITIES = new Set(["p0", "p1", "p2"]);
|
||||
export const MAX_INLINE_COMMENTS = 10;
|
||||
// A model that wraps the block in a code fence leaves an empty fence behind once the block is cut out.
|
||||
const EMPTY_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[a-z]*[ \t]*\n\s*\1[ \t]*$/gim;
|
||||
const FINGERPRINT_RE = /<!--\s*ai-review-fp:([0-9a-f]{40})\s*-->/g;
|
||||
const SEVERITY_LABEL = { critical: "🔴 Blocker", warning: "🟡 Warning" };
|
||||
const SEVERITY_LABEL = { p0: "🔴 P0", p1: "🟠 P1", p2: "🟡 P2" };
|
||||
|
||||
// Lines a review comment may anchor to with side RIGHT: added and context lines of each hunk, keyed by new path.
|
||||
export function rightSideLines(diff) {
|
||||
@@ -63,7 +65,7 @@ function normalizeFinding(raw) {
|
||||
return {
|
||||
file: String(raw.file ?? "").trim().replace(/^\.?\//, ""),
|
||||
line: Number.parseInt(raw.line, 10) || 0,
|
||||
severity: String(raw.severity ?? "").trim().toLowerCase(),
|
||||
severity: normalizeSeverity(raw.severity),
|
||||
category: stripComments(raw.category),
|
||||
title,
|
||||
body: stripComments(raw.body ?? raw.description),
|
||||
@@ -79,7 +81,11 @@ function findInlineBlocks(source) {
|
||||
while ((start = startRe.exec(source)) !== null) {
|
||||
const bodyAt = start.index + start[0].length;
|
||||
let end = source.indexOf("-->", bodyAt);
|
||||
if (end === -1) break;
|
||||
// Nothing closes it, so its findings cannot be read: it still counts as a block, and only the opener is cut so a reader sees the rest.
|
||||
if (end === -1) {
|
||||
blocks.push({ from: start.index, to: bodyAt, json: null, raw: "" });
|
||||
continue;
|
||||
}
|
||||
const firstEnd = end;
|
||||
let json = null;
|
||||
while (end !== -1) {
|
||||
@@ -97,7 +103,7 @@ function findInlineBlocks(source) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// Cuts every inline block out of the coordinator output and parses the first one.
|
||||
// Cuts every inline block out of the coordinator output and parses the first one; a further block is never counted, so it makes the count unknown.
|
||||
export function extractInlineBlock(text) {
|
||||
const source = String(text ?? "");
|
||||
const blocks = findInlineBlocks(source);
|
||||
@@ -108,7 +114,10 @@ export function extractInlineBlock(text) {
|
||||
const parsed = blocks[0].json;
|
||||
if (parsed === null) return { text: stripped, findings: [], error: "inline block is not valid JSON" };
|
||||
if (!Array.isArray(parsed)) return { text: stripped, findings: [], error: "inline block is not a JSON array" };
|
||||
return { text: stripped, findings: parsed.map(normalizeFinding).filter(Boolean), error: null };
|
||||
const findings = parsed.map(normalizeFinding).filter(Boolean);
|
||||
// The list backs "no blockers", so a lost or ungraded entry makes the count unknown even though the rest still post.
|
||||
const countable = blocks.length === 1 && findings.length === parsed.length && parsed.every(raw => isKnownSeverity(raw.severity));
|
||||
return { text: stripped, findings, error: countable ? null : "inline block has entries that cannot be counted" };
|
||||
}
|
||||
|
||||
export function normalizeTitle(title) {
|
||||
@@ -133,12 +142,12 @@ export function existingFingerprints(comments, botLogin) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// Keeps critical/warning findings whose anchor is a RIGHT-side line of the PR diff, skips ones already posted, caps the count.
|
||||
// GitHub rejects a whole review if one comment anchors outside the RIGHT side of the diff, so such findings are dropped here.
|
||||
export function selectInlineComments(findings, rightLines, posted = new Set(), max = MAX_INLINE_COMMENTS) {
|
||||
const selected = [];
|
||||
const dropped = { severity: 0, anchor: 0, duplicate: 0, cap: 0 };
|
||||
const seen = new Set(posted);
|
||||
const ranked = [...findings].sort((a, b) => (a.severity === "critical" ? 0 : 1) - (b.severity === "critical" ? 0 : 1));
|
||||
const ranked = [...findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
||||
for (const f of ranked) {
|
||||
if (!INLINE_SEVERITIES.has(f.severity)) { dropped.severity += 1; continue; }
|
||||
if (!f.file || !(f.line > 0) || !rightLines.get(f.file)?.has(f.line)) { dropped.anchor += 1; continue; }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { reviewerFinished, shortName } from "./confidence.mjs";
|
||||
|
||||
export const DEFAULT_COLD_PASSES = 2;
|
||||
export const MAX_COLD_PASSES = 3;
|
||||
|
||||
// Anything unreadable falls back to the default rather than to one pass, so a typo never quietly halves the review.
|
||||
export function parseColdPasses(raw) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(text)) return DEFAULT_COLD_PASSES;
|
||||
return Math.min(MAX_COLD_PASSES, Math.max(1, Number.parseInt(text, 10)));
|
||||
}
|
||||
|
||||
export function passLabel(agentName, pass, passes) {
|
||||
return passes > 1 ? `${agentName}, pass ${pass}/${passes}` : agentName;
|
||||
}
|
||||
|
||||
// Pass-major, so when the pool queues work every area gets its first pass before any gets a second.
|
||||
export function passPlan(agentKeys, passes) {
|
||||
const plan = [];
|
||||
for (let pass = 1; pass <= passes; pass += 1) {
|
||||
for (const agentKey of agentKeys) plan.push({ agentKey, pass });
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export async function runPooled(items, limit, worker) {
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
const lane = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next;
|
||||
next += 1;
|
||||
try {
|
||||
results[i] = { status: "fulfilled", value: await worker(items[i], i) };
|
||||
} catch (reason) {
|
||||
results[i] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, lane));
|
||||
return results;
|
||||
}
|
||||
|
||||
function mergeArea(group) {
|
||||
const finished = group.filter(reviewerFinished);
|
||||
const ran = group.filter(r => !r.skipped);
|
||||
const base = finished[0] ?? ran[0] ?? group[0];
|
||||
const covered = finished.length > 0;
|
||||
return {
|
||||
agentKey: base.agentKey,
|
||||
agentName: base.agentName,
|
||||
// Findings from a cut-off pass stay, as in a single-pass run: a blocker must not vanish because its pass failed.
|
||||
findings: group.flatMap(r => r.findings || []),
|
||||
rawText: "",
|
||||
error: covered ? null : (ran.find(r => r.error)?.error ?? null),
|
||||
timedOut: !covered && ran.length > 0 && ran.every(r => r.timedOut),
|
||||
genAIResponseId: base.genAIResponseId || "",
|
||||
skipped: group.every(r => r.skipped),
|
||||
fullDiff: base.fullDiff,
|
||||
parsed: covered,
|
||||
passes: group.length,
|
||||
passesFinished: finished.length,
|
||||
passResults: group,
|
||||
};
|
||||
}
|
||||
|
||||
// An area is reviewed when any of its cold passes finished; only an area whose every pass failed counts as unfinished.
|
||||
export function mergePassResults(passResults) {
|
||||
const groups = new Map();
|
||||
for (const r of passResults) {
|
||||
if (!groups.has(r.agentKey)) groups.set(r.agentKey, []);
|
||||
groups.get(r.agentKey).push(r);
|
||||
}
|
||||
return [...groups.values()].map(mergeArea);
|
||||
}
|
||||
|
||||
export function lostPasses(results) {
|
||||
return results.filter(r => !r.skipped && r.passesFinished > 0 && r.passesFinished < r.passes);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!lost.length) return "";
|
||||
const detail = lost.map(r => `${shortName(r.agentName)}: ${r.passes - r.passesFinished} of ${r.passes}`).join(", ");
|
||||
return `> ℹ️ Some reviewer passes did not finish (${detail}); those areas were still reviewed by the passes that finished.`;
|
||||
}
|
||||
@@ -37,6 +37,38 @@ export function fetchDiff({ repo, prNumber, prDir, mergeBase, head, env = proces
|
||||
}
|
||||
}
|
||||
|
||||
// The API serves the branch as it is now, so only a local diff of the two SHAs can be tied to the commit a review or an approval names.
|
||||
export function pinDiff({ diff, prDir, mergeBase, head, run = runFile, log = () => {} }) {
|
||||
if (!prDir || !mergeBase || !head) {
|
||||
log("no merge base or checkout to diff locally; the API diff is not pinned");
|
||||
return { diff, pinned: false };
|
||||
}
|
||||
try {
|
||||
return { diff: localDiff(prDir, mergeBase, head, run), pinned: true };
|
||||
} catch (err) {
|
||||
log(`could not diff ${mergeBase}..${head} locally; the API diff is not pinned: ${String(err?.message).slice(0, 200)}`);
|
||||
return { diff, pinned: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchPinnedDiff(params) {
|
||||
return pinDiff({ ...params, diff: fetchDiff(params) });
|
||||
}
|
||||
|
||||
// `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 };
|
||||
const sections = diff.split(/^diff --git /gm).filter(Boolean);
|
||||
const relevant = sections.filter(s => {
|
||||
const m = s.match(/^a\/(.*?) b\/(.*?)\n/);
|
||||
return m && fileFocus(pathOf(m[2]));
|
||||
});
|
||||
if (relevant.length === 0) return { diff, skipped: Boolean(requiresFocus), fullDiff: !requiresFocus };
|
||||
if (unfocused) return { diff, skipped: false, fullDiff: true };
|
||||
const narrowed = relevant.map(s => `diff --git ${s}`).join("");
|
||||
return { diff: narrowed, skipped: false, fullDiff: narrowed === diff };
|
||||
}
|
||||
|
||||
// Rewrites only header lines (before the first hunk of each file), so a removed line like "-- a/b" is never touched.
|
||||
export function prefixDiffPaths(diff, prefix) {
|
||||
if (!prefix) return diff;
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
function shortName(agentName) {
|
||||
return String(agentName || "unknown").replace(/\s+Reviewer$/i, "");
|
||||
import { REVIEW_HEADING } from "./comments.mjs";
|
||||
import { reviewerFinished, shortName } from "./confidence.mjs";
|
||||
import { SEVERITIES, SEVERITY_CAPS, normalizeSeverity, severityRank } from "./severity.mjs";
|
||||
|
||||
export { insertAfterCallout } from "./comments.mjs";
|
||||
|
||||
const MAX_FALLBACK_FINDINGS = 15;
|
||||
const NEARBY_LINES = 5;
|
||||
const NO_ISSUES_RE = /<no-issues(?:\s[^<>]*)?>[\s\S]*?<\/no-issues>|<no-issues(?:\s[^<>]*)?\/>/;
|
||||
const FINDING_BLOCK_RE = /<finding>([\s\S]*?)<\/finding>/g;
|
||||
const FINDING_PLACEHOLDER = "\u0000finding\u0000";
|
||||
// Prose outside the report that admits the review did not happen; a false hit only costs the area its "finished" mark.
|
||||
const INABILITY_RE = /\b(?:could ?n[o']t|can ?n[o']t|can't|unable to|was ?n[o']t able|were ?n[o']t able|failed to|no access|do(?:es)? ?n[o']t have access|did ?n[o']t have access|not able to)\b/i;
|
||||
// Inside a no-issues body only an inability to see the inputs or do the review counts: "could not find any problem" or "could not run the tests" is a clean result.
|
||||
const CANNOT = String.raw`\b(?:could ?n[o']t|couldn't|can ?n[o']t|can't|unable to|(?:was|were) ?n[o']t able to|wasn't able to|weren't able to|not able to|failed to)\s+(?:(?:fully|actually|properly|successfully|even|really)\s+)?(?:(?:[\w'-]+\s+){1,5}?(?:or|and|nor)\s+(?:also\s+)?)?`;
|
||||
const INPUT_NOUN = String.raw`\b(?:files?|diffs?|patch(?:es)?|repo(?:sitory)?|PR|pull request|code|changes|sources?|hunks?|commits?|contents?)\b`;
|
||||
const REVIEW_INABILITY_RE = new RegExp([
|
||||
String.raw`${CANNOT}(?:read|access|inspect|review|examine|analy[sz]e|view)(?![\w-])`,
|
||||
String.raw`${CANNOT}(?:open|load|fetch|retrieve|get|obtain)\b[^.;:!?\n]{0,40}?${INPUT_NOUN}`,
|
||||
String.raw`${CANNOT}(?:perform|complete|finish|do|conduct|carry out)\b[^.;:!?\n]{0,30}?\b(?:review|analysis)\b`,
|
||||
String.raw`\bno access\b|\b(?:do|does|did) ?n[o']t have access\b|\b(?:don't|doesn't|didn't) have access\b|\bwithout access\b`,
|
||||
String.raw`\btools? (?:call )?(?:failed|errored|was unavailable|is unavailable)\b`,
|
||||
].join("|"), "i");
|
||||
// Typographic apostrophes and broken spacing would otherwise slip a contraction past the inability patterns.
|
||||
const APOSTROPHE_RE = /[\u2018\u2019\u02BC\u2032\uFF07]/g;
|
||||
const FENCE_RE = /(```|~~~)[\s\S]*?(?:\1|$)/g;
|
||||
const BLOCKQUOTE_LINE_RE = /^[ \t]*>.*$/gm;
|
||||
const SEVERITY_GLYPH = { p0: "🔴", p1: "🟠", p2: "🟡", p3: "🔵" };
|
||||
const SEVERITY_SECTION = {
|
||||
p0: "#### 🔴 P0 · Blockers",
|
||||
p1: "#### 🟠 P1 · Blockers",
|
||||
p2: "#### 🟡 P2 · Should fix",
|
||||
p3: "#### 🔵 P3 · Worth a look",
|
||||
};
|
||||
// Cosmetic only: matches the coordinator's happy-path finding lines.
|
||||
const CATEGORY_GLYPH = {
|
||||
security: "🔒",
|
||||
performance: "⚡",
|
||||
correctness: "🐞",
|
||||
"code-quality": "🧩", "code quality": "🧩", quality: "🧩",
|
||||
documentation: "📝", docs: "📝",
|
||||
release: "📦",
|
||||
frontend: "🎨", ui: "🎨",
|
||||
qa: "🧪", test: "🧪",
|
||||
};
|
||||
|
||||
function plainProse(text) {
|
||||
return text.replace(APOSTROPHE_RE, "'").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function failureReason(result) {
|
||||
@@ -15,21 +61,6 @@ export function failedReviewersNote(failed, ranCount) {
|
||||
return `> ⚠️ ${failed.length} of ${ranCount} reviewers did not finish (${detail}); ${tail}.`;
|
||||
}
|
||||
|
||||
// Puts the note after the decision callout (or the heading, if the callout is missing) as its own blockquote.
|
||||
export function insertAfterCallout(body, note) {
|
||||
if (!note) return body;
|
||||
const lines = body.split("\n");
|
||||
const heading = lines.findIndex(l => /^#{1,3}\s/.test(l));
|
||||
let at = heading + 1;
|
||||
const callout = lines.findIndex((l, i) => i > heading && /^>\s*\[!(?:TIP|NOTE|WARNING|CAUTION|IMPORTANT)\]/i.test(l));
|
||||
if (callout !== -1) {
|
||||
at = callout;
|
||||
while (at < lines.length && lines[at].startsWith(">")) at += 1;
|
||||
}
|
||||
lines.splice(at, 0, "", note, "");
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
// Applies `clean` to every string in a JSON-shaped value; used to scrub the findings artifact field by field.
|
||||
export function mapStrings(value, clean) {
|
||||
if (typeof value === "string") return clean(value);
|
||||
@@ -39,3 +70,238 @@ export function mapStrings(value, clean) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseFindings(xmlText) {
|
||||
const findings = [];
|
||||
for (const [, block] of String(xmlText ?? "").matchAll(FINDING_BLOCK_RE)) {
|
||||
const getTag = (tag) => {
|
||||
const m = new RegExp(`<${tag}>(.*?)<\\/${tag}>`, "s").exec(block);
|
||||
return m ? m[1].trim() : "";
|
||||
};
|
||||
const finding = {
|
||||
category: getTag("category"),
|
||||
severity: getTag("severity"),
|
||||
file: getTag("file"),
|
||||
line: getTag("line"),
|
||||
summary: getTag("summary"),
|
||||
description: getTag("description"),
|
||||
suggestion: getTag("suggestion"),
|
||||
};
|
||||
if (finding.severity && finding.summary) {
|
||||
findings.push({ ...finding, severity: normalizeSeverity(finding.severity) });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Scanned outside the finished blocks, so a `<finding>` quoted inside one is not an opener; like an unclosed inline block, an opener left over means text was lost.
|
||||
function outputCutOff(text) {
|
||||
const rest = text.replace(FINDING_BLOCK_RE, "");
|
||||
const unclosed = (rest.match(/<findings\b/g)?.length ?? 0) > (rest.match(/<\/findings>/g)?.length ?? 0);
|
||||
return unclosed || /<finding\b/.test(rest) || rest.lastIndexOf("<findings") > rest.lastIndexOf("</findings>");
|
||||
}
|
||||
|
||||
// A fence holding nothing but the report is a formatting habit; anything else around the fence makes it a quoted example.
|
||||
function unwrapWholeFence(text) {
|
||||
const m = text.match(/^\s*(```|~~~)[\w-]*[ \t]*\n([\s\S]*?)\n?[ \t]*\1\s*$/);
|
||||
return m && !m[2].includes(m[1]) ? m[2] : text;
|
||||
}
|
||||
|
||||
function quotesReportInFence(text) {
|
||||
return [...text.matchAll(FENCE_RE)].some(([fence]) => /<\/?findings\b|<no-issues\b|<finding\b/.test(fence));
|
||||
}
|
||||
|
||||
// Neither a renderer nor a reader sees a report inside code or a comment, so it cannot be the answer.
|
||||
function withoutQuotedText(text) {
|
||||
return text.replace(FENCE_RE, "").replace(/(`+)[\s\S]*?\1|`/g, "");
|
||||
}
|
||||
|
||||
// Finding blocks become placeholders first, so code a finding quotes cannot hide its closing tag.
|
||||
function hasReport(outside, findingCount) {
|
||||
// A blockquoted report is a quoted example; its prose still counts toward the inability checks.
|
||||
return [...withoutQuotedText(outside).replace(BLOCKQUOTE_LINE_RE, "").matchAll(/<findings(?:\s[^<>]*)?>([\s\S]*?)<\/findings>/g)]
|
||||
.some(([, body]) => (findingCount > 0 ? body.includes(FINDING_PLACEHOLDER) : NO_ISSUES_RE.test(body)));
|
||||
}
|
||||
|
||||
// "No findings" must be stated in full, inside the <findings> wrapper the prompt asks for: empty, free-form, quoted or cut-off output is a reviewer that said nothing, not one that found nothing, and an unread block could be the blocker.
|
||||
export function reviewerReported(text, findings) {
|
||||
const output = unwrapWholeFence(String(text ?? "").replace(/<!--[\s\S]*?(?:-->|$)/g, ""));
|
||||
if (outputCutOff(output) || (output.match(FINDING_BLOCK_RE)?.length ?? 0) !== findings.length) return false;
|
||||
const outside = output.replace(FINDING_BLOCK_RE, FINDING_PLACEHOLDER);
|
||||
if (quotesReportInFence(outside)) return false;
|
||||
const prose = withoutQuotedText(outside);
|
||||
if (INABILITY_RE.test(plainProse(prose.replace(new RegExp(NO_ISSUES_RE.source, "g"), "")))) return false;
|
||||
if ([...prose.matchAll(new RegExp(NO_ISSUES_RE.source, "g"))].some(([block]) => REVIEW_INABILITY_RE.test(plainProse(block)))) return false;
|
||||
return hasReport(outside, findings.length);
|
||||
}
|
||||
|
||||
// Same shape as an entry of the coordinator's inline block.
|
||||
export function toKeptFinding(f) {
|
||||
return {
|
||||
file: (f.file || "").replace(/^\.?\//, ""),
|
||||
line: Number.parseInt(f.line, 10) || 0,
|
||||
severity: normalizeSeverity(f.severity),
|
||||
category: f.category || "",
|
||||
title: f.summary,
|
||||
body: [f.description, f.suggestion ? `**Fix:** ${f.suggestion}` : ""].filter(Boolean).join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
const TITLE_STOPWORDS = new Set(["the", "and", "for", "not", "but", "with", "from", "that", "this", "are", "was", "when", "can", "its", "into", "than", "then", "will", "has", "have"]);
|
||||
|
||||
function titleWords(summary) {
|
||||
const words = (String(summary || "").toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []).filter(w => !TITLE_STOPWORDS.has(w));
|
||||
return new Set(words.map(w => (w.length > 4 ? w.replace(/(?:ing|ed|es|s)$/, "") : w)));
|
||||
}
|
||||
|
||||
function normalizedTitle(summary) {
|
||||
return (String(summary || "").toLowerCase().match(/[a-z0-9_]+/g) ?? []).join(" ");
|
||||
}
|
||||
|
||||
function isBlocker(f) {
|
||||
return ["p0", "p1"].includes(normalizeSeverity(f.severity));
|
||||
}
|
||||
|
||||
function lineOf(f) {
|
||||
return Number.parseInt(f.line, 10) || 0;
|
||||
}
|
||||
|
||||
// Two cold passes word the same issue differently, so a near match on place and title is a duplicate too.
|
||||
function sameIssueNearby(a, b) {
|
||||
const file = f => (f.file || "").replace(/^\.?\//, "");
|
||||
if (!file(a) || file(a) !== file(b)) return false;
|
||||
const [la, lb] = [lineOf(a), lineOf(b)];
|
||||
if ((la === 0) !== (lb === 0) || Math.abs(la - lb) > NEARBY_LINES) return false;
|
||||
// A wrong merge loses a bug while a missed one only repeats a line, so two blockers merge only on the same title.
|
||||
if (isBlocker(a) && isBlocker(b)) return normalizedTitle(a.summary) === normalizedTitle(b.summary);
|
||||
const [wa, wb] = [titleWords(a.summary), titleWords(b.summary)];
|
||||
const shared = [...wa].filter(w => wb.has(w)).length;
|
||||
// A content word on each side that the other lacks names a different subject ("organization" vs "stream" filter).
|
||||
const subset = shared === Math.min(wa.size, wb.size);
|
||||
return subset && shared >= 2 && shared / Math.max(wa.size, wb.size) >= 0.5;
|
||||
}
|
||||
|
||||
function dedupeFindings(agentResults) {
|
||||
const seen = new Map();
|
||||
const deduped = [];
|
||||
for (const f of agentResults.flatMap(r => r.findings)) {
|
||||
const key = `${(f.category || "").toLowerCase()}|${(f.summary || "").toLowerCase().slice(0, 120)}`;
|
||||
const existing = seen.get(key) ?? deduped.find(d => sameIssueNearby(d, f));
|
||||
if (!existing) {
|
||||
seen.set(key, { ...f });
|
||||
deduped.push(seen.get(key));
|
||||
} else if (severityRank(f.severity) < severityRank(existing.severity)) {
|
||||
existing.severity = f.severity;
|
||||
existing.description = f.description || existing.description;
|
||||
existing.suggestion = f.suggestion || existing.suggestion;
|
||||
existing.file = f.file || existing.file;
|
||||
existing.line = f.line || existing.line;
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
// The caps apply here so the fallback comment and its inline threads list the same findings.
|
||||
export function selectFallbackFindings(agentResults) {
|
||||
const counts = {};
|
||||
const ranked = dedupeFindings(agentResults).sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
||||
const kept = ranked.filter(f => {
|
||||
const severity = normalizeSeverity(f.severity);
|
||||
counts[severity] = (counts[severity] || 0) + 1;
|
||||
return counts[severity] <= (SEVERITY_CAPS[severity] ?? Infinity);
|
||||
});
|
||||
// Ranked, so the cap only ever reaches past the blockers it must not drop.
|
||||
return kept.filter((f, i) => i < MAX_FALLBACK_FINDINGS || !(normalizeSeverity(f.severity) in SEVERITY_CAPS));
|
||||
}
|
||||
|
||||
function severityCountLine(counts) {
|
||||
const parts = SEVERITIES.map(s => `${SEVERITY_GLYPH[s]} ${s.toUpperCase()}: ${counts[s] ?? 0}`);
|
||||
return `**${parts.join(" · ")}**`;
|
||||
}
|
||||
|
||||
function fallbackFindingLine(f) {
|
||||
const glyph = CATEGORY_GLYPH[(f.category || "").toLowerCase()] || "•";
|
||||
const loc = f.file && f.line && f.line !== "0" ? `\`${f.file}:${f.line}\` ` : f.file ? `\`${f.file}\` ` : "";
|
||||
// The sanitizer keeps a line it can read as a finding, so the tag is always present and cannot close early.
|
||||
const category = (f.category || "general").replace(/[\]\n]+/g, " ");
|
||||
const cat = `**[${category.charAt(0).toUpperCase() + category.slice(1)}]** `;
|
||||
const fix = f.suggestion ? ` (→ ${f.suggestion})` : "";
|
||||
return `- ${glyph} ${loc}${cat}${f.summary}${fix}`;
|
||||
}
|
||||
|
||||
function fallbackDedupNote(rawCount, uniqueCount, shownCount) {
|
||||
const deduped = rawCount > uniqueCount
|
||||
? ` Deduplicated: ${rawCount} raw findings → ${uniqueCount} unique (${rawCount - uniqueCount} duplicates removed).`
|
||||
: "";
|
||||
const capped = uniqueCount > shownCount ? ` Showing the ${shownCount} most severe.` : "";
|
||||
return deduped || capped ? `\n>${deduped}${capped}` : "";
|
||||
}
|
||||
|
||||
function groupBySeverity(findings) {
|
||||
return Object.fromEntries(SEVERITIES.map(s => [s, findings.filter(f => normalizeSeverity(f.severity) === s)]));
|
||||
}
|
||||
|
||||
function severitySections(bySeverity) {
|
||||
return SEVERITIES
|
||||
.filter(s => bySeverity[s].length > 0)
|
||||
.flatMap(s => [SEVERITY_SECTION[s], ...bySeverity[s].map(fallbackFindingLine), ""]);
|
||||
}
|
||||
|
||||
// A cut-off report still holds finished findings, and a blocker must not vanish because the run failed.
|
||||
export function partialFindingsBlock(agentResults) {
|
||||
const shown = selectFallbackFindings(agentResults);
|
||||
if (shown.length === 0) return "";
|
||||
return `<details open>\n<summary>📋 Findings so far (${shown.length})</summary>\n\n${severitySections(groupBySeverity(shown)).join("\n")}\n</details>`;
|
||||
}
|
||||
|
||||
export function buildFallbackReview(agentResults, tier, failedAgents, marker) {
|
||||
const shown = selectFallbackFindings(agentResults);
|
||||
const failedLine = failedAgents.length > 0 ? `- Failed reviewers: ${failedAgents.map(r => r.agentName).join(", ")}` : "";
|
||||
|
||||
if (shown.length === 0) {
|
||||
return `${marker}
|
||||
${REVIEW_HEADING}
|
||||
|
||||
> [!TIP]
|
||||
> ### ✅ Approved
|
||||
> LGTM — No issues found by automated reviewers.
|
||||
|
||||
${severityCountLine({})}
|
||||
|
||||
<details>
|
||||
<summary>🧾 Review details</summary>
|
||||
|
||||
- Risk tier: ${tier}
|
||||
${failedLine}
|
||||
|
||||
</details>`;
|
||||
}
|
||||
|
||||
const bySeverity = groupBySeverity(shown);
|
||||
const sections = severitySections(bySeverity);
|
||||
const blocked = bySeverity.p0.length + bySeverity.p1.length > 0;
|
||||
const callout = blocked ? "> [!CAUTION]\n> ### ⛔ Blockers found" : "> [!NOTE]\n> ### 💬 Approved with comments";
|
||||
const rawCount = agentResults.flatMap(r => r.findings).length;
|
||||
const dedupNote = fallbackDedupNote(rawCount, dedupeFindings(agentResults).length, shown.length);
|
||||
|
||||
return `${marker}
|
||||
${REVIEW_HEADING}
|
||||
|
||||
${callout}
|
||||
> Automated review — ${failedAgents.length > 0 ? "some reviewers failed and " : ""}coordinator consolidation was skipped. Findings below are deduplicated but un-judged.${dedupNote}
|
||||
|
||||
${severityCountLine(Object.fromEntries(SEVERITIES.map(s => [s, bySeverity[s].length])))}
|
||||
|
||||
<details open>
|
||||
<summary>📋 Show findings (${shown.length})</summary>
|
||||
|
||||
${sections.join("\n")}
|
||||
|
||||
---
|
||||
|
||||
- Risk tier: ${tier}
|
||||
- Reviewers completed: ${agentResults.filter(reviewerFinished).map(r => r.agentName).join(", ") || "none"}
|
||||
${failedLine}
|
||||
|
||||
</details>`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const SECRET_PATTERNS = [
|
||||
/sk-[A-Za-z0-9_-]{20,}/g,
|
||||
];
|
||||
const AUTH_HEADER_RE = /(Authorization:)[^\n]*/gi;
|
||||
const MARKER_RE = /<!--(\s*o2-review)/gi;
|
||||
const MARKER_RE = /<!--(\s*o2-(?:review|paired-pointer))/gi;
|
||||
const MENTION_RE = /(^|[^\w`@/])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\/[A-Za-z0-9][\w.-]*)?)/g;
|
||||
|
||||
export function secretValues(env) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const SEVERITIES = ["p0", "p1", "p2", "p3"];
|
||||
// Per-review limits; p0 and p1 are never capped because a blocker must always be reported.
|
||||
export const SEVERITY_CAPS = { p2: 3, p3: 2 };
|
||||
const LEGACY_SEVERITIES = new Map([
|
||||
["critical", "p0"],
|
||||
["warning", "p2"],
|
||||
["suggestion", "p3"],
|
||||
["low", "p3"],
|
||||
]);
|
||||
|
||||
// An unrecognised value lands on p3 so a model typo can never raise a finding to a blocker.
|
||||
export function normalizeSeverity(severity) {
|
||||
const value = String(severity ?? "").trim().toLowerCase();
|
||||
if (SEVERITIES.includes(value)) return value;
|
||||
return LEGACY_SEVERITIES.get(value) ?? "p3";
|
||||
}
|
||||
|
||||
// A value normalizeSeverity had to guess at says nothing about how bad the finding really is.
|
||||
export function isKnownSeverity(severity) {
|
||||
const value = String(severity ?? "").trim().toLowerCase();
|
||||
return SEVERITIES.includes(value) || LEGACY_SEVERITIES.has(value);
|
||||
}
|
||||
|
||||
// Lower is more severe, so ascending sorts put p0 first.
|
||||
export function severityRank(severity) {
|
||||
return SEVERITIES.indexOf(normalizeSeverity(severity));
|
||||
}
|
||||
@@ -1,9 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { reviewPassCount, reviewPassTag, selectOwnReviewComment } from "../src/comments.mjs";
|
||||
import {
|
||||
ANY_REVIEW_MARKER,
|
||||
REVIEW_HEADING,
|
||||
bodyAtPassLimit,
|
||||
bodyAtReviewedHead,
|
||||
bodyAtUnreviewedHead,
|
||||
claimedReviewBody,
|
||||
finishReviewBody,
|
||||
parseMaxPasses,
|
||||
passLimitNote,
|
||||
previousReviewText,
|
||||
redactProviderIdentity,
|
||||
reviewKey,
|
||||
reviewPassCount,
|
||||
reviewPassTag,
|
||||
reviewShaTag,
|
||||
reviewSkipReason,
|
||||
reviewedCommitLine,
|
||||
reviewedKey,
|
||||
sanitizeReviewBody,
|
||||
selectOwnReviewComment,
|
||||
shouldSkipAsReviewed,
|
||||
withPassLimitNote,
|
||||
withPassTag,
|
||||
withReviewShaTag,
|
||||
withoutAutoApproveLine,
|
||||
} from "../src/comments.mjs";
|
||||
import { autoApproveLine } from "../src/auto-approve.mjs";
|
||||
import { mergeConfidence } from "../src/confidence.mjs";
|
||||
|
||||
const MARKER = "<!--\\s*ai-code-review[a-z0-9-]*\\s*-->";
|
||||
const MARKER = ANY_REVIEW_MARKER;
|
||||
const comment = (id, login, type, body) => ({ id, user: { login, type }, body });
|
||||
|
||||
test("only the posting bot's own marker comments count, the newest wins", () => {
|
||||
@@ -30,3 +59,567 @@ test("the pass count reads the hidden tag, and a legacy review counts as one", (
|
||||
assert.equal(reviewPassCount("<!-- o2-paired-pointer -->", 0), 0);
|
||||
assert.equal(reviewPassCount(`<!-- o2-paired-pointer -->\n${reviewPassTag(2)}`, 0), 2);
|
||||
});
|
||||
|
||||
const SHA_A = "a".repeat(40);
|
||||
const SHA_B = "b".repeat(40);
|
||||
const SHA_C = "c".repeat(40);
|
||||
const ENT_MARKER = "<!-- ai-code-review-deepseek -->";
|
||||
const IDS = { marker: ENT_MARKER, providerId: "deepseek-review", modelId: "deepseek-flash", env: {} };
|
||||
const HIGH = mergeConfidence({ ranCount: 1, prodChanged: true, testsChanged: true });
|
||||
const sanitize = (body, ids = IDS) => sanitizeReviewBody(body, ids).text;
|
||||
const REVIEW = `${REVIEW_HEADING}\n\n> [!TIP]\n> ### ✅ Approved\n> LGTM.\n\n**🔴 P0: 0 · 🟠 P1: 0 · 🟡 P2: 0 · 🔵 P3: 0**`;
|
||||
const finished = (head, passes, maxPasses) => finishReviewBody(sanitize(REVIEW), { key: head, passes, maxPasses, reviewed: true, confidence: HIGH });
|
||||
|
||||
test("a marker naming the provider survives sanitize and the next run still finds the comment", () => {
|
||||
const posted = sanitize(`${ENT_MARKER}\n${REVIEW}\nFrom DeepSeek deepseek-flash.`);
|
||||
assert.ok(posted.startsWith(`${ENT_MARKER}\n## 🔎`));
|
||||
assert.equal(posted.includes("<!-- ai-code-review-[redacted] -->"), false);
|
||||
assert.match(posted, /From \[redacted\] \[redacted\]\./);
|
||||
assert.equal(selectOwnReviewComment([comment(9, "bot", "Bot", posted)], "bot", ANY_REVIEW_MARKER).id, 9);
|
||||
});
|
||||
|
||||
test("the marker survives a secret scrub and stays single when the model echoes markers", () => {
|
||||
const env = { REVIEW_TOKEN: "ai-code-review-deepseek" };
|
||||
const posted = sanitize(`<!-- ai-code-review -->\n<!-- ai-code-review-[redacted] -->\n${REVIEW}`, { ...IDS, env });
|
||||
assert.equal(posted.match(/<!--\s*ai-code-review/g).length, 1);
|
||||
assert.ok(posted.startsWith(ENT_MARKER));
|
||||
});
|
||||
|
||||
test("redaction cannot rebuild a marker that the first strip missed", () => {
|
||||
const posted = sanitize(`${REVIEW}\n<!-- ai-code-review.tool.x -->`, { ...IDS, modelId: ".tool.x", providerId: "" });
|
||||
assert.equal(posted.match(/<!--\s*ai-code-review/g).length, 1);
|
||||
});
|
||||
|
||||
test("a legacy redacted comment is adopted, a human's is not", () => {
|
||||
const legacy = `<!-- ai-code-review-[redacted] -->\n${REVIEW}`;
|
||||
const comments = [comment(1, "bot", "Bot", legacy), comment(2, "mallory", "User", legacy)];
|
||||
assert.equal(selectOwnReviewComment(comments, "bot", ANY_REVIEW_MARKER).id, 1);
|
||||
assert.equal(sanitize(legacy).includes("[redacted] -->"), false);
|
||||
});
|
||||
|
||||
test("the sha tag reads back, replaces an older one, and carries the paired head", () => {
|
||||
assert.equal(reviewKey(SHA_A, ""), SHA_A);
|
||||
assert.equal(reviewKey(SHA_A, SHA_B), `${SHA_A}+${SHA_B}`);
|
||||
assert.equal(reviewKey("", SHA_B), "");
|
||||
assert.equal(reviewedKey(`x\n${reviewShaTag(SHA_A)}`), SHA_A);
|
||||
assert.equal(reviewedKey("no tag"), "");
|
||||
assert.equal(reviewedKey(undefined), "");
|
||||
const twice = withReviewShaTag(withReviewShaTag("x", SHA_A), reviewKey(SHA_A, SHA_B));
|
||||
assert.equal(reviewedKey(twice), `${SHA_A}+${SHA_B}`);
|
||||
assert.equal(twice.match(/o2-review-sha/g).length, 1);
|
||||
});
|
||||
|
||||
test("claiming a pass keeps the reviewed commit, so a stopped run does not hide it", () => {
|
||||
const claimed = withPassTag(finished(SHA_A, 1, 0), 2);
|
||||
assert.equal(reviewedKey(claimed), SHA_A);
|
||||
assert.equal(reviewPassCount(claimed), 2);
|
||||
});
|
||||
|
||||
test("dedupe skips only the same key, unless a manual override asks for a re-review", () => {
|
||||
const body = finished(SHA_A, 1, 0);
|
||||
assert.equal(shouldSkipAsReviewed(body, SHA_A), true);
|
||||
assert.equal(shouldSkipAsReviewed(body, SHA_B), false);
|
||||
assert.equal(shouldSkipAsReviewed(body, SHA_A, { ignorePassLimit: true }), false);
|
||||
assert.equal(shouldSkipAsReviewed(body, SHA_A, { forceFull: true }), false);
|
||||
assert.equal(shouldSkipAsReviewed(undefined, SHA_A), false);
|
||||
assert.equal(shouldSkipAsReviewed(body, ""), false);
|
||||
assert.equal(shouldSkipAsReviewed(`x\n${reviewShaTag("")}`, ""), false);
|
||||
const paired = finishReviewBody(sanitize(REVIEW), { key: reviewKey(SHA_A, SHA_B), passes: 1, maxPasses: 0, reviewed: true });
|
||||
assert.equal(shouldSkipAsReviewed(paired, reviewKey(SHA_A, SHA_B)), true);
|
||||
assert.equal(shouldSkipAsReviewed(paired, reviewKey(SHA_A, SHA_C)), false);
|
||||
assert.equal(shouldSkipAsReviewed(paired, SHA_A), false);
|
||||
});
|
||||
|
||||
test("no cap: any non-positive or invalid setting, and the visible line has no ceiling", () => {
|
||||
for (const value of [undefined, "", " ", "0", "-3", "abc", "5x", "2.5"]) assert.equal(parseMaxPasses(value), 0, String(value));
|
||||
assert.equal(parseMaxPasses("7"), 7);
|
||||
assert.equal(parseMaxPasses(" 7 "), 7);
|
||||
assert.match(finished(SHA_A, 3, 0), /<sub>Reviewed commit `aaaaaaa` · review 3<\/sub>/);
|
||||
assert.match(finished(SHA_A, 3, 5), /<sub>Reviewed commit `aaaaaaa` · review 3 of 5<\/sub>/);
|
||||
});
|
||||
|
||||
test("without a cap every new head is reviewed at any pass count, and the same head skips", () => {
|
||||
for (const passesDone of [2, 5, 50]) {
|
||||
const body = finished(SHA_A, passesDone, 0);
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_B, passesDone, maxPasses: 0 }), "", `pass ${passesDone + 1}`);
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_A, passesDone, maxPasses: 0 }), "already_reviewed");
|
||||
assert.equal(body.includes("automatic reviews"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("a plain or paired dispatch on a reviewed key skips; the override reviews it", () => {
|
||||
const key = reviewKey(SHA_A, SHA_B);
|
||||
const body = finished(key, 1, 0);
|
||||
assert.equal(reviewSkipReason({ body, key, passesDone: 1, maxPasses: 0 }), "already_reviewed");
|
||||
assert.equal(reviewSkipReason({ body, key, passesDone: 1, maxPasses: 0, ignorePassLimit: true }), "");
|
||||
});
|
||||
|
||||
test("a set cap still stops a new head, after dedupe, and the override passes it", () => {
|
||||
const body = finished(SHA_A, 2, 2);
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_B, passesDone: 2, maxPasses: 2 }), "pass_limit_reached");
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_A, passesDone: 2, maxPasses: 2 }), "already_reviewed");
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_B, passesDone: 1, maxPasses: 2 }), "");
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_B, passesDone: 2, maxPasses: 2, ignorePassLimit: true }), "");
|
||||
assert.equal(reviewSkipReason({ body, key: SHA_B, passesDone: 2, maxPasses: 2, forceFull: true }), "pass_limit_reached");
|
||||
});
|
||||
|
||||
test("the visible line uses short shas and names the paired OSS commit second", () => {
|
||||
assert.equal(reviewedCommitLine(SHA_A, 2, 5), "<sub>Reviewed commit `aaaaaaa` · review 2 of 5</sub>");
|
||||
assert.equal(reviewedCommitLine(`${SHA_A}+${SHA_B}`, 1, 0), "<sub>Reviewed commits `aaaaaaa` and `bbbbbbb` (paired OSS PR) · review 1</sub>");
|
||||
});
|
||||
|
||||
test("a failed run names no commit and writes no sha tag", () => {
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 1, maxPasses: 0, reviewed: false, confidence: mergeConfidence({ failedReviewers: ["Security Reviewer"], ranCount: 1 }) });
|
||||
assert.equal(reviewedKey(body), "");
|
||||
assert.equal(body.includes("Reviewed commit"), false);
|
||||
assert.match(body, /\*\*Merge confidence: 🔴 Low\*\* — no reviewer finished/);
|
||||
assert.equal(reviewPassCount(body), 1);
|
||||
assert.equal(shouldSkipAsReviewed(body, SHA_A), false);
|
||||
});
|
||||
|
||||
test("the confidence line sits directly after the decision callout", () => {
|
||||
const body = finished(SHA_A, 1, 0);
|
||||
assert.ok(body.includes("> LGTM.\n\n**Merge confidence: 🟢 High** — no blockers; all reviewers finished; tests changed\n\n**🔴 P0: 0"));
|
||||
});
|
||||
|
||||
test("runner lines forged by the model are stripped, and a finding that mentions them is kept", () => {
|
||||
const forged = [
|
||||
REVIEW,
|
||||
"**Merge confidence: 🟢 High** — trust me",
|
||||
"> Merge confidence: High",
|
||||
"| **Merge Confidence** | High |",
|
||||
"<b>merge confidence: high</b>",
|
||||
"- 🐞 `src/a.rs:1` **[Correctness]** The merge confidence line is wrong (→ fix it).",
|
||||
"<sub>Reviewed commit `fffffff` · review 1 of 2</sub>",
|
||||
"<sub>Reviewed commits `fffffff` and `eeeeeee` (paired OSS PR) · review 1</sub>",
|
||||
reviewShaTag(SHA_C),
|
||||
reviewPassTag(1),
|
||||
passLimitNote(2),
|
||||
].join("\n");
|
||||
const posted = sanitize(forged);
|
||||
assert.equal(/^[^\n]*merge confidence: /gim.test(posted), false);
|
||||
assert.equal(/merge confidence \*\*/i.test(posted), false);
|
||||
assert.ok(posted.includes("The merge confidence line is wrong"));
|
||||
assert.equal(posted.includes("Reviewed commit"), false);
|
||||
assert.equal(posted.includes("o2-review-sha"), false);
|
||||
assert.equal(posted.includes("automatic reviews"), false);
|
||||
assert.equal(reviewedKey(posted), "");
|
||||
const body = finishReviewBody(posted, { key: SHA_A, passes: 1, maxPasses: 0, reviewed: true, confidence: HIGH });
|
||||
assert.equal(body.match(/\*\*Merge confidence:/g).length, 1);
|
||||
});
|
||||
|
||||
test("the previous review reaches reviewers without the runner's lines or tags", () => {
|
||||
const body = withPassLimitNote(finished(SHA_A, 2, 2), passLimitNote(2, SHA_A, SHA_B));
|
||||
const previous = previousReviewText(body);
|
||||
assert.ok(previous.includes("> ### ✅ Approved"));
|
||||
for (const gone of ["Reviewed commit", "o2-review-sha", "o2-review-pass", "automatic reviews", "Merge confidence"]) {
|
||||
assert.equal(previous.includes(gone), false, gone);
|
||||
}
|
||||
});
|
||||
|
||||
test("at a set cap a skipped push names both commits, once, and marks confidence stale", () => {
|
||||
const body = withPassTag(finished(SHA_A, 2, 2), 2);
|
||||
const once = bodyAtPassLimit(body, 2, SHA_B);
|
||||
assert.match(once, /> This PR has used its 2 automatic reviews\. This review covers `aaaaaaa`; `bbbbbbb` and later pushes are not reviewed\. Run the AI Code Review workflow by hand with `ignore_pass_limit` to review it again\./);
|
||||
assert.equal(once.match(/automatic reviews/g).length, 1);
|
||||
assert.equal(once.match(/Merge confidence/g).length, 1);
|
||||
assert.ok(once.includes("> LGTM.\n\n**Merge confidence: 🔴 Low** — `bbbbbbb` has not been reviewed\n\n**🔴 P0"));
|
||||
assert.equal(once.includes("🟢"), false);
|
||||
assert.equal(reviewedKey(once), SHA_A);
|
||||
assert.equal(reviewPassCount(once), 2);
|
||||
assert.ok(once.endsWith(`${reviewShaTag(SHA_A)}\n${reviewPassTag(2)}`));
|
||||
assert.equal(bodyAtPassLimit(once, 2, SHA_B), once);
|
||||
const next = bodyAtPassLimit(once, 2, SHA_C);
|
||||
assert.equal(next.match(/automatic reviews/g).length, 1);
|
||||
assert.ok(next.includes("`ccccccc` and later pushes"));
|
||||
assert.ok(next.includes("`ccccccc` has not been reviewed"));
|
||||
assert.equal(next.includes("bbbbbbb"), false);
|
||||
});
|
||||
|
||||
test("a head the last review did not cover loses the verdict once, and a covered head is left alone", () => {
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 1, maxPasses: 0, reviewed: true, confidence: HIGH, gateLine: "**Auto-approved** — small change, all gates passed." });
|
||||
const stale = bodyAtUnreviewedHead(body, SHA_B);
|
||||
assert.equal(stale.includes("Auto-approved"), false);
|
||||
assert.equal(stale.match(/Merge confidence/g).length, 1);
|
||||
assert.ok(stale.includes("`bbbbbbb` has not been reviewed"));
|
||||
assert.equal(reviewedKey(stale), SHA_A);
|
||||
assert.equal(reviewPassCount(stale), 1);
|
||||
assert.equal(bodyAtUnreviewedHead(stale, SHA_B), stale);
|
||||
assert.equal(bodyAtUnreviewedHead(body, SHA_A), body);
|
||||
});
|
||||
|
||||
test("a legacy review with no sha tag or confidence line is marked stale, a placeholder is not", () => {
|
||||
const stale = bodyAtUnreviewedHead(REVIEW, SHA_B);
|
||||
assert.ok(stale.includes("> LGTM.\n\n**Merge confidence: 🔴 Low** — `bbbbbbb` has not been reviewed"));
|
||||
assert.equal(stale.match(/Merge confidence/g).length, 1);
|
||||
assert.equal(bodyAtUnreviewedHead(stale, SHA_B), stale);
|
||||
for (const tag of ["<!-- o2-paired-pointer -->", "<!-- o2-review-notice -->"]) {
|
||||
const placeholder = `<!-- ai-code-review -->\n${tag}\n${REVIEW_HEADING}\n\n> [!NOTE]\n> Review in progress.`;
|
||||
assert.equal(bodyAtUnreviewedHead(placeholder, SHA_B), placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
test("a full review replaces the stale line and the limit note", () => {
|
||||
const stale = bodyAtPassLimit(finished(SHA_A, 2, 2), 2, SHA_B);
|
||||
const previous = previousReviewText(stale);
|
||||
const fresh = finishReviewBody(sanitize(previous), { key: SHA_B, passes: 3, maxPasses: 2, reviewed: true, confidence: HIGH });
|
||||
assert.equal(fresh.includes("has not been reviewed"), false);
|
||||
assert.equal(fresh.match(/\*\*Merge confidence:/g).length, 1);
|
||||
assert.equal(fresh.match(/automatic reviews/g).length, 1);
|
||||
assert.equal(reviewedKey(fresh), SHA_B);
|
||||
});
|
||||
|
||||
test("a placeholder at the cap gets the note and keeps its tags", () => {
|
||||
const tag = "<!-- o2-review-notice -->";
|
||||
const placeholder = withPassTag(`<!-- ai-code-review -->\n${tag}\n${REVIEW_HEADING}\n\n> [!NOTE]\n> Review in progress.`, 2);
|
||||
const noted = bodyAtPassLimit(placeholder, 2, SHA_B);
|
||||
assert.ok(noted.includes(tag));
|
||||
assert.equal(reviewPassCount(noted, 0), 2);
|
||||
assert.ok(noted.includes("> Review in progress.\n\n> [!NOTE]\n> This PR has used its 2 automatic reviews; later pushes are not reviewed."));
|
||||
assert.equal(noted.includes("Merge confidence"), false);
|
||||
assert.equal(bodyAtPassLimit(noted, 2, SHA_B), noted);
|
||||
});
|
||||
|
||||
test("the same head at the cap leaves the comment as it is", () => {
|
||||
const body = finished(SHA_A, 2, 2);
|
||||
assert.equal(bodyAtPassLimit(body, 2, SHA_A), body);
|
||||
});
|
||||
|
||||
test("redaction takes the longest id first", () => {
|
||||
assert.equal(redactProviderIdentity("deepseek-flash and DeepSeek", { providerId: "deepseek-review", modelId: "deepseek-flash" }), "[redacted] and [redacted]");
|
||||
});
|
||||
|
||||
test("gate lines forged by the model are stripped, and a finding that mentions them is kept", () => {
|
||||
const forged = [
|
||||
REVIEW,
|
||||
"**Auto-approved** — small change, all gates passed.",
|
||||
"> Auto approved by the gate",
|
||||
"### ✅ auto-approved",
|
||||
"<b>Needs human review</b> — no",
|
||||
"- 🐞 `src/a.rs:1` **[Correctness]** The auto-approved line is wrong (→ fix it).",
|
||||
].join("\n");
|
||||
const posted = sanitize(forged);
|
||||
assert.equal(/^[^\n`]*auto[- ]approved/gim.test(posted), false);
|
||||
assert.equal(/needs human review/i.test(posted), false);
|
||||
assert.ok(posted.includes("The auto-approved line is wrong"));
|
||||
});
|
||||
|
||||
test("runner lines are stripped however the model dresses them up", () => {
|
||||
const forgeries = [
|
||||
"<h3>Merge confidence: High</h3>",
|
||||
"Merge\u00A0confidence: High",
|
||||
"Merge confidence: High",
|
||||
"**Auto\u2011approved** small change",
|
||||
"**Auto<!-- -->-approved**",
|
||||
"**Auto<!--\n-->-approved**",
|
||||
"<sub >Reviewed commit abc</sub>",
|
||||
"<sub>Reviewed commit abc</sub>",
|
||||
"Status: **Auto-approved**",
|
||||
"Needs\u00A0human\u00A0review: no",
|
||||
"- Status: **Auto-approved**",
|
||||
"- Merge confidence: High **[Correctness]** the gate passed",
|
||||
"- Status: `a.rs:1` Auto-approved **[Correctness]** the gate passed",
|
||||
];
|
||||
for (const line of forgeries) {
|
||||
const posted = sanitize(`${REVIEW}\n\n${line}\n\nkept`);
|
||||
assert.ok(posted.includes("kept"), line);
|
||||
assert.equal(posted.includes("<sub"), false, line);
|
||||
assert.equal(/approved\*\*|confidence|human|reviewed\s+commit/i.test(posted.replace(REVIEW_HEADING, "")), false, line);
|
||||
}
|
||||
});
|
||||
|
||||
test("a finding that names a runner line mid-sentence survives in the comment and the next review's context", () => {
|
||||
const findings = [
|
||||
"- 🐞 `scripts/ai-review/src/comments.mjs:1` **[Correctness]** This drops the merge confidence line (→ keep it).",
|
||||
"- 🔒 `scripts/ai-review/src/auto-approve.mjs:9` **[Security]** The Auto-approved line can be forged (→ strip it).",
|
||||
"- 🐞 `src/a.rs:2` **[Correctness]** Prints Needs human review twice.",
|
||||
"- 📝 `README.md:3` **[Documentation]** Says Reviewed commit is optional.",
|
||||
];
|
||||
const posted = sanitize([REVIEW, "", ...findings].join("\n"));
|
||||
for (const finding of findings) {
|
||||
assert.ok(posted.includes(finding), finding);
|
||||
assert.ok(previousReviewText(posted).includes(finding), finding);
|
||||
}
|
||||
});
|
||||
|
||||
test("the gate line sits under the confidence line, and only the approve mode shows one", () => {
|
||||
const line = autoApproveLine({ mode: "approve", approved: true, reasons: [] });
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 1, maxPasses: 0, reviewed: true, confidence: HIGH, gateLine: line });
|
||||
assert.ok(body.includes("tests changed\n\n**Auto-approved** — small change, all gates passed.\n\n**🔴 P0"));
|
||||
assert.equal(autoApproveLine({ mode: "approve", approved: false, reasons: ["there are findings", "the head moved"] }), "**Needs human review** — there are findings; the head moved");
|
||||
assert.equal(autoApproveLine({ mode: "shadow", approved: false, reasons: ["x"] }), "");
|
||||
assert.equal(autoApproveLine(null), "");
|
||||
});
|
||||
|
||||
test("a gate verdict never outlives its commit in the comment or the reviewers' context", () => {
|
||||
const line = autoApproveLine({ mode: "approve", approved: true, reasons: [] });
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 2, maxPasses: 2, reviewed: true, confidence: HIGH, gateLine: line });
|
||||
assert.equal(withoutAutoApproveLine(body).includes("Auto-approved"), false);
|
||||
assert.equal(previousReviewText(body).includes("Auto-approved"), false);
|
||||
const stale = bodyAtPassLimit(body, 2, SHA_B);
|
||||
assert.equal(stale.includes("Auto-approved"), false);
|
||||
assert.equal(bodyAtPassLimit(stale, 2, SHA_B), stale);
|
||||
});
|
||||
|
||||
test("claiming a pass for a new head rewrites the confidence line, once, and keeps every tag", () => {
|
||||
const body = finished(SHA_A, 1, 0);
|
||||
const claimed = claimedReviewBody(body, 2, SHA_B);
|
||||
assert.ok(claimed.includes("> LGTM.\n\n**Merge confidence: 🟡 Reviewing** — `bbbbbbb` is being reviewed\n\n**🔴 P0"));
|
||||
assert.equal(claimed.includes("🟢"), false);
|
||||
assert.equal(claimed.match(/Merge confidence/g).length, 1);
|
||||
assert.equal(reviewedKey(claimed), SHA_A);
|
||||
assert.equal(reviewPassCount(claimed), 2);
|
||||
assert.equal(claimedReviewBody(claimed, 2, SHA_B), claimed);
|
||||
const next = claimedReviewBody(claimed, 3, SHA_C);
|
||||
assert.ok(next.includes("`ccccccc` is being reviewed"));
|
||||
assert.equal(next.match(/Merge confidence/g).length, 1);
|
||||
});
|
||||
|
||||
test("the paired key counts as new when only the OSS head moved", () => {
|
||||
const body = finished(reviewKey(SHA_A, SHA_B), 1, 0);
|
||||
assert.ok(claimedReviewBody(body, 2, reviewKey(SHA_A, SHA_C)).includes("`aaaaaaa+ccccccc` is being reviewed"));
|
||||
});
|
||||
|
||||
test("a claim on the same head or on a comment with no confidence line adds nothing", () => {
|
||||
const body = finished(SHA_A, 1, 0);
|
||||
assert.ok(claimedReviewBody(body, 2, SHA_A).includes("🟢 High"));
|
||||
const placeholder = withPassTag(`<!-- ai-code-review -->\n<!-- o2-review-notice -->\n${REVIEW_HEADING}\n\n> [!NOTE]\n> Review in progress.`, 1);
|
||||
assert.equal(claimedReviewBody(placeholder, 2, SHA_B).includes("Merge confidence"), false);
|
||||
assert.equal(reviewPassCount(claimedReviewBody(placeholder, 2, SHA_B)), 2);
|
||||
});
|
||||
|
||||
test("a legacy review with no confidence line loses its approval look when a new head is claimed", () => {
|
||||
const legacy = withPassTag(`<!-- ai-code-review -->\n${REVIEW_HEADING}\n\n> [!TIP]\n> ### ✅ Approved\n> LGTM.\n\n**🔴 0 blockers · 🟡 0 warnings**`, 1);
|
||||
const claimed = claimedReviewBody(legacy, 2, SHA_B);
|
||||
assert.ok(claimed.includes("> LGTM.\n\n**Merge confidence: 🟡 Reviewing** — `bbbbbbb` is being reviewed"), claimed);
|
||||
assert.equal(claimed.match(/Merge confidence/g).length, 1);
|
||||
assert.equal(reviewPassCount(claimed), 2);
|
||||
assert.equal(claimedReviewBody(claimed, 2, SHA_B), claimed);
|
||||
});
|
||||
|
||||
test("the gate line comes off at the claim along with the old confidence", () => {
|
||||
const line = autoApproveLine({ mode: "approve", approved: true, reasons: [] });
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 1, maxPasses: 0, reviewed: true, confidence: HIGH, gateLine: line });
|
||||
assert.equal(claimedReviewBody(body, 2, SHA_B).includes("Auto-approved"), false);
|
||||
});
|
||||
|
||||
test("the Reviewing line never reaches reviewers, is stripped when forged, and the final review replaces it", () => {
|
||||
const claimed = claimedReviewBody(finished(SHA_A, 1, 0), 2, SHA_B);
|
||||
const previous = previousReviewText(claimed);
|
||||
assert.equal(previous.includes("Reviewing"), false);
|
||||
assert.equal(sanitize(`${REVIEW}\n**Merge confidence: 🟡 Reviewing** — \`bbbbbbb\` is being reviewed`).includes("Reviewing"), false);
|
||||
const fresh = finishReviewBody(sanitize(claimed), { key: SHA_B, passes: 2, maxPasses: 0, reviewed: true, confidence: HIGH });
|
||||
assert.equal(fresh.includes("is being reviewed"), false);
|
||||
assert.equal(fresh.match(/\*\*Merge confidence:/g).length, 1);
|
||||
assert.equal(reviewedKey(fresh), SHA_B);
|
||||
});
|
||||
|
||||
test("a cancelled run's Reviewing line becomes the unreviewed line at a set cap", () => {
|
||||
const claimed = claimedReviewBody(finished(SHA_A, 1, 2), 2, SHA_B);
|
||||
const capped = bodyAtPassLimit(claimed, 2, SHA_B);
|
||||
assert.ok(capped.includes("`bbbbbbb` has not been reviewed"));
|
||||
assert.equal(capped.includes("Reviewing"), false);
|
||||
});
|
||||
|
||||
test("a finding keeps its place whatever its text opens with, because a hidden finding costs more than a cosmetic forgery", () => {
|
||||
const findings = [
|
||||
"- 🐞 `src/merge_confidence.rs:1` **[Correctness]** Merge confidence stays High when a reviewer crashes (→ lower it).",
|
||||
"- 🐞 `scripts/ai-review/src/confidence.mjs:52` **[Correctness]** Merge confidence: High is emitted after an invalid blocker entry is discarded (→ mark findings unknown).",
|
||||
"- 🐞 `a.rs:1` **[Correctness]** Merge confidence: 🟢 High",
|
||||
"- 🔒 `a.rs:2` **[Security]** **Auto-approved** is written before the gate has decided.",
|
||||
"- ⚡ `a.rs:3` **[Performance]** Needs human review — every run repeats the lookup.",
|
||||
"- 🐞 `a.rs:4` **[Correctness]** Reviewed commit abc1234 names the wrong head.",
|
||||
"- 🐞 `merge confidence.md:1` **[Documentation]** Spelling of the heading.",
|
||||
"- 🐞 `auto-approved.md:1` **[Documentation]** Spelling of the heading.",
|
||||
"- • **[Correctness]** Merge confidence: High with no file to anchor to.",
|
||||
];
|
||||
for (const finding of findings) {
|
||||
assert.ok(sanitize(`## 🔎 OpenObserve Code Review\n\n${finding}`).includes(finding), finding);
|
||||
assert.ok(previousReviewText(`## 🔎 OpenObserve Code Review\n\n${finding}`).includes(finding), finding);
|
||||
}
|
||||
});
|
||||
|
||||
test("a review that named no commit still loses its confidence line once the cap skips a newer head", () => {
|
||||
const body = finishReviewBody(`${REVIEW_HEADING}\n\n> [!TIP]\n> ### ✅ Approved\n> LGTM.`, { key: SHA_A, passes: 2, maxPasses: 2, reviewed: false, confidence: { level: "high", reasons: ["ok"] } });
|
||||
assert.equal(reviewedKey(body), "");
|
||||
const capped = bodyAtPassLimit(body, 2, SHA_B);
|
||||
assert.ok(capped.includes("`bbbbbbb` has not been reviewed"));
|
||||
assert.equal(capped.includes("🟢 High"), false);
|
||||
});
|
||||
|
||||
test("a tag that repeats its own opener finishes fast, however long the repetition", () => {
|
||||
const url = new URL("../src/comments.mjs", import.meta.url).href;
|
||||
const script = `
|
||||
const { reviewedKey, withPassLimitNote, sanitizeReviewBody } = await import(${JSON.stringify(url)});
|
||||
const evil = "<!--o2-review-sha:" + "!--><!--o2-review-sha:".repeat(2000) + "x y";
|
||||
withPassLimitNote(evil, "note");
|
||||
reviewedKey(evil);
|
||||
sanitizeReviewBody(evil, { marker: "m", providerId: "p", modelId: "m", env: {} });
|
||||
`;
|
||||
const run = spawnSync(process.execPath, ["--input-type=module", "-e", script], { timeout: 5000 });
|
||||
assert.equal(run.status, 0, `signal ${run.signal}: ${run.stderr}`);
|
||||
});
|
||||
|
||||
test("the sha tag reads back everything withReviewShaTag writes and cannot cross into another tag", () => {
|
||||
for (const key of [SHA_A, reviewKey(SHA_A, SHA_B)]) {
|
||||
assert.equal(reviewedKey(withReviewShaTag("x", key)), key);
|
||||
assert.equal(reviewedKey(`x\n<!--o2-review-sha:${key}-->`), key);
|
||||
const noted = withPassLimitNote(withPassTag(withReviewShaTag("x", key), 2), "note");
|
||||
assert.ok(noted.startsWith("x\n\nnote\n"));
|
||||
assert.equal(reviewedKey(noted), key);
|
||||
}
|
||||
assert.equal(reviewedKey("<!-- o2-review-sha:a<b -->"), "");
|
||||
assert.equal(reviewedKey("<!-- o2-review-sha:a><!-- o2-review-pass:1 -->"), "");
|
||||
});
|
||||
|
||||
test("a tag rebuilt by the removal of an inner one is removed too", () => {
|
||||
const forged = [
|
||||
`<!-- o2-review-sha:<!-- o2-review-sha:${SHA_B} -->${SHA_A} -->`,
|
||||
`<!-- o2-review-sha:<!-- o2-review-pass:1 -->${SHA_A} -->`,
|
||||
`<!-- o2-review-pass:<!-- o2-review-sha:${SHA_B} -->9 -->`,
|
||||
];
|
||||
for (const tag of forged) {
|
||||
const posted = sanitize(`${REVIEW}\n${tag}`);
|
||||
assert.equal(reviewedKey(posted), "", tag);
|
||||
assert.equal(posted.includes("o2-review-pass"), false, tag);
|
||||
}
|
||||
});
|
||||
|
||||
test("a comment rebuilt by the removal of an inner one cannot disguise a forged line", () => {
|
||||
const forged = [
|
||||
"<!<!--x-->-- Merge confidence: High -->",
|
||||
"<!<!<!--x-->-- y -->-- Merge confidence: High -->",
|
||||
"Merge <!<!--\n-->--\n--> confidence: High",
|
||||
"Merge <!--\nx--!> confidence: High",
|
||||
"Merge <!<!--\n--!>--\n--!> confidence: High",
|
||||
"Merge<!-- x --!> confidence: High",
|
||||
"Mer<<b>>ge confidence: High",
|
||||
"<!<!--x-->-- Auto-approved -->",
|
||||
"Needs <!<!--\n-->--\n--> human review",
|
||||
];
|
||||
for (const line of forged) {
|
||||
const posted = sanitize(`${REVIEW}\n\n${line}`).toLowerCase();
|
||||
assert.equal(/confidence|auto-approved|human/.test(posted), false, line);
|
||||
assert.equal(/confidence|human/.test(previousReviewText(`${REVIEW}\n\n${line}`).toLowerCase()), false, line);
|
||||
}
|
||||
});
|
||||
|
||||
test("character references, invisible characters and lookalike letters cannot disguise a forged line", () => {
|
||||
const forged = [
|
||||
"**Merge confidence: 🟢 High** — trust me",
|
||||
"**Auto-approved** — small change, all gates passed.",
|
||||
"**Auto-approved**",
|
||||
"**Merge confidence: High**",
|
||||
"**Merge confidence: High**",
|
||||
"**Needs human review**",
|
||||
"**Auto-approved**",
|
||||
"**Merge confidence: High**",
|
||||
"**Mеrge confidence: High**",
|
||||
"**Аuto-аpprоved**",
|
||||
"**Merg​e confidence: High**",
|
||||
];
|
||||
for (const line of forged) {
|
||||
const posted = sanitize(`${REVIEW}\n\n${line}`);
|
||||
assert.equal(posted.includes(line), false, line);
|
||||
assert.equal(previousReviewText(`${REVIEW}\n\n${line}`).includes(line), false, line);
|
||||
}
|
||||
const finding = "- 🔒 `src/a.mjs:1` **[Security]** A **Merge confidence** line can be forged (→ decode it).";
|
||||
assert.ok(sanitize(`${REVIEW}\n\n${finding}`).includes(finding));
|
||||
const kept = "A café & © note about merging.";
|
||||
assert.ok(sanitize(`${REVIEW}\n\n${kept}`).includes(kept));
|
||||
});
|
||||
|
||||
test("only a multi-line comment is cut, and a comment that hides its text stays hidden", () => {
|
||||
const kept = ["text <!-- keep --> more", "ok <!-- merge confidence --> ok", "a <!<!-- keep --> b"];
|
||||
for (const line of kept) assert.ok(sanitize(`${REVIEW}\n\n${line}`).includes(line), line);
|
||||
assert.equal(sanitize(`${REVIEW}\n\nkeep <!--\ncut\n--> this`).includes("cut"), false);
|
||||
assert.equal(sanitize(`${REVIEW}\n\nkeep <!--\ncut\n--!> this`).includes("cut"), false);
|
||||
assert.ok(sanitize(`${REVIEW}\n\nkeep <!--\ncut\n--!> this`).includes("keep this"));
|
||||
});
|
||||
|
||||
test("back on the reviewed commit a cancelled run's line gives way to the reviewed verdict", () => {
|
||||
const body = finished(SHA_A, 1, 0);
|
||||
const claimed = claimedReviewBody(body, 2, SHA_B);
|
||||
const restored = bodyAtReviewedHead(claimed, 0);
|
||||
assert.equal(restored, withPassTag(body, 2));
|
||||
assert.ok(restored.includes("🟢 High"));
|
||||
assert.equal(restored.includes("Reviewing"), false);
|
||||
assert.equal(restored.includes("o2-review-held"), false);
|
||||
assert.equal(reviewPassCount(restored), 2);
|
||||
assert.equal(reviewedKey(restored), SHA_A);
|
||||
assert.equal(bodyAtReviewedHead(restored, 0), restored);
|
||||
assert.equal(bodyAtReviewedHead(body, 0), body);
|
||||
});
|
||||
|
||||
test("the verdict held across several cancelled heads is the reviewed one, whatever its level", () => {
|
||||
const medium = { level: "medium", reasons: ["1 P2 finding"] };
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 1, maxPasses: 0, reviewed: true, confidence: medium });
|
||||
const twice = claimedReviewBody(claimedReviewBody(body, 2, SHA_B), 3, SHA_C);
|
||||
const restored = bodyAtReviewedHead(twice, 0);
|
||||
assert.ok(restored.includes("🟡 Medium** — 1 P2 finding"));
|
||||
assert.equal(restored.match(/Merge confidence/g).length, 1);
|
||||
assert.equal(restored.includes("is being reviewed"), false);
|
||||
});
|
||||
|
||||
test("a push the cap skipped leaves no trace once the head is the reviewed commit again", () => {
|
||||
const body = finished(SHA_A, 2, 2);
|
||||
const capped = bodyAtPassLimit(body, 2, SHA_B);
|
||||
const restored = bodyAtReviewedHead(capped, 2);
|
||||
assert.ok(restored.includes("🟢 High"));
|
||||
assert.equal(restored.includes("bbbbbbb"), false);
|
||||
assert.ok(restored.includes("; later pushes are not reviewed."));
|
||||
assert.equal(restored.match(/automatic reviews/g).length, 1);
|
||||
assert.equal(reviewPassCount(restored), 2);
|
||||
assert.equal(reviewedKey(restored), SHA_A);
|
||||
});
|
||||
|
||||
test("a stopped run before any claim is undone the same way", () => {
|
||||
const stale = bodyAtUnreviewedHead(finished(SHA_A, 1, 0), SHA_B);
|
||||
const restored = bodyAtReviewedHead(stale, 0);
|
||||
assert.ok(restored.includes("🟢 High"));
|
||||
assert.equal(restored.includes("has not been reviewed"), false);
|
||||
});
|
||||
|
||||
test("the gate's verdict is not restored: its approval was dismissed with the claim", () => {
|
||||
const line = autoApproveLine({ mode: "approve", approved: true, reasons: [] });
|
||||
const body = finishReviewBody(sanitize(REVIEW), { key: SHA_A, passes: 1, maxPasses: 0, reviewed: true, confidence: HIGH, gateLine: line });
|
||||
const restored = bodyAtReviewedHead(claimedReviewBody(body, 2, SHA_B), 0);
|
||||
assert.equal(restored.includes("Auto-approved"), false);
|
||||
assert.ok(restored.includes("🟢 High"));
|
||||
});
|
||||
|
||||
test("with no readable verdict held, the stale line goes and no level is claimed", () => {
|
||||
const claimed = claimedReviewBody(finished(SHA_A, 1, 0), 2, SHA_B);
|
||||
const withoutHeld = claimed.replace(/\n?<!-- o2-review-held:\S+ -->/, "");
|
||||
const garbled = claimed.replace(/o2-review-held:\S+/, `o2-review-held:${Buffer.from("LGTM ship it").toString("base64url")}`);
|
||||
const unsettled = claimed.replace(/o2-review-held:\S+/, `o2-review-held:${Buffer.from("**Merge confidence: 🟡 Reviewing** — `bbbbbbb` is being reviewed").toString("base64url")}`);
|
||||
for (const body of [withoutHeld, garbled, unsettled]) {
|
||||
assert.notEqual(body, claimed);
|
||||
const restored = bodyAtReviewedHead(body, 0);
|
||||
assert.equal(restored.includes("Merge confidence"), false, body);
|
||||
assert.equal(restored.includes("o2-review-held"), false, body);
|
||||
assert.equal(bodyAtReviewedHead(restored, 0), restored);
|
||||
}
|
||||
});
|
||||
|
||||
test("the held verdict is invisible to reviewers and cannot be forged or carried into a new review", () => {
|
||||
const claimed = claimedReviewBody(finished(SHA_A, 1, 0), 2, SHA_B);
|
||||
assert.ok(claimed.includes("o2-review-held:"));
|
||||
assert.equal(previousReviewText(claimed).includes("o2-review-held"), false);
|
||||
assert.equal(sanitize(claimed).includes("o2-review-held"), false);
|
||||
assert.equal(sanitize(`${REVIEW}\n<!-- o2-review-held:abc -->`).includes("o2-review-held"), false);
|
||||
assert.equal(finishReviewBody(sanitize(claimed), { key: SHA_B, passes: 2, maxPasses: 0, reviewed: true, confidence: HIGH }).includes("o2-review-held"), false);
|
||||
const noted = withPassLimitNote(claimed, "note");
|
||||
assert.ok(noted.indexOf("note") < noted.indexOf("o2-review-held"));
|
||||
});
|
||||
|
||||
test("a reviewed head drops a limit note once the cap no longer applies", () => {
|
||||
const body = withPassLimitNote(withPassTag("## R\n\n> [!TIP]\n> ok\n\n**Merge confidence: 🟢 High** — x", 2), passLimitNote(2, "aaaaaaaaa", "bbbbbbbbb"));
|
||||
for (const cap of [0, 3]) {
|
||||
const cleared = bodyAtReviewedHead(body, cap);
|
||||
assert.equal(cleared.includes("automatic reviews"), false);
|
||||
assert.ok(cleared.includes("Merge confidence: 🟢 High"));
|
||||
assert.equal(bodyAtReviewedHead(cleared, cap), cleared);
|
||||
}
|
||||
assert.equal(bodyAtReviewedHead(body, 2), body);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { MAX_USER_PROMPT_CHARS, fitCoordinatorPrompt } from "../src/coordinator-input.mjs";
|
||||
import { mergePassResults } from "../src/passes.mjs";
|
||||
import { buildFallbackReview, selectFallbackFindings } from "../src/report.mjs";
|
||||
|
||||
const AGENTS = ["Security", "Performance", "Correctness", "Code Quality", "Documentation", "Release", "Frontend", "QA"];
|
||||
|
||||
function passResults({ perReviewer, descriptionChars, passes = 2 }) {
|
||||
const results = [];
|
||||
for (let pass = 1; pass <= passes; pass += 1) {
|
||||
for (const [a, agentName] of AGENTS.entries()) {
|
||||
const findings = Array.from({ length: perReviewer }, (_, i) => ({
|
||||
category: "correctness",
|
||||
severity: "p2",
|
||||
file: `src/area${a}/file${i}.rs`,
|
||||
line: String(10 + i),
|
||||
summary: `pass ${pass} ${agentName} finding ${i}`,
|
||||
description: `${"d".repeat(descriptionChars)} pass${pass}-${a}-${i}`,
|
||||
suggestion: `fix ${pass}-${a}-${i}`,
|
||||
}));
|
||||
results.push({ agentKey: `a${a}`, agentName, pass, findings, rawText: "", error: null, parsed: true, genAIResponseId: "" });
|
||||
}
|
||||
}
|
||||
const last = results.at(-1).findings.at(-1);
|
||||
Object.assign(last, { severity: "p0", summary: "LAST PASS BLOCKER drops every row", file: "src/qa/last.rs", line: "99" });
|
||||
return results;
|
||||
}
|
||||
|
||||
const build = findingsXml => `<coordinator_task>\n<all_findings>${findingsXml}</all_findings>\n</coordinator_task>`;
|
||||
|
||||
test("an oversized two-pass set is compacted so the last pass's blocker still reaches the coordinator", () => {
|
||||
const results = passResults({ perReviewer: 8, descriptionChars: 945 });
|
||||
const fit = fitCoordinatorPrompt(results, 2, build);
|
||||
assert.equal(fit.mode, "compacted");
|
||||
assert.ok(fit.prompt.length <= MAX_USER_PROMPT_CHARS, `prompt ${fit.prompt.length} over the limit`);
|
||||
assert.ok(fit.prompt.includes("LAST PASS BLOCKER drops every row"));
|
||||
assert.ok(fit.prompt.includes("<severity>p0</severity>"));
|
||||
assert.ok(fit.prompt.includes("fix 2-7-7"), "suggestions are kept whole");
|
||||
assert.equal((fit.prompt.match(/<finding>/g) ?? []).length, 8 * 8 * 2, "no finding is dropped");
|
||||
});
|
||||
|
||||
test("a set that fits is sent unchanged", () => {
|
||||
const results = passResults({ perReviewer: 2, descriptionChars: 100 });
|
||||
const fit = fitCoordinatorPrompt(results, 2, build);
|
||||
assert.equal(fit.mode, "full");
|
||||
assert.ok(fit.prompt.includes(`${"d".repeat(100)} pass2-7-1`));
|
||||
});
|
||||
|
||||
test("a set too large even compacted skips the coordinator and the fallback keeps the last pass's blocker", () => {
|
||||
const results = passResults({ perReviewer: 400, descriptionChars: 945 });
|
||||
const fit = fitCoordinatorPrompt(results, 2, build);
|
||||
assert.equal(fit.mode, "too_large");
|
||||
assert.equal(fit.prompt, null);
|
||||
const merged = mergePassResults(results);
|
||||
assert.ok(selectFallbackFindings(merged).some(f => f.summary === "LAST PASS BLOCKER drops every row"));
|
||||
assert.ok(buildFallbackReview(merged, "full", [], "<!-- m -->").includes("LAST PASS BLOCKER drops every row"));
|
||||
});
|
||||
|
||||
test("exact duplicate findings across passes are sent once when compacting", () => {
|
||||
const results = passResults({ perReviewer: 8, descriptionChars: 2000 });
|
||||
for (const r of results.filter(r => r.pass === 2)) {
|
||||
r.findings = results.find(p => p.pass === 1 && p.agentKey === r.agentKey).findings.map(f => ({ ...f }));
|
||||
}
|
||||
results.at(-1).findings.push({ category: "qa", severity: "p0", file: "src/qa/last.rs", line: "99", summary: "LAST PASS BLOCKER", description: "x", suggestion: "y" });
|
||||
const fit = fitCoordinatorPrompt(results, 2, build);
|
||||
assert.ok(fit.prompt.length <= MAX_USER_PROMPT_CHARS);
|
||||
assert.equal((fit.prompt.match(/<finding>/g) ?? []).length, 8 * 8 + 1);
|
||||
assert.ok(fit.prompt.includes("LAST PASS BLOCKER"));
|
||||
});
|
||||
|
||||
test("the runner never lets the transport slice the coordinator prompt", () => {
|
||||
const source = readFileSync(new URL("../run-review.mjs", import.meta.url), "utf8");
|
||||
assert.match(source, /userPrompt\.slice\(0, MAX_USER_PROMPT_CHARS\)/);
|
||||
const coordinator = source.slice(source.indexOf("async function runCoordinator"), source.indexOf("// ─── GitHub integration"));
|
||||
assert.match(coordinator, /fitCoordinatorPrompt\(/);
|
||||
assert.match(coordinator, /too_large/);
|
||||
assert.match(coordinator, /"review\.coordinator_input"/);
|
||||
});
|
||||
@@ -58,7 +58,7 @@ test("the inline block is parsed and cut out of the summary, fenced or not", ()
|
||||
"",
|
||||
"```",
|
||||
"<!-- ai-review-inline",
|
||||
'[{"file": "./src/lib.rs", "line": "11", "severity": "Critical", "category": "Code Quality", "title": "Bug <!-- x -->", "body": "Fix it"}, {"title": ""}]',
|
||||
'[{"file": "./src/lib.rs", "line": "11", "severity": "Critical", "category": "Code Quality", "title": "Bug <!-- x -->", "body": "Fix it"}]',
|
||||
"-->",
|
||||
"```",
|
||||
].join("\n");
|
||||
@@ -66,12 +66,12 @@ test("the inline block is parsed and cut out of the summary, fenced or not", ()
|
||||
assert.equal(out.text, "## 🔎 OpenObserve Code Review\n\nbody");
|
||||
assert.equal(out.error, null);
|
||||
assert.deepEqual(out.findings, [
|
||||
{ file: "src/lib.rs", line: 11, severity: "critical", category: "Code Quality", title: "Bug", body: "Fix it" },
|
||||
{ file: "src/lib.rs", line: 11, severity: "p0", category: "Code Quality", title: "Bug", body: "Fix it" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("nested or unterminated comment openers in finding text are stripped", () => {
|
||||
const block = (title, body) => `<!-- ai-review-inline\n${JSON.stringify([{ file: "src/lib.rs", line: 11, severity: "warning", title, body }])}\n-->`;
|
||||
const block = (title, body) => `<!-- ai-review-inline\n${JSON.stringify([{ file: "src/lib.rs", line: 11, severity: "p2", title, body }])}\n-->`;
|
||||
const [f] = extractInlineBlock(block("A <!<!---->-- x --> B", "tail <!-- open")).findings;
|
||||
assert.equal(f.title.includes("<!--"), false);
|
||||
assert.equal(f.body.includes("<!--"), false);
|
||||
@@ -86,25 +86,74 @@ test("a missing or malformed inline block leaves the summary intact and yields n
|
||||
assert.match(bad.error, /not valid JSON/);
|
||||
});
|
||||
|
||||
test("only critical/warning findings on valid anchors are selected, critical first, capped", () => {
|
||||
test("an entry that cannot be counted leaves the count unknown, and the readable ones still post", () => {
|
||||
const good = { file: "src/lib.rs", line: 11, severity: "p2", title: "Bug" };
|
||||
const block = entries => `<!-- ai-review-inline ${JSON.stringify(entries)} -->`;
|
||||
const uncountable = [
|
||||
{ file: "src/lib.rs", line: 11, severity: "p1", description: "no title" },
|
||||
{ title: "", severity: "p1" },
|
||||
{ title: "no severity" },
|
||||
{ title: "bad grade", severity: "blocker" },
|
||||
"p1",
|
||||
null,
|
||||
];
|
||||
for (const entry of uncountable) {
|
||||
const out = extractInlineBlock(block([good, entry]));
|
||||
assert.match(out.error, /cannot be counted/, JSON.stringify(entry));
|
||||
assert.ok(out.findings.some(f => f.title === "Bug"), JSON.stringify(entry));
|
||||
}
|
||||
assert.equal(extractInlineBlock(block([{ ...good, severity: "Critical" }])).error, null);
|
||||
assert.equal(extractInlineBlock(block([])).error, null);
|
||||
});
|
||||
|
||||
test("a second inline block is never read, so it leaves the count unknown", () => {
|
||||
const out = extractInlineBlock(`<!-- ai-review-inline [] -->\n<!-- ai-review-inline [{"title":"x","severity":"p0"}] -->`);
|
||||
assert.match(out.error, /cannot be counted/);
|
||||
assert.equal(out.text.includes("ai-review-inline"), false);
|
||||
});
|
||||
|
||||
test("an unterminated opener leaves the count unknown wherever it sits, and its text stays readable", () => {
|
||||
const dangling = `<!-- ai-review-inline [{"title":"truncated blocker","severity":"p1"}]`;
|
||||
for (const [text, findings] of [[`Text\n<!-- ai-review-inline [] -->\n${dangling}`, 0], [dangling, 0], [`${dangling}\n${dangling}`, 0]]) {
|
||||
const out = extractInlineBlock(text);
|
||||
assert.ok(out.error, text);
|
||||
assert.equal(out.findings.length, findings);
|
||||
assert.equal(out.text.includes("<!--"), false, text);
|
||||
assert.ok(out.text.includes("truncated blocker"), text);
|
||||
}
|
||||
});
|
||||
|
||||
test("only p0, p1 and p2 findings on valid anchors are selected, most severe first, capped", () => {
|
||||
const lines = rightSideLines(DIFF);
|
||||
const f = (severity, file, line, title) => ({ severity, file, line, title, body: "" });
|
||||
const { selected, dropped } = selectInlineComments([
|
||||
f("warning", "src/lib.rs", 12, "warn"),
|
||||
f("critical", "src/lib.rs", 11, "crit"),
|
||||
f("suggestion", "src/lib.rs", 11, "nit"),
|
||||
f("critical", "src/lib.rs", 20, "outside the hunk"),
|
||||
f("critical", "other.rs", 1, "file not in diff"),
|
||||
f("warning", "src/lib.rs", 0, "no line"),
|
||||
f("warning", "src/lib.rs", 13, "Crit!"),
|
||||
f("warning", "src/lib.rs", 13, "crit"),
|
||||
f("warning", "new.ts", 1, "over the cap"),
|
||||
f("p2", "src/lib.rs", 12, "warn"),
|
||||
f("p0", "src/lib.rs", 11, "crit"),
|
||||
f("p3", "src/lib.rs", 11, "nit"),
|
||||
f("p0", "src/lib.rs", 20, "outside the hunk"),
|
||||
f("p0", "other.rs", 1, "file not in diff"),
|
||||
f("p2", "src/lib.rs", 0, "no line"),
|
||||
f("p2", "src/lib.rs", 13, "Crit!"),
|
||||
f("p2", "src/lib.rs", 13, "crit"),
|
||||
f("p2", "new.ts", 1, "over the cap"),
|
||||
], lines, new Set(), 2);
|
||||
assert.deepEqual(selected.map(s => s.title), ["crit", "warn"]);
|
||||
assert.equal(selected[0].fingerprint, fingerprint("src/lib.rs", "crit"));
|
||||
assert.deepEqual(dropped, { severity: 1, anchor: 3, duplicate: 2, cap: 1 });
|
||||
});
|
||||
|
||||
test("p1 sorts between p0 and p2 and parsed legacy severities reach the right threads", () => {
|
||||
const block = `<!-- ai-review-inline\n${JSON.stringify([
|
||||
{ file: "src/lib.rs", line: 11, severity: "warning", title: "two" },
|
||||
{ file: "src/lib.rs", line: 12, severity: "P1", title: "one" },
|
||||
{ file: "src/lib.rs", line: 13, severity: "suggestion", title: "three" },
|
||||
{ file: "src/lib.rs", line: 14, severity: "critical", title: "zero" },
|
||||
])}\n-->`;
|
||||
const { selected, dropped } = selectInlineComments(extractInlineBlock(block).findings, rightSideLines(DIFF));
|
||||
assert.deepEqual(selected.map(s => [s.title, s.severity]), [["zero", "p0"], ["one", "p1"], ["two", "p2"]]);
|
||||
assert.equal(dropped.severity, 1);
|
||||
});
|
||||
|
||||
test("fingerprints already posted by the bot are skipped; other authors are ignored", () => {
|
||||
const fp = fingerprint("src/lib.rs", "crit");
|
||||
const comments = [
|
||||
@@ -115,20 +164,20 @@ test("fingerprints already posted by the bot are skipped; other authors are igno
|
||||
assert.deepEqual([...posted], [fp]);
|
||||
assert.equal(existingFingerprints(comments, "").size, 0);
|
||||
const { selected, dropped } = selectInlineComments([
|
||||
{ severity: "critical", file: "src/lib.rs", line: 11, title: " CRIT. ", body: "" },
|
||||
{ severity: "warning", file: "src/lib.rs", line: 12, title: "warn", body: "" },
|
||||
{ severity: "p0", file: "src/lib.rs", line: 11, title: " CRIT. ", body: "" },
|
||||
{ severity: "p2", file: "src/lib.rs", line: 12, title: "warn", body: "" },
|
||||
], rightSideLines(DIFF), posted);
|
||||
assert.deepEqual(selected.map(s => s.title), ["warn"]);
|
||||
assert.equal(dropped.duplicate, 1);
|
||||
});
|
||||
|
||||
test("the review payload comments only, on the RIGHT side, with the fingerprint after the scrubbed text", () => {
|
||||
const selected = [{ severity: "critical", category: "Security", file: "src/lib.rs", line: 11, title: "t", body: "b", fingerprint: "a".repeat(40) }];
|
||||
const selected = [{ severity: "p0", category: "Security", file: "src/lib.rs", line: 11, title: "t", body: "b", fingerprint: "a".repeat(40) }];
|
||||
const payload = buildReviewPayload(selected, "abc123", "summary", text => text.toUpperCase());
|
||||
assert.equal(payload.event, "COMMENT");
|
||||
assert.equal(payload.commit_id, "abc123");
|
||||
assert.deepEqual(Object.keys(payload.comments[0]), ["path", "line", "side", "body"]);
|
||||
assert.equal(payload.comments[0].side, "RIGHT");
|
||||
assert.equal(payload.comments[0].body, `**🔴 BLOCKER · SECURITY:** T\n\nB\n\n<!-- ai-review-fp:${"a".repeat(40)} -->`);
|
||||
assert.equal(payload.comments[0].body, `**🔴 P0 · SECURITY:** T\n\nB\n\n<!-- ai-review-fp:${"a".repeat(40)} -->`);
|
||||
assert.equal("commit_id" in buildReviewPayload(selected, "", "s"), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { isPlaceholderComment, sanitizeReviewBody } from "../src/comments.mjs";
|
||||
|
||||
const POINTER = "<!-- ai-code-review -->\n<!-- o2-paired-pointer -->\n## 🔎 OpenObserve Code Review";
|
||||
|
||||
test("a finished review quoting the pointer or notice tag is not a pointer", () => {
|
||||
const SHA = "<!-- o2-review-sha:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -->";
|
||||
const quoting = [
|
||||
`<!-- ai-code-review -->\n## 🔎 OpenObserve Code Review\n\n- \`a.sh:3\` **[Correctness]** The script writes \`<!-- o2-paired-pointer -->\` twice.\n${SHA}`,
|
||||
`<!-- ai-code-review -->\n## 🔎 OpenObserve Code Review\n\n<!-- o2-paired-pointer -->\n${SHA}`,
|
||||
`<!-- ai-code-review -->\n## 🔎 OpenObserve Code Review\n\nQuoted: <!-- o2-review-notice -->`,
|
||||
`<!-- ai-code-review -->\n<!-- o2-paired-pointer -->\n## 🔎 OpenObserve Code Review\n\nAll good.\n${SHA}`,
|
||||
];
|
||||
for (const body of quoting) {
|
||||
assert.equal(isPlaceholderComment(body), false, body);
|
||||
}
|
||||
const notice = "<!-- ai-code-review -->\n<!-- o2-review-notice -->\n## 🔎 OpenObserve Code Review\n\n> [!NOTE]\n> Review in progress.\n<!-- o2-review-pass:2 -->";
|
||||
assert.equal(isPlaceholderComment(notice), true);
|
||||
assert.equal(isPlaceholderComment(`${POINTER}\n\n> [!NOTE]\n> paired\n<!-- o2-review-pass:1 -->`), true);
|
||||
});
|
||||
|
||||
test("model-written review text can never carry the pointer or notice tag", () => {
|
||||
const ids = { marker: "<!-- ai-code-review -->", providerId: "zz-provider", modelId: "zz-model", env: {} };
|
||||
const model = "<!-- o2-paired-pointer -->\n<!-- o2-review-notice -->\n## 🔎 OpenObserve Code Review\n\n- Quotes `<!-- o2-paired-pointer -->` and ```\n<!--o2-paired-pointer-->\n```";
|
||||
const text = sanitizeReviewBody(model, ids).text;
|
||||
assert.equal(/<!--\s*o2-paired-pointer/.test(text), false, text);
|
||||
assert.equal(/<!--\s*o2-review-notice/.test(text), false, text);
|
||||
assert.equal(isPlaceholderComment(text), false);
|
||||
});
|
||||
|
||||
test("a failed or skipped paired lookup always reviews here, whatever pointer the PR carries", () => {
|
||||
const workflow = readFileSync(new URL("../../../.github/workflows/ai-code-review.yml", import.meta.url), "utf8");
|
||||
const step = workflow.slice(workflow.indexOf("- name: Defer to a paired o2-enterprise PR"), workflow.indexOf("- name: Check out PR head"));
|
||||
assert.equal(step.includes("o2-paired-pointer"), false);
|
||||
assert.equal(step.includes("newest-review-is-pointer"), false);
|
||||
assert.match(step, /if \[ -z "\$ENT_TOKEN" \]; then\n\s*review_here "/);
|
||||
assert.match(step, /--jq [^\n]*\)"; then\n\s*review_here "/);
|
||||
assert.match(step, /-f state=open/);
|
||||
const fn = step.slice(step.indexOf("review_here() {"), step.indexOf("}", step.indexOf("review_here() {")));
|
||||
assert.equal(fn.includes("REVIEW_RUN=false"), false);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mergeConfidence, unfinishedReviewers } from "../src/confidence.mjs";
|
||||
import { reviewerFacts } from "../src/auto-approve.mjs";
|
||||
import {
|
||||
DEFAULT_COLD_PASSES,
|
||||
lostPassesNote,
|
||||
mergePassResults,
|
||||
parseColdPasses,
|
||||
passLabel,
|
||||
passPlan,
|
||||
runPooled,
|
||||
} from "../src/passes.mjs";
|
||||
import { buildFallbackReview, partialFindingsBlock, selectFallbackFindings } from "../src/report.mjs";
|
||||
|
||||
const finding = (summary, { severity = "p1", file = "src/a.rs", line = "10", category = "correctness" } = {}) =>
|
||||
({ category, severity, file, line, summary, description: "d", suggestion: "" });
|
||||
const done = (agentKey, pass, findings = []) => ({ agentKey, agentName: `${agentKey} Reviewer`, pass, findings, parsed: true, error: null, fullDiff: true });
|
||||
const failed = (agentKey, pass, extra = {}) => ({ agentKey, agentName: `${agentKey} Reviewer`, pass, findings: [], error: "boom", ...extra });
|
||||
|
||||
test("the cold pass count defaults to 2 and is clamped to 1..3", () => {
|
||||
const table = [
|
||||
[undefined, 2], [null, 2], ["", 2], [" ", 2], ["abc", 2], ["2.5", 2], ["1e2", 2],
|
||||
["1", 1], ["2", 2], ["3", 3], [" 3 ", 3], ["0", 1], ["-4", 1], ["9", 3], [3, 3],
|
||||
];
|
||||
for (const [raw, expected] of table) assert.equal(parseColdPasses(raw), expected, String(raw));
|
||||
assert.equal(DEFAULT_COLD_PASSES, 2);
|
||||
});
|
||||
|
||||
test("the plan runs every area's first pass before any second pass, and labels name the pass", () => {
|
||||
assert.deepEqual(passPlan(["security", "correctness"], 2), [
|
||||
{ agentKey: "security", pass: 1 }, { agentKey: "correctness", pass: 1 },
|
||||
{ agentKey: "security", pass: 2 }, { agentKey: "correctness", pass: 2 },
|
||||
]);
|
||||
assert.equal(passLabel("Security Reviewer", 1, 2), "Security Reviewer, pass 1/2");
|
||||
assert.equal(passLabel("Security Reviewer", 1, 1), "Security Reviewer");
|
||||
});
|
||||
|
||||
test("the pool never runs more than its limit at once and keeps results in plan order", async () => {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const results = await runPooled([5, 1, 3, 2, 4], 2, async n => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise(r => setTimeout(r, n));
|
||||
active -= 1;
|
||||
if (n === 3) throw new Error("three");
|
||||
return n * 10;
|
||||
});
|
||||
assert.equal(peak, 2);
|
||||
assert.deepEqual(results.map(r => r.status === "fulfilled" ? r.value : r.reason.message), [50, 10, "three", 20, 40]);
|
||||
});
|
||||
|
||||
test("an area is finished when any of its passes finished, and unfinished only when every pass failed", () => {
|
||||
const results = mergePassResults([
|
||||
done("security", 1), failed("correctness", 1), failed("quality", 1, { timedOut: true }),
|
||||
failed("security", 2, { timedOut: true }), done("correctness", 2), failed("quality", 2, { timedOut: true }),
|
||||
]);
|
||||
assert.deepEqual(results.map(r => r.agentKey), ["security", "correctness", "quality"]);
|
||||
assert.deepEqual(unfinishedReviewers(results), ["quality Reviewer"]);
|
||||
assert.deepEqual(results.map(r => [r.passes, r.passesFinished]), [[2, 1], [2, 1], [2, 0]]);
|
||||
const quality = results[2];
|
||||
assert.equal(quality.error, "boom");
|
||||
assert.equal(quality.timedOut, true);
|
||||
assert.deepEqual(reviewerFacts(results).map(f => f.finished), [true, true, false]);
|
||||
assert.equal(mergeConfidence({ failedReviewers: unfinishedReviewers(results.slice(0, 2)), ranCount: 2 }).level, "high");
|
||||
});
|
||||
|
||||
test("an area whose passes were all skipped stays skipped, not failed", () => {
|
||||
const skip = pass => ({ agentKey: "qa", agentName: "QA / Test Reviewer", pass, findings: [], error: null, skipped: true });
|
||||
const [qa] = mergePassResults([skip(1), skip(2)]);
|
||||
assert.equal(qa.skipped, true);
|
||||
assert.equal(lostPassesNote([qa]), "");
|
||||
});
|
||||
|
||||
test("a lost pass is named while its area still counts as reviewed", () => {
|
||||
const results = mergePassResults([done("security", 1), failed("security", 2), done("correctness", 1), done("correctness", 2)]);
|
||||
assert.equal(lostPassesNote(results), "> ℹ️ Some reviewer passes did not finish (security: 1 of 2); those areas were still reviewed by the passes that finished.");
|
||||
assert.equal(lostPassesNote(mergePassResults([done("security", 1)])), "");
|
||||
});
|
||||
|
||||
test("the fallback unions both passes and merges the same issue worded differently nearby", () => {
|
||||
const results = mergePassResults([
|
||||
done("correctness", 1, [
|
||||
finding("Unbounded retry loop never gives up on 429", { severity: "p2", line: "40" }),
|
||||
finding("Stream name is not escaped in the SQL filter", { line: "12" }),
|
||||
]),
|
||||
done("correctness", 2, [
|
||||
finding("Retry loop on 429 is unbounded", { severity: "p1", line: "42" }),
|
||||
finding("Stream name interpolated into the SQL filter without escaping", { severity: "p2", line: "10" }),
|
||||
finding("Missing index on created_at", { severity: "p2", file: "src/b.rs", line: "5" }),
|
||||
]),
|
||||
]);
|
||||
const kept = selectFallbackFindings(results);
|
||||
assert.equal(kept.length, 3);
|
||||
const retry = kept.find(f => /retry/i.test(f.summary));
|
||||
assert.equal(retry.severity, "p1");
|
||||
assert.equal(kept.find(f => /stream name/i.test(f.summary)).severity, "p1");
|
||||
assert.ok(kept.some(f => f.file === "src/b.rs"));
|
||||
const review = buildFallbackReview(results, "full", [], "<!-- m -->");
|
||||
assert.match(review, /5 raw findings → 3 unique/);
|
||||
assert.match(partialFindingsBlock(results), /Findings so far \(3\)/);
|
||||
});
|
||||
|
||||
test("the fallback keeps distinct issues apart even at the same place", () => {
|
||||
const results = mergePassResults([
|
||||
done("correctness", 1, [finding("Off-by-one in page offset", { line: "10" })]),
|
||||
done("correctness", 2, [
|
||||
finding("Lock held across await in flush", { line: "11" }),
|
||||
finding("Page offset is off by one", { line: "12", file: "src/other.rs" }),
|
||||
finding("Page offset off-by-one", { line: "50" }),
|
||||
]),
|
||||
]);
|
||||
assert.equal(selectFallbackFindings(results).length, 4);
|
||||
});
|
||||
|
||||
test("the fallback never merges two different blockers that share most of their title", () => {
|
||||
const results = mergePassResults([
|
||||
done("correctness", 1, [finding("Delete query ignores the organization filter", { line: "40" })]),
|
||||
done("correctness", 2, [finding("Delete query ignores the stream filter", { line: "42" })]),
|
||||
]);
|
||||
assert.equal(selectFallbackFindings(results).length, 2);
|
||||
const minor = mergePassResults([
|
||||
done("correctness", 1, [finding("Delete query ignores the organization filter", { severity: "p2", line: "40" })]),
|
||||
done("correctness", 2, [finding("Delete query ignores the stream filter", { severity: "p3", line: "42" })]),
|
||||
]);
|
||||
assert.equal(selectFallbackFindings(minor).length, 2);
|
||||
const reworded = mergePassResults([
|
||||
done("correctness", 1, [finding("Retry loop on 429 is unbounded", { line: "40" })]),
|
||||
done("correctness", 2, [finding("Unbounded retry loop never gives up on 429", { line: "42" })]),
|
||||
]);
|
||||
assert.equal(selectFallbackFindings(reworded).length, 2);
|
||||
const same = mergePassResults([
|
||||
done("correctness", 1, [finding("Delete query ignores the stream filter.", { line: "40", category: "security" })]),
|
||||
done("correctness", 2, [finding("delete query ignores the stream filter", { line: "42" })]),
|
||||
]);
|
||||
assert.equal(selectFallbackFindings(same).length, 1);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { ANY_REVIEW_MARKER, isPlaceholderComment, selectOwnReviewComment } from "../src/comments.mjs";
|
||||
|
||||
const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), "../post-paired-pointer.sh");
|
||||
const MARKER = "<!-- ai-code-review -->";
|
||||
const BOT = "o2-reviewer[bot]";
|
||||
@@ -46,3 +48,29 @@ test("the pointer keeps the review count of the comment it replaces", t => {
|
||||
assert.match(t.body, /<!-- o2-paired-pointer -->/);
|
||||
assert.match(t.body, /<!-- o2-review-pass:2 -->$/);
|
||||
});
|
||||
|
||||
test("the pointer replaces the same comment the runner and the fallback reader pick, whatever marker it carries", t => {
|
||||
const retired = "<!-- ai-code-review-claude -->";
|
||||
const pages = [
|
||||
[comment(1, BOT, `${MARKER}\nolder review`)],
|
||||
[comment(2, BOT, `${retired}\nnewer review\n<!-- o2-review-pass:3 -->`), comment(3, "mallory", MARKER)],
|
||||
];
|
||||
const calls = runWithFakeGh(t, pages);
|
||||
const selected = selectOwnReviewComment(pages.flat(), BOT, ANY_REVIEW_MARKER);
|
||||
assert.equal(calls, `PATCH repos/o/r/issues/comments/${selected.id}`);
|
||||
assert.match(t.body, /<!-- o2-review-pass:3 -->$/);
|
||||
const after = pages.flat().map(c => (c.id === selected.id ? { ...c, body: t.body } : c));
|
||||
assert.equal(isPlaceholderComment(selectOwnReviewComment(after, BOT, ANY_REVIEW_MARKER).body), true);
|
||||
});
|
||||
|
||||
test("the pointer never adopts a comment when the bot login is empty", t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pointer-test-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
writeFileSync(join(dir, "gh"), `#!/usr/bin/env bash
|
||||
if [ "$2" = "-X" ]; then echo "$3 $4" >> "${dir}/calls.log"; exit 0; fi
|
||||
echo '${JSON.stringify([comment(1, "", MARKER)])}'
|
||||
`);
|
||||
chmodSync(join(dir, "gh"), 0o755);
|
||||
execFileSync("bash", [SCRIPT, "o/r", "7", MARKER, "o/e#9", "reviewed", ""], { env: { PATH: `${dir}:${process.env.PATH}` } });
|
||||
assert.equal(readFileSync(join(dir, "calls.log"), "utf-8").trim(), "POST repos/o/r/issues/7/comments");
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { fetchDiff, localDiff, prefixDiffPaths } from "../src/pr-diff.mjs";
|
||||
import { parseRawDiff, sizeReasons } from "../src/auto-approve.mjs";
|
||||
import { fetchDiff, fetchPinnedDiff, localDiff, pinDiff, prefixDiffPaths } from "../src/pr-diff.mjs";
|
||||
|
||||
const DIFF = [
|
||||
"diff --git a/src/lib.rs b/src/lib.rs",
|
||||
@@ -88,3 +89,113 @@ test("local diff ignores .gitattributes diff drivers and binary marking", t => {
|
||||
const out = localDiff(dir, base, head);
|
||||
assert.match(out, /\+fn a\(\) \{ evil\(\) \}/);
|
||||
});
|
||||
|
||||
function repoWithCommits(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pr-diff-pin-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" });
|
||||
git("init", "-q");
|
||||
git("config", "user.email", "t@example.com");
|
||||
git("config", "user.name", "t");
|
||||
const commit = content => {
|
||||
writeFileSync(join(dir, "a.rs"), content);
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-qm", "c");
|
||||
return git("rev-parse", "HEAD").trim();
|
||||
};
|
||||
return { dir, base: commit("fn a() {}\n"), head: commit("fn a() { b() }\n"), later: commit("fn a() { pushed_later() }\n") };
|
||||
}
|
||||
|
||||
test("a pinned diff is the diff of the named commits, not what the API returned", t => {
|
||||
const { dir, base, head } = repoWithCommits(t);
|
||||
const pin = pinDiff({ diff: "api diff of a newer push", prDir: dir, mergeBase: base, head });
|
||||
assert.equal(pin.pinned, true);
|
||||
assert.match(pin.diff, /\+fn a\(\) \{ b\(\) \}/);
|
||||
assert.equal(pin.diff.includes("pushed_later"), false);
|
||||
});
|
||||
|
||||
test("the size check accepts what the local diff produces", t => {
|
||||
const { dir, base, head } = repoWithCommits(t);
|
||||
const parsed = parseRawDiff(pinDiff({ diff: "", prDir: dir, mergeBase: base, head }).diff);
|
||||
assert.deepEqual(sizeReasons(parsed), []);
|
||||
assert.deepEqual([parsed.fileCount, parsed.changedLines], [1, 2]);
|
||||
});
|
||||
|
||||
test("a diff that cannot be pinned stays the API diff and says so", t => {
|
||||
const { dir, base, head } = repoWithCommits(t);
|
||||
const logs = [];
|
||||
const log = message => logs.push(message);
|
||||
const cases = [
|
||||
{ prDir: dir, mergeBase: "0".repeat(40), head },
|
||||
{ prDir: dir, mergeBase: "", head },
|
||||
{ prDir: "", mergeBase: base, head },
|
||||
{ prDir: dir, mergeBase: base, head: "" },
|
||||
];
|
||||
for (const change of cases) assert.deepEqual(pinDiff({ diff: "api", log, ...change }), { diff: "api", pinned: false });
|
||||
assert.equal(logs.length, cases.length);
|
||||
assert.deepEqual(pinDiff({ diff: "api of a newer push", prDir: dir, mergeBase: head, head }), { diff: "", pinned: true });
|
||||
});
|
||||
|
||||
test("a large diff from a newer push is replaced by the captured commit's diff, whatever its size", t => {
|
||||
const { dir, base, head } = repoWithCommits(t);
|
||||
const apiDiff = Array.from({ length: 500 }, (_, i) => `+pushed_later ${i}`).join("\n");
|
||||
const run = (cmd, args, options) => (cmd === "gh" ? apiDiff : execFileSync(cmd, args, { encoding: "utf-8", ...options }));
|
||||
const pin = fetchPinnedDiff({ repo: "o/r", prNumber: 1, prDir: dir, mergeBase: base, head, env: {}, run });
|
||||
assert.equal(pin.pinned, true);
|
||||
assert.equal(pin.diff.includes("pushed_later"), false);
|
||||
assert.match(pin.diff, /\+fn a\(\) \{ b\(\) \}/);
|
||||
});
|
||||
|
||||
test("a fetched diff with no local proof stays usable but unpinned", () => {
|
||||
const run = cmd => { if (cmd === "git") throw new Error("no such commit"); return "api"; };
|
||||
assert.deepEqual(fetchPinnedDiff({ repo: "o/r", prNumber: 1, prDir: "/pr", mergeBase: "b", head: "h", env: {}, run }), { diff: "api", pinned: false });
|
||||
assert.deepEqual(fetchPinnedDiff({ repo: "o/r", prNumber: 1, prDir: "", mergeBase: "", head: "", env: {}, run }), { diff: "api", pinned: false });
|
||||
});
|
||||
|
||||
function renameDiff(t, setup, rename) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pr-diff-rename-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" });
|
||||
git("init", "-q");
|
||||
git("config", "user.email", "t@example.com");
|
||||
git("config", "user.name", "t");
|
||||
writeFileSync(join(dir, "notes.md"), "a\n");
|
||||
setup(dir, git);
|
||||
git("add", "--", "notes.md");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-qm", "base");
|
||||
const base = git("rev-parse", "HEAD").trim();
|
||||
rename(dir, git);
|
||||
writeFileSync(join(dir, "notes.md"), "b\n");
|
||||
git("add", "--", "notes.md");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-qm", "head");
|
||||
return localDiff(dir, base, git("rev-parse", "HEAD").trim());
|
||||
}
|
||||
|
||||
test("a renamed symlink or submodule whose header carries no mode cannot pass as a plain file", t => {
|
||||
const symlink = renameDiff(t, (dir, git) => {
|
||||
symlinkSync("/etc/passwd", join(dir, "link"));
|
||||
git("add", "link");
|
||||
}, (_, git) => git("mv", "link", "moved"));
|
||||
const gitlink = renameDiff(
|
||||
t,
|
||||
(_, git) => git("update-index", "--add", "--cacheinfo", `160000,${"1".repeat(40)},vendor/x`),
|
||||
(_, git) => {
|
||||
git("update-index", "--force-remove", "vendor/x");
|
||||
git("update-index", "--add", "--cacheinfo", `160000,${"1".repeat(40)},vendor/y`);
|
||||
},
|
||||
);
|
||||
for (const diff of [symlink, gitlink]) {
|
||||
assert.match(diff, /^similarity index 100%$/m);
|
||||
assert.deepEqual(parseRawDiff(diff).problems, ["a renamed file's type could not be read"], diff);
|
||||
}
|
||||
const edited = renameDiff(t, (dir, git) => {
|
||||
writeFileSync(join(dir, "a.md"), "1\n2\n3\n4\n5\n6\n");
|
||||
git("add", "a.md");
|
||||
}, (dir, git) => {
|
||||
git("mv", "a.md", "b.md");
|
||||
writeFileSync(join(dir, "b.md"), "1\n2\n3\n4\n5\n7\n");
|
||||
git("add", "b.md");
|
||||
});
|
||||
assert.match(edited, /^index \S+ 100644$/m);
|
||||
assert.deepEqual(parseRawDiff(edited).problems, []);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,13 @@ use infra::table::scorers::ScorerType;
|
||||
use openobserve_core::llm_evaluations::scorers::schema_derivation::ExtraMetadataField;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::{
|
||||
PartialSchema, ToSchema,
|
||||
openapi::{
|
||||
RefOr,
|
||||
schema::{ObjectBuilder, OneOfBuilder, Schema, Type},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
@@ -73,13 +79,30 @@ pub struct RemoteScorerHeader {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, ToSchema)]
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ScorerCreateConfig {
|
||||
LlmJudge(LlmJudgeScorerRequest),
|
||||
Remote(RemoteScorerRequest),
|
||||
}
|
||||
|
||||
// Not derived: utoipa's allOf keeps additionalProperties:false and so forbids the tag.
|
||||
impl PartialSchema for ScorerCreateConfig {
|
||||
fn schema() -> RefOr<Schema> {
|
||||
OneOfBuilder::new()
|
||||
.item(tagged_variant("llm_judge", LlmJudgeScorerRequest::schema()))
|
||||
.item(tagged_variant("remote", RemoteScorerRequest::schema()))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSchema for ScorerCreateConfig {
|
||||
fn schemas(schemas: &mut Vec<(String, RefOr<Schema>)>) {
|
||||
LlmJudgeScorerRequest::schemas(schemas);
|
||||
RemoteScorerRequest::schemas(schemas);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, ToSchema)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LlmJudgeScorerRequest {
|
||||
@@ -104,13 +127,36 @@ pub struct RemoteScorerRequest {
|
||||
pub params: RemoteScorerParams,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, ToSchema)]
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ScorerUpdateConfig {
|
||||
LlmJudge(LlmJudgeScorerUpdateRequest),
|
||||
Remote(RemoteScorerUpdateRequest),
|
||||
}
|
||||
|
||||
// Not derived for the same reason as `ScorerCreateConfig`.
|
||||
impl PartialSchema for ScorerUpdateConfig {
|
||||
fn schema() -> RefOr<Schema> {
|
||||
OneOfBuilder::new()
|
||||
.item(tagged_variant(
|
||||
"llm_judge",
|
||||
LlmJudgeScorerUpdateRequest::schema(),
|
||||
))
|
||||
.item(tagged_variant(
|
||||
"remote",
|
||||
RemoteScorerUpdateRequest::schema(),
|
||||
))
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSchema for ScorerUpdateConfig {
|
||||
fn schemas(schemas: &mut Vec<(String, RefOr<Schema>)>) {
|
||||
LlmJudgeScorerUpdateRequest::schemas(schemas);
|
||||
RemoteScorerUpdateRequest::schemas(schemas);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, ToSchema)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LlmJudgeScorerUpdateRequest {
|
||||
@@ -139,6 +185,22 @@ fn params_to_value<T: Serialize>(params: T) -> Value {
|
||||
serde_json::to_value(params).unwrap_or_else(|_| serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Adds the serde `type` tag to a variant's own object schema.
|
||||
fn tagged_variant(tag: &str, variant: RefOr<Schema>) -> RefOr<Schema> {
|
||||
let RefOr::T(Schema::Object(mut object)) = variant else {
|
||||
return variant;
|
||||
};
|
||||
object.properties.insert(
|
||||
"type".to_string(),
|
||||
ObjectBuilder::new()
|
||||
.schema_type(Type::String)
|
||||
.enum_values(Some([tag]))
|
||||
.into(),
|
||||
);
|
||||
object.required.push("type".to_string());
|
||||
RefOr::T(Schema::Object(object))
|
||||
}
|
||||
|
||||
/// HTTP request body for creating a Scorer.
|
||||
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -452,6 +514,30 @@ impl From<Vec<infra::table::scorers::Scorer>> for ListScorerVersionsResponseBody
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Indexes of the `oneOf` variants a body satisfies on required keys, known keys and tag.
|
||||
fn matching_variants(schema: RefOr<Schema>, body: &Value) -> Vec<usize> {
|
||||
let schema = serde_json::to_value(schema).unwrap();
|
||||
let body = body.as_object().unwrap();
|
||||
let variants = schema["oneOf"].as_array().unwrap();
|
||||
(0..variants.len())
|
||||
.filter(|&i| {
|
||||
let variant = &variants[i];
|
||||
let properties = variant["properties"].as_object().unwrap();
|
||||
let closed = variant["additionalProperties"] == Value::Bool(false);
|
||||
let required = variant["required"].as_array().unwrap();
|
||||
let tag_ok = variant["properties"]["type"]["enum"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&body["type"]);
|
||||
tag_ok
|
||||
&& required
|
||||
.iter()
|
||||
.all(|k| body.contains_key(k.as_str().unwrap()))
|
||||
&& (!closed || body.keys().all(|k| properties.contains_key(k)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_scorer() -> infra::table::scorers::Scorer {
|
||||
infra::table::scorers::Scorer {
|
||||
id: "abc".to_string(),
|
||||
@@ -472,6 +558,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scorer_config_schemas_admit_exactly_the_bodies_serde_accepts() {
|
||||
let judge = serde_json::json!({
|
||||
"type": "llm_judge",
|
||||
"producesScoreConfigId": "scfg-entity-1",
|
||||
"template": "Judge {{input}}",
|
||||
"outputSchema": {"type": "object"},
|
||||
"params": {"provider_id": "p1"}
|
||||
});
|
||||
let remote = serde_json::json!({
|
||||
"type": "remote",
|
||||
"template": "{{output}}",
|
||||
"params": {"endpoint": "https://eval.example.com", "timeout_ms": null}
|
||||
});
|
||||
for (body, variant) in [(&judge, 0), (&remote, 1)] {
|
||||
serde_json::from_value::<ScorerCreateConfig>(body.clone()).unwrap();
|
||||
serde_json::from_value::<ScorerUpdateConfig>(body.clone()).unwrap();
|
||||
assert_eq!(
|
||||
matching_variants(ScorerCreateConfig::schema(), body),
|
||||
[variant]
|
||||
);
|
||||
assert_eq!(
|
||||
matching_variants(ScorerUpdateConfig::schema(), body),
|
||||
[variant]
|
||||
);
|
||||
}
|
||||
|
||||
let mut unknown = judge.clone();
|
||||
unknown["bogus"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<ScorerCreateConfig>(unknown.clone()).is_err());
|
||||
assert!(matching_variants(ScorerCreateConfig::schema(), &unknown).is_empty());
|
||||
|
||||
let mut mistagged = judge.clone();
|
||||
mistagged["type"] = serde_json::json!("remote");
|
||||
assert!(serde_json::from_value::<ScorerCreateConfig>(mistagged.clone()).is_err());
|
||||
assert!(matching_variants(ScorerCreateConfig::schema(), &mistagged).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scorer_response_body_from_entity() {
|
||||
let s = sample_scorer();
|
||||
|
||||
@@ -161,8 +161,8 @@ pub struct ListQuery {
|
||||
pub name: Option<String>,
|
||||
/// Exact filter on the toolset kind (`mcp`, `cli`, `skill`, `generic`).
|
||||
pub kind: Option<String>,
|
||||
/// Maximum number of results to return.
|
||||
pub limit: Option<i64>,
|
||||
/// Maximum number of results to return; a negative value is rejected with 400.
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -177,11 +177,12 @@ pub struct ListQuery {
|
||||
("org_id" = String, Path, description = "Organization name"),
|
||||
("name" = Option<String>, Query, description = "Substring filter on name (case-insensitive)"),
|
||||
("kind" = Option<String>, Query, description = "Exact filter on kind: mcp | cli | skill | generic"),
|
||||
("limit" = Option<i64>, Query, description = "Maximum number of results"),
|
||||
("limit" = Option<u64>, Query, description = "Maximum number of results"),
|
||||
),
|
||||
security(("Authorization" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of toolsets", content_type = "application/json", body = Object),
|
||||
(status = 400, description = "Invalid query parameters, such as a negative limit"),
|
||||
(status = 403, description = "Feature not supported", content_type = "application/json"),
|
||||
),
|
||||
extensions(("x-o2-ratelimit" = json!({"module": "AI", "operation": "list"})), ("x-o2-mcp" = json!({"enabled": false})))
|
||||
|
||||
@@ -21,9 +21,10 @@ pub mod meta;
|
||||
pub mod series;
|
||||
pub mod tag_values;
|
||||
|
||||
use arrow_schema::Schema;
|
||||
use axum::{Json, extract::rejection::JsonRejection, http::Method, response::Response};
|
||||
use config::{
|
||||
get_config,
|
||||
TIMESTAMP_COL_NAME, get_config,
|
||||
meta::{
|
||||
search::{Query, Request, RequestEncoding, Response as SearchResponse, SearchEventType},
|
||||
stream::StreamType,
|
||||
@@ -532,11 +533,7 @@ pub(crate) fn default_result_size() -> i64 {
|
||||
|
||||
pub(crate) async fn load_schema_field_names(org_id: &str, stream_name: &str) -> Vec<String> {
|
||||
match infra::schema::get(org_id, stream_name, StreamType::Profiles).await {
|
||||
Ok(schema) => schema
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|f| f.name().to_string())
|
||||
.collect(),
|
||||
Ok(schema) => queryable_field_names(&schema),
|
||||
Err(e) => {
|
||||
warn!("profiles schema lookup failed: {e}");
|
||||
Vec::new()
|
||||
@@ -544,6 +541,27 @@ pub(crate) async fn load_schema_field_names(org_id: &str, stream_name: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// On a UDS stream the other columns are packed into `_all`, so they hold no rows to filter on.
|
||||
fn queryable_field_names(schema: &Schema) -> Vec<String> {
|
||||
let defined_schema_fields = infra::schema::unwrap_stream_settings(schema)
|
||||
.map(|settings| settings.defined_schema_fields)
|
||||
.unwrap_or_default();
|
||||
let column_all = &get_config().common.column_all;
|
||||
// `_all` outlives a cleared UDS in the schema, so it is never a filter key
|
||||
schema
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|f| f.name())
|
||||
.filter(|name| {
|
||||
*name != column_all
|
||||
&& (defined_schema_fields.is_empty()
|
||||
|| *name == TIMESTAMP_COL_NAME
|
||||
|| defined_schema_fields.contains(name))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -718,4 +736,49 @@ mod tests {
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queryable_field_names_follow_the_user_defined_schema() {
|
||||
use arrow_schema::{DataType, Field};
|
||||
use config::meta::stream::StreamSettings;
|
||||
|
||||
let fields = [
|
||||
TIMESTAMP_COL_NAME,
|
||||
"stack",
|
||||
"value",
|
||||
"service_name",
|
||||
"k8s_pod_name",
|
||||
"k8s_node_name",
|
||||
"_all",
|
||||
]
|
||||
.map(|name| Field::new(name, DataType::Utf8, true));
|
||||
let settings = StreamSettings {
|
||||
defined_schema_fields: ["k8s_pod_name", "service_name", "stack", "value"]
|
||||
.map(str::to_string)
|
||||
.to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
let metadata = std::collections::HashMap::from([(
|
||||
"settings".to_string(),
|
||||
json::to_string(&settings).unwrap(),
|
||||
)]);
|
||||
|
||||
let cleared_uds = queryable_field_names(&Schema::new(fields.to_vec()));
|
||||
assert_eq!(cleared_uds.len(), fields.len() - 1);
|
||||
assert!(!cleared_uds.iter().any(|name| name == "_all"));
|
||||
|
||||
let uds = Schema::new_with_metadata(fields.to_vec(), metadata);
|
||||
let mut names = queryable_field_names(&uds);
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
TIMESTAMP_COL_NAME,
|
||||
"k8s_pod_name",
|
||||
"service_name",
|
||||
"stack",
|
||||
"value"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ impl StreamType {
|
||||
pub fn support_uds(&self) -> bool {
|
||||
matches!(
|
||||
*self,
|
||||
StreamType::Logs | StreamType::Metrics | StreamType::Traces
|
||||
StreamType::Logs | StreamType::Metrics | StreamType::Traces | StreamType::Profiles
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1734,7 +1734,7 @@ mod tests {
|
||||
assert!(StreamType::Metrics.support_uds());
|
||||
assert!(StreamType::Traces.support_uds());
|
||||
assert!(!StreamType::EnrichmentTables.support_uds());
|
||||
assert!(!StreamType::Profiles.support_uds());
|
||||
assert!(StreamType::Profiles.support_uds());
|
||||
assert!(!StreamType::Filelist.support_uds());
|
||||
assert!(!StreamType::Metadata.support_uds());
|
||||
assert!(!StreamType::Index.support_uds());
|
||||
|
||||
@@ -819,11 +819,7 @@ pub async fn get_uds_and_original_data_streams(
|
||||
);
|
||||
|
||||
if !stream_settings.defined_schema_fields.is_empty() {
|
||||
let mut fields =
|
||||
HashSet::<_>::from_iter(stream_settings.defined_schema_fields.iter().cloned());
|
||||
if !fields.contains(TIMESTAMP_COL_NAME) {
|
||||
fields.insert(TIMESTAMP_COL_NAME.to_string());
|
||||
}
|
||||
let fields = uds_pack_fields(&stream_settings.defined_schema_fields);
|
||||
user_defined_schema_map.insert(stream.stream_name.to_string(), Some(fields));
|
||||
} else {
|
||||
user_defined_schema_map.insert(stream.stream_name.to_string(), None);
|
||||
@@ -831,6 +827,34 @@ pub async fn get_uds_and_original_data_streams(
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields to repack a batch with when its own schema check turned the UDS on, else None.
|
||||
pub fn newly_enabled_uds_fields(
|
||||
had_uds: bool,
|
||||
schema: Option<&SchemaCache>,
|
||||
) -> Option<HashSet<String>> {
|
||||
if had_uds {
|
||||
return None;
|
||||
}
|
||||
let settings = infra::schema::unwrap_stream_settings(schema?.schema())?;
|
||||
if settings.defined_schema_fields.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut fields = uds_pack_fields(&settings.defined_schema_fields);
|
||||
// ingest adds these after packing, so a repack must not move them into `_all`
|
||||
fields.extend(settings.uds_internal_columns());
|
||||
Some(fields)
|
||||
}
|
||||
|
||||
/// Packs each record's columns outside `fields` into `_all`.
|
||||
pub fn repack_records<'a>(
|
||||
records: impl IntoIterator<Item = &'a mut Map<String, Value>>,
|
||||
fields: &HashSet<String>,
|
||||
) {
|
||||
for record in records {
|
||||
*record = refactor_map(std::mem::take(record), fields);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls the SnowflakeIdGenerator instance associated with this stream to generate a new i64 ID.
|
||||
pub fn generate_record_id(org_id: &str, stream_name: &str, stream_type: &StreamType) -> i64 {
|
||||
let key = format!("{org_id}/{stream_type}/{stream_name}");
|
||||
@@ -890,6 +914,12 @@ pub fn refactor_map(
|
||||
new_map
|
||||
}
|
||||
|
||||
fn uds_pack_fields(defined_schema_fields: &[String]) -> HashSet<String> {
|
||||
let mut fields = HashSet::<_>::from_iter(defined_schema_fields.iter().cloned());
|
||||
fields.insert(TIMESTAMP_COL_NAME.to_string());
|
||||
fields
|
||||
}
|
||||
|
||||
/// The downtime that silences a real-time firing, only when every ingested row is muted.
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn realtime_downtime(
|
||||
@@ -1077,6 +1107,53 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_newly_enabled_uds_fields_only_when_the_check_turned_the_uds_on() {
|
||||
let with_settings = |settings: &config::meta::stream::StreamSettings| {
|
||||
SchemaCache::new(arrow_schema::Schema::empty().with_metadata(
|
||||
std::collections::HashMap::from([(
|
||||
"settings".to_string(),
|
||||
json::to_string(settings).unwrap(),
|
||||
)]),
|
||||
))
|
||||
};
|
||||
let uds = config::meta::stream::StreamSettings {
|
||||
defined_schema_fields: vec!["foo".to_string()],
|
||||
store_original_data: true,
|
||||
..Default::default()
|
||||
};
|
||||
let uds_schema = with_settings(&uds);
|
||||
let no_uds_schema = with_settings(&Default::default());
|
||||
|
||||
let fields = newly_enabled_uds_fields(false, Some(&uds_schema)).unwrap();
|
||||
for name in [
|
||||
"foo",
|
||||
TIMESTAMP_COL_NAME,
|
||||
config::ID_COL_NAME,
|
||||
config::ORIGINAL_DATA_COL_NAME,
|
||||
] {
|
||||
assert!(fields.contains(name), "missing {name}");
|
||||
}
|
||||
assert!(newly_enabled_uds_fields(true, Some(&uds_schema)).is_none());
|
||||
assert!(newly_enabled_uds_fields(false, Some(&no_uds_schema)).is_none());
|
||||
assert!(newly_enabled_uds_fields(false, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repack_records_moves_columns_outside_the_uds_into_all() {
|
||||
let mut records = [json::json!({"foo": 1, "bar": "x"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()];
|
||||
let fields = HashSet::from(["foo".to_string()]);
|
||||
repack_records(records.iter_mut(), &fields);
|
||||
assert!(records[0].contains_key("foo") && !records[0].contains_key("bar"));
|
||||
let all = records[0][&get_config().common.column_all]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert!(all.contains("bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_the_fail_closed_refusal_is_recognised() {
|
||||
let reason = config::meta::self_reporting::redaction::fail_closed_rejection(
|
||||
|
||||
@@ -50,8 +50,9 @@ use crate::{
|
||||
alerts::alert::AlertExt,
|
||||
common::meta::stream::SchemaRecords,
|
||||
ingestion::{
|
||||
PartitionMemo, TriggerAlertData, evaluate_trigger, resolve_batch_schema,
|
||||
schema_records_to_entries, write_entries, write_entries_synced,
|
||||
PartitionMemo, TriggerAlertData, evaluate_trigger, newly_enabled_uds_fields,
|
||||
repack_records, resolve_batch_schema, schema_records_to_entries, write_entries,
|
||||
write_entries_synced,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -416,7 +417,7 @@ async fn write_logs(
|
||||
org_id: &str,
|
||||
stream_name: &str,
|
||||
status: &mut IngestionStatus,
|
||||
json_data: Vec<(i64, Map<String, Value>)>,
|
||||
mut json_data: Vec<(i64, Map<String, Value>)>,
|
||||
columnar: Option<columnar::JsonColumnar>,
|
||||
is_derived: bool,
|
||||
) -> Result<RequestStats> {
|
||||
@@ -482,7 +483,7 @@ async fn write_logs(
|
||||
// End get stream alert
|
||||
|
||||
// start check for schema
|
||||
let (schema_evolution, infer_schema) = resolve_batch_schema(
|
||||
let (mut schema_evolution, mut infer_schema) = resolve_batch_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Logs,
|
||||
@@ -492,6 +493,20 @@ async fn write_logs(
|
||||
has_uds,
|
||||
)
|
||||
.await?;
|
||||
// the check that turned the UDS on projected the schema, which would drop unpacked columns
|
||||
if let Some(fields) = newly_enabled_uds_fields(has_uds, stream_schema_map.get(stream_name)) {
|
||||
repack_records(json_data.iter_mut().map(|(_, record)| record), &fields);
|
||||
(schema_evolution, infer_schema) = resolve_batch_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Logs,
|
||||
&mut stream_schema_map,
|
||||
&json_data,
|
||||
is_derived,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// get schema
|
||||
let latest_schema = stream_schema_map
|
||||
|
||||
@@ -48,8 +48,8 @@ use crate::{
|
||||
common::meta::stream::SchemaRecords,
|
||||
ingestion::{
|
||||
PartitionMemo, TriggerAlertData, evaluate_trigger, get_stream_partition_keys,
|
||||
get_thread_id, inferred_column_type, refactor_map, schema_records_to_entries,
|
||||
write_entries,
|
||||
get_thread_id, inferred_column_type, newly_enabled_uds_fields, refactor_map,
|
||||
repack_records, schema_records_to_entries, write_entries,
|
||||
},
|
||||
pipeline::batch_execution::ExecutablePipeline,
|
||||
};
|
||||
@@ -204,48 +204,38 @@ pub(super) async fn run_pipelines<T: Clone>(
|
||||
(outputs, failures)
|
||||
}
|
||||
|
||||
/// The stream's schema once `records` are in it, as the write path keys and partitions by it.
|
||||
pub(super) async fn resolve_batch_schema(
|
||||
/// The stream's schema once `rows` are in it, as the write path keys and partitions by it.
|
||||
pub(super) async fn resolve_batch_schema<T>(
|
||||
org_id: &str,
|
||||
stream_name: &str,
|
||||
stream_schema_map: &mut HashMap<String, SchemaCache>,
|
||||
records: &[&json::Map<String, json::Value>],
|
||||
rows: &mut [T],
|
||||
record: fn(&mut T) -> &mut json::Map<String, json::Value>,
|
||||
min_timestamp: i64,
|
||||
has_uds: bool,
|
||||
) -> Result<(Arc<Schema>, String)> {
|
||||
check_columns_per_record(org_id, stream_name, records)?;
|
||||
// probed once per field of every record, so it takes foldhash rather than SipHash
|
||||
let schema_fields: hashbrown::HashMap<&str, &DataType> =
|
||||
match stream_schema_map.get(stream_name) {
|
||||
Some(schema) => schema
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|f| (f.name().as_str(), f.data_type()))
|
||||
.collect(),
|
||||
None => hashbrown::HashMap::default(),
|
||||
};
|
||||
let evolving: Vec<&json::Map<String, json::Value>> = records
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|record| record_evolves_schema(record, &schema_fields))
|
||||
.collect();
|
||||
drop(schema_fields);
|
||||
// a user-defined schema is applied by check_for_schema itself, so it cannot be skipped
|
||||
if !evolving.is_empty() || has_uds {
|
||||
let checked = if evolving.is_empty() {
|
||||
records.to_vec()
|
||||
} else {
|
||||
evolving
|
||||
};
|
||||
check_for_schema(
|
||||
let records: Vec<_> = rows.iter_mut().map(|row| &*record(row)).collect();
|
||||
check_metrics_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
stream_schema_map,
|
||||
&records,
|
||||
min_timestamp,
|
||||
has_uds,
|
||||
)
|
||||
.await?;
|
||||
drop(records);
|
||||
// the check that turned the UDS on projected the schema, which would drop unpacked columns
|
||||
if let Some(fields) = newly_enabled_uds_fields(has_uds, stream_schema_map.get(stream_name)) {
|
||||
repack_records(rows.iter_mut().map(record), &fields);
|
||||
let records: Vec<_> = rows.iter_mut().map(|row| &*record(row)).collect();
|
||||
check_metrics_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Metrics,
|
||||
stream_schema_map,
|
||||
checked,
|
||||
&records,
|
||||
min_timestamp,
|
||||
false, // is_derived is false for metrics
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -504,6 +494,54 @@ pub(super) fn spawn_triggers(stream_trigger_map: HashMap<String, Option<TriggerA
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the schema check for the records that can still change the stream schema.
|
||||
async fn check_metrics_schema(
|
||||
org_id: &str,
|
||||
stream_name: &str,
|
||||
stream_schema_map: &mut HashMap<String, SchemaCache>,
|
||||
records: &[&json::Map<String, json::Value>],
|
||||
min_timestamp: i64,
|
||||
has_uds: bool,
|
||||
) -> Result<()> {
|
||||
check_columns_per_record(org_id, stream_name, records)?;
|
||||
// probed once per field of every record, so it takes foldhash rather than SipHash
|
||||
let schema_fields: hashbrown::HashMap<&str, &DataType> =
|
||||
match stream_schema_map.get(stream_name) {
|
||||
Some(schema) => schema
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|f| (f.name().as_str(), f.data_type()))
|
||||
.collect(),
|
||||
None => hashbrown::HashMap::default(),
|
||||
};
|
||||
let evolving: Vec<&json::Map<String, json::Value>> = records
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|record| record_evolves_schema(record, &schema_fields))
|
||||
.collect();
|
||||
drop(schema_fields);
|
||||
// a user-defined schema is applied by check_for_schema itself, so it cannot be skipped
|
||||
if !evolving.is_empty() || has_uds {
|
||||
let checked = if evolving.is_empty() {
|
||||
records.to_vec()
|
||||
} else {
|
||||
evolving
|
||||
};
|
||||
check_for_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Metrics,
|
||||
stream_schema_map,
|
||||
checked,
|
||||
min_timestamp,
|
||||
false, // is_derived is false for metrics
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `check_for_schema` applies the column limit to the batch's merged schema, not to one record.
|
||||
fn check_columns_per_record(
|
||||
org_id: &str,
|
||||
|
||||
@@ -311,7 +311,7 @@ fn finish_rows(
|
||||
async fn buffer_stream_rows(
|
||||
org_id: &str,
|
||||
stream_name: &str,
|
||||
(rows, first_type): (Vec<Row>, Option<&'static str>),
|
||||
(mut rows, first_type): (Vec<Row>, Option<&'static str>),
|
||||
lookups: &mut StreamLookups,
|
||||
partitions: &mut HashMap<String, SchemaRecords>,
|
||||
) -> Result<Option<TriggerAlertData>> {
|
||||
@@ -340,18 +340,16 @@ async fn buffer_stream_rows(
|
||||
|
||||
let has_uds = matches!(lookups.user_defined_schemas.get(stream_name), Some(Some(_)));
|
||||
let min_timestamp = rows.iter().map(|(_, ts)| *ts).min().unwrap_or(0);
|
||||
let record_refs: Vec<&json::Map<String, json::Value>> =
|
||||
rows.iter().map(|(record, ..)| record).collect();
|
||||
let (schema, schema_key) = ingest::resolve_batch_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
&mut lookups.schemas,
|
||||
&record_refs,
|
||||
&mut rows,
|
||||
|(record, _)| record,
|
||||
min_timestamp,
|
||||
has_uds,
|
||||
)
|
||||
.await?;
|
||||
drop(record_refs);
|
||||
|
||||
let alerts = lookups
|
||||
.alerts
|
||||
|
||||
@@ -642,9 +642,11 @@ pub async fn handle_otlp_request(
|
||||
}
|
||||
json_data
|
||||
};
|
||||
let mut json_data = json_data;
|
||||
let record_refs: Vec<&json::Map<String, json::Value>> =
|
||||
json_data.iter().map(|(record, _)| record).collect();
|
||||
let min_timestamp = batch_min_timestamp(&record_refs, Utc::now().timestamp_micros());
|
||||
drop(record_refs);
|
||||
let has_uds = matches!(
|
||||
user_defined_schema_map.get(&local_metric_name),
|
||||
Some(Some(_))
|
||||
@@ -653,12 +655,12 @@ pub async fn handle_otlp_request(
|
||||
org_id,
|
||||
&local_metric_name,
|
||||
&mut metric_schema_map,
|
||||
&record_refs,
|
||||
&mut json_data,
|
||||
|(record, _)| record,
|
||||
min_timestamp,
|
||||
has_uds,
|
||||
)
|
||||
.await?;
|
||||
drop(record_refs);
|
||||
|
||||
let alerts = stream_alerts_map.get(&cache_stream_key(
|
||||
org_id,
|
||||
|
||||
@@ -663,18 +663,16 @@ pub async fn remote_write(
|
||||
finish_identity_columns(&mut json_data);
|
||||
let has_uds = matches!(user_defined_schema_map.get(&stream_name), Some(Some(_)));
|
||||
let min_timestamp = json_data.iter().map(|(_, ts, _)| *ts).min().unwrap_or(0);
|
||||
let record_refs: Vec<&json::Map<String, json::Value>> =
|
||||
json_data.iter().map(|(record, ..)| record).collect();
|
||||
let (schema, schema_key) = ingest::resolve_batch_schema(
|
||||
org_id,
|
||||
&stream_name,
|
||||
&mut metric_schema_map,
|
||||
&record_refs,
|
||||
&mut json_data,
|
||||
|(record, ..)| record,
|
||||
min_timestamp,
|
||||
has_uds,
|
||||
)
|
||||
.await?;
|
||||
drop(record_refs);
|
||||
|
||||
let alerts =
|
||||
stream_alerts_map.get(&cache_stream_key(org_id, StreamType::Metrics, &stream_name));
|
||||
|
||||
@@ -29,7 +29,11 @@ use axum::{http, response::Response as HttpResponse};
|
||||
use bytes::Bytes;
|
||||
use config::{
|
||||
TIMESTAMP_COL_NAME, get_config,
|
||||
meta::{otlp::OtlpRequestType, self_reporting::usage::UsageType, stream::StreamType},
|
||||
meta::{
|
||||
otlp::OtlpRequestType,
|
||||
self_reporting::usage::UsageType,
|
||||
stream::{StreamParams, StreamType},
|
||||
},
|
||||
metrics,
|
||||
utils::{
|
||||
json,
|
||||
@@ -60,8 +64,9 @@ use crate::{
|
||||
stream::SchemaRecords,
|
||||
},
|
||||
ingestion::{
|
||||
check_ingestion_allowed, get_stream_partition_keys, get_thread_id, get_write_partition_key,
|
||||
write_file,
|
||||
check_ingestion_allowed, get_stream_partition_keys, get_thread_id,
|
||||
get_uds_and_original_data_streams, get_write_partition_key, newly_enabled_uds_fields,
|
||||
repack_records, write_file,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -342,6 +347,22 @@ pub async fn handle_otlp_request(
|
||||
#[cfg(feature = "vectorscan")]
|
||||
apply_redaction(org_id, &stream_name, &mut all_records).await;
|
||||
|
||||
let mut user_defined_schema_map = HashMap::with_capacity(1);
|
||||
get_uds_and_original_data_streams(
|
||||
&[StreamParams::new(
|
||||
org_id,
|
||||
&stream_name,
|
||||
StreamType::Profiles,
|
||||
)],
|
||||
&mut user_defined_schema_map,
|
||||
&mut HashMap::new(),
|
||||
&mut HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
if let Some(Some(fields)) = user_defined_schema_map.get(&stream_name) {
|
||||
repack_records(all_records.iter_mut(), fields);
|
||||
}
|
||||
|
||||
if !all_records.is_empty() {
|
||||
buffer_records(
|
||||
org_id,
|
||||
@@ -396,7 +417,7 @@ pub async fn handle_otlp_request(
|
||||
async fn buffer_records(
|
||||
org_id: &str,
|
||||
stream_name: &str,
|
||||
records: Vec<json::Map<String, json::Value>>,
|
||||
mut records: Vec<json::Map<String, json::Value>>,
|
||||
partition_keys: &Vec<config::meta::stream::StreamPartition>,
|
||||
partition_time_level: config::meta::stream::PartitionTimeLevel,
|
||||
write_buf: &mut HashMap<String, SchemaRecords>,
|
||||
@@ -435,17 +456,34 @@ async fn buffer_records(
|
||||
stream_schema_map.insert(stream_name.to_string(), SchemaCache::new(schema));
|
||||
}
|
||||
|
||||
let record_refs: Vec<&json::Map<String, json::Value>> = records.iter().collect();
|
||||
let (_schema_evolution, _infer_schema) = check_for_schema(
|
||||
let had_uds = stream_schema_map
|
||||
.get(stream_name)
|
||||
.and_then(|schema| infra::schema::unwrap_stream_settings(schema.schema()))
|
||||
.is_some_and(|settings| !settings.defined_schema_fields.is_empty());
|
||||
check_for_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Profiles,
|
||||
stream_schema_map,
|
||||
record_refs,
|
||||
records.iter().collect(),
|
||||
min_timestamp,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
// the check that turned the UDS on projected the schema, which would drop unpacked columns
|
||||
if let Some(fields) = newly_enabled_uds_fields(had_uds, stream_schema_map.get(stream_name)) {
|
||||
repack_records(records.iter_mut(), &fields);
|
||||
check_for_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Profiles,
|
||||
stream_schema_map,
|
||||
records.iter().collect(),
|
||||
min_timestamp,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let schema = stream_schema_map
|
||||
.get(stream_name)
|
||||
|
||||
@@ -408,6 +408,27 @@ fn restore_derived_fields(
|
||||
}
|
||||
}
|
||||
|
||||
/// Packs a batch into a UDS its own schema check turned on, keeping what per-span packing keeps.
|
||||
fn repack_for_new_uds(json_data: &mut [(i64, Map<String, json::Value>)], fields: &HashSet<String>) {
|
||||
let mut fields = fields.clone();
|
||||
// per-span packing writes these after refactor_map, so they stay columns
|
||||
fields.extend(
|
||||
[
|
||||
"gen_ai_agent_name",
|
||||
"gen_ai_agent_id",
|
||||
"gen_ai_agent_env",
|
||||
"gen_ai_agent_version",
|
||||
O2_INGEST_TS_COL_NAME,
|
||||
]
|
||||
.map(str::to_string),
|
||||
);
|
||||
for (_, record_val) in json_data.iter_mut() {
|
||||
let saved_fields = save_derived_fields_for_uds(record_val, &fields);
|
||||
*record_val = crate::ingestion::refactor_map(std::mem::take(record_val), &fields);
|
||||
restore_derived_fields(record_val, saved_fields);
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived identity columns of one OTLP span; true when DBM stamped it.
|
||||
pub fn enrich_otlp_span(
|
||||
span_kind: i32,
|
||||
@@ -1849,7 +1870,7 @@ async fn write_traces_by_stream(
|
||||
async fn write_traces(
|
||||
org_id: &str,
|
||||
stream_name: &str,
|
||||
json_data: Vec<(i64, json::Map<String, json::Value>)>,
|
||||
mut json_data: Vec<(i64, json::Map<String, json::Value>)>,
|
||||
) -> Result<RequestStats, Error> {
|
||||
let cfg = get_config();
|
||||
// get schema and stream settings
|
||||
@@ -1908,6 +1929,23 @@ async fn write_traces(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
// the check that turned the UDS on projected the schema, which would drop unpacked columns
|
||||
if let Some(fields) =
|
||||
crate::ingestion::newly_enabled_uds_fields(has_uds, traces_schema_map.get(stream_name))
|
||||
{
|
||||
repack_for_new_uds(&mut json_data, &fields);
|
||||
check_batch_schema(
|
||||
org_id,
|
||||
stream_name,
|
||||
StreamType::Traces,
|
||||
&mut traces_schema_map,
|
||||
&json_data,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
}
|
||||
let record_schema = traces_schema_map
|
||||
.get(stream_name)
|
||||
.unwrap()
|
||||
@@ -3029,6 +3067,34 @@ mod tests {
|
||||
assert_eq!(record_val, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repack_for_new_uds_keeps_derived_and_agent_columns() {
|
||||
use config::utils::json;
|
||||
|
||||
let record = json::json!({
|
||||
"trace_id": "5b8efff798038103d269b633813fc60c",
|
||||
"o2_db_system": "postgresql",
|
||||
"infer_peer_key": "db:5432",
|
||||
"gen_ai_agent_name": "planner",
|
||||
"junk_attr": "packed",
|
||||
});
|
||||
let mut json_data = vec![(1, record.as_object().unwrap().clone())];
|
||||
let fields = std::collections::HashSet::from(["trace_id".to_string()]);
|
||||
|
||||
super::repack_for_new_uds(&mut json_data, &fields);
|
||||
|
||||
let record = &json_data[0].1;
|
||||
for name in [
|
||||
"trace_id",
|
||||
"o2_db_system",
|
||||
"infer_peer_key",
|
||||
"gen_ai_agent_name",
|
||||
] {
|
||||
assert!(record.contains_key(name), "missing {name}");
|
||||
}
|
||||
assert!(!record.contains_key("junk_attr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finalize_keeps_o2_db_fields_under_uds() {
|
||||
// A user-defined schema that doesn't list the o2_db_* columns must not
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
//! org's per-org DEK via [`cipher::get_dek`].
|
||||
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
SqlErr,
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, Order, QueryFilter, QueryOrder,
|
||||
QuerySelect, Set, SqlErr,
|
||||
};
|
||||
use svix_ksuid::{Ksuid, KsuidLike};
|
||||
|
||||
@@ -193,9 +193,17 @@ pub async fn get_by_name(org: &str, name: &str) -> Result<Option<OrgToolset>, er
|
||||
pub async fn list(
|
||||
org: &str,
|
||||
filter: ListFilter,
|
||||
limit: Option<i64>,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<OrgToolset>, errors::Error> {
|
||||
list_with(get_orm_client_ro().await, org, filter, limit).await
|
||||
}
|
||||
|
||||
pub async fn list_with<C: ConnectionTrait>(
|
||||
conn: &C,
|
||||
org: &str,
|
||||
filter: ListFilter,
|
||||
limit: Option<u64>,
|
||||
) -> Result<Vec<OrgToolset>, errors::Error> {
|
||||
let client = get_orm_client_ro().await;
|
||||
let mut query = Entity::find()
|
||||
.filter(Column::Org.eq(org))
|
||||
.order_by(Column::CreatedAt, Order::Desc);
|
||||
@@ -207,10 +215,11 @@ pub async fn list(
|
||||
query = query.filter(Column::Kind.eq(kind));
|
||||
}
|
||||
if let Some(n) = limit {
|
||||
query = query.limit(n as u64);
|
||||
// sqlx binds LIMIT as i64 and panics on anything larger.
|
||||
query = query.limit(n.min(i64::MAX as u64));
|
||||
}
|
||||
|
||||
let rows = query.all(client).await?;
|
||||
let rows = query.all(conn).await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(vec![]);
|
||||
@@ -280,6 +289,19 @@ mod tests {
|
||||
// ListFilter
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_with_an_out_of_range_limit_does_not_panic() {
|
||||
use sea_orm::{Database, Schema};
|
||||
|
||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||
let backend = db.get_database_backend();
|
||||
let stmt = Schema::new(backend).create_table_from_entity(Entity);
|
||||
db.execute(backend.build(&stmt)).await.unwrap();
|
||||
|
||||
let rows = list_with(&db, "org", ListFilter::default(), Some(u64::MAX)).await;
|
||||
assert!(rows.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_filter_default() {
|
||||
let f = ListFilter::default();
|
||||
|
||||
@@ -968,6 +968,20 @@ pub fn check_schema_for_defined_schema_fields(
|
||||
}
|
||||
}
|
||||
}
|
||||
StreamType::Profiles => {
|
||||
fields.insert("stack".to_string());
|
||||
fields.insert("value".to_string());
|
||||
fields.insert("profile_type".to_string());
|
||||
fields.insert("profile_unit".to_string());
|
||||
fields.insert("service_name".to_string());
|
||||
fields.insert("otel_scope_name".to_string());
|
||||
fields.insert("service_instance_id".to_string());
|
||||
fields.insert("host_name".to_string());
|
||||
fields.insert("process_pid".to_string());
|
||||
fields.insert("profile_duration_nanos".to_string());
|
||||
fields.insert("trace_id".to_string());
|
||||
fields.insert("span_id".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
fields
|
||||
@@ -1303,6 +1317,33 @@ mod tests {
|
||||
assert!(result.contains("my_field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_schema_for_defined_schema_fields_profiles_adds_query_columns() {
|
||||
let schema = Schema::new(vec![] as Vec<Field>);
|
||||
let input = vec!["k8s_pod_name".to_string()];
|
||||
let result = check_schema_for_defined_schema_fields(StreamType::Profiles, &schema, input);
|
||||
let mut fields = result.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
fields.sort_unstable();
|
||||
assert_eq!(
|
||||
fields,
|
||||
[
|
||||
"host_name",
|
||||
"k8s_pod_name",
|
||||
"otel_scope_name",
|
||||
"process_pid",
|
||||
"profile_duration_nanos",
|
||||
"profile_type",
|
||||
"profile_unit",
|
||||
"service_instance_id",
|
||||
"service_name",
|
||||
"span_id",
|
||||
"stack",
|
||||
"trace_id",
|
||||
"value",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_schema_for_defined_schema_fields_traces_includes_gen_ai_fields() {
|
||||
let schema = Schema::new(vec![
|
||||
|
||||
@@ -135,7 +135,7 @@ Fleet runs leave `fl_auto_*` orgs behind, because org delete is cloud-only. Most
|
||||
## Troubleshooting
|
||||
|
||||
- **`KeyError: 'ZO_BASE_URL'` on collection** — `conftest.py` reads env vars at import. Export them before running pytest (even `--collect-only`).
|
||||
- **On-call ack-token tests skip** — they read the paging mail to get the acknowledgement token, which exists only in the delivered message. Start Mailpit (`MAILPIT_BASE_URL`, default `http://127.0.0.1:8025`) and point the server at it with `ZO_SMTP_ENABLED=true ZO_SMTP_HOST=127.0.0.1 ZO_SMTP_PORT=1025`. CI does not start Mailpit, so these skip there by design.
|
||||
- **On-call ack-token tests skip locally** — they read the paging mail to get the acknowledgement token, which exists only in the delivered message. Start Mailpit with `mailpit --smtp 127.0.0.1:1025 --listen 127.0.0.1:8025 --smtp-allowed-recipients '(?i)@test\.invalid$'` (the filter makes mail to the reachability tests' other domain a real refusal), point the server at it with `ZO_SMTP_ENABLED=true ZO_SMTP_HOST=127.0.0.1 ZO_SMTP_PORT=1025 ZO_SMTP_FROM_EMAIL=oncall@ci.openobserve.test`, and set `MAILPIT_BASE_URL` if it is not `http://127.0.0.1:8025`. With no mailbox they skip locally and fail when `CI` is set. The enterprise `ent_oncall` shard starts Mailpit v1.21.8 on 1025/8025 with that recipient filter and starts the server with the same SMTP settings.
|
||||
- **`test_workflow.py` fails with SMTP error** — `ZO_SMTP_ENABLED=true` not set. CI does this; locally you'd need to wire an SMTP env.
|
||||
- **Test_organisations.py warning about `yield`** — `test_create_organization` uses pre-pytest-4 yield-style and is silently ignored. Scheduled for rewrite in Phase 4.
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@ template and the two Alert Destinations already provisioned, and a teardown
|
||||
sweep of every team, alert, ownership rule and user the module registered on it.
|
||||
|
||||
Everything here is **enterprise-gated**: `_require_oncall` skips the whole
|
||||
directory when on-call is not served, so an OSS build reports skips rather than
|
||||
a wall of 404s.
|
||||
directory on an OSS build, so it reports skips rather than a wall of 404s, and
|
||||
FAILS on an enterprise build with on-call off rather than skipping green.
|
||||
|
||||
It decides that by PROBING `GET {org}/oncall/teams` — 200/403 served, 404 absent
|
||||
— not by reading `oncall_enabled` off `/config`. That key is what the original
|
||||
issue quoted, and a current enterprise build does not publish it on the public
|
||||
`/config`, so gating on it skipped all 104 tests against a server that was
|
||||
serving on-call perfectly well. See `oncall_helpers.oncall_enabled`.
|
||||
It decides that by PROBING `GET {org}/oncall/teams` — 200/403 served, 404 not —
|
||||
and on a 404 reading `build_type` off the public `/config`. No on-call flag is
|
||||
published there, so gating on one skipped all 104 tests against a server that
|
||||
was serving on-call perfectly well. See `oncall_helpers.oncall_enabled`.
|
||||
|
||||
Scope is `module`, not `function`: a page only exists once the alert scheduler
|
||||
has evaluated a rule, which costs the better part of a minute, and a per-test
|
||||
@@ -46,14 +45,14 @@ def oncall_available(client: OpenObserveClient, oncall_org: str) -> bool:
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _require_oncall(oncall_available: bool) -> None:
|
||||
"""Skip cleanly rather than fail when the feature is not built or not on.
|
||||
"""Skip cleanly on an OSS build; `oncall_enabled` raises on an enterprise build with on-call off.
|
||||
|
||||
Session-scoped on purpose: a function-scoped gate is set up AFTER the
|
||||
module-scoped fixtures it is meant to protect, so an OSS build would seed
|
||||
teams and wait on pages that can never arrive before ever reaching the skip.
|
||||
"""
|
||||
if not oncall_available:
|
||||
pytest.skip("this build does not serve the on-call routes")
|
||||
pytest.skip("on-call is enterprise-only and this is an OSS build")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
@@ -12,11 +12,12 @@ the behaviour under test.
|
||||
|
||||
**Enterprise-gated.** Every route here exists only when `O2_ONCALL_ENABLED` is
|
||||
on in an enterprise build; the router does not even register them otherwise, so
|
||||
an OSS instance answers 404 rather than 403. `oncall_enabled()` probes
|
||||
`GET {org}/oncall/teams` and skips on a 404. It does NOT read a `/config` flag:
|
||||
a current enterprise build does not publish `config.oncall_enabled` at all, and
|
||||
gating on it skipped all 104 tests against a server that was serving on-call
|
||||
perfectly well. Any status other than 200/403/404 raises rather than skips.
|
||||
the probe `GET {org}/oncall/teams` answers 404 rather than 403. `oncall_enabled()`
|
||||
skips on that 404 only when the public `/config` reports `build_type:
|
||||
opensource`; on an enterprise build the 404 means the flag is off (the default
|
||||
since o2-enterprise#2748), and it raises, because skipping there hid the whole
|
||||
suite in CI. It does NOT read an on-call flag off `/config`: none is published.
|
||||
Any status other than 200/403/404 raises rather than skips.
|
||||
|
||||
Environment the suite reads:
|
||||
|
||||
@@ -25,11 +26,15 @@ Environment the suite reads:
|
||||
* `MAILPIT_BASE_URL` (default `http://127.0.0.1:8025`) — where to read the
|
||||
paging mail. An acknowledgement token is minted per person and exists ONLY
|
||||
inside the delivered mail: no API exposes it. So without a reachable
|
||||
mailbox the ack-token cases cannot run, and they SKIP with that reason
|
||||
rather than passing emptily. Nothing starts Mailpit in CI today, so those
|
||||
cases are a known, visible coverage gap rather than an enforced one; run
|
||||
them locally with `mailpit` on its default ports and the server pointed at
|
||||
it (`ZO_SMTP_ENABLED=true ZO_SMTP_HOST=127.0.0.1 ZO_SMTP_PORT=1025`).
|
||||
mailbox the ack-token cases cannot run: they SKIP locally and FAIL when
|
||||
`CI` is set (`skip_or_fail_in_ci`), because the enterprise `ent_oncall` API
|
||||
shard starts Mailpit, so a missing mailbox there is a broken job. That
|
||||
Mailpit accepts only `test.invalid` recipients and refuses `MAILBOX_DOMAIN`,
|
||||
which is how §7 gets real delivery failures beside real deliveries. Locally:
|
||||
`mailpit --smtp-allowed-recipients '(?i)@test\\.invalid$'` on its default
|
||||
ports and the server pointed at it (`ZO_SMTP_ENABLED=true
|
||||
ZO_SMTP_HOST=127.0.0.1 ZO_SMTP_PORT=1025 ZO_SMTP_FROM_EMAIL=<any address>`;
|
||||
without a From address every send fails "Invalid input").
|
||||
|
||||
Org addressing: on a multi-tenant deployment an org is addressed by its
|
||||
**identifier** (a ksuid), not by its display name. `resolve_org` maps whichever
|
||||
@@ -60,8 +65,10 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from typing import Any, NoReturn
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from support.client import OpenObserveClient
|
||||
@@ -116,6 +123,9 @@ SINK_DEST = f"{PREFIX}_dest_sink"
|
||||
DEAD_DEST = f"{PREFIX}_dest_dead"
|
||||
DEAD_URL = "http://127.0.0.1:1/never-listens"
|
||||
|
||||
# Not IANA-reserved, so reachability counts its failures; it has no MX or A record, and the ent_oncall CI Mailpit refuses it.
|
||||
MAILBOX_DOMAIN = "oncall-pt.openobserve.ai"
|
||||
|
||||
|
||||
def uniq(prefix: str = PREFIX) -> str:
|
||||
"""A unique, human-readable name. uuid4, not a timestamp: two xdist workers
|
||||
@@ -149,30 +159,47 @@ def resolve_org(client: OpenObserveClient, wanted: str) -> str:
|
||||
return wanted
|
||||
|
||||
|
||||
def build_type(client: OpenObserveClient) -> str:
|
||||
"""`opensource`, `enterprise` or `cloud`, read off the unauthenticated `/config`."""
|
||||
url = urljoin(client.base_url, "config")
|
||||
try:
|
||||
resp = requests.get(url, timeout=client.timeout)
|
||||
body = resp.json() if resp.status_code == 200 else None
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
raise AssertionError(f"cannot read build_type from {url}: {exc}") from exc
|
||||
kind = body.get("build_type") if isinstance(body, dict) else None
|
||||
if not kind:
|
||||
raise AssertionError(
|
||||
f"no build_type from {url}: HTTP {resp.status_code} — {resp.text[:200]}")
|
||||
return kind
|
||||
|
||||
|
||||
def oncall_enabled(client: OpenObserveClient, org: str) -> bool:
|
||||
"""Whether this build serves the on-call routes.
|
||||
"""Whether this build serves the on-call routes; False only on an OSS build.
|
||||
|
||||
Probes `GET {org}/oncall/teams` rather than reading a flag off `/config`.
|
||||
An earlier version read `config.oncall_enabled`, which is what the issue
|
||||
report quoted — but a current enterprise build does not publish that key at
|
||||
all, so the gate skipped all 104 tests against a server that was serving
|
||||
on-call perfectly well.
|
||||
Probes `GET {org}/oncall/teams`: 200 or 403 (the route answering) means
|
||||
served. A 404 is a skip only when `/config` says `opensource`; an enterprise
|
||||
build answering 404 has `O2_ONCALL_ENABLED` off, and skipping there is what
|
||||
turned every enterprise CI run of this suite into a silent green skip.
|
||||
|
||||
A 200 (or a 403, which is still the route answering) means the feature is
|
||||
there; a 404 means this build does not carry it. ANYTHING ELSE RAISES, and
|
||||
deliberately so: this gate is autouse and session-scoped, so returning False
|
||||
for a 500 would skip every on-call test and report a green run against a
|
||||
broken server. A deployment that cannot answer is not a deployment without
|
||||
the feature, and the two must not collapse into the same silent skip.
|
||||
EVERYTHING ELSE RAISES: this gate is autouse and session-scoped, so returning
|
||||
False for a 500 would report a green run against a broken server.
|
||||
"""
|
||||
resp = client.get("oncall/teams", org=org)
|
||||
if resp.status_code in (200, 403):
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
if resp.status_code != 404:
|
||||
raise AssertionError(
|
||||
f"on-call probe failed: HTTP {resp.status_code} on {org}/oncall/teams — "
|
||||
f"{resp.text[:200]}"
|
||||
)
|
||||
kind = build_type(client)
|
||||
if kind == "opensource":
|
||||
return False
|
||||
raise AssertionError(
|
||||
f"on-call probe failed: HTTP {resp.status_code} on {org}/oncall/teams — "
|
||||
f"{resp.text[:200]}"
|
||||
f"on-call is off on this {kind} build: {org}/oncall/teams answered 404. "
|
||||
"Start the server with O2_ONCALL_ENABLED=true (off by default since "
|
||||
"o2-enterprise#2748) — this suite fails rather than skips on an enterprise build."
|
||||
)
|
||||
|
||||
|
||||
@@ -211,6 +238,13 @@ def mailpit_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def skip_or_fail_in_ci(reason: str) -> NoReturn:
|
||||
"""Skip locally; fail when `CI` is set, where the ent_oncall shard provisions every dependency these cases need."""
|
||||
if os.environ.get("CI"):
|
||||
pytest.fail(f"{reason} (CI is set: the ent_oncall job provides this, so its absence is a broken job)")
|
||||
pytest.skip(reason)
|
||||
|
||||
|
||||
def ack_link_for(recipient_marker: str, *, timeout_s: int = 180) -> str | None:
|
||||
"""The acknowledge URL out of the newest page email addressed to a recipient.
|
||||
|
||||
@@ -796,14 +830,15 @@ class OnCallClient:
|
||||
|
||||
|
||||
def make_user(client: OpenObserveClient, org: str, role: str,
|
||||
password: str = "Complexpass#123") -> tuple[str, OpenObserveClient]:
|
||||
password: str = "Complexpass#123", *,
|
||||
domain: str = "test.invalid") -> tuple[str, OpenObserveClient]:
|
||||
"""Create an org user with `role` and return (email, a client acting as them).
|
||||
|
||||
The returned client authenticates as that user, which is the only way to
|
||||
assert what the API refuses them — asserting with root's credentials and a
|
||||
`user_email` field in the body would test nothing.
|
||||
"""
|
||||
email = f"{PREFIX}_{role}_{uuid.uuid4().hex[:8]}@test.invalid"
|
||||
email = f"{PREFIX}_{role}_{uuid.uuid4().hex[:8]}@{domain}"
|
||||
payload = {
|
||||
"email": email, "password": password, "first_name": "OnCall",
|
||||
"last_name": role.capitalize(), "role": role, "organization": org,
|
||||
|
||||
@@ -70,7 +70,7 @@ def editor(client: OpenObserveClient, oncall_org: str, oncall: OnCallClient) ->
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def member(client: OpenObserveClient, oncall_org: str, oncall: OnCallClient) -> tuple[str, OpenObserveClient]:
|
||||
return _non_admin(client, oncall_org, oncall, "user")
|
||||
return _non_admin(client, oncall_org, oncall, "viewer")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -131,5 +131,6 @@ def test_an_ack_on_the_page_acks_the_alert(oncall: OnCallClient, admin):
|
||||
assert oncall.acknowledge(page["id"]).status_code == 200
|
||||
|
||||
acks = wait_until(lambda: _acks(oncall, alert_id), timeout=30, interval=2, msg="the page ack reached the alert")
|
||||
assert acks[0]["ack_source"] == "oncall"
|
||||
# The source records how the person acked (an API call here), not that it was a page.
|
||||
assert acks[0]["ack_source"] == "api"
|
||||
assert acks[0]["acked_by"] == oncall._c.email
|
||||
|
||||
@@ -141,7 +141,7 @@ def test_until_it_clears_with_keep_firing_for_ends_on_the_run_that_ends_the_hold
|
||||
|
||||
def test_the_mute_rule_refuses_a_viewer_with_names_and_lets_an_editor_and_a_responder_mute(
|
||||
client: OpenObserveClient, oncall_org: str, oncall: OnCallClient, admin):
|
||||
responder_email, as_responder = _non_admin(client, oncall_org, oncall, "user")
|
||||
responder_email, as_responder = _non_admin(client, oncall_org, oncall, "viewer")
|
||||
_, as_viewer = _non_admin(client, oncall_org, oncall, "viewer")
|
||||
_, as_editor = _non_admin(client, oncall_org, oncall, "editor")
|
||||
admin_email, _ = admin
|
||||
|
||||
@@ -26,7 +26,7 @@ why this surface carries the sharpest invariants in the feature — from
|
||||
* a GET must only look — a link-prefetching mail gateway would otherwise
|
||||
acknowledge a page nobody read
|
||||
|
||||
## The token could not be obtained, and nothing here pretends otherwise
|
||||
## The token exists only inside the page email, so it is read from a mailbox
|
||||
|
||||
An acknowledgement token is minted inside the enterprise crate
|
||||
(`o2_enterprise::enterprise::oncall::token`) and embedded in the outgoing
|
||||
@@ -44,18 +44,18 @@ notification. Everything reachable from this repo was checked:
|
||||
(the one OSS reference, src/jobs/src/job/oncall_maintenance.rs, passes None).
|
||||
* No notification- or template-preview route exists.
|
||||
|
||||
So 12.3, 12.4 and 12.6 are BLOCKED on a mailbox (or an enterprise mint helper)
|
||||
and are marked skip with that reason. They are written out in full rather than
|
||||
stubbed, so they run the moment `minted_ack_token` can return a real one. No
|
||||
token is fabricated and no verifier is stubbed to turn them green — a test that
|
||||
passes without exercising `ack_claims` would prove nothing about this route.
|
||||
So 12.3, 12.4 and 12.6 read the token out of a real mailbox (Mailpit), which
|
||||
the enterprise `ent_oncall` API shard starts. Without one they skip locally and
|
||||
FAIL when `CI` is set: a skip there would hide a broken job behind a green run.
|
||||
No token is fabricated and no verifier is stubbed to turn them green — a test
|
||||
that passes without exercising `ack_claims` would prove nothing about this route.
|
||||
|
||||
12.1, 12.2, 12.5 and 12.7 need only a malformed, expired or foreign token and
|
||||
run for real. Read the honest note on each: with no signing key on this side, a
|
||||
401 cannot be attributed to the *specific* check that produced it, so the
|
||||
expired and foreign-org cases pin "no such token acts", not "the expiry check
|
||||
fired". 12.7 is live for everything observable without a valid token (a GET
|
||||
never says "Acknowledged", never carries a Location) and its full form is skipped.
|
||||
never says "Acknowledged", never carries a Location) and its full form needs the mailbox.
|
||||
|
||||
Only the pages' fixed headings are asserted ("Acknowledged", "Already acknowledged
|
||||
by", "This link no longer works"); the rest is markup a person reads. Status codes,
|
||||
@@ -76,20 +76,13 @@ from tests.oncall.oncall_helpers import (
|
||||
ack_link_for,
|
||||
mailpit_available,
|
||||
make_user,
|
||||
oncall_enabled,
|
||||
rotation,
|
||||
shift_rule,
|
||||
skip_or_fail_in_ci,
|
||||
uniq,
|
||||
)
|
||||
|
||||
TOKEN_MINT_UNAVAILABLE = (
|
||||
"no acknowledgement token is obtainable: it is minted inside "
|
||||
"o2_enterprise::enterprise::oncall::token and only ever leaves the server "
|
||||
"inside the notification body. The delivery ledger "
|
||||
"(GET /oncall/responses/{id}/deliveries), the personal inbox "
|
||||
"(GET /oncall/my/deliveries) and test-page all omit it, and no "
|
||||
"notification-preview or mint endpoint exists. Needs a captured mailbox or "
|
||||
"an enterprise-side mint helper exposed to tests."
|
||||
)
|
||||
|
||||
# JWT-shaped so the route gets something structurally plausible to reject, but
|
||||
# signed with nothing — this must never be mistaken for a real token.
|
||||
@@ -97,9 +90,7 @@ UNSIGNED_SIGNATURE = "not-a-real-signature"
|
||||
|
||||
# Shapes a hostile or broken link can arrive in. Each must be refused, and
|
||||
# refused the same way: 401 from the claims check.
|
||||
# The whole feature needs an enterprise build with `O2_ONCALL_ENABLED`; the
|
||||
# marker lets CI hold this directory separately, and the session-scoped gate
|
||||
# in conftest skips it rather than failing when the flag is off.
|
||||
# Enterprise-only: the conftest gate skips on an OSS build and fails on an enterprise one without `O2_ONCALL_ENABLED`.
|
||||
pytestmark = pytest.mark.enterprise
|
||||
|
||||
MALFORMED_TOKENS = [
|
||||
@@ -118,26 +109,14 @@ MALFORMED_TOKENS = [
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def require_oncall(create_session, base_url, org_id):
|
||||
"""Skip the module unless the build actually serves the on-call routes.
|
||||
def require_oncall(client, org_id):
|
||||
"""Skip the module only on an OSS build; `oncall_enabled` raises on anything else.
|
||||
|
||||
Probes `oncall/teams` rather than reading `config.oncall_enabled`: that key
|
||||
is what the issue report quoted, but a current enterprise build does not
|
||||
publish it, and gating on it skipped every test against a server that was
|
||||
serving on-call correctly. A 404 means the routes are not registered, in
|
||||
which case `/oncall/ack` falls through to the authenticated catch-all and
|
||||
every assertion below would be measuring the auth middleware instead.
|
||||
Without the routes `/oncall/ack` falls through to the authenticated
|
||||
catch-all, and every assertion below would be measuring the auth middleware.
|
||||
"""
|
||||
resp = create_session.get(f"{base_url}api/{org_id}/oncall/teams")
|
||||
if resp.status_code == 404:
|
||||
pytest.skip("this build does not serve the on-call routes")
|
||||
# 404 above is the only legitimate skip: the routes are genuinely absent. Any
|
||||
# OTHER unexpected status is the server being broken, and skipping on it takes
|
||||
# all 42 cases in this file with it — a 500 would have retired the whole ack
|
||||
# surface silently.
|
||||
assert resp.status_code in (200, 403), (
|
||||
f"on-call is registered but `oncall/teams` answered {resp.status_code}, so the "
|
||||
f"server is not healthy enough to measure the ack route against: {resp.text[:200]}")
|
||||
if not oncall_enabled(client, org_id):
|
||||
pytest.skip("on-call is enterprise-only and this is an OSS build")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -152,7 +131,31 @@ def foreign_org():
|
||||
|
||||
@pytest.fixture
|
||||
def minted_ack_token(oncall):
|
||||
"""A real token, the response it acks, and its subject.
|
||||
"""A real token, the response it acks, and its (admin) subject, alone on the team."""
|
||||
return mint_ack_token(oncall, make_user(oncall._c, oncall.org, "admin")[0])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer_ack_token_with_a_teammate(oncall):
|
||||
"""A real token whose subject is a viewer, on a team that has a second member.
|
||||
|
||||
Neither exemption in `record_for_actor` applies: a viewer cannot work any
|
||||
page on role alone, and a team that still has members is not open to the org.
|
||||
"""
|
||||
try:
|
||||
subject, _ = make_user(oncall._c, oncall.org, "viewer")
|
||||
except AssertionError as e:
|
||||
if "require open-fga" in str(e):
|
||||
pytest.skip("non-admin roles need OpenFGA on")
|
||||
raise
|
||||
oncall.users.append(subject)
|
||||
teammate, _ = make_user(oncall._c, oncall.org, "admin")
|
||||
oncall.users.append(teammate)
|
||||
return mint_ack_token(oncall, subject, teammates=[teammate])
|
||||
|
||||
|
||||
def mint_ack_token(oncall, email, teammates=()):
|
||||
"""Page `email` for real and return (token, response id, email).
|
||||
|
||||
The token is minted server-side and leaves only inside the page email, so it
|
||||
is read back out of a mailbox. A TEST page will not do — those are sent with
|
||||
@@ -161,30 +164,31 @@ def minted_ack_token(oncall):
|
||||
on call" instead of a page, which is the trap that looks like a missing token.
|
||||
"""
|
||||
if not mailpit_available():
|
||||
pytest.skip(
|
||||
skip_or_fail_in_ci(
|
||||
f"no readable mailbox at {MAILPIT_BASE}; the ack token exists only "
|
||||
"inside a page email. Run Mailpit and point the server's SMTP at it."
|
||||
)
|
||||
|
||||
email, _ = make_user(oncall._c, oncall.org, "admin")
|
||||
marker = email.split("@")[0]
|
||||
|
||||
team_id = oncall.team_id(uniq("oncall_ack_team"))
|
||||
oncall.add_member(team_id, email)
|
||||
for member in (email, *teammates):
|
||||
added = oncall.add_member(team_id, member)
|
||||
assert added.status_code == 200, f"adding {member} refused: {added.status_code} {added.text}"
|
||||
resp = oncall.set_schedule(team_id, [rotation("R1", "Primary", [shift_rule("L1", [email])])])
|
||||
assert resp.status_code == 200, f"schedule refused: {resp.status_code} {resp.text}"
|
||||
|
||||
page = oncall.open_pages_for([team_id], priority=2)[0]
|
||||
link = ack_link_for(marker)
|
||||
if not link:
|
||||
pytest.skip(
|
||||
skip_or_fail_in_ci(
|
||||
"a page opened but no acknowledge link reached the mailbox within the "
|
||||
"wait; check the server's SMTP settings and ZO_WEB_URL"
|
||||
)
|
||||
|
||||
token = urllib.parse.parse_qs(urllib.parse.urlparse(link).query).get("token", [None])[0]
|
||||
assert token, f"ack link carried no token: {link}"
|
||||
yield token, page["id"], email
|
||||
return token, page["id"], email
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -509,12 +513,10 @@ def test_the_ack_route_does_not_enumerate_orgs(base_url, org_id, foreign_org):
|
||||
@pytest.mark.parametrize("token", MALFORMED_TOKENS)
|
||||
def test_a_get_never_redirects(base_url, org_id, token):
|
||||
"""The "Acknowledged" page is the only thing an acknowledgement emits, and a
|
||||
GET must never emit it, nor a redirect. A mail gateway prefetching the link would otherwise answer the
|
||||
page on the responder's behalf.
|
||||
GET must never emit it, nor a redirect. A mail gateway prefetching the link
|
||||
would otherwise answer the page on the responder's behalf.
|
||||
|
||||
Partial by necessity: without a valid token this covers every shape a
|
||||
prefetcher could be handed from a *broken* link, not the real one. The full
|
||||
case is the skipped test below.
|
||||
Covers only broken-link shapes; the valid-token case below runs whenever the mailbox is reachable.
|
||||
"""
|
||||
assert_did_not_act(get_ack(base_url, org_id, token))
|
||||
|
||||
@@ -570,7 +572,7 @@ def test_a_get_with_a_valid_token_does_not_acknowledge(create_session, base_url,
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 12.3, 12.4, 12.6 — blocked on a token that cannot be obtained
|
||||
# 12.3, 12.4, 12.6 — a real token, read from the page email
|
||||
# =============================================================================
|
||||
|
||||
def test_a_valid_token_acknowledges_and_links_with_the_org(create_session, base_url,
|
||||
@@ -652,26 +654,15 @@ def test_a_used_link_opened_again_shows_the_state_not_the_button(create_session,
|
||||
assert_did_not_act(again)
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason=(
|
||||
"PRODUCT DEFECT, not a test gap: the emailed link still acknowledges after "
|
||||
"its subject is removed from the team. Measured on :5090 — remove the member "
|
||||
"(200, membership empty), POST the link, 200 and the record reads acknowledged, "
|
||||
"attributed to the removed person. The signed-in path refuses the same action "
|
||||
"with NotOnThisTeam. o2-enterprise#2481."
|
||||
),
|
||||
strict=True,
|
||||
)
|
||||
def test_a_token_whose_subject_left_the_team_is_refused(create_session, base_url, org_id,
|
||||
minted_ack_token):
|
||||
"""12.6, and invariant 3: claims resolve through `service::ack_claims`, so
|
||||
the same membership check as the authenticated route applies here.
|
||||
def test_a_token_whose_subject_left_the_team_does_not_acknowledge(
|
||||
create_session, base_url, org_id, viewer_ack_token_with_a_teammate):
|
||||
"""12.6: a viewer removed from a team that still has members cannot take its page from their old email.
|
||||
|
||||
The token is minted while the subject is on the team and presented after
|
||||
they are removed — a leaver's old page email must stop working, or §1's
|
||||
team scoping has a hole in it that needs no credentials at all.
|
||||
`ack_claims` checks only org membership, so the link still opens; the ack
|
||||
rule then needs edit access to the alert or being on call for it, hence the
|
||||
viewer subject, and the teammate keeps the team from being emptied.
|
||||
"""
|
||||
token, response_id, subject = minted_ack_token
|
||||
token, response_id, subject = viewer_ack_token_with_a_teammate
|
||||
|
||||
record = create_session.get(f"{base_url}api/{org_id}/oncall/responses/{response_id}")
|
||||
assert record.status_code == 200, record.text
|
||||
@@ -682,10 +673,22 @@ def test_a_token_whose_subject_left_the_team_is_refused(create_session, base_url
|
||||
params={"user_email": subject},
|
||||
)
|
||||
assert removed.status_code == 200, removed.text
|
||||
roster = create_session.get(f"{base_url}api/{org_id}/oncall/teams/{team_id}/members")
|
||||
assert roster.status_code == 200, roster.text
|
||||
remaining = [m.get("user_email") for m in roster.json()]
|
||||
# An emptied team is open to the whole org, which would make this case measure the wrong rule.
|
||||
assert remaining, "the teammate must still be on the team"
|
||||
assert subject not in remaining, f"the subject must be gone: {remaining}"
|
||||
|
||||
assert_handler_refused(post_ack(base_url, org_id, token))
|
||||
assert_handler_refused(get_ack(base_url, org_id, token))
|
||||
assert_did_not_act(get_ack(base_url, org_id, token))
|
||||
|
||||
leaver_post = post_ack(base_url, org_id, token)
|
||||
assert leaver_post.status_code == 403, f"{leaver_post.status_code}: {leaver_post.text[:300]}"
|
||||
assert leaver_post.json().get("code") == "ack_refused", leaver_post.text[:300]
|
||||
assert_did_not_act(leaver_post)
|
||||
|
||||
after = create_session.get(f"{base_url}api/{org_id}/oncall/responses/{response_id}")
|
||||
assert after.status_code == 200, after.text
|
||||
assert after.json()["response"].get("state") != "acknowledged"
|
||||
body = after.json()["response"]
|
||||
assert body.get("state") != "acknowledged", body
|
||||
assert (body.get("acked_by") or "").lower() != subject.lower(), body
|
||||
|
||||
@@ -51,9 +51,7 @@ from .oncall_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The whole feature needs an enterprise build with `O2_ONCALL_ENABLED`; the
|
||||
# marker lets CI hold this directory separately, and the session-scoped gate
|
||||
# in conftest skips it rather than failing when the flag is off.
|
||||
# Enterprise-only: the conftest gate skips on an OSS build and fails on an enterprise one without `O2_ONCALL_ENABLED`.
|
||||
pytestmark = pytest.mark.enterprise
|
||||
|
||||
# A column no semantic field group maps to a dimension, so no ownership rule
|
||||
|
||||
@@ -52,9 +52,7 @@ from .oncall_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The whole feature needs an enterprise build with `O2_ONCALL_ENABLED`; the
|
||||
# marker lets CI hold this directory separately, and the session-scoped gate
|
||||
# in conftest skips it rather than failing when the flag is off.
|
||||
# Enterprise-only: the conftest gate skips on an OSS build and fails on an enterprise one without `O2_ONCALL_ENABLED`.
|
||||
pytestmark = pytest.mark.enterprise
|
||||
|
||||
|
||||
|
||||
@@ -54,9 +54,7 @@ from .oncall_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The whole feature needs an enterprise build with `O2_ONCALL_ENABLED`; the
|
||||
# marker lets CI hold this directory separately, and the session-scoped gate
|
||||
# in conftest skips it rather than failing when the flag is off.
|
||||
# Enterprise-only: the conftest gate skips on an OSS build and fails on an enterprise one without `O2_ONCALL_ENABLED`.
|
||||
pytestmark = pytest.mark.enterprise
|
||||
|
||||
# Two owning namespaces and one nobody claims, all three uniquified. The owned
|
||||
|
||||
@@ -19,12 +19,15 @@ window applies at no instant at all, which is what §8.2 refuses.
|
||||
§7's two-failure threshold is deliberate, and §7.2 exists to guard it against
|
||||
being tightened: one blip must not bench somebody who is holding the pager.
|
||||
Producing a *recorded* delivery failure needs a transport that is configured
|
||||
and fails, which is what the helper's dead destination (a closed port on
|
||||
loopback) is for. Where the deployment cannot deliver to a member at all — no
|
||||
SMTP, no webhook — a degradation caused by failures cannot be told apart from
|
||||
one caused by there being no transport, and those tests skip with that reason
|
||||
rather than asserting something they cannot see. That is a real gap in coverage
|
||||
on an SMTP-less instance and is called out rather than papered over.
|
||||
and fails. Email alone decides `would_a_page_land`, and delivery health is kept
|
||||
per recipient and channel (a webhook page is recorded against the team's
|
||||
channel, never the person), so the failures are email ones: mail configured but
|
||||
not landing. In the enterprise `ent_oncall` CI shard that is a Mailpit that
|
||||
refuses `MAILBOX_DOMAIN` (and accepts the ack-token tests' `test.invalid`).
|
||||
Where the deployment cannot deliver to a member at all — no SMTP — or where its
|
||||
mail does land, a degradation caused by failures cannot be produced: those tests
|
||||
skip with that reason locally, and FAIL when `CI` is set, where either state
|
||||
means the shard's mail setup is broken.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,15 +41,17 @@ from support.client import OpenObserveClient
|
||||
from support.wait import WaitTimeout
|
||||
|
||||
from .oncall_helpers import (
|
||||
DEAD_DEST,
|
||||
MAILBOX_DOMAIN,
|
||||
MINUTES_PER_DAY,
|
||||
OnCallClient,
|
||||
delivered_recipients,
|
||||
ladder,
|
||||
make_user,
|
||||
micros_from_now,
|
||||
rotation,
|
||||
rotation_target,
|
||||
shift_rule,
|
||||
skip_or_fail_in_ci,
|
||||
uniq,
|
||||
user_target,
|
||||
)
|
||||
@@ -54,9 +59,7 @@ from .oncall_helpers import failed_recipients as failures_in
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The whole feature needs an enterprise build with `O2_ONCALL_ENABLED`; the
|
||||
# marker lets CI hold this directory separately, and the session-scoped gate
|
||||
# in conftest skips it rather than failing when the flag is off.
|
||||
# Enterprise-only: the conftest gate skips on an OSS build and fails on an enterprise one without `O2_ONCALL_ENABLED`.
|
||||
pytestmark = pytest.mark.enterprise
|
||||
|
||||
ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
|
||||
@@ -72,7 +75,7 @@ DEGRADE_AFTER = 2
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rota_member(client: OpenObserveClient, oncall: OnCallClient, oncall_org: str) -> str:
|
||||
email, _unused = make_user(client, oncall_org, "user")
|
||||
email, _unused = make_user(client, oncall_org, "user", domain=MAILBOX_DOMAIN)
|
||||
oncall.users.append(email)
|
||||
return email
|
||||
|
||||
@@ -93,6 +96,12 @@ def member_verdict(oncall: OnCallClient, team_id: str, email: str) -> dict | Non
|
||||
return None
|
||||
|
||||
|
||||
def skip_if_mail_lands(ledger: dict, email: str) -> None:
|
||||
"""A deployment whose mail is delivered cannot produce the failures §7 counts."""
|
||||
if any(email.lower() in r.lower() for r in delivered_recipients(ledger)):
|
||||
skip_or_fail_in_ci("mail to this member was delivered here, so no delivery failure can be produced")
|
||||
|
||||
|
||||
def one_rotation(rotation_id: str, members: list[str], **kw) -> list[dict]:
|
||||
"""A schedule body with a single named rotation."""
|
||||
return [rotation(rotation_id, "Primary",
|
||||
@@ -269,36 +278,50 @@ def test_the_escalation_preview_agrees_with_reachability(
|
||||
f"preview says {recipient}, reachability says {verdict}")
|
||||
|
||||
|
||||
def test_a_preview_for_a_transport_that_can_deliver_nothing_says_so(
|
||||
oncall: OnCallClient, rota_team: str, rota_member: str):
|
||||
def test_a_preview_whose_ladder_can_reach_nobody_says_so(
|
||||
client: OpenObserveClient, oncall: OnCallClient, oncall_org: str,
|
||||
rota_team: str, rota_member: str):
|
||||
"""§7.3 — the loudest finding a preview can report.
|
||||
|
||||
Reachable only on a deployment with no SMTP, which is the ordinary state of
|
||||
a test instance: with the ladder set to the email channel alone, nothing
|
||||
the preview describes can reach a human, and it has to say so rather than
|
||||
drawing a ladder that looks configured.
|
||||
When nothing the preview describes can reach a human it has to say so rather
|
||||
than draw a ladder that looks configured. `reaches_nobody` is "no recipient's
|
||||
email would land" (email alone decides the verdict, whatever channels the
|
||||
ladder names), so it is produced either way a deployment can be: with no
|
||||
SMTP, for any member; with SMTP, for a member whose address mail can never
|
||||
reach — a reserved domain. Each branch pins its own reason, so neither can
|
||||
pass on the other's.
|
||||
"""
|
||||
reach = oncall.reachability(rota_team)
|
||||
assert reach.status_code == 200, reach.text
|
||||
if reach.json().get("smtp_configured"):
|
||||
pytest.skip("SMTP is configured here, so the email channel can deliver")
|
||||
member, _unused = make_user(client, oncall_org, "user")
|
||||
oncall.users.append(member)
|
||||
team = oncall.staffed_team([member], name=uniq("oncall_pt_nowhere"))
|
||||
cause = "uses a domain reserved for documentation"
|
||||
else:
|
||||
team, member, cause = rota_team, rota_member, "no SMTP transport"
|
||||
|
||||
assert oncall.set_policy(
|
||||
rota_team, [ladder([user_target(rota_member)], channels=["email"])]
|
||||
team, [ladder([user_target(member)], channels=["email"])]
|
||||
).status_code == 200
|
||||
|
||||
preview = oncall.escalation_preview(rota_team, priority=1)
|
||||
preview = oncall.escalation_preview(team, priority=1)
|
||||
assert preview.status_code == 200, preview.text
|
||||
body = preview.json()
|
||||
assert body.get("reaches_nobody") is True, body
|
||||
for rung in body.get("rungs", []):
|
||||
for recipient in rung.get("recipients", []):
|
||||
assert recipient.get("would_a_page_land") is False, recipient
|
||||
recipients = [r for rung in body.get("rungs", []) for r in rung.get("recipients", [])]
|
||||
assert recipients, f"the rung targets {member} and must list them: {body}"
|
||||
for recipient in recipients:
|
||||
assert recipient.get("would_a_page_land") is False, recipient
|
||||
assert cause in (recipient.get("why_not") or ""), recipient
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def failing_team(oncall: OnCallClient, rota_member: str) -> dict:
|
||||
"""A team whose only transport is a destination that cannot be reached.
|
||||
"""A team that pages its only member by email alone.
|
||||
|
||||
Email because it alone moves `would_a_page_land`: a webhook page is recorded
|
||||
against the team's channel, so it can never bench a person.
|
||||
|
||||
Two rungs to the same person: the first fires with the record and the
|
||||
second is reachable on demand via `escalate`, which is how a SECOND
|
||||
@@ -309,9 +332,8 @@ def failing_team(oncall: OnCallClient, rota_member: str) -> dict:
|
||||
baseline = member_verdict(oncall, team, rota_member)
|
||||
assert oncall.set_policy(
|
||||
team,
|
||||
[ladder([user_target(rota_member)], channels=["webhook"],
|
||||
delays=[0, 30 * 60 * 1_000_000])],
|
||||
destinations=[DEAD_DEST]).status_code == 200
|
||||
[ladder([user_target(rota_member)], channels=["email"],
|
||||
delays=[0, 30 * 60 * 1_000_000])]).status_code == 200
|
||||
page = oncall.open_pages_for([team])[0]
|
||||
return {"team": team, "page": page, "baseline": baseline}
|
||||
|
||||
@@ -320,25 +342,22 @@ def test_a_single_delivery_failure_does_not_bench_a_responder(
|
||||
oncall: OnCallClient, failing_team: dict, rota_member: str):
|
||||
"""§7.2 — the guard on the threshold. One blip is not a pattern.
|
||||
|
||||
Skipped rather than asserted when the member was already unreachable before
|
||||
any page was sent: a degradation caused by two failures cannot be told
|
||||
Skipped locally (failed in CI) rather than asserted when the member was
|
||||
already unreachable before any page was sent: a degradation caused by two failures cannot be told
|
||||
apart from one caused by there being no transport at all, and asserting
|
||||
into that ambiguity would produce a test that passes for the wrong reason.
|
||||
"""
|
||||
if not (failing_team["baseline"] or {}).get("would_a_page_land"):
|
||||
pytest.skip("this member is unreachable before any failure; "
|
||||
"a failure-driven degradation cannot be isolated here")
|
||||
skip_or_fail_in_ci("this member is unreachable before any failure; "
|
||||
"a failure-driven degradation cannot be isolated here")
|
||||
|
||||
ledger = oncall.wait_for_deliveries(failing_team["page"]["id"])
|
||||
skip_if_mail_lands(ledger, rota_member)
|
||||
mine = [r for r in failures_in(ledger) if r and rota_member.lower() in r.lower()]
|
||||
# NOT a skip. `failing_team` routes through DEAD_DEST, a destination that
|
||||
# cannot be reached, so exactly one recorded failure is deterministic by
|
||||
# construction — nothing about a deployment changes it. Skipping here turns a
|
||||
# broken delivery ledger into a green run, which is the failure this whole
|
||||
# section exists to catch.
|
||||
# NOT a skip: mail is configured and was not delivered, so a missing failure row is a broken ledger.
|
||||
assert len(mine) == 1, (
|
||||
f"expected exactly one recorded failure against {rota_member}, got {len(mine)}. "
|
||||
"The destination is unreachable by construction, so this is the delivery "
|
||||
"Mail is configured here and was not delivered, so this is the delivery "
|
||||
f"ledger not recording failures, not an environment difference: {ledger}")
|
||||
|
||||
verdict = member_verdict(oncall, failing_team["team"], rota_member)
|
||||
@@ -356,8 +375,8 @@ def test_two_consecutive_failures_drop_a_responder_out_of_reachable(
|
||||
one page.
|
||||
"""
|
||||
if not (failing_team["baseline"] or {}).get("would_a_page_land"):
|
||||
pytest.skip("this member is unreachable before any failure; "
|
||||
"a failure-driven degradation cannot be isolated here")
|
||||
skip_or_fail_in_ci("this member is unreachable before any failure; "
|
||||
"a failure-driven degradation cannot be isolated here")
|
||||
|
||||
resp = oncall.escalate(failing_team["page"]["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -370,11 +389,12 @@ def test_two_consecutive_failures_drop_a_responder_out_of_reachable(
|
||||
count=DEGRADE_AFTER)
|
||||
except WaitTimeout:
|
||||
ledger = oncall.deliveries(failing_team["page"]["id"]).json()
|
||||
skip_if_mail_lands(ledger, rota_member)
|
||||
mine = [r for r in failures_in(ledger) if r and rota_member.lower() in r.lower()]
|
||||
# NOT a skip, for the same reason as above: DEAD_DEST guarantees the failures.
|
||||
# NOT a skip, for the same reason as above: nothing was delivered, so every attempt must be a failure.
|
||||
assert len(mine) >= DEGRADE_AFTER, (
|
||||
f"expected at least {DEGRADE_AFTER} recorded failures against {rota_member}, "
|
||||
f"got {len(mine)}. The destination is unreachable by construction, so a short "
|
||||
f"got {len(mine)}. Mail is configured here and was not delivered, so a short "
|
||||
f"count means the ledger stopped recording failures: {ledger}")
|
||||
|
||||
verdict = member_verdict(oncall, failing_team["team"], rota_member)
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"logs-table-stream-switch.spec.js",
|
||||
"logs-internal-streams.spec.js",
|
||||
"logs-11966-value-multiselect-quotes.spec.js",
|
||||
"logs-explain-sql-mode.spec.js"
|
||||
"logs-explain-sql-mode.spec.js",
|
||||
"logs-3322-field-filter-similar-names.spec.js"
|
||||
],
|
||||
"disabled": []
|
||||
},
|
||||
|
||||
@@ -156,8 +156,6 @@ export class MetricsPage {
|
||||
this.toastMessage = page.locator('[data-test="o-toast-message"]');
|
||||
|
||||
this.historyButton = page.locator('[data-test="metrics-history-btn"]');
|
||||
this.historyPopover = page.locator('[data-test="recent-searches-popover"]');
|
||||
this.historyViewAll = this.historyPopover.locator('[data-test="recent-searches-view-all"]');
|
||||
this.historyDrawer = page.locator('[data-test="recent-searches-drawer"]');
|
||||
this.historySearch = this.historyDrawer.locator('[data-test="recent-searches-search"] input');
|
||||
this.historyStarredOnly = this.historyDrawer.locator('[data-test="recent-searches-starred-only"]');
|
||||
@@ -2806,7 +2804,6 @@ export class MetricsPage {
|
||||
|
||||
async openQueryHistory() {
|
||||
await this.historyButton.click();
|
||||
await this.historyViewAll.click();
|
||||
await this.historyDrawer.waitFor({ state: 'visible', timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ export class OnCallPagesListPage {
|
||||
// The data-test lands on the OCheckbox LABEL; the thing that toggles is the
|
||||
// inner `button[role="checkbox"]`, so that is what is clicked.
|
||||
selectAll: '[data-test="o2-table-select-all"] button[role="checkbox"]',
|
||||
confirmOk: '[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]',
|
||||
whoIsOn: '[data-test="oncall-who-is-on"]',
|
||||
whoIsOnPrimaryReach: '[data-test="oncall-who-is-on-primary-reach"]',
|
||||
setupBanner: '[data-test="oncall-setup-banner"]',
|
||||
@@ -143,7 +142,6 @@ export class OnCallPagesListPage {
|
||||
getSelectAll() { return this.page.locator(this.locators.selectAll).first(); }
|
||||
getBulkAck() { return this.page.locator(this.locators.bulkAck); }
|
||||
getBulkResolve() { return this.page.locator(this.locators.bulkResolve); }
|
||||
getConfirmOk() { return this.page.locator(this.locators.confirmOk).first(); }
|
||||
getRowByIndex(index) { return this.page.locator(this.rowByIndex(index)); }
|
||||
getEscalationCell(responseId) { return this.page.locator(this.escalationCell(responseId)); }
|
||||
getWhoIsOn() { return this.page.locator(this.locators.whoIsOn); }
|
||||
@@ -164,6 +162,16 @@ export class OnCallPagesListPage {
|
||||
return await el.getAttribute('data-state');
|
||||
}
|
||||
|
||||
/** Both the checklist and the table's loading flag wait on `setupLoaded`, so either settling means the setup context is in. */
|
||||
async waitForSetupContext() {
|
||||
const settled = this.page.locator([
|
||||
this.locators.setupChecklist,
|
||||
this.locators.setupBanner,
|
||||
`${this.locators.table} [data-test="o2-table"][data-test-loading="false"]`,
|
||||
].join(', ')).first();
|
||||
await expect(settled).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
/** Open the checklist if it is sitting collapsed behind its banner. */
|
||||
async expandSetupChecklist() {
|
||||
if (await this.getSetupBanner().count()) {
|
||||
@@ -511,6 +519,22 @@ export class OnCallPagesListPage {
|
||||
await this.page.locator(this.rowSelect(target)).click();
|
||||
}
|
||||
|
||||
/** Resolve every selected row through the bulk dialog, picking `cause` by its wire value. */
|
||||
async bulkResolveWithCause(cause) {
|
||||
await this.getBulkResolve().click();
|
||||
const dialog = this.page.locator('[data-test="oncall-bulk-resolve-dialog"]');
|
||||
await expect(dialog).toBeVisible({ timeout: 20000 });
|
||||
const field = '[data-test="oncall-bulk-resolve-cause"]';
|
||||
const trigger = this.page.locator(part(field, 'trigger')).first();
|
||||
await trigger.waitFor({ state: 'visible', timeout: 20000 });
|
||||
await trigger.click();
|
||||
const option = this.page.locator(`${part(field, 'option')}[data-test-value="${cause}"]`).first();
|
||||
await option.waitFor({ state: 'visible', timeout: 20000 });
|
||||
await option.click();
|
||||
await this.page.locator('[data-test="oncall-bulk-resolve-confirm"]').click();
|
||||
await expect(dialog).toBeHidden({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async readBulkCount() {
|
||||
const node = this.page.locator(this.locators.bulkCount);
|
||||
if ((await node.count()) === 0) return null;
|
||||
|
||||
@@ -68,7 +68,6 @@ export class OnCallPolicyEditorPage {
|
||||
teamChannel: '[data-test="oncall-team-channel"]',
|
||||
teamChannelSelect: '[data-test="oncall-team-channel-select"]',
|
||||
teamChannelSource: '[data-test="oncall-team-channel-source"]',
|
||||
teamChannelSave: '[data-test="oncall-team-channel-save"]',
|
||||
teamChannelClear: '[data-test="oncall-team-channel-clear"]',
|
||||
teamChannelSilent: '[data-test="oncall-team-channel-silent"]',
|
||||
|
||||
@@ -256,10 +255,6 @@ export class OnCallPolicyEditorPage {
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
async saveTeamChannel() {
|
||||
await this.page.locator(this.locators.teamChannelSave).first().click();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ L0
|
||||
|
||||
async expectL0EditorVisible() {
|
||||
|
||||
@@ -39,6 +39,10 @@ function part(selector, suffix) {
|
||||
return selector.replace(/"\]$/, `-${suffix}"]`);
|
||||
}
|
||||
|
||||
/** A 404 on this record's agent report is the designed "no agent ran" answer (request/oncall/mod.rs get_response_report). */
|
||||
export const isNoReportYet = (url, org, responseId) =>
|
||||
new URL(url).pathname === `/api/${org}/oncall/responses/${responseId}/report`;
|
||||
|
||||
export class OnCallResponseDetailPage {
|
||||
constructor(page) {
|
||||
this.page = page;
|
||||
@@ -416,6 +420,11 @@ export class OnCallResponseDetailPage {
|
||||
await expect(this.page.locator(this.locators.teamLink)).toContainText(teamName, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async expectTeamLinkTargets(teamId) {
|
||||
await expect(this.page.locator(this.locators.teamLink))
|
||||
.toHaveAttribute('href', new RegExp(`/oncall/teams/${teamId}(/|\\?|$)`), { timeout: 30000 });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default OnCallResponseDetailPage;
|
||||
|
||||
@@ -71,7 +71,7 @@ export class OnCallTeamDetailPage {
|
||||
error: '[data-test="oncall-team-detail-error"]',
|
||||
notAvailable: '[data-test="oncall-team-detail-not-available"]',
|
||||
coverageTag: '[data-test="oncall-team-coverage"]',
|
||||
configRisks: '[data-test="oncall-team-config-risks"]',
|
||||
configRisks: '[data-test="oncall-attention-expand"]',
|
||||
coverageCard: '[data-test="oncall-team-coverage-card"]',
|
||||
openSchedule: '[data-test="oncall-team-open-schedule"]',
|
||||
|
||||
@@ -527,6 +527,16 @@ export class OnCallTeamDetailPage {
|
||||
await this.getQuickStartApply().click();
|
||||
}
|
||||
|
||||
/** Accept the "replace the shift rules of …" confirm and return the preset write it issues. */
|
||||
async confirmQuickStartReplace() {
|
||||
const write = this.page.waitForResponse(
|
||||
(res) => /\/oncall\/teams\/[^/]+\/schedule\/from-preset/.test(res.url()) && res.request().method() === 'POST',
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await this.page.locator(this.locators.confirmOk).click();
|
||||
return await write;
|
||||
}
|
||||
|
||||
/**
|
||||
* §11.2: Save with regions unnamed and unstaffed must not be a silent no-op.
|
||||
*
|
||||
@@ -667,14 +677,7 @@ export class OnCallTeamDetailPage {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* The config-risk count riding the title.
|
||||
*
|
||||
* It is a COUNT, not a list — the risk texts live on
|
||||
* `GET /teams/{id}/config-risks` and are derived on read, so a spec asserting
|
||||
* that a risk cleared reads the endpoint and asserts the tag disappeared.
|
||||
* Returns null when the tag is absent, which is what "no risks" looks like.
|
||||
*/
|
||||
/** The attention banner's finding count (config risks, with folded findings discounted), or null when no banner shows. */
|
||||
async readConfigRiskCount() {
|
||||
const tag = this.page.locator(this.locators.configRisks);
|
||||
if ((await tag.count()) === 0) return null;
|
||||
|
||||
@@ -3107,6 +3107,29 @@ export class PipelinesPage {
|
||||
return (dataTest ?? '').replace(`logs-search-subfield-add-${fieldName}-`, '');
|
||||
}
|
||||
|
||||
/** Replace the Associate Query SQL through the Monaco model; the field list reads the editor value. */
|
||||
async setQuerySourceSql(query) {
|
||||
const host = '[data-test="scheduled-pipeline-sql-editor"]';
|
||||
await this.page.locator(host).waitFor({ state: 'visible', timeout: 15000 });
|
||||
await this.page.evaluate(({ selector, value }) => {
|
||||
const root = document.querySelector(selector);
|
||||
const editor = (window.monaco?.editor?.getEditors?.() ?? []).find((ed) => root?.contains(ed.getDomNode?.()));
|
||||
if (!editor) throw new Error('Associate Query SQL editor not found');
|
||||
const model = editor.getModel();
|
||||
editor.executeEdits('setQuerySourceSql', [{ range: model.getFullModelRange(), text: value }]);
|
||||
}, { selector: host, value: query });
|
||||
await expect.poll(() => this.getQuerySourceSql(), { timeout: 5000 }).toBe(query);
|
||||
}
|
||||
|
||||
/** The Associate Query SQL exactly as the editor model holds it. */
|
||||
async getQuerySourceSql() {
|
||||
return await this.page.evaluate((selector) => {
|
||||
const root = document.querySelector(selector);
|
||||
const editor = (window.monaco?.editor?.getEditors?.() ?? []).find((ed) => root?.contains(ed.getDomNode?.()));
|
||||
return editor ? editor.getValue() : null;
|
||||
}, '[data-test="scheduled-pipeline-sql-editor"]');
|
||||
}
|
||||
|
||||
/** Untick every selected value for the open field, which removes its query condition. */
|
||||
async clearQueryFieldValueFilter() {
|
||||
const clearBtn = this.page.locator('[data-test="field-values-panel-clear-selection-btn"]');
|
||||
|
||||
@@ -478,10 +478,9 @@ test.describe('On-call covers, swaps and absences', {
|
||||
const t0 = nowMicros();
|
||||
const beforeA = (await holdersAt(page, a.team.id, t0)).Primary;
|
||||
const beforeB = (await holdersAt(page, b.team.id, t0)).Primary;
|
||||
test.skip(
|
||||
beforeA !== beforeB,
|
||||
'this case needs one person on call on both teams; the anchors resolved to two',
|
||||
);
|
||||
expect(beforeA, 'the first team must have somebody on call to mark away').toBeTruthy();
|
||||
expect(beforeB, 'the same roster half a shift past its anchor must put the same person on call on both teams')
|
||||
.toBe(beforeA);
|
||||
|
||||
const created = await createAbsence(page, {
|
||||
userEmail: beforeA, fromMicros: t0 - HOUR, toMicros: t0 + 2 * HOUR,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* TS-13.03 a test page goes down the real path and leaves no trace
|
||||
* TS-13.06 every link in every page email resolves to a real route (the W-01 guard)
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* WHY THESE FOUR ARE ONE FILE. They are the same claim from four directions: a
|
||||
* dry run has to be TRUE and has to be FREE. A preview that lies converts a
|
||||
@@ -67,6 +68,7 @@ const {
|
||||
simulateRouting,
|
||||
getEscalationPreview,
|
||||
getCauseAnalytics,
|
||||
requireMailSink,
|
||||
waitForMail,
|
||||
extractLinks,
|
||||
} = require('../utils/oncall-seed-ext.js');
|
||||
@@ -171,6 +173,7 @@ test.describe('On-call dry runs', {
|
||||
const destination = await seedNotificationDestination(page, f.prefix.toLowerCase());
|
||||
const { pages } = await firePageAndWait(page, {
|
||||
alertOptions: { name: `${f.prefix}_alert`, stream, destinations: [destination] },
|
||||
expectTeamId: f.team.id,
|
||||
});
|
||||
|
||||
// The prediction and the firing have to agree on the team AND on the person.
|
||||
@@ -269,6 +272,7 @@ test.describe('On-call dry runs', {
|
||||
test('a test page reaches a real mailbox and leaves no record behind', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
await requireMailSink(test);
|
||||
const f = await seedRoutableTeam(page, testInfo, 'test');
|
||||
|
||||
const recordsBefore = await listResponses(page, { teamId: f.team.id, includeResolved: true });
|
||||
@@ -323,6 +327,7 @@ test.describe('On-call dry runs', {
|
||||
test('every link in a page email resolves to a route that renders its own screen', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
await requireMailSink(test);
|
||||
const f = await seedRoutableTeam(page, testInfo, 'links');
|
||||
const onCall = (await whoIsOnCall(page, f.team.id)).map((slot) => slot.user_email);
|
||||
|
||||
@@ -341,6 +346,7 @@ test.describe('On-call dry runs', {
|
||||
const pageSince = Date.now() - 2000;
|
||||
const { pages } = await firePageAndWait(page, {
|
||||
alertOptions: { name: `${f.prefix}_alert`, stream, destinations: [destination] },
|
||||
expectTeamId: f.team.id,
|
||||
});
|
||||
const pageMail = await waitForMail({ to: onCall[0], sinceMs: pageSince, subject: /\[P\d\]/, timeout: 120000 });
|
||||
expect(pageMail, 'a fired page must produce a page email').not.toBeNull();
|
||||
|
||||
@@ -8,15 +8,11 @@
|
||||
* 9.3 P4 `only` — nobody is paged, and the agent still records a verdict
|
||||
* 9.4 agent unavailable — the preview must not claim `available: true`
|
||||
*
|
||||
* THESE ARE EXPECTED RED UNTIL THE AGENT IS LIVE, AND THAT IS THE POINT.
|
||||
* On build `8401c636ea` `escalation-preview` reported `available: true` for all
|
||||
* three modes while nothing from the agent ever reached a page. They are
|
||||
* written against the documented contract rather than against that behaviour,
|
||||
* and tagged `@oncall-l0` so CI can hold them separately instead of letting a
|
||||
* known product gap block the suite. A green run here is news; a red one is the
|
||||
* status quo the plan already records.
|
||||
* Where a case needs a reachable agent (a verdict on a real page), it skips
|
||||
* unless the preview reports one; CI runs without an agent.
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* There is no L0 page object, and none is invented here. The lever is
|
||||
* `setTeamPolicy`'s `l0` argument — absent means UNCHANGED, so it is only ever
|
||||
@@ -27,14 +23,6 @@
|
||||
* P1 invariant (always `parallel`) and the pages-nobody rule (P4/P5 -> `only`)
|
||||
* before answering, so nothing here re-derives it from `policy.l0.mode`.
|
||||
*
|
||||
* BLOCKED, and recorded rather than faked — the timing halves of 9.1-9.3 need a
|
||||
* page opened AT a chosen priority. An alert carries a top-level `priority`
|
||||
* field on the wire, but `createPagingAlert` in the seeding layer does not pass
|
||||
* one through, so a UI spec can only open a page at whatever priority the alert
|
||||
* path defaults to. What each case CAN assert is stated on the test. Closing the
|
||||
* rest needs either a priority argument on the seed helper or the API suite,
|
||||
* which owns per-priority ladder timing.
|
||||
*
|
||||
* Self-cleaning, worker-scoped prefix.
|
||||
*/
|
||||
|
||||
@@ -70,16 +58,6 @@ const workerPrefix = (testInfo) => `${PREFIX}_w${testInfo.workerIndex}`;
|
||||
|
||||
const gate = { checked: false, available: false, reason: '' };
|
||||
|
||||
async function listOrgUsers(page) {
|
||||
const res = await page.request.get(`${baseUrl()}/api/${orgId()}/users`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok()) return [];
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const list = Array.isArray(body) ? body : (body?.data ?? []);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A dry run of one priority's ladder, agent step included.
|
||||
*
|
||||
@@ -150,11 +128,7 @@ test.describe('On-call L0 / AI SRE', {
|
||||
* below are the only ones those two accept.
|
||||
*/
|
||||
async function seedTeamWithL0(page, testInfo, { p2Mode = 'gate', rungs = [1, 2, 3, 4] } = {}) {
|
||||
// Seed our OWN member rather than borrowing listOrgUsers()[0]. The org is
|
||||
// shared across workers and every suite now sweeps the accounts it creates,
|
||||
// so the first user in the list can be another worker's fixture that is
|
||||
// deleted mid-test — which surfaces as a 400 on add-members, naming an
|
||||
// address this spec never chose.
|
||||
// Our own member: the first org user may be another worker's fixture, deleted mid-test.
|
||||
const { email: memberEmail } = await createOrgUser(page, {
|
||||
email: oncallUserEmail(uniqueName(workerPrefix(testInfo)), 'l0'),
|
||||
});
|
||||
@@ -203,7 +177,9 @@ test.describe('On-call L0 / AI SRE', {
|
||||
const { team } = await seedTeamWithL0(page, testInfo);
|
||||
|
||||
const preview = await escalationPreview(page, team.id, 1);
|
||||
const claimed = preview?.l0?.available === true;
|
||||
expect(preview.l0, 'the policy configured L0, so the preview must answer whether an agent is available')
|
||||
.toBeTruthy();
|
||||
const claimed = preview.l0.available === true;
|
||||
testLogger.info('§9.4 preview L0 block', { l0: preview?.l0 ?? null });
|
||||
|
||||
const stream = uniqueName(`${workerPrefix(testInfo)}_stream`);
|
||||
@@ -245,30 +221,16 @@ test.describe('On-call L0 / AI SRE', {
|
||||
|
||||
// -------------------------------------------------------------------- 9.1
|
||||
|
||||
/**
|
||||
* §9.1 — P1 is pinned `parallel`, and parallel means nothing waits.
|
||||
*
|
||||
* Holding a critical page behind a model is not a setting the product offers;
|
||||
* the server pins P1 and 400s anything else, so the preview resolving P1 to
|
||||
* anything but `parallel` is a server bug rather than a policy one. The
|
||||
* paging half is read from the record that a real firing opened: the first
|
||||
* rung must be at +0, not deferred behind a triage budget.
|
||||
*
|
||||
* Blocked half: the verdict is asserted only when the record this fired is
|
||||
* itself a P1. Without a priority on the seed's alert helper the priority is
|
||||
* the alert path's default, so it is read back rather than assumed.
|
||||
*/
|
||||
test('§9.1 P1 is parallel — the first rung fires at +0 and the agent stays out of an alert-backed page', {
|
||||
/** §9.1 — P1 is pinned `parallel` server-side, and parallel means the first rung does not wait on the agent. */
|
||||
test('§9.1 P1 is parallel — the first rung fires at +0 without waiting on the agent', {
|
||||
tag: ['@P1', '@oncall-l0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
test.setTimeout(900_000);
|
||||
const { team } = await seedTeamWithL0(page, testInfo);
|
||||
|
||||
const preview = await escalationPreview(page, team.id, 1);
|
||||
expect(
|
||||
preview?.l0?.mode ?? 'parallel',
|
||||
'P1 is pinned parallel server-side — a critical page is never held',
|
||||
).toBe('parallel');
|
||||
expect(preview.l0, 'the policy configured L0, so the P1 preview must carry its block').toBeTruthy();
|
||||
expect(preview.l0.mode, 'P1 is pinned parallel server-side — a critical page is never held').toBe('parallel');
|
||||
|
||||
const stream = uniqueName(`${workerPrefix(testInfo)}_stream`);
|
||||
await seedOnCallStream(page, stream);
|
||||
@@ -281,66 +243,56 @@ test.describe('On-call L0 / AI SRE', {
|
||||
},
|
||||
});
|
||||
const record = pages[0];
|
||||
|
||||
const progress = await getEscalationProgress(page, record.id);
|
||||
testLogger.info('§9.1 ladder progress', { priority: record.priority, progress });
|
||||
const first = (progress?.fired ?? [])[0];
|
||||
expect(first, 'a parallel priority pages immediately, so a rung must already have fired').toBeTruthy();
|
||||
expect(
|
||||
first?.after_micros ?? 0,
|
||||
'parallel means the page goes out at +0 while the agent runs alongside',
|
||||
).toBe(0);
|
||||
|
||||
expect(
|
||||
Number(record.priority),
|
||||
'the alert names P1, so its page must open at P1 — otherwise this asserts another rung\'s behaviour',
|
||||
).toBe(1);
|
||||
|
||||
const detail = await getResponse(page, record.id);
|
||||
const actors = new Set((detail?.events ?? []).map((e) => e?.actor).filter(Boolean));
|
||||
testLogger.info('§9.1 actors on an alert-backed P1 page', { actors: [...actors] });
|
||||
let progress;
|
||||
await expect.poll(async () => {
|
||||
progress = await getEscalationProgress(page, record.id);
|
||||
return (progress?.fired ?? []).length;
|
||||
}, {
|
||||
timeout: 60000, intervals: [2000], message: 'a parallel priority pages immediately, so a rung must have fired',
|
||||
}).toBeGreaterThan(0);
|
||||
testLogger.info('§9.1 ladder progress', { priority: record.priority, progress });
|
||||
expect(
|
||||
actors.has('o2-sre'),
|
||||
'the agent does not participate on an alert-backed page — see the open P1 in o2-enterprise#2481',
|
||||
).toBe(false);
|
||||
progress.fired[0].after_micros,
|
||||
'parallel means the page goes out at +0 while the agent runs alongside',
|
||||
).toBe(0);
|
||||
// Measured from `opened_at`, not `ladder_anchor`: a triage hold moves the anchor, so only the open time exposes it.
|
||||
expect(
|
||||
progress.fired[0].at - record.opened_at,
|
||||
'the first rung must fire inside the triage budget — a held P1 would fire only after it',
|
||||
).toBeLessThan(TRIAGE_BUDGET_SECONDS * 1_000_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* §9.1b — the verdict half, which this deployment cannot answer.
|
||||
*
|
||||
* The agent only joins a page whose subject is an INCIDENT. Two things have to
|
||||
* be true and neither is here: `/config` reports `incidents_enabled: false`, so
|
||||
* no incident-backed page can open at all, and `ai_enabled: false` with no model
|
||||
* provider configured, so nothing would attach a verdict even if one did.
|
||||
*
|
||||
* `creates_incident` is NOT the obstacle — it is a field on the ordinary alert
|
||||
* (`entity/alerts.rs`), and a plain alert accepts and stores it. An earlier note
|
||||
* here blamed the composite-alert entity; that was wrong, and it made the gap
|
||||
* look structural when it is only configuration.
|
||||
*
|
||||
* To un-park: run against a deployment with `O2_INCIDENTS_ENABLED=true`,
|
||||
* `O2_AI_ENABLED=true` and a real model provider, seed the alert with
|
||||
* `createsIncident: true`, and assert the verdict as below. Measuring this on an
|
||||
* alert-backed page instead is how the documented split gets misread as a
|
||||
* product bug (o2-enterprise#2481, open P1).
|
||||
*/
|
||||
test.fixme('§9.1b a parallel agent attaches its verdict on an incident-backed page. o2-enterprise#2481.', {
|
||||
test('§9.1b a parallel agent attaches its verdict on an alert-backed P1 page', {
|
||||
tag: ['@P1', '@oncall-l0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
test.setTimeout(900_000);
|
||||
const { team } = await seedTeamWithL0(page, testInfo);
|
||||
const record = { id: null, team };
|
||||
let verdicts = 0;
|
||||
const deadline = Date.now() + (TRIAGE_BUDGET_SECONDS + 120) * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const detail = await getResponse(page, record.id);
|
||||
verdicts = (detail?.events ?? []).filter((e) => e?.kind === 'ai_verdict').length;
|
||||
if (verdicts > 0) break;
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
expect(
|
||||
verdicts,
|
||||
'a parallel agent must still attach its verdict — running alongside is not running never',
|
||||
).toBeGreaterThan(0);
|
||||
const preview = await escalationPreview(page, team.id, 1);
|
||||
test.skip(!usableL0(preview), 'needs a reachable AI SRE agent (O2_AGENT_URL); this deployment has none');
|
||||
|
||||
const stream = uniqueName(`${workerPrefix(testInfo)}_stream`);
|
||||
await seedOnCallStream(page, stream);
|
||||
const { pages } = await firePageAndWait(page, {
|
||||
alertOptions: {
|
||||
name: uniqueName(`${workerPrefix(testInfo)}_alert`),
|
||||
stream,
|
||||
teamId: team.id,
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
await expect.poll(async () => {
|
||||
const detail = await getResponse(page, pages[0].id);
|
||||
return (detail?.events ?? []).filter((e) => e?.kind === 'ai_verdict').length;
|
||||
}, {
|
||||
timeout: (TRIAGE_BUDGET_SECONDS + 120) * 1000,
|
||||
intervals: [5000],
|
||||
message: 'a parallel agent must still attach its verdict — running alongside is not running never',
|
||||
}).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------- 9.2
|
||||
@@ -348,13 +300,13 @@ test.describe('On-call L0 / AI SRE', {
|
||||
/**
|
||||
* §9.2 — a gate holds the first rung for the budget, or until the verdict.
|
||||
*
|
||||
* What is assertable without a P2 page: the server resolves P2 to `gate` with
|
||||
* the budget the policy set, and the screen draws the hold ONLY when an agent
|
||||
* is actually reachable — `available: false` must draw no L0 step at all,
|
||||
* because a hold rendered there reads as configured and is wrong.
|
||||
* The server resolves P2 to `gate` with the budget the policy set, and the
|
||||
* screen draws the hold ONLY when an agent is actually reachable —
|
||||
* `available: false` must draw no L0 step at all, because a hold rendered
|
||||
* there reads as configured and is wrong.
|
||||
*
|
||||
* Blocked half: that the rung is genuinely deferred. It needs a page opened at
|
||||
* P2, which the seeding layer cannot do.
|
||||
* Not asserted: that the rung is genuinely deferred. With no reachable agent a
|
||||
* gate holds nothing, and CI runs without one.
|
||||
*/
|
||||
test('§9.2 P2 resolves to a gate with its budget, and the hold is drawn only when an agent exists', {
|
||||
tag: ['@P1', '@oncall-l0'],
|
||||
@@ -394,10 +346,10 @@ test.describe('On-call L0 / AI SRE', {
|
||||
*
|
||||
* Both halves of "nobody is paged" are read from the preview, which is the
|
||||
* dry run of exactly that question: P4 resolves to `only`, and the ladder for
|
||||
* P4 reaches nobody. That is assertable without opening a P4 page, which is
|
||||
* why it is the one case here that is not blocked on the seed's alert helper.
|
||||
* P4 reaches nobody.
|
||||
*
|
||||
* Blocked half: that a verdict is still recorded. That needs a P4 page.
|
||||
* Not asserted: that a verdict is still recorded. That needs a reachable
|
||||
* agent, and CI runs without one.
|
||||
*/
|
||||
test('§9.3 P4 is investigate-only until the team gives it a rung', {
|
||||
tag: ['@P1', '@oncall-l0'],
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* TS-04.02 a member on no rotation is named out loud, not silently stranded
|
||||
* TS-04.04 a member payload that adds nobody is refused (the W-02 guard)
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* WHAT IS DELIBERATELY *NOT* ASSERTED HERE:
|
||||
* - The exact default ladder DELAYS. The plan quotes the book's 5/15/30/60m;
|
||||
@@ -235,7 +236,7 @@ test.describe('On-call team staffing', {
|
||||
* green the moment the risk is derived, and weakening it to assert the gap
|
||||
* would pin the gap as correct.
|
||||
*/
|
||||
test.fixme('a member on no rotation is named as a config risk rather than silently stranded — not wired: config-risks derives no such kind, verified 15 Sep on :5090. o2-enterprise#2481.', {
|
||||
test.fixme('a member on no rotation is named as a config risk rather than silently stranded — config-risks derives no such kind. o2-enterprise#2481.', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const prefix = uniqueName(`${workerPrefix(testInfo)}_strand`);
|
||||
|
||||
@@ -29,9 +29,7 @@
|
||||
*
|
||||
* ISOLATION IS STRUCTURAL, NOT A FILTER. The inbox takes no user parameter at
|
||||
* all: the recipient is the auth header, server-side, and read markers live in
|
||||
* their own table keyed (org, user, event). The cross-user half of TS-19.03 is
|
||||
* parked — see its fixme — because every non-root identity on this deployment
|
||||
* gets 403 on everything.
|
||||
* their own table keyed (org, user, event).
|
||||
*/
|
||||
|
||||
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
|
||||
@@ -45,12 +43,12 @@ const {
|
||||
setTeamSchedule,
|
||||
rotation,
|
||||
detachPolicyFromRotations,
|
||||
setTeamPolicy,
|
||||
createOwnershipRule,
|
||||
seedOnCallStream,
|
||||
waitForStreamSearchable,
|
||||
seedNotificationDestination,
|
||||
firePageAndWait,
|
||||
triggerAlertOrLetSchedulerFire,
|
||||
listResponses,
|
||||
orgId,
|
||||
uniqueName,
|
||||
@@ -252,6 +250,7 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
await firePageAndWait(page, {
|
||||
// P1: the parallel rung. A lower priority walks the ladder one slot at a time.
|
||||
alertOptions: { name: `${prefix}_alert`, stream, destinations: [destination], priority: 1 },
|
||||
expectTeamId: team.id,
|
||||
});
|
||||
|
||||
// The inbox is keyed on the caller server-side, so the row arrives without
|
||||
@@ -299,55 +298,18 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
.toBeFalsy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of TS-19.03: one person's read marker is invisible to another.
|
||||
*
|
||||
* PARKED. The isolation is structural and I read it — `list_my_deliveries`
|
||||
* takes the recipient from the auth header with the comment "the caller,
|
||||
* never a parameter", the SQL filters `Recipient.eq(user_email)`, and the
|
||||
* read join is scoped to the caller's email ("joining on the event alone
|
||||
* reads a row a colleague read") — but proving it needs a SECOND identity
|
||||
* that can call the endpoint, and on this deployment no non-root identity
|
||||
* can. A freshly created **admin** gets 403 on `/oncall/my/deliveries`,
|
||||
* `/oncall/teams` and even `/streams`; role tuples are not being written, so
|
||||
* it is the environment, not the product. Verified 16 Sep on :5090.
|
||||
*/
|
||||
/**
|
||||
* TS-19.03b — read markers are per person, and that is the whole point of the
|
||||
* inbox: it answers "what have I not looked at", not "what has anyone looked at".
|
||||
*
|
||||
* PARKED on a seeding gap, not on a missing assertion. What is written below is
|
||||
* correct and runs; what is missing is a way to get one page into two inboxes.
|
||||
*
|
||||
* Measured on :5090 rather than assumed:
|
||||
* - a rotation is a SEQUENCE, so two members on one rotation yields ONE page
|
||||
* - adding members to a rotationless team leaves NOBODY on call: the mail that
|
||||
* goes out is "<team> has nobody on call", not a page, so no delivery row is
|
||||
* written for anyone. A staffed rotation is required before any of this works
|
||||
* - with a staffed rotation the inbox is fine — one member, one delivery row
|
||||
*
|
||||
* So the gap is purely the two-recipient seed: a page has to reach two people at
|
||||
* once, and neither one rotation with two members nor an auto-staffed team does
|
||||
* that. Two staffed rotations on one parallel rung is the shape to try next.
|
||||
*
|
||||
* Asserting isolation with ONE actor proves nothing — marking a row read and
|
||||
* reading it back as the same person is true by construction, which is what the
|
||||
* original stub here did.
|
||||
*/
|
||||
test.fixme('TS-19.03b one responder\'s read markers are invisible to another — not wired: no seeding shape found that puts ONE page in TWO inboxes, see the note above. o2-enterprise#2481.', {
|
||||
test('TS-19.03b one responder\'s read markers are invisible to another', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page, browser }, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const prefix = uniqueName(`${workerPrefix(testInfo)}_iso`);
|
||||
// createOrgUsers returns ADDRESSES, not user objects.
|
||||
const [mine, theirs] = await createOrgUsers(page, prefix, 2);
|
||||
|
||||
// No schedule is written on purpose. A rotation is a SEQUENCE — only one of
|
||||
// its members is on call at a time — so two people on one rotation produces
|
||||
// one page, not two. Adding members to a rotationless team auto-staffs the
|
||||
// primary and secondary slots and builds a P1 rung that pages both in
|
||||
// parallel, which is the only default shape that reaches two inboxes at once.
|
||||
const team = await createTeam(page, { name: `${prefix}_team` });
|
||||
await addTeamMembers(page, team.id, [mine, theirs]);
|
||||
// A rotation pages one person at a time; a whole-team first rung puts this one page in both inboxes.
|
||||
await setTeamPolicy(page, team.id, {
|
||||
rungs: [{ priority: 2, steps: [{ after_micros: 0, targets: [{ kind: 'whole_team' }] }], channels: ['email'] }],
|
||||
});
|
||||
|
||||
const service = `${prefix}_svc`;
|
||||
const stream = prefix.toLowerCase();
|
||||
@@ -355,9 +317,11 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
await waitForStreamSearchable(page, stream, seeded.records);
|
||||
await createOwnershipRule(page, { teamId: team.id, dimensions: { service } });
|
||||
const destination = await seedNotificationDestination(page, stream);
|
||||
await firePageAndWait(page, {
|
||||
alertOptions: { name: `${prefix}_alert`, stream, destinations: [destination] },
|
||||
const { pages } = await firePageAndWait(page, {
|
||||
alertOptions: { name: `${prefix}_alert`, stream, destinations: [destination], priority: 2 },
|
||||
expectTeamId: team.id,
|
||||
});
|
||||
const pageId = pages[0].id;
|
||||
|
||||
const first = await loginAs(browser, { email: mine });
|
||||
const second = await loginAs(browser, { email: theirs });
|
||||
@@ -365,13 +329,13 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
const rowFor = async (session) => {
|
||||
let found;
|
||||
await expect.poll(async () => {
|
||||
const box = await myDeliveries(session.page, { limit: 200 });
|
||||
found = (box?.deliveries ?? []).find((d) => String(d.title ?? '').includes(prefix));
|
||||
const box = await myDeliveries(session.page, { limit: 200, asSession: true });
|
||||
found = (box?.deliveries ?? []).find((d) => d.response_id === pageId);
|
||||
return Boolean(found);
|
||||
}, {
|
||||
timeout: 120000,
|
||||
intervals: [3000],
|
||||
message: 'both responders on the rotation must receive the page',
|
||||
message: 'both responders on the whole-team rung must receive the page',
|
||||
}).toBe(true);
|
||||
return found;
|
||||
};
|
||||
@@ -381,27 +345,30 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
testLogger.info('TS-19.03b both inboxes hold the page', {
|
||||
ours: ourRow.event_id, theirs: theirRow.event_id,
|
||||
});
|
||||
// Rows carry no recipient, so distinct ledger rows are what prove two different inboxes were read.
|
||||
expect(theirRow.event_id, 'each responder must be reading their own inbox row').not.toBe(ourRow.event_id);
|
||||
|
||||
// One person reads it. Their own marker is the only thing that may move.
|
||||
expect((await markDeliveriesRead(first.page, {
|
||||
eventIds: [ourRow.event_id], read: true,
|
||||
eventIds: [ourRow.event_id], read: true, asSession: true,
|
||||
})).status).toBe(200);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const box = await myDeliveries(first.page, { limit: 200 });
|
||||
const box = await myDeliveries(first.page, { limit: 200, asSession: true });
|
||||
return (box?.deliveries ?? []).find((d) => d.event_id === ourRow.event_id)?.read;
|
||||
}, {
|
||||
timeout: 30000, intervals: [1000],
|
||||
message: 'the reader\'s own marker must actually move',
|
||||
}).toBeTruthy();
|
||||
|
||||
const theirBox = await myDeliveries(second.page, { limit: 200 });
|
||||
const theirBox = await myDeliveries(second.page, { limit: 200, asSession: true });
|
||||
const stillTheirs = (theirBox.deliveries ?? []).find((d) => d.event_id === theirRow.event_id);
|
||||
testLogger.info('TS-19.03b the other inbox after the read', { read: stillTheirs?.read });
|
||||
expect(stillTheirs, 'the colleague\'s row must still be in their inbox').toBeTruthy();
|
||||
expect(
|
||||
stillTheirs?.read,
|
||||
stillTheirs.read,
|
||||
'a colleague reading a page must not mark it read for me',
|
||||
).toBeFalsy();
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await first.context.close().catch(() => {});
|
||||
await second.context.close().catch(() => {});
|
||||
@@ -460,8 +427,10 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
const destination = await seedNotificationDestination(page, stream);
|
||||
|
||||
// Firing one, closed with a distinctive cause: this is the history.
|
||||
// A 30-minute window keeps the rule firing well past the dampening wait, so the second firing is certain.
|
||||
const first = await firePageAndWait(page, {
|
||||
alertOptions: { name: `${prefix}_alert`, stream, destinations: [destination] },
|
||||
alertOptions: { name: `${prefix}_alert`, stream, destinations: [destination], periodMinutes: 30 },
|
||||
expectTeamId: team.id,
|
||||
});
|
||||
const earlier = first.pages[0];
|
||||
const cause = RESOLUTION_CAUSES[6]; // genuine_defect — distinctive on purpose
|
||||
@@ -488,8 +457,7 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
});
|
||||
await page.waitForTimeout(FLAP_DAMPENING_MS + 15_000);
|
||||
|
||||
// Firing two, from the SAME alert — a second firing of one rule.
|
||||
await triggerAlertOrLetSchedulerFire(page, first.alert.id);
|
||||
// Firing two, from the SAME alert: the scheduler re-fires it every minute once dampening ends.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const all = await listResponses(page, { teamId: team.id, includeResolved: true });
|
||||
@@ -522,6 +490,7 @@ test.describe('On-call — my duty, my inbox, my history', {
|
||||
await createOwnershipRule(page, { teamId: team.id, dimensions: { service: otherService } });
|
||||
const otherFire = await firePageAndWait(page, {
|
||||
alertOptions: { name: `${otherPrefix}_alert`, stream: otherStream, destinations: [destination] },
|
||||
expectTeamId: team.id,
|
||||
});
|
||||
const otherPriors = await getPriorCauses(page, otherFire.pages[0].id);
|
||||
expect(
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* On-Call — Quick start actually writes a schedule (TS-06.07)
|
||||
*
|
||||
* Plan: docs/test_generator/test-plans/oncall-ui-test-plan.md, Tier 1 —
|
||||
* TS-06.07 applying a preset must issue a real `PUT /teams/{id}/schedule`,
|
||||
* and the stored schedule afterwards must be the preset's.
|
||||
* TS-06.07 applying a preset must issue a real `POST /teams/{id}/schedule/from-preset`,
|
||||
* and the primary rotation afterwards must hold the preset's shift rules.
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* WHY THIS IS ITS OWN FILE AND NOT AN APPEND. `oncall-quickstart.spec.js` is
|
||||
* being repaired by another workstream in this same run, so it is off-limits.
|
||||
@@ -16,16 +17,16 @@
|
||||
* and issues NOTHING looks exactly like success: the drawer disappears, no
|
||||
* error is shown, and the team's schedule is whatever it was before. So the
|
||||
* assertion is not "the drawer closed" — it is that the request went out AND
|
||||
* that what came back is the preset's shape. `attemptQuickStartSave()` watches
|
||||
* the network for precisely this reason.
|
||||
* that what came back is the preset's shape. `confirmQuickStartReplace()` waits
|
||||
* on the write itself for precisely this reason.
|
||||
*
|
||||
* WHAT IS DELIBERATELY *NOT* ASSERTED HERE:
|
||||
* - The preset's exact restriction minutes. Those are the catalogue's
|
||||
* business decision, read from `GET /oncall/schedule-presets` at runtime;
|
||||
* pinning them here would make the test a copy of the catalogue rather than
|
||||
* a check on it. What is asserted is that the two staffed groups became two
|
||||
* rotations holding the people who were staffed into them, and that the
|
||||
* rotations are no longer the auto-created `source: "default"` pair.
|
||||
* shift rules on the primary rotation holding the people staffed into them,
|
||||
* and that the rotation is no longer the auto-created `source: "default"` one.
|
||||
*
|
||||
* Self-cleaning, worker-scoped prefix.
|
||||
*/
|
||||
@@ -42,7 +43,6 @@ const {
|
||||
setTeamSchedule,
|
||||
rotation,
|
||||
createOwnershipRule,
|
||||
listOwnershipRules,
|
||||
seedOnCallStream,
|
||||
waitForStreamSearchable,
|
||||
seedNotificationDestination,
|
||||
@@ -51,13 +51,17 @@ const {
|
||||
orgId,
|
||||
uniqueName,
|
||||
deleteOnCallFixturesByPrefix,
|
||||
deleteTeamById,
|
||||
} = require('../utils/oncall-seed.js');
|
||||
const {
|
||||
createOrgUsers,
|
||||
getCoverageGaps,
|
||||
waitForMail,
|
||||
requireMailSink,
|
||||
extractLinks,
|
||||
deleteDestination,
|
||||
deleteAlertById,
|
||||
} = require('../utils/oncall-seed-ext.js');
|
||||
const { withFreshOrg } = require('../utils/first-event-helpers.js');
|
||||
|
||||
const PREFIX = 'e2e_oncall_onboarding';
|
||||
|
||||
@@ -107,22 +111,8 @@ test.describe('On-call onboarding', {
|
||||
|
||||
// ------------------------------------------------------------------ TS-06.07
|
||||
|
||||
/**
|
||||
* UNWIRED, and kept as a fixme with its real assertions intact.
|
||||
*
|
||||
* Reproduced on this build, 15 Sep: with the `weekday_weekend` preset chosen
|
||||
* and both required groups staffed, Apply is ENABLED (`disabled: false`),
|
||||
* clicking it issues NO request (`requestSeen: false`), and the screen says
|
||||
* nothing about why (`error: null`, `invalidFields: 0`). A Quick start that
|
||||
* validates, looks successful and writes nothing is indistinguishable from
|
||||
* one that worked, which is the entire reason this case exists.
|
||||
*
|
||||
* The body asserts the CORRECT behaviour — the apply issues
|
||||
* `PUT /teams/{id}/schedule` and the stored schedule is the preset's — so it
|
||||
* flips green the moment the handler is wired, and weakening it to assert the
|
||||
* no-op would pin the defect as correct.
|
||||
*/
|
||||
test.fixme('applying a Quick start preset issues a real schedule write, not a silent no-op — not wired: Apply is enabled, issues no request and reports no error, verified 15 Sep on :5090. o2-enterprise#2481.', {
|
||||
// Apply rewrites only the PRIMARY rotation's shift rules in place, so escalation steps that name it keep working.
|
||||
test('applying a Quick start preset issues a real schedule write, not a silent no-op', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const prefix = uniqueName(`${workerPrefix(testInfo)}_preset`);
|
||||
@@ -130,14 +120,15 @@ test.describe('On-call onboarding', {
|
||||
const team = await createTeam(page, { name: `${prefix}_team` });
|
||||
await addTeamMembers(page, team.id, emails);
|
||||
|
||||
// The auto-staffed starting point, so "the schedule changed" is a real
|
||||
// before/after rather than an assumption about an empty team.
|
||||
const before = await getTeamSchedule(page, team.id);
|
||||
testLogger.info('TS-06.07 schedule before Quick start', {
|
||||
rotations: before.rotations.map((r) => ({ id: r.id, name: r.name, source: r.source })),
|
||||
});
|
||||
expect(before.rotations.length, 'a staffed team starts with an auto-created rotation for Quick start to rewrite')
|
||||
.toBeGreaterThan(0);
|
||||
expect(before.rotations.every((r) => r.source === 'default'),
|
||||
'a new team starts on auto-created rotations — that is what Quick start replaces').toBe(true);
|
||||
const [primary, ...others] = before.rotations;
|
||||
|
||||
await pm.oncallTeamDetailPage.goto(ORG, team.id, 'schedule');
|
||||
await pm.oncallTeamDetailPage.expectAvailable();
|
||||
@@ -145,167 +136,140 @@ test.describe('On-call onboarding', {
|
||||
await pm.oncallTeamDetailPage.openQuickStart();
|
||||
await pm.oncallTeamDetailPage.chooseQuickStartTemplate(PRESET_ID);
|
||||
|
||||
// Two groups staffed with different people, so the stored rotations can be
|
||||
// told apart afterwards.
|
||||
const staffing = {
|
||||
weekdays: [emails[0], emails[1]],
|
||||
weekend: [emails[2], emails[3]],
|
||||
};
|
||||
// Only `members` is required on this preset — `name` is optional and takes
|
||||
// a sensible default — so staffing is the whole of filling it in.
|
||||
for (const row of PRESET_ROWS) {
|
||||
await pm.oncallTeamDetailPage.setQuickStartRegionMembers(row, staffing[row]);
|
||||
}
|
||||
|
||||
// The network watch is the point: a Quick start that closes the drawer
|
||||
// without issuing a PUT looks identical to one that worked.
|
||||
const attempt = await pm.oncallTeamDetailPage.attemptQuickStartSave({ settleMs: 4000 });
|
||||
testLogger.info('TS-06.07 apply attempt', attempt);
|
||||
expect(attempt.disabled, 'a fully-filled Quick start must be applicable').toBe(false);
|
||||
expect(attempt.requestSeen, 'applying a preset must issue PUT /teams/{id}/schedule — a silent no-op is the defect')
|
||||
.toBe(true);
|
||||
expect(attempt.error, 'a valid preset must not be refused').toBeNull();
|
||||
await pm.oncallTeamDetailPage.applyQuickStart();
|
||||
await expect(pm.oncallTeamDetailPage.getConfirmDialog(),
|
||||
'rewriting a stored rotation is confirmed first, naming it').toContainText(primary.name, { timeout: 20000 });
|
||||
const write = await pm.oncallTeamDetailPage.confirmQuickStartReplace();
|
||||
expect(write.status(), `the preset write must be accepted: ${await write.text().catch(() => '')}`).toBe(200);
|
||||
|
||||
// And the stored schedule has to actually be the preset's.
|
||||
await expect.poll(
|
||||
async () => (await getTeamSchedule(page, team.id)).rotations.some((r) => r.source !== 'default'),
|
||||
{ timeout: 60000, intervals: [1000], message: 'the schedule never left its auto-created state' },
|
||||
).toBe(true);
|
||||
async () => (await getTeamSchedule(page, team.id)).rotations.find((r) => r.id === primary.id)?.source ?? 'gone',
|
||||
{ timeout: 60000, intervals: [1000], message: 'the primary rotation never left its auto-created state' },
|
||||
).not.toBe('default');
|
||||
|
||||
const after = await getTeamSchedule(page, team.id);
|
||||
testLogger.info('TS-06.07 schedule after Quick start', {
|
||||
rotations: after.rotations.map((r) => ({
|
||||
id: r.id, name: r.name, source: r.source,
|
||||
members: r.shift_rules?.[0]?.members,
|
||||
rules: (r.shift_rules ?? []).map((rule) => ({ name: rule.name, members: rule.members })),
|
||||
})),
|
||||
});
|
||||
expect(after.rotations.length, 'a two-group preset stores two rotations').toBe(PRESET_ROWS.length);
|
||||
|
||||
// Each staffed group became a rotation holding exactly the people put in it
|
||||
// — the strongest available statement that the preset, not something else,
|
||||
// is what got written.
|
||||
const storedMembers = after.rotations.map((r) => [...(r.shift_rules?.[0]?.members ?? [])].sort());
|
||||
const rewritten = after.rotations.find((r) => r.id === primary.id);
|
||||
expect(rewritten, 'Quick start must rewrite the primary rotation in place, not delete or replace it').toBeTruthy();
|
||||
expect(rewritten.name, 'the rewritten rotation keeps its name, so escalation steps still point at it')
|
||||
.toBe(primary.name);
|
||||
const storedMembers = (rewritten.shift_rules ?? []).map((rule) => [...(rule.members ?? [])].sort());
|
||||
expect(storedMembers.length, 'a two-group preset writes one shift rule per group').toBe(PRESET_ROWS.length);
|
||||
for (const row of PRESET_ROWS) {
|
||||
expect(storedMembers, `the "${row}" group must be stored with the people staffed into it`)
|
||||
.toContainEqual([...staffing[row]].sort());
|
||||
}
|
||||
// The write goes through `set_schedule`, which clears `source` on every rotation, so only the shape is compared.
|
||||
const shape = (r) => ({ id: r.id, name: r.name, shift_rules: r.shift_rules });
|
||||
expect(after.rotations.filter((r) => r.id !== primary.id).map(shape),
|
||||
'only the primary rotation is rewritten; every other rotation is left as it was').toEqual(others.map(shape));
|
||||
});
|
||||
|
||||
/**
|
||||
* TS-22.02 — the checklist is derived from live configuration, never
|
||||
* remembered.
|
||||
*
|
||||
* A checklist that stored "done" would keep saying done after somebody
|
||||
* deleted the destination it was about, and the first anybody would hear of
|
||||
* it is a page that announced nowhere.
|
||||
*
|
||||
* ASSERTED IN BOTH DIRECTIONS, INCLUDING ABSENCE. The obvious shape for this
|
||||
* test skips when the checklist is not drawn — and on any org that has been
|
||||
* used, it never is, so the case would be permanently skipped and nobody
|
||||
* would notice. But "not drawn" is itself derived: the component appears only
|
||||
* while at least one step is outstanding. So a fully configured org must draw
|
||||
* NOTHING, and that is just as much an assertion as a tick is. This computes
|
||||
* what the four steps should say from the live configuration and holds the
|
||||
* screen to it either way.
|
||||
*/
|
||||
// Staged in an org of its own: in the shared org other workers' setup completes all four steps, so the checklist is never drawn there.
|
||||
test('TS-22.02 checklist state is derived from live configuration, including its absence', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }) => {
|
||||
// What the product computes the four steps from, read independently.
|
||||
const teams = (await listTeams(page)) ?? [];
|
||||
const rules = (await listOwnershipRules(page)) ?? [];
|
||||
const gaps = await getCoverageGaps(page);
|
||||
const hasTeam = teams.length > 0;
|
||||
const hasRouting = rules.length > 0;
|
||||
// "Would any team page somebody": fewer teams in the gap list than exist.
|
||||
const gapCount = Array.isArray(gaps) ? gaps.length : (gaps?.total ?? gaps?.teams?.length ?? 0);
|
||||
const hasStaffedRotation = hasTeam && gapCount < teams.length;
|
||||
|
||||
await pm.oncallPagesListPage.goto(ORG);
|
||||
|
||||
const checklist = pm.oncallPagesListPage.getSetupChecklist();
|
||||
await pm.oncallPagesListPage.expandSetupChecklist();
|
||||
|
||||
if (!(await checklist.count())) {
|
||||
// Absence is derived too: the checklist withdraws only once nothing is
|
||||
// outstanding.
|
||||
//
|
||||
// NARROWED ON PURPOSE. I first asserted all four steps were satisfied and
|
||||
// it failed — because my re-derivation was wrong, not the product:
|
||||
// `hasRouting` in OnCallResponses.vue is
|
||||
// `rules.length > 0 || responses.some(r => !!r.team_id)`, so an org with
|
||||
// no ownership rules but routed pages legitimately has that step done.
|
||||
// Re-implementing the product's derivation only tests my copy of it. What
|
||||
// is checkable without duplicating internals is the unambiguous subset:
|
||||
// a withdrawn checklist must not be hiding an org with no teams, or one
|
||||
// where no team would page anybody.
|
||||
expect(
|
||||
hasTeam,
|
||||
'the checklist is not drawn, so the org must at least have a team — a checklist that hides an unconfigured org is remembering, not deriving',
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasStaffedRotation,
|
||||
'and at least one team must actually put somebody on call',
|
||||
).toBe(true);
|
||||
testLogger.info('TS-22.02 checklist absent, and the configuration says it should be', {
|
||||
teams: teams.length, rules: rules.length, gapCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(checklist).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const stateOf = (key) => pm.oncallPagesListPage.readSetupStepState(key);
|
||||
|
||||
// Each step's rendered state must follow from the configuration, not from
|
||||
// anything the component kept.
|
||||
// The snapshot above was taken BEFORE the screen rendered, and the org is
|
||||
// shared with every other worker — one of them creating a team between the
|
||||
// two reads makes the checklist right and the snapshot stale. So each step is
|
||||
// compared against a configuration re-read alongside it, bounded: the
|
||||
// assertion still requires the rendered state to follow from the
|
||||
// configuration, it just does not require the org to hold still.
|
||||
const satisfiedNow = async (key) => {
|
||||
const teamsNow = (await listTeams(page)) ?? [];
|
||||
if (key === 'team') return teamsNow.length > 0;
|
||||
if (key === 'routing') return ((await listOwnershipRules(page)) ?? []).length > 0;
|
||||
const g = await getCoverageGaps(page);
|
||||
const n = Array.isArray(g) ? g.length : (g?.total ?? g?.teams?.length ?? 0);
|
||||
return teamsNow.length > 0 && n < teamsNow.length;
|
||||
}, async ({ page }, testInfo) => {
|
||||
// Opening a page needs a real alert firing in the staged org.
|
||||
test.setTimeout(480_000);
|
||||
const list = pm.oncallPagesListPage;
|
||||
const checklist = list.getSetupChecklist();
|
||||
const stateOf = (key) => list.readSetupStepState(key);
|
||||
const expectSteps = async (expected, why) => {
|
||||
for (const [key, done] of Object.entries(expected)) {
|
||||
// A step that is not drawn reads null, so a missing checklist cannot pass as "not done".
|
||||
await expect.poll(async () => {
|
||||
const state = await stateOf(key);
|
||||
return state === null ? null : state === 'done';
|
||||
}, {
|
||||
timeout: 20000, intervals: [1000],
|
||||
message: `${why}: the "${key}" step must read ${done ? 'done' : 'not done'}`,
|
||||
}).toBe(done);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of ['team', 'routing', 'rotation']) {
|
||||
expect(await stateOf(key), `the "${key}" step must render its state as data, not only as a colour`)
|
||||
.toBeTruthy();
|
||||
await expect.poll(
|
||||
async () => (await stateOf(key)) === 'done' === (await satisfiedNow(key)),
|
||||
{ timeout: 20000, intervals: [1000],
|
||||
message: `the "${key}" step must read done exactly when the live configuration satisfies it` },
|
||||
).toBe(true);
|
||||
}
|
||||
await withFreshOrg(page, 'oncall_checklist', async (stagedOrg) => {
|
||||
const sharedOrg = process.env.ORGNAME;
|
||||
// Every seed helper reads its org from ORGNAME, and a worker runs one test at a time.
|
||||
process.env.ORGNAME = stagedOrg;
|
||||
let alertId = null;
|
||||
try {
|
||||
const prefix = uniqueName(`${workerPrefix(testInfo)}_chk`);
|
||||
const reload = async () => {
|
||||
await list.goto(stagedOrg);
|
||||
await list.waitForSetupContext();
|
||||
await list.expandSetupChecklist();
|
||||
await expect(checklist, 'an unfinished setup must draw the checklist').toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
// Nothing was clicked and nothing was stored, so a reload must recompute
|
||||
// the same answer from the same configuration.
|
||||
const before = await stateOf('team');
|
||||
await pm.oncallPagesListPage.goto(ORG);
|
||||
await pm.oncallPagesListPage.expandSetupChecklist();
|
||||
await expect(checklist).toBeVisible({ timeout: 30000 });
|
||||
expect(await stateOf('team'),
|
||||
'a reload must recompute the same state — the checklist remembers nothing of its own')
|
||||
.toBe(before);
|
||||
await reload();
|
||||
await expect(checklist, 'an org with nothing configured must be shown the checklist').toBeVisible({ timeout: 30000 });
|
||||
await expectSteps({ team: false, rotation: false, routing: false, destinations: false }, 'an empty org');
|
||||
await expect(list.getSetupCollapse(),
|
||||
'with no pages the checklist is the whole screen, so it offers nothing to hide it behind').toHaveCount(0);
|
||||
|
||||
// "Hide for now" is a view state, and a checklist that stayed hidden would
|
||||
// hide an unfinished setup for good.
|
||||
const collapse = pm.oncallPagesListPage.getSetupCollapse();
|
||||
if (await collapse.count()) {
|
||||
await collapse.first().click();
|
||||
await pm.oncallPagesListPage.goto(ORG);
|
||||
const reappeared = (await pm.oncallPagesListPage.getSetupBanner().count())
|
||||
+ (await checklist.count());
|
||||
expect(reappeared,
|
||||
'hiding the checklist must not survive a reload while the setup is still unfinished')
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const team = await createTeam(page, { name: `${prefix}_team` });
|
||||
await reload();
|
||||
await expectSteps({ team: true, rotation: false }, 'a team with nobody in it');
|
||||
|
||||
await deleteTeamById(page, team.id);
|
||||
await expect.poll(async () => (await listTeams(page)).length, { timeout: 30000 }).toBe(0);
|
||||
await reload();
|
||||
await expectSteps({ team: false }, 'the only team was deleted, so a remembered tick would be stale');
|
||||
|
||||
const [member] = await createOrgUsers(page, prefix, 1);
|
||||
const staffed = await createTeam(page, { name: `${prefix}_staffed` });
|
||||
await addTeamMembers(page, staffed.id, [member]);
|
||||
const destination = await seedNotificationDestination(page, prefix);
|
||||
await reload();
|
||||
await expectSteps({ team: true, rotation: true, routing: false, destinations: true },
|
||||
'a staffed team and a destination, but nothing routed');
|
||||
|
||||
const stream = `${prefix}_stream`.toLowerCase();
|
||||
await seedOnCallStream(page, stream);
|
||||
const fired = await firePageAndWait(page, {
|
||||
alertOptions: { name: `${prefix}_alert`, stream, teamId: staffed.id },
|
||||
});
|
||||
alertId = fired.alert.id;
|
||||
|
||||
// A page bound straight to a team completes routing without a rule, so all four steps are done.
|
||||
await list.goto(stagedOrg);
|
||||
await expect(list.getRowByIndex(0), 'the fired page must be on the list').toBeVisible({ timeout: 30000 });
|
||||
await list.waitForSetupContext();
|
||||
await expect(checklist, 'a fully configured org must draw no checklist').toHaveCount(0);
|
||||
await expect(list.getSetupBanner(), 'and no setup banner either').toHaveCount(0);
|
||||
|
||||
const deleted = await deleteDestination(page, destination, stagedOrg);
|
||||
expect(deleted.ok(), `deleting the destination must succeed: ${deleted.status()}`).toBe(true);
|
||||
await list.goto(stagedOrg);
|
||||
await expect(list.getSetupBanner(),
|
||||
'the only destination is gone, so the setup is unfinished again and must say so over the live list')
|
||||
.toBeVisible({ timeout: 30000 });
|
||||
await list.expandSetupChecklist();
|
||||
await expectSteps({ team: true, rotation: true, routing: true, destinations: false }, 'the destination was deleted');
|
||||
|
||||
await list.getSetupCollapse().click();
|
||||
await expect(checklist, '"Hide for now" folds the checklist back into its banner').toHaveCount(0);
|
||||
await list.goto(stagedOrg);
|
||||
await expect(list.getSetupBanner(),
|
||||
'hiding the checklist must not survive a reload while the setup is still unfinished').toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
if (alertId) await deleteAlertById(page, alertId, stagedOrg);
|
||||
process.env.ORGNAME = sharedOrg;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -442,12 +406,13 @@ test.describe('On-call onboarding', {
|
||||
* declaration on the `h1` fixes it. Kept as a failing expectation so it goes
|
||||
* green when that lands.
|
||||
*/
|
||||
test.fixme('TS-16.06 the acknowledge landing page is usable at a phone viewport — not wired: the ack page h1 has no word-break, so a long record title overflows a 390px viewport by 159px and can push the Acknowledge button off screen (ack_confirm_page, request/oncall/mod.rs), verified 16 Sep on :5090. o2-enterprise#2481.', {
|
||||
test.fixme('TS-16.06 the acknowledge landing page is usable at a phone viewport — a long title overflows it. o2-enterprise#2481.', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page, browser }, testInfo) => {
|
||||
// A real firing has to clear ingestion, the scheduler and the ladder; the
|
||||
// 3-minute default is for screens, not for this.
|
||||
test.setTimeout(480_000);
|
||||
await requireMailSink(test);
|
||||
const prefix = uniqueName(`${workerPrefix(testInfo)}_ack`);
|
||||
const emails = await createOrgUsers(page, prefix, 1);
|
||||
const team = await createTeam(page, { name: `${prefix}_team` });
|
||||
@@ -475,7 +440,7 @@ test.describe('On-call onboarding', {
|
||||
const mail = await waitForMail({
|
||||
to: emails[0], sinceMs: since, subject: /\[P\d\]/, timeout: 180000,
|
||||
});
|
||||
test.skip(!mail, 'no mail sink is reachable on this deployment, so there is no ack link to open');
|
||||
expect(mail, `a real firing must page ${emails[0]} by email`).not.toBeNull();
|
||||
|
||||
const ackLink = extractLinks(mail).find((u) => /\/oncall\/ack\?/.test(u));
|
||||
expect(ackLink, 'a page email must carry an acknowledge link — that is its purpose').toBeTruthy();
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* them, and does not silently invent one
|
||||
* TS-29.07 console and network hygiene on every on-call route
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* FOUR SURFACES, ONE TRUTH. A page record is described in four places — the
|
||||
* record itself, the human timeline, the delivery ledger and the cause
|
||||
@@ -41,6 +42,7 @@
|
||||
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
|
||||
const testLogger = require('../utils/test-logger.js');
|
||||
const PageManager = require('../../pages/page-manager.js');
|
||||
const { isNoReportYet } = require('../../pages/oncallPages/oncallResponseDetailPage.js');
|
||||
const path = require('path');
|
||||
|
||||
/** The signed-in state global-setup writes; see enhanced-baseFixtures.js. */
|
||||
@@ -70,6 +72,7 @@ const {
|
||||
getCauseAnalytics,
|
||||
eventsOfKind,
|
||||
getResponseHistory,
|
||||
requireMailSink,
|
||||
waitForMail,
|
||||
waitForResponseState,
|
||||
RESOLUTION_CAUSES,
|
||||
@@ -150,13 +153,14 @@ test.describe('On-call page lifecycle', {
|
||||
test('the golden path: fire, page the right person, ack, resolve with a cause', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
await requireMailSink(test);
|
||||
const f = await seedRoutedAlert(page, testInfo, 'golden');
|
||||
// Two later rungs inside a minute, so "the ack stopped the ladder" is a
|
||||
// claim about rungs that would otherwise have fired during the test.
|
||||
await shortenPolicyForSpeed(page, f.team.id, 2, [0, 45, 90]);
|
||||
|
||||
const since = Date.now() - 2000;
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const record = pages[0];
|
||||
testLogger.info('TS-15.01 record opened', { id: record.id, team: record.team_id, priority: record.priority });
|
||||
expect(record.team_id, 'the ownership rule must put the page on the team that claims the service')
|
||||
@@ -247,94 +251,68 @@ test.describe('On-call page lifecycle', {
|
||||
|
||||
// ------------------------------------------------------------------ TS-15.03
|
||||
|
||||
/**
|
||||
* UNWIRED, and kept as a fixme with its real assertions intact.
|
||||
*
|
||||
* Verified on this build, 15 Sep: an alert on a one-minute evaluation window
|
||||
* over a stream nothing is still writing to DOES notice it went healthy — the
|
||||
* record's timeline collects `recovery: the alert stopped firing`, once per
|
||||
* evaluation — and the record nevertheless stays `state: "triggered"` with no
|
||||
* `closed_at` eight minutes and seven recovery events later. The engine sees
|
||||
* the recovery and does not act on it.
|
||||
*
|
||||
* That is the worst shape this can take: the Pages list keeps showing a page
|
||||
* that needs somebody for a condition that has already cleared, so the screen
|
||||
* that exists to say "what still needs a human" fills up with things that do
|
||||
* not. The body asserts the correct behaviour — the record closes itself, no
|
||||
* acknowledgement is forged, and nothing is delivered afterwards — so it goes
|
||||
* green the moment the recovery is acted on.
|
||||
*/
|
||||
test.fixme('a recovery closes the record without a human, and says nobody answered — not wired: the record collects repeated `recovery` events and stays triggered, verified 15 Sep on :5090. o2-enterprise#2481.', {
|
||||
test('a recovery closes the record without a human, and says nobody answered', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
test.setTimeout(15 * 60 * 1000);
|
||||
// A one-minute evaluation window over a stream nothing is still writing to:
|
||||
// the window drains on its own, which is what "the condition went healthy"
|
||||
// looks like from the engine's side.
|
||||
test.setTimeout(10 * 60 * 1000);
|
||||
// A one-minute window over rows ingested once drains within about two minutes: that is the recovery.
|
||||
const f = await seedRoutedAlert(page, testInfo, 'recover', {
|
||||
periodMinutes: 1, frequencyMinutes: 1,
|
||||
});
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const record = pages[0];
|
||||
testLogger.info('TS-15.03 record opened', { id: record.id });
|
||||
|
||||
// Nobody acks. The close must happen anyway.
|
||||
const closed = await waitForResponseState(page, record.id, ['resolved', 'recovered', 'closed'], {
|
||||
timeout: 10 * 60 * 1000, pollMs: 10000,
|
||||
const closed = await waitForResponseState(page, record.id, ['resolved'], {
|
||||
timeout: 6 * 60 * 1000, pollMs: 5000,
|
||||
});
|
||||
// The record row is written before its `state` event is appended, so the timeline can lag the state.
|
||||
let detail;
|
||||
await expect.poll(async () => {
|
||||
detail = await getResponseDetail(page, record.id);
|
||||
return eventsOfKind(detail.events, 'state').some((e) => /^resolved/.test(String(e.body)));
|
||||
}, { timeout: 30000, intervals: [1000], message: 'the resolved state event never reached the timeline' })
|
||||
.toBe(true);
|
||||
testLogger.info('TS-15.03 record after recovery', {
|
||||
state: closed.state, closedAt: closed.closed_at, ackedBy: closed.acked_by,
|
||||
state: closed.state, closedAt: closed.closed_at, ackedBy: closed.acked_by, cause: closed.cause,
|
||||
events: detail.events.map((e) => [e.kind, e.actor, e.body]),
|
||||
});
|
||||
expect(closed.state, 'an alert that stops firing must close its own record — nobody is coming')
|
||||
.not.toBe('triggered');
|
||||
|
||||
// "Nobody ever answered this" has to stay countable: an un-acked recovery
|
||||
// is a different fact from one a person handled, and MTTA depends on it.
|
||||
expect(closed.closed_at, 'a record that closed itself must carry its close time').toBeTruthy();
|
||||
expect(closed.acked_by ?? null, 'a recovery must not forge an acknowledgement nobody made').toBeNull();
|
||||
expect(eventsOfKind(detail.events, 'ack'), 'nobody answered, and the timeline must not say otherwise')
|
||||
.toEqual([]);
|
||||
expect(closed.cause ?? null, 'the engine knows the signal stopped, not why, so it must not invent a cause')
|
||||
.toBeNull();
|
||||
|
||||
// The timeline says WHY it closed, so a reader is not left guessing.
|
||||
const detail = await getResponseDetail(page, record.id);
|
||||
const closing = detail.events.filter((e) => /recover|resolv|clos/i.test(String(e.body ?? '')));
|
||||
testLogger.info('TS-15.03 closing events', { closing });
|
||||
expect(closing.length, 'a record that closes itself must say so on its own timeline').toBeGreaterThan(0);
|
||||
expect(closing[closing.length - 1].actor, 'an automatic close is the engine\'s act, not a person\'s')
|
||||
.not.toBe(closed.acked_by ?? 'never');
|
||||
const recoveries = eventsOfKind(detail.events, 'recovery');
|
||||
expect(recoveries.length, 'the timeline must say the signal recovered — once, not once per evaluation')
|
||||
.toBe(1);
|
||||
expect(recoveries[0].actor, 'an automatic close is the engine\'s act, not a person\'s').toBe('o2-engine');
|
||||
const closes = eventsOfKind(detail.events, 'state').filter((e) => /^resolved/.test(String(e.body)));
|
||||
expect(closes.length, 'the record closed once').toBe(1);
|
||||
expect(closes[0].actor, 'the engine closed it').toBe('o2-engine');
|
||||
expect(closes[0].body, 'and the timeline says it recovered rather than that someone fixed it')
|
||||
.toMatch(/recovered automatically/);
|
||||
// `o2-sre` is the AI agent, which writes verdicts on agent-enabled deployments; neither is a person.
|
||||
const humanActors = detail.events.filter((e) => !['o2-engine', 'o2-sre'].includes(e.actor));
|
||||
expect(humanActors, 'nobody but the engine may have acted on a page nobody answered').toEqual([]);
|
||||
|
||||
// And the escalation stops with it: no delivery after the close.
|
||||
const ledger = await getDeliveries(page, record.id);
|
||||
const afterClose = ledger.deliveries.filter((row) => row.at > closed.closed_at);
|
||||
expect(afterClose, 'nothing may be delivered for a record that has already closed').toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The header half of TS-15.01, UNWIRED, kept as a fixme with its real
|
||||
* assertions intact.
|
||||
*
|
||||
* Verified on this build, 15 Sep: on an open record whose
|
||||
* `oncall-response-detail-page` root, `oncall-response-stats` and
|
||||
* `oncall-response-elapsed` all render, the ENTIRE subtitle slot is absent —
|
||||
* `oncall-response-subtitle`, `oncall-response-team-link` and
|
||||
* `oncall-response-firing` each have a DOM count of 0. The component draws
|
||||
* them under `v-if="response"` inside the page header's `#subtitle` slot, and
|
||||
* that slot is not reaching the page.
|
||||
*
|
||||
* What it costs a responder: the record never says which team it belongs to,
|
||||
* never links to that team's rotation — `OnCallResponseDetail.vue` calls that
|
||||
* link "the next click a responder makes" in its own comment — and never
|
||||
* shows the firing count that distinguishes "again" from "first time". The
|
||||
* rest of the golden path works, which is why it is asserted green above and
|
||||
* this is a separate fixme rather than a failure hung on the whole case.
|
||||
*/
|
||||
test.fixme('the record header names the team the page belongs to and links to it — not wired: the whole subtitle slot renders zero elements, verified 15 Sep on :5090. o2-enterprise#2481.', {
|
||||
test('the record header names the team the page belongs to and links to it', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const f = await seedRoutedAlert(page, testInfo, 'header');
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
|
||||
await pm.oncallResponseDetailPage.goto(ORG, pages[0].id);
|
||||
await pm.oncallResponseDetailPage.expectDetailVisible();
|
||||
await pm.oncallResponseDetailPage.expectTeamLinkNames(f.team.name);
|
||||
await pm.oncallResponseDetailPage.expectSubtitleContains(f.team.name);
|
||||
await pm.oncallResponseDetailPage.expectTeamLinkNames(f.team.name);
|
||||
await pm.oncallResponseDetailPage.expectTeamLinkTargets(f.team.id);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ TS-15.04
|
||||
@@ -346,11 +324,14 @@ test.describe('On-call page lifecycle', {
|
||||
const f = await seedRoutedAlert(page, testInfo, 'flap');
|
||||
// Deliberately NOT shortened: the point is that nothing new fires, so the
|
||||
// default P2 ladder's five-minute second rung stays out of the window.
|
||||
const { alert, pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { alert, pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const record = pages[0];
|
||||
// The record opens before its first rung's delivery rows are written.
|
||||
await expect.poll(
|
||||
async () => (await getDeliveries(page, record.id))?.total ?? 0,
|
||||
{ timeout: 90000, intervals: [2000], message: 'the first rung has to have fired for "once" to mean anything' },
|
||||
).toBeGreaterThan(0);
|
||||
const firstLedger = await getDeliveries(page, record.id);
|
||||
expect(firstLedger.total, 'the first rung has to have fired for "once" to mean anything')
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// The alert re-evaluates every minute over a window that still holds rows,
|
||||
// so it keeps firing for the whole of this wait.
|
||||
@@ -380,7 +361,7 @@ test.describe('On-call page lifecycle', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const f = await seedRoutedAlert(page, testInfo, 'cause');
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const record = pages[0];
|
||||
|
||||
await pm.oncallResponseDetailPage.goto(ORG, record.id);
|
||||
@@ -421,7 +402,7 @@ test.describe('On-call page lifecycle', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const f = await seedRoutedAlert(page, testInfo, 'hygiene');
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const record = pages[0];
|
||||
|
||||
const consoleErrors = [];
|
||||
@@ -442,10 +423,15 @@ test.describe('On-call page lifecycle', {
|
||||
const sharedShellAsset = (message) =>
|
||||
/Failed to load resource/.test(message.text())
|
||||
&& (message.location()?.url ?? '').includes('/web/src/assets/');
|
||||
const missingReport = (message) =>
|
||||
/Failed to load resource: .* 404/.test(message.text())
|
||||
&& Boolean(message.location()?.url) && isNoReportYet(message.location().url, ORG, record.id);
|
||||
|
||||
page.on('console', (message) => {
|
||||
const text = message.text();
|
||||
if (message.type() === 'error' && !sharedShellAsset(message)) consoleErrors.push(text);
|
||||
if (message.type() === 'error' && !sharedShellAsset(message) && !missingReport(message)) {
|
||||
consoleErrors.push(text);
|
||||
}
|
||||
if (/\[Vue warn\]/.test(text)) vueWarnings.push(text);
|
||||
});
|
||||
page.on('pageerror', (error) => consoleErrors.push(`uncaught: ${error.message}`));
|
||||
@@ -454,7 +440,9 @@ test.describe('On-call page lifecycle', {
|
||||
const url = response.url();
|
||||
if (!/\/api\/.*\/oncall\//.test(url)) return;
|
||||
apiCallsHere += 1;
|
||||
if (response.status() === 404) notFound.push(`${response.status()} ${url}`);
|
||||
if (response.status() === 404 && !isNoReportYet(url, ORG, record.id)) {
|
||||
notFound.push(`${response.status()} ${url}`);
|
||||
}
|
||||
// G24: the Pages list once called a count endpoint that does not exist,
|
||||
// 404ing on every single load without anybody noticing.
|
||||
if (/\/oncall\/responses\/count/.test(url)) countCalls.push(url);
|
||||
@@ -517,7 +505,7 @@ test.describe('On-call page lifecycle', {
|
||||
// 3-minute default is for screens, not for this.
|
||||
test.setTimeout(600_000);
|
||||
const f = await seedRoutedAlert(page, testInfo, 'deep');
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const id = pages[0].id;
|
||||
|
||||
// A genuinely cold context: no history, no warmed store, no prior route —
|
||||
@@ -537,7 +525,9 @@ test.describe('On-call page lifecycle', {
|
||||
const failures = [];
|
||||
cold.on('response', (res) => {
|
||||
const url = res.url();
|
||||
if (url.includes('/oncall/') && res.status() >= 400) failures.push(`${res.status()} ${url}`);
|
||||
if (!url.includes('/oncall/') || res.status() < 400) return;
|
||||
if (res.status() === 404 && isNoReportYet(url, ORG, id)) return;
|
||||
failures.push(`${res.status()} ${url}`);
|
||||
});
|
||||
|
||||
await coldPm.oncallResponseDetailPage.goto(ORG, id);
|
||||
@@ -596,7 +586,7 @@ test.describe('On-call page lifecycle', {
|
||||
// 3-minute default is for screens, not for this.
|
||||
test.setTimeout(600_000);
|
||||
const f = await seedRoutedAlert(page, testInfo, 'ledger');
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
const { pages } = await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
const id = pages[0].id;
|
||||
|
||||
await expect.poll(async () => (await getDeliveries(page, id))?.total, { timeout: 120000 })
|
||||
@@ -654,7 +644,7 @@ test.describe('On-call page lifecycle', {
|
||||
// 3-minute default is for screens, not for this.
|
||||
test.setTimeout(600_000);
|
||||
const f = await seedRoutedAlert(page, testInfo, 'g24');
|
||||
await firePageAndWait(page, { alertOptions: f.alertOptions });
|
||||
await firePageAndWait(page, { alertOptions: f.alertOptions, expectTeamId: f.team.id });
|
||||
|
||||
// Scoped to on-call's OWN endpoints. G24 was an on-call count request that
|
||||
// did not exist; widening this to every `/api/` call on the screen would
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
*
|
||||
* Plan: docs/test_generator/features/oncall-test-plan.md
|
||||
* §2.4 the page detail badges each responder with what the ledger says
|
||||
* §3.2 an exhausted ladder reads "Ladder finished — nobody left"
|
||||
* §3.2 an exhausted ladder reads "Nobody answered"
|
||||
* §3.3 a ladder still climbing keeps saying so — the wording is per-state
|
||||
* §11.3 a group heading's count against the rows actually drawn
|
||||
* §11.6 a teamless page reads "Unrouted", and the exhaust reason must not
|
||||
* claim the team was deleted
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* SLOW BY NATURE. Every case here needs a REAL page, which means an alert that
|
||||
* fires and a scheduler that turns the firing into a record — `firePageAndWait`
|
||||
@@ -58,13 +59,14 @@ const {
|
||||
deleteOnCallFixturesByPrefix,
|
||||
} = require('../utils/oncall-seed.js');
|
||||
const { createOrgUser, oncallUserEmail } = require('../utils/oncall-seed-ext.js');
|
||||
const { DEFAULT_TEAM_LOCK, acquireOrgLock } = require('../utils/oncall-org-lock.js');
|
||||
|
||||
const PREFIX = 'e2e_oncall_pages';
|
||||
|
||||
/** Wording the plan pins, verbatim from `oncall.ladderFinished`. */
|
||||
const FINISHED_WORDING = 'Ladder finished';
|
||||
/** The blanket wording §3.2 guards against, from `oncall.escalationClimbing`. */
|
||||
const CLIMBING_WORDING = 'Escalating';
|
||||
/** Wording the plan pins, verbatim from `oncall.state_exhausted`. */
|
||||
const FINISHED_WORDING = 'Nobody answered';
|
||||
/** The climbing wording an exhausted ladder must drop, from `oncall.escalationPagingStep*`. */
|
||||
const CLIMBING_WORDING = 'Paging step';
|
||||
/** `oncall.statUnrouted` — what a page nothing claimed must say. */
|
||||
const UNROUTED_WORDING = 'Unrouted';
|
||||
|
||||
@@ -177,7 +179,16 @@ test.describe('On-call pages list', {
|
||||
|
||||
test('the pages list loads with its table', {
|
||||
tag: ['@P0', '@smoke'],
|
||||
}, async () => {
|
||||
}, async ({ page }, testInfo) => {
|
||||
test.setTimeout(480_000);
|
||||
// An org that has never paged draws the setup checklist instead of the table, and CI starts every shard fresh.
|
||||
const { team } = await seedPageableTeam(page, testInfo);
|
||||
const stream = uniqueName(`${workerPrefix(testInfo)}_stream`);
|
||||
await seedOnCallStream(page, stream);
|
||||
await firePageAndWait(page, {
|
||||
alertOptions: { name: uniqueName(`${workerPrefix(testInfo)}_alert`), stream, teamId: team.id },
|
||||
});
|
||||
|
||||
await pm.oncallPagesListPage.goto(ORG);
|
||||
await pm.oncallPagesListPage.expectAvailable();
|
||||
await pm.oncallPagesListPage.expectListVisible();
|
||||
@@ -244,7 +255,7 @@ test.describe('On-call pages list', {
|
||||
* situations. The honest wording is per-state, so this reads the same cell
|
||||
* twice: once while the ladder is still climbing — it must not already claim
|
||||
* to be finished — and once after the server reports exhaustion, where it must
|
||||
* say so in words and must NOT still read "Escalating".
|
||||
* say so in words and must NOT still read "Paging step".
|
||||
*
|
||||
* BOTH READS GO THROUGH THE TEAM FILTER, and that is what makes this test
|
||||
* survive a busy org rather than a freshly-wiped one. The list loads a
|
||||
@@ -277,6 +288,8 @@ test.describe('On-call pages list', {
|
||||
name: uniqueName(`${workerPrefix(testInfo)}_alert`),
|
||||
stream,
|
||||
teamId: team.id,
|
||||
// seedPageableTeam writes a P1-only ladder; at the default P2 nothing would climb at all.
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
const responseId = pages[0].id;
|
||||
@@ -292,11 +305,20 @@ test.describe('On-call pages list', {
|
||||
// §3.3 — while it is still climbing the cell must not claim it is over.
|
||||
await openListOnThisTeam();
|
||||
await expect(cell, 'the freshly opened page must be on the list').toBeVisible({ timeout: 60000 });
|
||||
const climbing = await getEscalationProgress(page, responseId);
|
||||
if (climbing && climbing.exhausted !== true) {
|
||||
await expect(cell, 'a ladder still due to fire must not read as finished')
|
||||
.not.toContainText(FINISHED_WORDING);
|
||||
}
|
||||
// Read after the list fetched, so a ladder still climbing now was climbing when the cell was drawn.
|
||||
// The first rung is written after the record opens, so it is polled for rather than read once.
|
||||
let climbing;
|
||||
await expect.poll(async () => {
|
||||
climbing = await getEscalationProgress(page, responseId);
|
||||
return climbing?.fired?.length ?? 0;
|
||||
}, {
|
||||
timeout: 60_000,
|
||||
intervals: [2000],
|
||||
message: 'the P1 ladder must have fired its first rung',
|
||||
}).toBeGreaterThan(0);
|
||||
expect(climbing?.exhausted, 'a ladder whose second rung is a minute away has not run out yet').not.toBe(true);
|
||||
await expect(cell, 'a ladder still due to fire must not read as finished')
|
||||
.not.toContainText(FINISHED_WORDING);
|
||||
|
||||
// The ladder's own cadence decides when it runs out; poll the server rather
|
||||
// than the screen, so a UI that never updates fails as a UI failure.
|
||||
@@ -329,52 +351,34 @@ test.describe('On-call pages list', {
|
||||
* §11.6 — a page nothing routed is "Unrouted", and the reason must be true.
|
||||
*
|
||||
* A teamless page is still opened and must stay closable, so the row reads
|
||||
* "Unrouted" rather than going blank. The second half matters more: the
|
||||
* exhaust reason has to say nothing claimed it, not that a team was deleted —
|
||||
* the second sends somebody looking for a team that never existed.
|
||||
*
|
||||
* Requires the org to have NO default routing team. Setting one is org-wide
|
||||
* and would break every other worker's routing, so this skips rather than
|
||||
* touching it.
|
||||
*
|
||||
* PARKED ON A CONFIRMED PRODUCT DEFECT, NOT ON A TEST PROBLEM. The second
|
||||
* half genuinely fails on this build, and the assertion below is the correct
|
||||
* one — so it is left exactly as written and the test is fixme'd rather than
|
||||
* softened. Reproduced without Playwright: fire an alert with no
|
||||
* `oncall_team` into an org whose `default_team_id` is null, and
|
||||
* `GET /api/{org}/oncall/responses/{id}/escalation` answers
|
||||
* {"stopped_because": "the owning team was deleted — this page needs a new owner"}
|
||||
* on a page whose `team_id` has been null since it opened. Nothing was ever
|
||||
* deleted; no team ever claimed it. The string belongs to the enterprise
|
||||
* backend (it appears in this repo only as a fixture, in
|
||||
* web/src/components/oncall/OnCallEscalation.spec.ts:430), so the fix is a
|
||||
* paired o2-enterprise change. Everything up to that assertion — the page
|
||||
* opening teamless, the row reading "Unrouted" — passes.
|
||||
* "Unrouted" rather than going blank, and the reason it went nowhere must say
|
||||
* nothing claimed it rather than blame a deletion that never happened.
|
||||
*/
|
||||
test.fixme('§11.6 a teamless page reads Unrouted and blames nothing on a deletion. o2-enterprise#2481.', {
|
||||
test('§11.6 a teamless page reads Unrouted and blames nothing on a deletion', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
test.setTimeout(480_000);
|
||||
const routing = await getRoutingConfig(page);
|
||||
test.skip(
|
||||
Boolean(routing?.default_team_id),
|
||||
'this org nominates a catch-all team, so nothing can be unrouted — clearing it is org-wide and would break parallel workers',
|
||||
);
|
||||
|
||||
test.setTimeout(720_000);
|
||||
const stream = uniqueName(`${workerPrefix(testInfo)}_stream`);
|
||||
// A dimension value nothing owns: the shared service names are claimed by
|
||||
// other fixtures, and a rule matching one would route this page after all.
|
||||
// A dimension value nothing owns, so no ownership rule can claim the page.
|
||||
const service = uniqueName(`${workerPrefix(testInfo)}_svc`);
|
||||
await seedOnCallStream(page, stream, { services: [service] });
|
||||
|
||||
// No `oncall_team`, so routing is left to the rules and nothing claims the page.
|
||||
const destination = await seedNotificationDestination(page, workerPrefix(testInfo));
|
||||
const alertName = uniqueName(`${workerPrefix(testInfo)}_alert`);
|
||||
const { pages } = await firePageAndWait(page, {
|
||||
alertOptions: { name: alertName, stream, destinations: [destination] },
|
||||
});
|
||||
const record = pages[0];
|
||||
expect(record.team_id ?? null, 'nothing must have claimed this page').toBeFalsy();
|
||||
|
||||
// Held until the page has opened: a default team nominated meanwhile by the routing spec would claim it.
|
||||
const releaseLock = await acquireOrgLock(DEFAULT_TEAM_LOCK, { testInfo });
|
||||
let record;
|
||||
try {
|
||||
expect((await getRoutingConfig(page))?.default_team_id ?? null,
|
||||
'no test may leave a default team nominated, or nothing in this org can be unrouted').toBeNull();
|
||||
const { pages } = await firePageAndWait(page, {
|
||||
alertOptions: { name: alertName, stream, destinations: [destination] },
|
||||
expectTeamId: null,
|
||||
});
|
||||
record = pages[0];
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
await pm.oncallPagesListPage.goto(ORG);
|
||||
await pm.oncallPagesListPage.expectListVisible();
|
||||
@@ -388,18 +392,20 @@ test.describe('On-call pages list', {
|
||||
'a page no rule claimed reads Unrouted — the team cell is a tag, not a link, so it carries no row-scoped data-test',
|
||||
).toContainText(UNROUTED_WORDING, { timeout: 30000 });
|
||||
|
||||
// The sentences the product would show for why this went nowhere.
|
||||
const progress = await getEscalationProgress(page, record.id);
|
||||
const detail = await getResponse(page, record.id);
|
||||
expect(progress, 'the escalation state of an open page must be readable').toBeTruthy();
|
||||
expect(detail, 'the page must be readable').toBeTruthy();
|
||||
testLogger.info('§11.6 escalation state', { stoppedBecause: progress.stopped_because });
|
||||
expect(progress.stopped_because, 'the ladder must say why it is not climbing: nothing ever routed this page')
|
||||
.toMatch(/never routed/i);
|
||||
|
||||
const reasons = [
|
||||
progress?.stopped_because,
|
||||
detail?.response?.why_not,
|
||||
...(detail?.events ?? [])
|
||||
.filter((e) => e?.kind === 'exhausted')
|
||||
.map((e) => e?.body),
|
||||
progress.stopped_because,
|
||||
detail.response?.why_not,
|
||||
...(detail.events ?? []).filter((e) => e?.kind === 'exhausted').map((e) => e?.body),
|
||||
].filter((s) => typeof s === 'string' && s.length > 0);
|
||||
testLogger.info('§11.6 exhaust reasons', { reasons });
|
||||
|
||||
for (const reason of reasons) {
|
||||
expect(
|
||||
reason,
|
||||
@@ -437,22 +443,22 @@ test.describe('On-call pages list', {
|
||||
name: uniqueName(`${workerPrefix(testInfo)}_alert`),
|
||||
stream,
|
||||
teamId: team.id,
|
||||
// seedPageableTeam writes a P1-only ladder; at the default P2 nobody would be paged.
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
const responseId = pages[0].id;
|
||||
|
||||
// The ledger is written as the ladder runs, so an empty read a second after
|
||||
// the record opens is "not yet", not "nothing was sent". Polled with a
|
||||
// deadline rather than asserted, because an empty ledger is a legitimate
|
||||
// outcome this test then asserts the ABSENCE of a badge against.
|
||||
let sends = [];
|
||||
const ledgerDeadline = Date.now() + 120_000;
|
||||
while (Date.now() < ledgerDeadline) {
|
||||
await expect.poll(async () => {
|
||||
sends = deliveryRows(await getDeliveries(page, responseId))
|
||||
.filter((row) => row?.recipient === memberEmail);
|
||||
if (sends.length) break;
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
return sends.length;
|
||||
}, {
|
||||
timeout: 120_000,
|
||||
intervals: [3000],
|
||||
message: 'the P1 first rung pages the rotation holder, so the ledger must record a send to them',
|
||||
}).toBeGreaterThan(0);
|
||||
testLogger.info('§2.4 ledger for the on-call responder', {
|
||||
memberEmail, sends: sends.length,
|
||||
});
|
||||
@@ -461,14 +467,6 @@ test.describe('On-call pages list', {
|
||||
await expect(pm.oncallPagesListPage.getWhoIsOn()).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const badge = pm.oncallPagesListPage.getWhoIsOnPrimaryReach();
|
||||
if (sends.length === 0) {
|
||||
await expect(
|
||||
badge,
|
||||
'with nothing in the ledger for this address there is nothing to badge',
|
||||
).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(badge, 'a responder the ladder paged must carry a reach badge')
|
||||
.toBeVisible({ timeout: 30000 });
|
||||
const landed = sends.some((row) => row?.delivered === true);
|
||||
|
||||
@@ -298,17 +298,18 @@ test.describe('On-call escalation policy editor', {
|
||||
* arrive from legacy data or a newer engine. Writing one needs a fixture at
|
||||
* the storage layer, which is a decision, not a test.
|
||||
*/
|
||||
test.fixme('TS-10.08b an unrecognised target kind renders as unrecognised rather than as a deleted rotation — not wired: POST/PUT policy 422s any kind outside rotation|user|whole_team, so the state cannot be seeded through the API, verified 16 Sep on :5090. o2-enterprise#2481.', {
|
||||
test.fixme('TS-10.08b an unrecognised target kind renders as unrecognised rather than as a deleted rotation — the API 422s an unknown kind, so it cannot be seeded. o2-enterprise#2481.', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const name = uniqueName(`${workerPrefix(testInfo)}_kind`);
|
||||
const { team } = await stageTeam(page, name);
|
||||
await putPolicy(page, team.id, {
|
||||
const seeded = await putPolicy(page, team.id, {
|
||||
rungs: [{
|
||||
priority: 2, channels: ['email'],
|
||||
steps: [{ after_micros: 0, targets: [{ kind: 'martian_relay' }] }],
|
||||
}],
|
||||
});
|
||||
expect(seeded.status, `the unknown-kind policy must be stored before its rendering can be read: ${seeded.text}`).toBe(200);
|
||||
|
||||
await pm.oncallPolicyEditorPage.gotoEscalationTab(ORG, team.id);
|
||||
await pm.oncallPolicyEditorPage.openEditor();
|
||||
@@ -366,6 +367,8 @@ test.describe('On-call escalation policy editor', {
|
||||
|
||||
// None — never set, so the policy's list applies again.
|
||||
await pm.oncallPolicyEditorPage.clearTeamChannel();
|
||||
// The channel choice is only a draft until the policy's Save, which also closes the editor.
|
||||
await pm.oncallPolicyEditorPage.savePolicy();
|
||||
await expect
|
||||
.poll(async () => (await getTeamChannel(page, team.id))?.source, {
|
||||
timeout: 20000,
|
||||
@@ -377,6 +380,8 @@ test.describe('On-call escalation policy editor', {
|
||||
expect(cleared.destinations,
|
||||
'both states render an empty list — which is exactly why source has to carry the difference')
|
||||
.toEqual([]);
|
||||
await pm.oncallPolicyEditorPage.openEditor();
|
||||
await pm.oncallPolicyEditorPage.openTab('delivery');
|
||||
const policySource = await pm.oncallPolicyEditorPage.readChannelSourceText();
|
||||
expect(policySource.toLowerCase(),
|
||||
'and the screen must now attribute it to the policy instead')
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* "Quick start with regions unnamed and unstaffed: Save is disabled, or it
|
||||
* names the missing field — never a silent no-op."
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* THE PLAN'S NOTE UNDER §11.2 IS STALE AGAINST MAIN. It records "still
|
||||
* reproduces: Save fires no request, shows no inline error and no toast" from an
|
||||
@@ -169,15 +170,10 @@ test.describe('On-call Quick start', {
|
||||
const { team } = await seedEmptyScheduleTeam(page, testInfo);
|
||||
await openQuickStartOn(team.id);
|
||||
|
||||
const regions = pm.oncallTeamDetailPage.getPresetTab(REGIONS_PRESET);
|
||||
if (await regions.count()) {
|
||||
await pm.oncallTeamDetailPage.chooseQuickStartTemplate(REGIONS_PRESET);
|
||||
} else {
|
||||
// The catalogue is server-supplied; on a build without the regions shape
|
||||
// the landing shape still has a required people picker, which is the same
|
||||
// question asked of a different form.
|
||||
testLogger.warn('Quick start has no follow_the_sun shape — using the landing shape');
|
||||
}
|
||||
// The catalogue is fetched after the drawer opens, so wait for the tab rather than count it.
|
||||
await expect(pm.oncallTeamDetailPage.getPresetTab(REGIONS_PRESET),
|
||||
'the server catalogue ships follow_the_sun, the shape whose regions carry a required name').toBeVisible({ timeout: 20000 });
|
||||
await pm.oncallTeamDetailPage.chooseQuickStartTemplate(REGIONS_PRESET);
|
||||
|
||||
const outcome = await pm.oncallTeamDetailPage.attemptQuickStartSave();
|
||||
testLogger.info('§11.2 Quick start save outcome', outcome);
|
||||
@@ -198,14 +194,12 @@ test.describe('On-call Quick start', {
|
||||
'a Save that fires no request AND says nothing is the §11.2 defect',
|
||||
).toBe(false);
|
||||
|
||||
// Whatever the screen did, nothing may have been written.
|
||||
if (!outcome.requestSeen) {
|
||||
const stored = await getTeamSchedule(page, team.id);
|
||||
expect(
|
||||
(stored?.rotations ?? []).length,
|
||||
'a refused apply must not have created rotations',
|
||||
).toBe(0);
|
||||
}
|
||||
expect(outcome.requestSeen, 'unnamed, unstaffed regions are invalid, so nothing may reach the server').toBe(false);
|
||||
const stored = await getTeamSchedule(page, team.id);
|
||||
expect(
|
||||
(stored?.rotations ?? []).length,
|
||||
'a refused apply must not have created rotations',
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -231,11 +225,8 @@ test.describe('On-call Quick start', {
|
||||
const { team, members } = await seedEmptyScheduleTeam(page, testInfo);
|
||||
await openQuickStartOn(team.id);
|
||||
|
||||
const regions = pm.oncallTeamDetailPage.getPresetTab(REGIONS_PRESET);
|
||||
test.skip(
|
||||
(await regions.count()) === 0,
|
||||
'this build\'s preset catalogue has no follow_the_sun shape, which is the only one with a required name',
|
||||
);
|
||||
await expect(pm.oncallTeamDetailPage.getPresetTab(REGIONS_PRESET),
|
||||
'the server catalogue ships follow_the_sun, the shape whose regions carry a required name').toBeVisible({ timeout: 20000 });
|
||||
|
||||
await pm.oncallTeamDetailPage.chooseQuickStartTemplate(REGIONS_PRESET);
|
||||
// Row keys for a repeated group are `<field>-<index>`, and a group_list
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* through: the manual pass asserted API status codes and never asserted what
|
||||
* the screen drew.
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* WHAT §10.5 ASKS FOR IS NOT WHAT MAIN DOES, AND THE DIVERGENCE IS DELIBERATE.
|
||||
* `useOnCallPermissions` carries this in its own comment: the API has no
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* renders as such rather than as a blank cell
|
||||
* TS-02.01 a viewer can work a page end to end
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* SNOOZE IS NOT AN ACK, AND THAT IS THE WHOLE CASE. Naively implemented, a
|
||||
* snooze either claims the record (so nobody else picks it up and nobody is
|
||||
@@ -69,6 +70,7 @@ const {
|
||||
promoteResponse,
|
||||
eventsOfKind,
|
||||
waitForMail,
|
||||
requireMailSink,
|
||||
loginAs,
|
||||
RESOLUTION_CAUSES,
|
||||
} = require('../utils/oncall-seed-ext.js');
|
||||
@@ -220,6 +222,7 @@ test.describe('On-call response verbs', {
|
||||
test('handing off to a person pages them, and is not settled until they answer', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
await requireMailSink(test);
|
||||
const f = await seedOpenPage(page, testInfo, 'hop', { delaysSeconds: [0, 600] });
|
||||
const id = f.record.id;
|
||||
const onCall = (await whoIsOnCall(page, f.team.id)).map((slot) => slot.user_email);
|
||||
@@ -279,6 +282,7 @@ test.describe('On-call response verbs', {
|
||||
test('handing off to another team re-resolves from that team\'s own schedule', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
await requireMailSink(test);
|
||||
const a = await seedOpenPage(page, testInfo, 'hot', { delaysSeconds: [0, 600] });
|
||||
const id = a.record.id;
|
||||
|
||||
@@ -429,14 +433,12 @@ test.describe('On-call response verbs', {
|
||||
test('a viewer can work a page end to end, with no verb hidden or disabled', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ browser, page }, testInfo) => {
|
||||
const f = await seedOpenPage(page, testInfo, 'viewer', { delaysSeconds: [0, 600] });
|
||||
const id = f.record.id;
|
||||
|
||||
test.skip(
|
||||
!(await nonAdminRolesAvailable(page)),
|
||||
'this deployment refuses non-admin roles (OpenFGA is off in the Playwright CI lane), '
|
||||
+ 'so a viewer cannot be created here; the RBAC contract itself is covered by the API suite',
|
||||
'non-admin roles need OpenFGA, which the OnCall Playwright lane runs without; the API suite covers the viewer contract',
|
||||
);
|
||||
const f = await seedOpenPage(page, testInfo, 'viewer', { delaysSeconds: [0, 600] });
|
||||
const id = f.record.id;
|
||||
|
||||
const viewerEmail = oncallUserEmail(uniqueName(`${workerPrefix(testInfo)}_v`), 'viewer');
|
||||
const viewer = await createOrgUser(page, { email: viewerEmail, role: 'viewer' });
|
||||
@@ -737,40 +739,37 @@ test.describe('On-call response verbs', {
|
||||
expect(record.acked_by, 'the seeded record must carry who acknowledged it').toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of TS-15.07: bulk resolve captures a cause.
|
||||
*
|
||||
* PARKED, because it is a real gap rather than a flaky assertion.
|
||||
*
|
||||
* Resolving ONE page forces a cause: `oncall-resolve-dialog` will not confirm
|
||||
* without one of the eight, and the whole cause-analytics surface
|
||||
* (`/oncall/analytics/causes`, the "this alert, before" card) is built on
|
||||
* them. Resolving FIFTY captures nothing — `bulkResolve` in
|
||||
* views/OnCall/OnCallResponses.vue calls
|
||||
* `oncallService.resolveResponse({ org_identifier, response_id })` with no
|
||||
* `cause` at all, and the server's `ResolveRequest.cause` is
|
||||
* `#[serde(default)] Option<..>`, so the records close with a null cause and
|
||||
* are skipped by `group_causes` forever.
|
||||
*
|
||||
* The bulk path is how a noisy morning gets cleaned up, so it is precisely
|
||||
* the path whose causes would have been most worth having. Left as a failing
|
||||
* expectation: it goes green when bulk resolve asks for a cause the way the
|
||||
* single-record dialog does.
|
||||
*/
|
||||
test.fixme('TS-15.07b bulk resolve captures a cause the way resolving one page does — not wired: OnCallResponses.vue bulkResolve calls resolveResponse with no cause, and ResolveRequest.cause is Option<_> with serde(default), so every bulk-resolved record stores a null cause and is dropped from cause analytics, verified 16 Sep on :5090. o2-enterprise#2481.', {
|
||||
/** The other half of TS-15.07: bulk resolve records the chosen cause, so a noisy morning's clean-up still feeds cause analytics. */
|
||||
test('TS-15.07b bulk resolve captures a cause the way resolving one page does', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
test.setTimeout(600_000);
|
||||
const f = await seedOpenPage(page, testInfo, 'bulkcause');
|
||||
const cause = 'noisy_threshold';
|
||||
expect(RESOLUTION_CAUSES, 'the cause this test picks must be one the product offers').toContain(cause);
|
||||
|
||||
await pm.oncallPagesListPage.goto(ORG);
|
||||
await pm.oncallPagesListPage.filterByTeam(f.team.id);
|
||||
await expect
|
||||
.poll(async () => (await pm.oncallPagesListPage.readRowKeys()).length, {
|
||||
timeout: 60000,
|
||||
intervals: [1000],
|
||||
message: 'the page this test fired never reached the list for its own team',
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await pm.oncallPagesListPage.getSelectAll().click();
|
||||
await pm.oncallPagesListPage.getBulkResolve().click();
|
||||
await pm.oncallPagesListPage.getConfirmOk().click();
|
||||
await pm.oncallPagesListPage.bulkResolveWithCause(cause);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getResponseRecord(page, f.record.id))?.cause, { timeout: 60000 })
|
||||
.toBeTruthy();
|
||||
.poll(async () => (await getResponseRecord(page, f.record.id))?.state, {
|
||||
timeout: 60000,
|
||||
message: 'the bulk resolve never closed the selected page',
|
||||
})
|
||||
.toBe('resolved');
|
||||
expect((await getResponseRecord(page, f.record.id))?.cause,
|
||||
'a bulk-resolved page must carry the cause picked in the dialog, or cause analytics never sees it')
|
||||
.toBe(cause);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* "Routing rule permanently shadowed: surfaced above the table, not only as a
|
||||
* per-row pill."
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* SHADOWING IS THE SERVER'S VERDICT. Deciding that rule A is shadowed means
|
||||
* comparing every rule against every other, including rules a team-scoped
|
||||
@@ -67,6 +68,7 @@ const {
|
||||
deleteTeamExpectingStatus,
|
||||
} = require('../utils/oncall-seed-ext.js');
|
||||
const { getAuthHeaders } = require('../utils/cloud-auth.js');
|
||||
const { DEFAULT_TEAM_LOCK, acquireOrgLock } = require('../utils/oncall-org-lock.js');
|
||||
|
||||
const PREFIX = 'e2e_oncall_routing';
|
||||
|
||||
@@ -143,7 +145,9 @@ test.describe('On-call routing rules', {
|
||||
|
||||
test('the routing page loads with both tabs', {
|
||||
tag: ['@P0', '@smoke'],
|
||||
}, async ({ page }) => {
|
||||
}, async ({ page }, testInfo) => {
|
||||
// With no team the screen draws its "create a team" empty state instead of the tabs.
|
||||
await createTeam(page, { name: uniqueName(`${workerPrefix(testInfo)}_smoke`) });
|
||||
await pm.oncallRoutingPage.goto(ORG);
|
||||
await pm.oncallRoutingPage.expectAvailable();
|
||||
await pm.oncallRoutingPage.expectPageVisible();
|
||||
@@ -384,7 +388,7 @@ test.describe('On-call routing rules', {
|
||||
* endpoint validates, and weakening it to assert the 200 would pin the defect
|
||||
* as correct.
|
||||
*/
|
||||
test.fixme('TS-12.10 an ownership rule on a dimension outside the semantic group list is refused — W-03: POST /oncall/ownership accepts {"totally_made_up_dim":"x"} with 200, verified 15 Sep on :5090. o2-enterprise#2481.', {
|
||||
test.fixme('TS-12.10 an ownership rule on a dimension outside the semantic group list is refused — the API stores it with 200. o2-enterprise#2481.', {
|
||||
tag: ['@P0'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const team = await createTeam(page, { name: uniqueName(`${workerPrefix(testInfo)}_w03`) });
|
||||
@@ -591,6 +595,28 @@ test.describe('On-call routing rules', {
|
||||
* they are at least good citizens of the org they borrow.
|
||||
*/
|
||||
test.describe.serial('org default team', () => {
|
||||
let releaseLock = null;
|
||||
let priorDefault = null;
|
||||
let restoreDefault = false;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
releaseLock = await acquireOrgLock(DEFAULT_TEAM_LOCK, { testInfo });
|
||||
priorDefault = (await getRoutingConfig(page))?.default_team_id ?? null;
|
||||
restoreDefault = true;
|
||||
});
|
||||
|
||||
// Restored even when the test fails: a leaked nomination routes every other worker's unmatched page.
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
// afterEach still runs when the outer beforeEach skipped, and then there is nothing to restore.
|
||||
if (restoreDefault) await setDefaultTeam(page, priorDefault);
|
||||
} finally {
|
||||
releaseLock?.();
|
||||
releaseLock = null;
|
||||
restoreDefault = false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* TS-14.01 and TS-14.02 in ONE test, deliberately.
|
||||
*
|
||||
@@ -701,15 +727,12 @@ test.describe('On-call routing rules', {
|
||||
test('TS-14.03 a signal that only defaulted is recorded as defaulted, and a claimed one is not recorded at all', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
// A real firing has to clear ingestion, the scheduler and the ladder; the
|
||||
// 3-minute default is for screens, not for this.
|
||||
test.setTimeout(900_000);
|
||||
// A real firing clears ingestion, the scheduler and the ladder; added to, not set, so beforeEach's lock wait is kept.
|
||||
test.setTimeout(testInfo.timeout - testInfo.project.timeout + 900_000);
|
||||
const f = await seedPageableFixture(page, testInfo, 'owned');
|
||||
await createOwnershipRule(page, { teamId: f.team.id, dimensions: { service: f.service } });
|
||||
|
||||
// Nominate a default so an unmatched signal has somewhere to land,
|
||||
// remembering what was there so the org is handed back as it was found.
|
||||
const priorDefault = (await getRoutingConfig(page))?.default_team_id ?? null;
|
||||
// Nominate a default so an unmatched signal has somewhere to land.
|
||||
await setDefaultTeam(page, f.team.id);
|
||||
await expect.poll(async () => (await getRoutingConfig(page))?.default_team_id,
|
||||
{ timeout: 60000, intervals: [1000] }).toBe(f.team.id);
|
||||
@@ -762,8 +785,6 @@ test.describe('On-call routing rules', {
|
||||
pm.oncallRoutingPage.getUnroutedFilterDefault(),
|
||||
'the screen must offer the defaulted-vs-unowned distinction, not just one undifferentiated queue',
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await setDefaultTeam(page, priorDefault).catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -791,7 +812,6 @@ test.describe('On-call routing rules', {
|
||||
}, async ({ page }, testInfo) => {
|
||||
const name = uniqueName(`${workerPrefix(testInfo)}_dflt`);
|
||||
const team = await createTeam(page, { name });
|
||||
const priorDefault = (await getRoutingConfig(page))?.default_team_id ?? null;
|
||||
|
||||
await pm.oncallRoutingPage.goto(ORG);
|
||||
await pm.oncallRoutingPage.expectAvailable();
|
||||
@@ -853,9 +873,6 @@ test.describe('On-call routing rules', {
|
||||
// And the screen still says which team is the catch-all.
|
||||
await pm.oncallRoutingPage.goto(ORG);
|
||||
await pm.oncallRoutingPage.expectDefaultTeamLabelNames(name);
|
||||
|
||||
// Leave the org as it was found: an org-wide nomination outlives this spec.
|
||||
await setDefaultTeam(page, priorDefault).catch(() => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
* §8.6 a shift rule with nobody in it says so
|
||||
* §11.5 timeline bars carry display names, not raw email addresses
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise). Skips with a reason via `isOnCallAvailable()`
|
||||
* rather than failing on an OSS build, where every on-call route is a 404 or a
|
||||
* 403 "Not Supported" and the product treats both as the same calm fact.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* Self-cleaning, worker-scoped prefix — `afterAll` runs once per worker in
|
||||
* parallel mode, so each worker sweeps only its own `_w<index>_` names.
|
||||
@@ -82,7 +81,7 @@ async function listOrgUsers(page) {
|
||||
const res = await page.request.get(`${baseUrl()}/api/${orgId()}/users`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok()) return [];
|
||||
if (!res.ok()) throw new Error(`listing org users failed: HTTP ${res.status()} — ${(await res.text()).slice(0, 200)}`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const list = Array.isArray(body) ? body : (body?.data ?? []);
|
||||
return Array.isArray(list) ? list : [];
|
||||
@@ -321,8 +320,7 @@ test.describe('On-call schedules and rotations', {
|
||||
* address, and at a dozen members two addresses clip to the same illegible
|
||||
* prefix. `nameOf()` falls back to the raw email for an account with no first
|
||||
* or last name, and that fallback is CORRECT — so this test staffs the
|
||||
* rotation with an account that has a display name, and skips with a reason
|
||||
* when the org has none. Asserting "no @ on the chart" against a nameless
|
||||
* rotation with an account it seeds with a display name. Asserting "no @ on the chart" against a nameless
|
||||
* roster would be asserting the fallback is a bug.
|
||||
*/
|
||||
test('§11.5 timeline bars carry display names, not raw email addresses', {
|
||||
@@ -336,10 +334,8 @@ test.describe('On-call schedules and rotations', {
|
||||
email: oncallUserEmail(uniqueName(workerPrefix(testInfo)), 'named'),
|
||||
});
|
||||
const named = (await listOrgUsers(page)).find((u) => u.email === seed.email);
|
||||
test.skip(
|
||||
!named,
|
||||
'no account in this org has a first or last name — the timeline correctly falls back to the address, so §11.5 has nothing to assert here',
|
||||
);
|
||||
expect(named, 'the account this test just created must be on the org roster').toBeTruthy();
|
||||
expect(displayNameOf(named), 'the seeded account carries a first and last name for the band to show').not.toBe('');
|
||||
|
||||
const { team, rotationId } = await seedAllDayTeam(page, testInfo, {
|
||||
memberEmail: named.email,
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
* while it is quiet — lives in `oncall-response-verbs.spec.js` and does run in
|
||||
* CI.
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
* Self-cleaning, worker-scoped prefix.
|
||||
*/
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* TS-19.01 the delivery ledger records every attempt, one row per
|
||||
* (run, rung, recipient, channel)
|
||||
*
|
||||
* ENTERPRISE-GATED (@enterprise); skips with a reason via `isOnCallAvailable()`.
|
||||
* ENTERPRISE-GATED (@enterprise). `isOnCallAvailable()` skips on an OSS build
|
||||
* and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* WHY "DERIVED ON READ" IS THE WHOLE OF TS-21.02. A stored risk list would
|
||||
* argue with the configuration beside it the instant somebody fixed something,
|
||||
@@ -71,6 +72,8 @@ const {
|
||||
getEscalationPreview,
|
||||
getTeamOverview,
|
||||
mailSince,
|
||||
requireMailSink,
|
||||
oncallUserEmail,
|
||||
} = require('../utils/oncall-seed-ext.js');
|
||||
|
||||
const PREFIX = 'e2e_oncall_health';
|
||||
@@ -337,30 +340,34 @@ test.describe('On-call team health', {
|
||||
const me = process.env.ZO_ROOT_USER_EMAIL;
|
||||
test.skip(
|
||||
!me || !/@example\.(com|org|net)$/i.test(me),
|
||||
'this case is about a reserved-domain address; the suite is running as a deliverable one',
|
||||
'needs a reserved-domain root user (CI runs as root@example.com); this run signs in with a deliverable address',
|
||||
);
|
||||
await requireMailSink(test);
|
||||
|
||||
// A deliverable member beside the reserved-domain one, so a reachability check that probed by sending would show up in the sink.
|
||||
const { email: deliverable } = await createOrgUser(page, { email: oncallUserEmail(name, 'ok') });
|
||||
const team = await createTeam(page, { name, timezone: 'UTC' });
|
||||
await addTeamMembers(page, team.id, [me]);
|
||||
await addTeamMembers(page, team.id, [me, deliverable]);
|
||||
await setTeamSchedule(page, team.id, {
|
||||
timezone: 'UTC',
|
||||
rotations: [rotation({ id: 'Primary', members: [me] })],
|
||||
// Half a shift in, so the reserved-domain member is the one holding the pager now.
|
||||
rotations: [rotation({ id: 'Primary', members: [me, deliverable], anchorMicros: Date.now() * 1000 - DAY_MICROS / 2 })],
|
||||
});
|
||||
|
||||
// Half one: nothing is sent. Counted across the call rather than assumed.
|
||||
const before = await mailSince(0);
|
||||
const since = Date.now();
|
||||
const reach = await getReachability(page, team.id);
|
||||
const after = await mailSince(0);
|
||||
expect(after.length,
|
||||
// A send is asynchronous, so give one the time it takes to reach the sink before reading it.
|
||||
await page.waitForTimeout(5000);
|
||||
const sent = (await mailSince(since)).filter((m) => m.to.includes(deliverable) || m.to.includes(me.toLowerCase()));
|
||||
expect(sent.map((m) => m.subject),
|
||||
'asking whether a page would land must not send one — it is a calculation, not a probe')
|
||||
.toBe(before.length);
|
||||
.toEqual([]);
|
||||
|
||||
expect(reach, 'the reachability endpoint must answer').toBeTruthy();
|
||||
expect(reach.total, 'the fixture has exactly one member').toBe(1);
|
||||
expect(reach.reachable, 'and that member is on a reserved domain, so none are reachable')
|
||||
.toBe(0);
|
||||
expect(reach.total, 'the fixture has exactly two members').toBe(2);
|
||||
expect(reach.reachable, 'only the member on a deliverable domain is reachable').toBe(1);
|
||||
expect(reach.unreachable_members, 'the unreachable member must be named, not just counted')
|
||||
.toContain(me);
|
||||
.toEqual([me]);
|
||||
|
||||
const member = reach.members.find((m) => m.user_email === me);
|
||||
expect(member.would_a_page_land, 'a page to a reserved domain does not land').toBe(false);
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
* Plan: docs/test_generator/features/oncall-test-plan.md (§11.1, plus the list
|
||||
* CRUD the rest of the suite depends on being able to reach).
|
||||
*
|
||||
* ENTERPRISE-GATED. On-call answers 404 (feature off) or 403 "Not Supported"
|
||||
* (OSS build) on every route, and the product treats both as the same calm
|
||||
* fact. `isOnCallAvailable()` turns that into one boolean, so an OSS runner
|
||||
* SKIPS with a reason instead of failing thirty seconds into a table timeout.
|
||||
* Tagged @enterprise, never @all.
|
||||
* ENTERPRISE-GATED (@enterprise, never @all). `isOnCallAvailable()` skips on an
|
||||
* OSS build and fails an enterprise build that has on-call switched off.
|
||||
*
|
||||
* Self-cleaning: every team is named `e2e_oncall_teams_w<worker>_*` and swept
|
||||
* by that prefix in afterAll. `afterAll` runs once PER WORKER in parallel mode,
|
||||
@@ -43,8 +40,10 @@ const {
|
||||
waitForStreamSearchable,
|
||||
seedNotificationDestination,
|
||||
createPagingAlert,
|
||||
triggerAlertOrLetSchedulerFire,
|
||||
waitForPages,
|
||||
listResponses,
|
||||
getDeliveries,
|
||||
getEscalationProgress,
|
||||
orgId,
|
||||
uniqueName,
|
||||
deleteOnCallFixturesByPrefix,
|
||||
@@ -53,6 +52,9 @@ const {
|
||||
createOrgUsers,
|
||||
getResponseRecord,
|
||||
getResponseHistory,
|
||||
eventsOfKind,
|
||||
resolveResponse,
|
||||
shortenPolicyForSpeed,
|
||||
deleteTeamExpectingStatus,
|
||||
} = require('../utils/oncall-seed-ext.js');
|
||||
|
||||
@@ -253,12 +255,7 @@ test.describe('On-call teams CRUD', { tag: ['@oncall', '@oncallTeams', '@enterpr
|
||||
|
||||
// -------------------------------------------------------------- P2 edge cases
|
||||
|
||||
/**
|
||||
* `oncall-teams-policies-btn` NAVIGATES to /oncall/policies. It reads like a
|
||||
* filter on this screen and is not one — a spec that treated it as one would
|
||||
* pass on an empty list forever.
|
||||
*/
|
||||
test.fixme('the policies button leaves the teams list for the policies screen — not wired: the button was deleted in f6ad43986f (#14502) and nothing replaced it; the oncall/policies route still registers and renders but no component links to it, so the screen is reachable only by typing the URL. o2-enterprise#2481', {
|
||||
test.fixme('the policies button leaves the teams list for the policies screen — nothing links to /oncall/policies since #14502. o2-enterprise#2628', {
|
||||
tag: ['@P2'],
|
||||
}, async ({ page }) => {
|
||||
await pm.oncallTeamsPage.goto(ORG);
|
||||
@@ -327,6 +324,9 @@ test.describe('On-call teams CRUD', { tag: ['@oncall', '@oncallTeams', '@enterpr
|
||||
await pm.oncallTeamsPage.fillTeamName(name);
|
||||
await pm.oncallTeamsPage.saveDrawer();
|
||||
await expect(pm.oncallTeamsPage.getFormDrawer()).toBeHidden({ timeout: 30000 });
|
||||
await expect(page, 'create must open the new team')
|
||||
.toHaveURL(/\/oncall\/teams\/[^/?]+/, { timeout: 30000 });
|
||||
await pm.oncallTeamsPage.goto(ORG);
|
||||
|
||||
await pm.oncallTeamsPage.search(name);
|
||||
const row = pm.oncallTeamsPage.getRows();
|
||||
@@ -401,90 +401,50 @@ test.describe('On-call teams CRUD', { tag: ['@oncall', '@oncallTeams', '@enterpr
|
||||
).toContainText(after, { timeout: 30000 });
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of TS-03.04: restriction windows re-evaluated in the new zone.
|
||||
*
|
||||
* PARKED — and it is a real split-brain, not a missing feature.
|
||||
*
|
||||
* The engine evaluates a rotation's restriction windows in the SCHEDULE's
|
||||
* stored `timezone`. `PUT /oncall/teams/{id}` changes the TEAM's timezone and
|
||||
* leaves the schedule's alone — verified against :5090: after moving a team
|
||||
* from UTC to Asia/Kolkata, `GET .../schedule` still answered
|
||||
* `"timezone": "UTC"` and a 09:00-17:00 weekday restriction still fired on
|
||||
* UTC hours (Wed 06:00 UTC = 11:30 IST resolved to NOBODY on call).
|
||||
*
|
||||
* Meanwhile the UI renders the whole schedule in the team's NEW zone:
|
||||
* OnCallTeamDetail.vue passes `:timezone="team?.timezone"` into every
|
||||
* schedule component, and OnCallScheduleEditor.vue only writes that zone onto
|
||||
* the schedule when somebody SAVES the schedule. So between a timezone change
|
||||
* and the next schedule save, the screen shows the roster in one zone and the
|
||||
* pager fires in another — on the one question the screen exists to answer.
|
||||
*
|
||||
* Left as a failing expectation on purpose: it goes green the day the team
|
||||
* write propagates the zone (or the UI stops claiming it has).
|
||||
*/
|
||||
test.fixme('TS-03.04b changing a team\'s timezone re-evaluates its restriction windows in the new zone — not wired: PUT /oncall/teams/{id} changes the team zone but leaves the schedule at its own stored zone, so the engine keeps evaluating restrictions in the old one while the UI renders the new one, verified 16 Sep on :5090. o2-enterprise#2481.', {
|
||||
test('TS-03.04b changing a team\'s timezone re-evaluates its restriction windows in the new zone', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
const name = uniqueName(`${workerPrefix(testInfo)}_tz`);
|
||||
const [member] = await createOrgUsers(page, name, 1);
|
||||
const team = await createTeam(page, { name, timezone: 'UTC' });
|
||||
// Weekdays and weekends alike, so the instants below need no day-of-week arithmetic.
|
||||
const officeHours = { days: [0, 1, 2, 3, 4, 5, 6], start_minute: 9 * 60, end_minute: 17 * 60 };
|
||||
await addTeamMembers(page, team.id, [member]);
|
||||
await setTeamSchedule(page, team.id, {
|
||||
timezone: 'UTC',
|
||||
rotations: [rotation({ id: 'Primary', members: [member], restrictions: [officeHours] })],
|
||||
});
|
||||
|
||||
// Tomorrow 01:00 UTC is 10:00 in Tokyo; tomorrow 10:00 UTC is 19:00 in Tokyo.
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
const at = (hourUtc) => Date.UTC(
|
||||
tomorrow.getUTCFullYear(), tomorrow.getUTCMonth(), tomorrow.getUTCDate(), hourUtc,
|
||||
) * 1000;
|
||||
const onCallAt = async (micros) => (await whoIsOnCall(page, team.id, micros)).map((slot) => slot.user_email);
|
||||
|
||||
expect(await onCallAt(at(1)), 'before the move, 01:00 UTC is outside UTC office hours').not.toContain(member);
|
||||
expect(await onCallAt(at(10)), 'before the move, 10:00 UTC is inside UTC office hours').toContain(member);
|
||||
|
||||
await pm.oncallTeamsPage.goto(ORG);
|
||||
await pm.oncallTeamsPage.openEditDrawer(team.id, { name });
|
||||
// The zone the picker can actually offer — see the note in TS-03.04. The
|
||||
// split-brain below is about the schedule not following the team, not about
|
||||
// which zone was chosen, and Kolkata is not in this runtime's ICU list.
|
||||
await pm.oncallTeamsPage.selectTimezone('Asia/Tokyo');
|
||||
await pm.oncallTeamsPage.saveDrawer();
|
||||
await expect(pm.oncallTeamsPage.getFormDrawer()).toBeHidden({ timeout: 30000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getTeamSchedule(page, team.id))?.timezone, { timeout: 20000 })
|
||||
.poll(async () => (await getTeamSchedule(page, team.id))?.timezone, {
|
||||
timeout: 20000, message: 'the schedule must move with the team it belongs to',
|
||||
})
|
||||
.toBe('Asia/Tokyo');
|
||||
expect(await onCallAt(at(1)), '01:00 UTC is 10:00 in Tokyo, inside the moved office hours').toContain(member);
|
||||
expect(await onCallAt(at(10)), '10:00 UTC is 19:00 in Tokyo, outside the moved office hours')
|
||||
.not.toContain(member);
|
||||
});
|
||||
|
||||
/**
|
||||
* TS-03.05 — deleting a team must never leave a live page pointing at nothing.
|
||||
*
|
||||
* Either answer is acceptable and the case accepts both: refuse the delete
|
||||
* and list what is still open, or close the records with an explicit timeline
|
||||
* entry saying the team was deleted. What is not acceptable is the third
|
||||
* outcome — the team vanishes and the open record keeps a `team_id` that
|
||||
* resolves to nothing, so the record can never be routed, escalated or
|
||||
* handed off again and nothing on screen says why.
|
||||
*/
|
||||
/**
|
||||
* TS-03.05 — PARKED. A real product gap, not a test that asks too much.
|
||||
*
|
||||
* `delete_team` (o2-enterprise .../oncall/service.rs:183) refuses ONLY when the
|
||||
* team is the org's nominated default. Past that it deletes ownership rules,
|
||||
* overrides, schedules and policies — and never queries `oncall_responses` at
|
||||
* all. Its own doc comment states the intent: "Response records are kept: they
|
||||
* are history, and history has to survive a reorg."
|
||||
*
|
||||
* That reasoning holds for a RESOLVED record. It does not hold for an OPEN one:
|
||||
* the delete succeeds (200), the page stays `triggered` against a `team_id`
|
||||
* that no longer resolves, and nothing escalates it any further — the ladder,
|
||||
* the schedule and the policy it would have climbed were all just deleted. The
|
||||
* record is not history; it is a live page nobody will ever be woken for, and
|
||||
* no timeline entry says why.
|
||||
*
|
||||
* The asymmetry is the argument that this is an oversight rather than a
|
||||
* decision: taking ONE MEMBER off a team already does the check. The same file
|
||||
* carries `open_records_acknowledged_by` (service.rs:1038), which calls
|
||||
* `oncall_responses::list_open` for the team and reports the open records that
|
||||
* person can no longer close. Deleting the entire team — which strands every
|
||||
* open record, not one person's — skips it.
|
||||
*
|
||||
* The test accepts EITHER honest answer — refuse and name what is still open,
|
||||
* or allow and close the record with a timeline entry. The product does
|
||||
* neither, verified 16 Sep on :5090. The body is left intact so it goes green
|
||||
* the day one of the two is implemented.
|
||||
*/
|
||||
test.fixme('TS-03.05 a team with a live page cannot be deleted into a dangling reference — not wired: delete_team (o2-enterprise oncall/service.rs:183) refuses only for the org default team and never queries oncall_responses, so DELETE /oncall/teams/{id} returns 200 while an open page stays triggered against a team, schedule and policy that no longer exist, with no timeline entry, verified 16 Sep on :5090. o2-enterprise#2481.', {
|
||||
test('TS-03.05 deleting a team with a live page stops its ladder and leaves the page open for a new owner', {
|
||||
tag: ['@P1'],
|
||||
}, async ({ page }, testInfo) => {
|
||||
// A real firing has to clear ingestion, the scheduler and the ladder; the
|
||||
// 3-minute default is for screens, not for this.
|
||||
test.setTimeout(600_000);
|
||||
const name = uniqueName(`${workerPrefix(testInfo)}_live`);
|
||||
const users = await createOrgUsers(page, name, 2);
|
||||
@@ -494,6 +454,8 @@ test.describe('On-call teams CRUD', { tag: ['@oncall', '@oncallTeams', '@enterpr
|
||||
timezone: 'UTC',
|
||||
rotations: [rotation({ id: 'Primary', members: users })],
|
||||
});
|
||||
// Past the 60s first-delivery poll, so it is still pending at the delete but due inside the wait below.
|
||||
await shortenPolicyForSpeed(page, team.id, 2, [0, 90]);
|
||||
|
||||
const stream = `${name}_stream`;
|
||||
await seedOnCallStream(page, stream);
|
||||
@@ -503,36 +465,48 @@ test.describe('On-call teams CRUD', { tag: ['@oncall', '@oncallTeams', '@enterpr
|
||||
name: `${name}_alert`, stream, teamId: team.id, priority: 2,
|
||||
destinations: [destination],
|
||||
});
|
||||
await triggerAlertOrLetSchedulerFire(page, alert.id);
|
||||
const pages = await waitForPages(page, { alertId: alert.id, expected: 1 });
|
||||
expect(pages.length, 'the fixture needs a genuinely open page on this team')
|
||||
.toBeGreaterThan(0);
|
||||
const openRecord = pages[0];
|
||||
const [openRecord] = await waitForPages(page, { alertId: alert.id, expected: 1, expectTeamId: team.id });
|
||||
|
||||
await expect.poll(async () => (await getDeliveries(page, openRecord.id))?.total ?? 0, {
|
||||
timeout: 60000, intervals: [1000], message: 'the first rung must have paged before the team goes',
|
||||
}).toBeGreaterThan(0);
|
||||
const ledgerAtDelete = await getDeliveries(page, openRecord.id);
|
||||
expect(ledgerAtDelete.deliveries.map((row) => row.rung_micros),
|
||||
'the second rung must still be pending when the team is deleted').toEqual(
|
||||
ledgerAtDelete.deliveries.map(() => 0),
|
||||
);
|
||||
|
||||
const deletedAtMicros = Date.now() * 1000;
|
||||
const deleted = await deleteTeamExpectingStatus(page, team.id);
|
||||
expect(deleted.status, `deleting a non-default team is allowed: ${deleted.text}`).toBe(200);
|
||||
|
||||
if (deleted.status >= 400) {
|
||||
// Answer one: refused. The refusal has to name what is still open,
|
||||
// or it is a wall rather than an explanation.
|
||||
expect(String(deleted.body?.message ?? deleted.text ?? ''),
|
||||
'a refusal must say what is still open, not just "cannot delete"')
|
||||
.toMatch(/page|open|response|firing/i);
|
||||
return;
|
||||
}
|
||||
|
||||
// Answer two: allowed — then the record must have been closed, and the
|
||||
// timeline must say why, rather than left triggered against a gone team.
|
||||
const record = await getResponseRecord(page, openRecord.id);
|
||||
expect(record, 'the record must still be readable after its team is deleted').toBeTruthy();
|
||||
expect(record.state,
|
||||
'deleting the team must close the page it left open rather than strand it')
|
||||
.toBe('resolved');
|
||||
testLogger.info('TS-03.05 record after the team delete', record);
|
||||
expect(record.state, 'the page stays open: somebody still has to own it').toBe('triggered');
|
||||
expect(record.exhausted_at, 'the ladder is stopped, and the record says when').toBeTruthy();
|
||||
expect(record.team_id, 'the record keeps naming the team it was lost from').toBe(team.id);
|
||||
|
||||
const events = await getResponseHistory(page, openRecord.id);
|
||||
const text = JSON.stringify(events);
|
||||
expect(text,
|
||||
'and the timeline must carry an explicit entry saying the team was deleted')
|
||||
.toMatch(/team.*(delete|removed|gone)/i);
|
||||
const exhausted = eventsOfKind(await getResponseHistory(page, openRecord.id), 'exhausted');
|
||||
expect(exhausted.length, 'the timeline must say once why the ladder stopped').toBe(1);
|
||||
expect(exhausted[0].actor, 'the engine stopped it, not a person').toBe('o2-engine');
|
||||
expect(exhausted[0].body, 'and say it was the team delete').toMatch(/owning team was deleted/);
|
||||
|
||||
const progress = await getEscalationProgress(page, openRecord.id);
|
||||
expect(progress.stopped_because, 'the escalation view names the same reason')
|
||||
.toMatch(/owning team was deleted/);
|
||||
expect(progress.next_at ?? null, 'and schedules nothing further').toBeNull();
|
||||
|
||||
await page.waitForTimeout(100_000);
|
||||
const ledgerAfter = await getDeliveries(page, openRecord.id);
|
||||
expect(ledgerAfter.deliveries.filter((row) => row.at > deletedAtMicros),
|
||||
'the pending rung must not fire once its team is gone').toEqual([]);
|
||||
|
||||
const open = await listResponses(page, { subjectType: 'alert', sourceId: alert.id });
|
||||
expect(open.map((r) => r.id), 'the page stays on the open list until somebody closes it')
|
||||
.toContain(openRecord.id);
|
||||
const resolved = await resolveResponse(page, openRecord.id, { cause: 'noisy_threshold' });
|
||||
expect(resolved.status, `a page whose team is gone can still be closed: ${resolved.text}`).toBe(200);
|
||||
expect((await getResponseRecord(page, openRecord.id)).state).toBe('resolved');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+1
-2
@@ -19,7 +19,6 @@ const {
|
||||
seedOnCallStream,
|
||||
seedNotificationDestination,
|
||||
createPagingAlert,
|
||||
triggerAlertOrLetSchedulerFire,
|
||||
waitForPages,
|
||||
getRoutingConfig,
|
||||
deleteTeamById,
|
||||
@@ -134,10 +133,10 @@ test.describe('On-call P2 fixes (o2-enterprise#3160)', {
|
||||
});
|
||||
made.alerts.push(alert.id);
|
||||
alerts.push({ ...spec, alert });
|
||||
await triggerAlertOrLetSchedulerFire(page, alert.id);
|
||||
}
|
||||
seededPages = {};
|
||||
for (const { key, teamId, alert } of alerts) {
|
||||
// The scheduler fires each new alert; a manual trigger would open a teamless page that swallows that firing.
|
||||
const [record] = await waitForPages(page, { alertId: alert.id, teamId });
|
||||
made.pages.push(record.id);
|
||||
seededPages[key] = { name: alert.name, record };
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
const { test, expect, navigateToBase } = require('../../utils/enhanced-baseFixtures.js');
|
||||
const testLogger = require('../../utils/test-logger.js');
|
||||
const PageManager = require('../../../pages/page-manager.js');
|
||||
const { sendRequest, getHeaders, getIngestionUrl, waitForStreamData } = require('../../utils/data-ingestion.js');
|
||||
const { getOrgIdentifier } = require('../../utils/cloud-auth.js');
|
||||
|
||||
// operation_name is the tail of db_operation_name, so an unbounded field match edits the wrong condition.
|
||||
const ROWS = [
|
||||
{ db_operation_name: 'SELECT', operation_name: 'GET /', name: 'row_a' },
|
||||
{ db_operation_name: 'SELECT', operation_name: 'GET /', name: 'row_b' },
|
||||
{ db_operation_name: 'SELECT', operation_name: 'POST /', name: 'row_c' },
|
||||
{ db_operation_name: 'INSERT', operation_name: 'GET /', name: 'row_d' },
|
||||
];
|
||||
|
||||
test.describe("Field-value filter on a field whose name ends another field's name (o2-enterprise#3322)", () => {
|
||||
let pm;
|
||||
let stream;
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testLogger.testStart(testInfo.title, testInfo.file);
|
||||
await navigateToBase(page);
|
||||
pm = new PageManager(page);
|
||||
|
||||
stream = 'e2e_3322_' + Math.random().toString(36).slice(2, 7);
|
||||
const orgId = getOrgIdentifier() || 'default';
|
||||
await sendRequest(page, getIngestionUrl(orgId, stream), ROWS, getHeaders());
|
||||
await waitForStreamData(page, stream, ROWS.length);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await new PageManager(page).logsPage.deleteStream(stream).catch((e) =>
|
||||
testLogger.warn(`Failed to delete stream ${stream}: ${e.message}`)
|
||||
);
|
||||
});
|
||||
|
||||
test("Logs SQL mode: ticking operation_name values keeps the db_operation_name condition", {
|
||||
tag: ['@bug-3322', '@P2', '@regression', '@logsRegression']
|
||||
}, async () => {
|
||||
const base = `SELECT * FROM "${stream}" WHERE db_operation_name = 'SELECT'`;
|
||||
await pm.logsPage.openLogsWithSqlQuery(stream, base);
|
||||
await pm.logsPage.clickRefreshButton();
|
||||
await expect.poll(async () => (await pm.logsPage.getResultRowTexts()).length, { timeout: 30000 }).toBe(3);
|
||||
|
||||
await pm.logsPage.clickFieldExpandButton('operation_name');
|
||||
await pm.logsPage.fieldValueCheckbox('operation_name', 'GET /').click();
|
||||
await expect.poll(() => pm.logsPage.getQueryEditorText(), {
|
||||
timeout: 15000,
|
||||
message: 'the db_operation_name condition must stay and operation_name must be added',
|
||||
}).toBe(`${base} AND operation_name = 'GET /'`);
|
||||
|
||||
await pm.logsPage.clickRefreshButton();
|
||||
await expect.poll(async () => (await pm.logsPage.getResultRowTexts()).length, { timeout: 30000 }).toBe(2);
|
||||
const rows = (await pm.logsPage.getResultRowTexts()).join('\n');
|
||||
expect(rows).toContain('row_a');
|
||||
expect(rows).toContain('row_b');
|
||||
|
||||
await pm.logsPage.fieldValueCheckbox('operation_name', 'POST /').click();
|
||||
await expect.poll(() => pm.logsPage.getQueryEditorText(), {
|
||||
timeout: 15000,
|
||||
message: 'a second value must replace only the operation_name condition',
|
||||
}).toBe(`${base} AND (operation_name = 'GET /' OR operation_name = 'POST /')`);
|
||||
|
||||
await pm.logsPage.clickRefreshButton();
|
||||
await expect.poll(async () => (await pm.logsPage.getResultRowTexts()).length, { timeout: 30000 }).toBe(3);
|
||||
expect((await pm.logsPage.getResultRowTexts()).join('\n')).not.toContain('row_d');
|
||||
});
|
||||
|
||||
test("Pipelines Query source SQL: ticking an operation_name value keeps the db_operation_name condition", {
|
||||
tag: ['@bug-3322', '@P2', '@regression', '@logsRegression', '@pipelinesRegressionQuery']
|
||||
}, async () => {
|
||||
await pm.pipelinesPage.openPipelineMenu();
|
||||
await pm.pipelinesPage.addPipeline();
|
||||
await pm.pipelinesPage.dragStreamToTarget(pm.pipelinesPage.queryButton);
|
||||
await pm.pipelinesPage.waitForScheduledPipelineDialog();
|
||||
await pm.pipelinesPage.expandBuildQuerySection();
|
||||
await pm.pipelinesPage.selectStreamType('logs');
|
||||
await pm.pipelinesPage.selectStreamName(stream);
|
||||
await pm.pipelinesPage.expectSqlEditorVisible();
|
||||
|
||||
const base = `SELECT * FROM "${stream}" WHERE db_operation_name='SELECT'`;
|
||||
await pm.pipelinesPage.setQuerySourceSql(base);
|
||||
|
||||
const value = await pm.pipelinesPage.addQueryFieldValueFilter('operation_name');
|
||||
testLogger.info(`Ticked operation_name='${value}'`);
|
||||
expect(['GET /', 'POST /']).toContain(value);
|
||||
await expect.poll(() => pm.pipelinesPage.getQuerySourceSql(), {
|
||||
timeout: 15000,
|
||||
message: 'the db_operation_name condition must stay and operation_name must be added',
|
||||
}).toBe(`${base} AND operation_name='${value}'`);
|
||||
|
||||
await pm.pipelinesPage.clickBodyCorner();
|
||||
await pm.pipelinesPage.clickCancelPipelineBtnForce();
|
||||
});
|
||||
});
|
||||
@@ -73,13 +73,14 @@ test.describe('Synthetics settings — locations and tokens', { tag: ['@syntheti
|
||||
|
||||
test('adds a public location from Settings', { tag: ['@P1'] }, async ({ page }, testInfo) => {
|
||||
const region = uniqueName('loc', testInfo);
|
||||
const id = `${PROVIDER}-${region}`;
|
||||
// The create form offers only AWS.
|
||||
const id = `aws-${region}`;
|
||||
testLogger.info('Opening the locations settings in the meta org');
|
||||
const s = pm.syntheticsSettingsPage;
|
||||
await s.gotoLocations(META_ORG);
|
||||
testLogger.info('Adding a public location', { locationId: id });
|
||||
await s.openAddForm();
|
||||
await s.fillLocationForm({ label: `E2E ${region}`, provider: 'custom', customProvider: PROVIDER, region });
|
||||
await s.fillLocationForm({ label: `E2E ${region}`, provider: 'aws', region });
|
||||
await s.submitLocationForm();
|
||||
testLogger.info('Verifying the location is listed');
|
||||
await s.expectToast('Location created successfully');
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
* mailinator — public inbox read over HTTP, works against a shared env where
|
||||
* you cannot run a container next to the app
|
||||
*
|
||||
* Tests that need a sink call `await sink.available()` and skip cleanly when
|
||||
* false, rather than failing.
|
||||
* Tests that need a sink check `await sink.available()` first: with no sink
|
||||
* `list()` throws rather than report an empty mailbox. On-call specs go through
|
||||
* `requireMailSink` (oncall-seed-ext.js), which skips locally and fails under CI,
|
||||
* where a missing sink is a broken runner.
|
||||
*/
|
||||
const MODE = process.env.MAIL_SINK || 'auto';
|
||||
const MAILPIT = process.env.MAILPIT_URL || 'http://localhost:8025';
|
||||
@@ -76,7 +78,7 @@ async function list() {
|
||||
const d = await r.json();
|
||||
return (d.msgs || []).map((m) => ({ id: m.id, subject: m.subject, ts: Number(m.time) || 0 }));
|
||||
}
|
||||
return [];
|
||||
throw new Error(`cannot list mail: ${await unavailableReason()}`);
|
||||
}
|
||||
|
||||
async function count() {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
// Tests that nominate the org default team and tests that need an unrouted page live in different files, so only a cross-worker lock orders them.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const testLogger = require('./test-logger.js');
|
||||
|
||||
/** The lock every test touching the org's default routing team takes. */
|
||||
const DEFAULT_TEAM_LOCK = 'oncall-default-team';
|
||||
|
||||
// Outlasts routing's whole default-team group (TS-14.01/02, TS-14.03's 15 minutes, TS-14.04), which can retake the lock back to back.
|
||||
const ORG_LOCK_WAIT_MS = 25 * 60 * 1000;
|
||||
|
||||
// A lock dir whose pid file is empty or unreadable this long after its last write is corrupt, not being stamped.
|
||||
const UNSTAMPED_STALE_MS = 5000;
|
||||
|
||||
const heldHere = new Set();
|
||||
|
||||
function lockDir(name) {
|
||||
const server = String(process.env.ZO_BASE_URL || 'local').replace(/[^a-z0-9]+/gi, '_');
|
||||
return path.join(os.tmpdir(), `o2-e2e-lock_${server}_${name}`);
|
||||
}
|
||||
|
||||
function readHolder(dir) {
|
||||
let pid = 0;
|
||||
try {
|
||||
const stat = fs.statSync(dir);
|
||||
try {
|
||||
pid = Number(fs.readFileSync(path.join(dir, 'pid'), 'utf8')) || 0;
|
||||
} catch {
|
||||
pid = 0;
|
||||
}
|
||||
// Released and replaced between the two reads: the pid is not this lock's, so look again.
|
||||
if (fs.statSync(dir).ino !== stat.ino) return null;
|
||||
return { pid, id: `${stat.ino}-${Math.trunc(stat.mtimeMs)}`, ageMs: Date.now() - stat.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isStale({ pid, ageMs }) {
|
||||
if (!pid) return ageMs > UNSTAMPED_STALE_MS;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
} catch (e) {
|
||||
return e.code !== 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function takeOver(dir, holder) {
|
||||
// The tombstone is kept and named after the dead lock, so a second waiter's rename onto it fails instead of moving a fresh lock.
|
||||
const tomb = `${dir}.stale-${holder.id}`;
|
||||
try {
|
||||
fs.renameSync(dir, tomb);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const entry of fs.readdirSync(path.dirname(dir))) {
|
||||
const other = path.join(path.dirname(dir), entry);
|
||||
if (other !== tomb && other.startsWith(`${dir}.stale-`)) fs.rmSync(other, { recursive: true, force: true });
|
||||
}
|
||||
testLogger.info('Org lock taken over from a dead holder', { dir, deadPid: holder.pid });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Take the named lock, waiting for whichever worker holds it, and return its release function; pass testInfo so the wait does not eat the test's own budget. */
|
||||
async function acquireOrgLock(name, { testInfo, timeout = ORG_LOCK_WAIT_MS, pollMs = 1000 } = {}) {
|
||||
if (heldHere.has(name)) {
|
||||
throw new Error(`org lock "${name}" is already held by this worker (pid ${process.pid}); release it before taking it again`);
|
||||
}
|
||||
const dir = lockDir(name);
|
||||
const started = Date.now();
|
||||
testInfo?.setTimeout(testInfo.timeout + timeout);
|
||||
// Built complete in a private dir and renamed into place, so a lock never exists without its pid.
|
||||
const fresh = `${dir}.new-${process.pid}-${started}`;
|
||||
fs.mkdirSync(fresh);
|
||||
fs.writeFileSync(path.join(fresh, 'pid'), String(process.pid));
|
||||
try {
|
||||
for (;;) {
|
||||
try {
|
||||
fs.renameSync(fresh, dir);
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOTEMPTY' && e.code !== 'EEXIST') throw e;
|
||||
}
|
||||
const holder = readHolder(dir);
|
||||
if (!holder || (isStale(holder) && takeOver(dir, holder))) continue;
|
||||
if (Date.now() - started > timeout) {
|
||||
throw new Error(`waited ${timeout}ms for the org-wide lock "${name}" (${dir}); pid ${holder.pid || 'unknown'} still holds it`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
}
|
||||
} catch (e) {
|
||||
fs.rmSync(fresh, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
const waited = Date.now() - started;
|
||||
testInfo?.setTimeout(testInfo.timeout - timeout + waited);
|
||||
heldHere.add(name);
|
||||
testLogger.info('Org lock taken', { name, pid: process.pid, waitedMs: waited });
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
heldHere.delete(name);
|
||||
if (readHolder(dir)?.pid !== process.pid) return;
|
||||
// Renamed away before deleting, so the lock path is never an empty dir another worker's rename could replace.
|
||||
const gone = `${dir}.released-${process.pid}-${Date.now()}`;
|
||||
fs.renameSync(dir, gone);
|
||||
fs.rmSync(gone, { recursive: true, force: true });
|
||||
testLogger.info('Org lock released', { name, pid: process.pid });
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_TEAM_LOCK, acquireOrgLock };
|
||||
@@ -77,8 +77,9 @@ function url(path) {
|
||||
* rule that should be rejected), so this deliberately does not throw on a
|
||||
* non-2xx the way `oncall-seed.js`'s `must()` does.
|
||||
*/
|
||||
async function call(page, method, path, data) {
|
||||
const opts = { headers: getAuthHeaders() };
|
||||
async function call(page, method, path, data, { asSession = false } = {}) {
|
||||
// A secondary sign-in is identified by its cookie alone; root's header would make the identity a precedence question.
|
||||
const opts = { headers: asSession ? {} : getAuthHeaders() };
|
||||
if (data !== undefined) opts.data = data;
|
||||
const res = await page.request[method](url(path), opts);
|
||||
const text = await res.text().catch(() => '');
|
||||
@@ -99,8 +100,8 @@ async function call(page, method, path, data) {
|
||||
* there", so every emptiness assertion built on one passed the moment the API
|
||||
* stopped answering. An error is not an empty result.
|
||||
*/
|
||||
async function read(page, path) {
|
||||
const { ok, status, body, text } = await call(page, 'get', path);
|
||||
async function read(page, path, { asSession = false } = {}) {
|
||||
const { ok, status, body, text } = await call(page, 'get', path, undefined, { asSession });
|
||||
if (!ok) {
|
||||
throw new Error(`GET ${path} failed: ${status} ${String(text ?? '').slice(0, 200)}`);
|
||||
}
|
||||
@@ -166,6 +167,9 @@ async function nonAdminRolesAvailable(page) {
|
||||
email, password: SEED_USER_PASSWORD, first_name: 'RBAC', last_name: 'Probe',
|
||||
role: 'viewer', organization: orgId(),
|
||||
});
|
||||
if (status >= 400 && !/already exist/i.test(text || '') && !/open-?fga/i.test(text || '')) {
|
||||
throw new Error(`probing for non-admin roles failed: HTTP ${status} — ${String(text).slice(0, 200)}`);
|
||||
}
|
||||
_nonAdminRoles = status === 200 || /already exist/i.test(text || '');
|
||||
if (_nonAdminRoles) await deleteOrgUser(page, email);
|
||||
testLogger.info('Non-admin roles probe', { available: _nonAdminRoles, status });
|
||||
@@ -344,7 +348,8 @@ async function waitForResponseState(page, responseId, states, { timeout = 180_00
|
||||
async function simulateRouting(page, { dimensions, oncallTeam = undefined }) {
|
||||
const payload = { dimensions };
|
||||
if (oncallTeam !== undefined) payload.oncall_team = oncallTeam;
|
||||
const { body } = await call(page, 'post', '/oncall/routing/preview', payload);
|
||||
const { ok, status, body, text } = await call(page, 'post', '/oncall/routing/preview', payload);
|
||||
if (!ok) throw new Error(`routing preview failed: HTTP ${status} — ${String(text).slice(0, 200)}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -465,18 +470,34 @@ async function shortenPolicyForSpeed(page, teamId, priority, delaysSeconds, { ta
|
||||
|
||||
const MAILPIT = process.env.MAILPIT_URL || 'http://localhost:8025';
|
||||
|
||||
/**
|
||||
* One message, fully fetched.
|
||||
*
|
||||
* `mail-sink.js` indexes messages (id, subject, timestamp) and can fetch the
|
||||
* NEWEST one, but a page fan-out puts several in the sink at once and the one a
|
||||
* case cares about is rarely the newest. This adds the per-id read on the
|
||||
* Mailpit backend only — the sink that exists on this environment — rather than
|
||||
* re-implementing the module's backend selection.
|
||||
*/
|
||||
/** Why the on-call mail helpers cannot read mail here, or null when Mailpit answers. */
|
||||
async function mailSinkProblem() {
|
||||
if ((await sink.backend()) === 'mailpit') return null;
|
||||
return `the on-call mail helpers read Mailpit only and it is not reachable on ${MAILPIT}; `
|
||||
+ await sink.unavailableReason();
|
||||
}
|
||||
|
||||
/** Throw rather than let a missing sink read as "no mail was sent". */
|
||||
async function assertMailSink() {
|
||||
const problem = await mailSinkProblem();
|
||||
if (problem) throw new Error(`cannot read mail: ${problem}`);
|
||||
}
|
||||
|
||||
/** Call first in a test that reads mail: skips locally when no sink is up, throws in CI where that is a broken runner. */
|
||||
async function requireMailSink(test) {
|
||||
const problem = await mailSinkProblem();
|
||||
if (!problem) return;
|
||||
if (process.env.CI) throw new Error(`this test reads mail and the CI runner has no mail sink: ${problem}`);
|
||||
test.skip(true, problem);
|
||||
}
|
||||
|
||||
/** One message by id, or null if it was deleted after it was listed; a fan-out's wanted message is rarely the newest. */
|
||||
async function fetchMessage(id) {
|
||||
const res = await fetch(`${MAILPIT}/api/v1/message/${id}`);
|
||||
if (!res.ok) return null;
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`reading Mailpit message ${id} failed: HTTP ${res.status} — ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
const m = await res.json();
|
||||
return {
|
||||
id,
|
||||
@@ -489,14 +510,14 @@ async function fetchMessage(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a message matching `to` and/or `subject`, newest first.
|
||||
* Wait for a message matching `to` and/or `subject`, newest first; null only when none arrived in time.
|
||||
*
|
||||
* `sinceMs` scopes to messages that arrived after a caller-recorded instant, so
|
||||
* a shared sink full of other workers' fixtures cannot satisfy the wait with
|
||||
* somebody else's page.
|
||||
*/
|
||||
async function waitForMail({ to = null, subject = null, sinceMs = 0, timeout = 120_000, pollMs = 2000 } = {}) {
|
||||
if (!(await sink.available())) return null;
|
||||
await assertMailSink();
|
||||
const wantTo = to ? String(to).toLowerCase() : null;
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
@@ -507,8 +528,7 @@ async function waitForMail({ to = null, subject = null, sinceMs = 0, timeout = 1
|
||||
: index;
|
||||
for (const row of [...candidates].sort((a, b) => b.ts - a.ts)) {
|
||||
const full = await fetchMessage(row.id);
|
||||
if (!full) continue;
|
||||
if (wantTo && !full.to.includes(wantTo)) continue;
|
||||
if (!full || (wantTo && !full.to.includes(wantTo))) continue;
|
||||
return full;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
@@ -518,7 +538,7 @@ async function waitForMail({ to = null, subject = null, sinceMs = 0, timeout = 1
|
||||
|
||||
/** Every message since an instant, fully fetched — for fan-out assertions. */
|
||||
async function mailSince(sinceMs) {
|
||||
if (!(await sink.available())) return [];
|
||||
await assertMailSink();
|
||||
const index = (await sink.list()).filter((m) => m.ts >= sinceMs);
|
||||
const out = [];
|
||||
for (const row of index) {
|
||||
@@ -583,19 +603,19 @@ async function putPolicy(page, teamId, { rungs, destinations = undefined, l0 = u
|
||||
return await call(page, 'put', `/oncall/teams/${encodeURIComponent(teamId)}/policy`, payload);
|
||||
}
|
||||
|
||||
/** The caller's own inbox: `{total, unread, deliveries}`. Keyed on the auth header, never a param. */
|
||||
async function myDeliveries(page, { unreadOnly = undefined, limit = undefined } = {}) {
|
||||
/** The caller's own inbox: `{total, unread, deliveries}`. Keyed on the caller's identity, never a param. */
|
||||
async function myDeliveries(page, { unreadOnly = undefined, limit = undefined, asSession = false } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (unreadOnly !== undefined) params.set('unread_only', String(unreadOnly));
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
const query = params.toString() ? `?${params}` : '';
|
||||
return await read(page, `/oncall/my/deliveries${query}`);
|
||||
return await read(page, `/oncall/my/deliveries${query}`, { asSession });
|
||||
}
|
||||
|
||||
/** Mark inbox rows read or unread again. `{all: true, read: true}` marks everything. */
|
||||
async function markDeliveriesRead(page, { eventIds = [], read: isRead = true, all = false } = {}) {
|
||||
async function markDeliveriesRead(page, { eventIds = [], read: isRead = true, all = false, asSession = false } = {}) {
|
||||
return await call(page, 'post', '/oncall/my/deliveries/read',
|
||||
{ event_ids: eventIds, read: isRead, all });
|
||||
{ event_ids: eventIds, read: isRead, all }, { asSession });
|
||||
}
|
||||
|
||||
/** The team overview: the summary card's own endpoint, built from four other reads. */
|
||||
@@ -613,7 +633,10 @@ async function getTeamOverview(page, teamId) {
|
||||
*/
|
||||
async function getTeamAttention(page, teamId) {
|
||||
const body = await read(page, `/oncall/teams/${encodeURIComponent(teamId)}/config-risks`);
|
||||
return body?.risks ?? [];
|
||||
if (!Array.isArray(body?.risks)) {
|
||||
throw new Error(`config-risks answered no risks list: ${JSON.stringify(body).slice(0, 200)}`);
|
||||
}
|
||||
return body.risks;
|
||||
}
|
||||
|
||||
/** Ownership-rule hit statistics, the numbers the rules table renders per row. */
|
||||
@@ -647,6 +670,26 @@ async function createOwnershipRuleExpectingStatus(page, body) {
|
||||
return await call(page, 'post', '/oncall/ownership', body);
|
||||
}
|
||||
|
||||
/** The org is explicit because a case staged in its own org must not fall back to the shared one. */
|
||||
async function deleteDestination(page, name, org = orgId()) {
|
||||
return await page.request.delete(
|
||||
`${baseUrl()}/api/${org}/alerts/destinations/${encodeURIComponent(name)}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
}
|
||||
|
||||
/** Teardown only: an already-gone alert is not a failure. */
|
||||
async function deleteAlertById(page, alertId, org = orgId()) {
|
||||
try {
|
||||
await page.request.delete(
|
||||
`${baseUrl()}/api/v2/${org}/alerts/${encodeURIComponent(alertId)}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
} catch (e) {
|
||||
testLogger.debug('Alert cleanup failed (non-fatal)', { alertId, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SEED_MAIL_DOMAIN,
|
||||
RESOLUTION_CAUSES,
|
||||
@@ -686,6 +729,7 @@ module.exports = {
|
||||
deleteAbsence,
|
||||
deleteOwnershipRule,
|
||||
shortenPolicyForSpeed,
|
||||
requireMailSink,
|
||||
waitForMail,
|
||||
mailSince,
|
||||
extractLinks,
|
||||
@@ -700,4 +744,6 @@ module.exports = {
|
||||
getOwnershipStats,
|
||||
createOwnershipRuleExpectingStatus,
|
||||
deleteTeamExpectingStatus,
|
||||
deleteDestination,
|
||||
deleteAlertById,
|
||||
};
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
*
|
||||
* Plan: docs/test_generator/features/oncall-test-plan.md
|
||||
*
|
||||
* On-Call is ENTERPRISE-GATED. Every route answers 404 (feature off) or 403
|
||||
* "Not Supported" (OSS build) when it is unavailable, and the product treats
|
||||
* both as the same calm fact rather than an error — so must we.
|
||||
* `isOnCallAvailable()` turns that into one boolean a spec can skip on, instead
|
||||
* of each spec failing thirty seconds into a timeout on an OSS runner.
|
||||
* On-Call is ENTERPRISE-GATED: `isOnCallAvailable()` skips on an OSS build and throws when an enterprise build has it off.
|
||||
*
|
||||
* Three environment facts this module encodes, each of which fails QUIETLY if
|
||||
* you get it wrong:
|
||||
@@ -53,8 +49,8 @@ const SEED_SERVICES = ['checkout', 'search', 'payments'];
|
||||
const FIRST_RUNG_MICROS = 0;
|
||||
const SECOND_RUNG_MICROS = 60 * MICROS;
|
||||
|
||||
/** How long a fired alert may take to become a page. Generous: the scheduler owns the cadence. */
|
||||
const PAGE_WAIT_MS = 180_000;
|
||||
/** A new alert is due at once and the scheduler ticks every 10s, so its page opens in about 10-20s. */
|
||||
const PAGE_WAIT_MS = 90_000;
|
||||
|
||||
function baseUrl() {
|
||||
const url = process.env.ZO_BASE_URL || 'http://localhost:5080';
|
||||
@@ -152,30 +148,42 @@ async function must(res, what, payload) {
|
||||
);
|
||||
}
|
||||
|
||||
/** GET one resource: null only when it does not exist (404), a throw on any other failure. */
|
||||
async function getOrNull(page, url, what) {
|
||||
const res = await page.request.get(url, { headers: getAuthHeaders() });
|
||||
if (res.status() === 404) return null;
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!res.ok()) throw new Error(`${what} failed: HTTP ${res.status()} — ${text.slice(0, 200)}`);
|
||||
return text.trim() ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- capability probe
|
||||
|
||||
/** The server's `build_type` from the unauthenticated `/config`, or a throw when it cannot be read. */
|
||||
async function readBuildType(page) {
|
||||
const res = await page.request.get(`${baseUrl()}/config`);
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!res.ok()) {
|
||||
throw new Error(`on-call probe: could not read GET /config (HTTP ${res.status()}) — ${text.slice(0, 200)}`);
|
||||
}
|
||||
let buildType;
|
||||
try {
|
||||
buildType = JSON.parse(text)?.build_type;
|
||||
} catch {
|
||||
buildType = undefined;
|
||||
}
|
||||
if (!buildType) throw new Error(`on-call probe: GET /config carries no build_type — ${text.slice(0, 200)}`);
|
||||
return buildType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is on-call reachable in this deployment?
|
||||
*
|
||||
* Asks the teams list rather than `/config`: the list IS the capability probe
|
||||
* the UI itself uses (§G.8.1), so a spec that skips on this skips on exactly
|
||||
* what the screens react to. `/config`'s `oncall_enabled` is checked as well
|
||||
* because it distinguishes "off" from "OSS build" in the reason string, which
|
||||
* is the difference between a misconfigured runner and a correct skip.
|
||||
* Is on-call reachable here? Skips only on an OSS build; anything else that is not a working on-call throws.
|
||||
*
|
||||
* @returns {Promise<{available: boolean, reason: string}>}
|
||||
*/
|
||||
async function isOnCallAvailable(page) {
|
||||
const org = orgId();
|
||||
if (!org) return { available: false, reason: 'no org identifier — ORGNAME / cloud-config is unset' };
|
||||
|
||||
let flag = null;
|
||||
try {
|
||||
const cfg = await page.request.get(`${baseUrl()}/config`, { headers: getAuthHeaders() });
|
||||
if (cfg.ok()) flag = (await cfg.json().catch(() => ({})))?.oncall_enabled ?? null;
|
||||
} catch {
|
||||
// A failed /config read is not itself a verdict; the teams probe below decides.
|
||||
}
|
||||
if (!org) throw new Error('on-call probe: no org identifier — ORGNAME / cloud-config is unset');
|
||||
|
||||
const res = await page.request.get(`${baseUrl()}/api/${org}/oncall/teams`, {
|
||||
headers: getAuthHeaders(),
|
||||
@@ -183,22 +191,24 @@ async function isOnCallAvailable(page) {
|
||||
if (res.ok()) return { available: true, reason: '' };
|
||||
|
||||
const body = await res.text().catch(() => '');
|
||||
if (isUnavailableStatus(res.status(), body)) {
|
||||
// Every spec skips on `available: false`, so only a genuinely absent feature may return it.
|
||||
if (!isUnavailableStatus(res.status(), body)) {
|
||||
throw new Error(
|
||||
`on-call probe failed: HTTP ${res.status()} on /oncall/teams — ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const buildType = await readBuildType(page);
|
||||
if (buildType === 'opensource') {
|
||||
return {
|
||||
available: false,
|
||||
reason: flag === false
|
||||
? 'on-call is disabled on this deployment (config.oncall_enabled=false)'
|
||||
: `on-call is not available here (HTTP ${res.status()}) — OSS build or O2_ONCALL_ENABLED unset`,
|
||||
reason: `on-call is enterprise-only and this is an OSS build (HTTP ${res.status()} on /oncall/teams)`,
|
||||
};
|
||||
}
|
||||
// A THROW, not another `available: false`. Every spec calls this in
|
||||
// `beforeEach` and skips on a false, so returning one here turns any backend
|
||||
// fault into a silent pass: a 500 on this one route would skip all 106 tests
|
||||
// and report both shards green. Only 404 and 403 "Not Supported" say the
|
||||
// feature is absent; a 500, 502 or 401 says the deployment is broken, and a
|
||||
// broken deployment must fail the suite rather than excuse it.
|
||||
throw new Error(
|
||||
`on-call probe failed: HTTP ${res.status()} on /oncall/teams — ${body.slice(0, 200)}`,
|
||||
`on-call is switched off on this ${buildType} build: /oncall/teams answered HTTP ${res.status()}. ` +
|
||||
'Start the server with O2_ONCALL_ENABLED=true; skipping here would report the on-call suite green ' +
|
||||
'without running it.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,12 +249,10 @@ async function listTeams(page) {
|
||||
}
|
||||
|
||||
async function getTeam(page, teamId) {
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/teams/${encodeURIComponent(teamId)}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/teams/${encodeURIComponent(teamId)}`,
|
||||
`Reading team ${teamId}`,
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,12 +360,10 @@ async function setTeamSchedule(page, teamId, { timezone = 'UTC', rotations }) {
|
||||
}
|
||||
|
||||
async function getTeamSchedule(page, teamId) {
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/teams/${encodeURIComponent(teamId)}/schedule`,
|
||||
{ headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/teams/${encodeURIComponent(teamId)}/schedule`,
|
||||
`Reading the schedule of team ${teamId}`,
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
/** Who the engine says is on call, optionally at a future instant (micros). */
|
||||
@@ -495,12 +501,10 @@ async function detachPolicyFromRotations(page, teamId) {
|
||||
}
|
||||
|
||||
async function getTeamPolicy(page, teamId) {
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/teams/${encodeURIComponent(teamId)}/policy`,
|
||||
{ headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/teams/${encodeURIComponent(teamId)}/policy`,
|
||||
`Reading the policy of team ${teamId}`,
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ownership
|
||||
@@ -554,11 +558,10 @@ async function ownershipStats(page, { teamId = undefined, days = undefined } = {
|
||||
if (teamId) params.set('team_id', teamId);
|
||||
if (days !== undefined) params.set('days', String(days));
|
||||
const query = params.toString() ? `?${params}` : '';
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/ownership/stats${query}`, { headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/ownership/stats${query}`,
|
||||
'Reading ownership-rule stats',
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
/** Nominate the org's catch-all team, or clear it with null. */
|
||||
@@ -569,11 +572,10 @@ async function setDefaultTeam(page, teamId) {
|
||||
}
|
||||
|
||||
async function getRoutingConfig(page) {
|
||||
const res = await page.request.get(`${baseUrl()}/api/${orgId()}/oncall/routing/config`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/routing/config`,
|
||||
'Reading the routing config',
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ log data
|
||||
@@ -768,58 +770,6 @@ async function createPagingAlert(page, {
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an alert now instead of waiting for its schedule.
|
||||
*
|
||||
* PATCH, not POST — the route is `PATCH /api/v2/{org}/alerts/{id}/trigger`.
|
||||
* Without this a spec waits a whole frequency window for the scheduler.
|
||||
*/
|
||||
async function triggerAlert(page, alertId, { folder = 'default' } = {}) {
|
||||
const res = await api(
|
||||
page, 'patch',
|
||||
`${baseUrl()}/api/v2/${orgId()}/alerts/${encodeURIComponent(alertId)}/trigger?folder=${encodeURIComponent(folder)}`,
|
||||
);
|
||||
return await must(res, `Trigger alert ${alertId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire now if the route will, otherwise let the scheduler do it.
|
||||
*
|
||||
* THE MANUAL TRIGGER IS BROKEN FOR EXACTLY THE ALERT SHAPE ON-CALL USES, and
|
||||
* the failure is a bare `500 {"code":500,"message":""}` with nothing to read.
|
||||
* Reproduced against this build, smallest case: an alert carrying
|
||||
* `oncall_team` and `destinations: []` — which `POST /api/v2/{org}/alerts`
|
||||
* ACCEPTS with 200, and which the scheduler then fires correctly, opening its
|
||||
* page within ~15s — 500s on `PATCH .../trigger`. Add any destination to that
|
||||
* same alert and the same PATCH answers 200. So the create route, the
|
||||
* scheduler and the trigger route disagree about whether the shape is legal,
|
||||
* and only the trigger route is wrong.
|
||||
*
|
||||
* Firing is SCAFFOLDING for the UI cases here — none of them assert on the
|
||||
* trigger route — so a spec must not die on it. This swallows the 500 and
|
||||
* falls through to `waitForPages`, which still proves the page opened; a
|
||||
* scheduler that then never fires fails the spec with its own message rather
|
||||
* than this one. The swallow is deliberately narrow: any other failure is
|
||||
* re-thrown, so a trigger route that breaks generally still fails loudly.
|
||||
*
|
||||
* @returns {Promise<boolean>} whether the manual trigger actually took
|
||||
*/
|
||||
async function triggerAlertOrLetSchedulerFire(page, alertId, { folder = 'default' } = {}) {
|
||||
try {
|
||||
await triggerAlert(page, alertId, { folder });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!/failed: HTTP 500/.test(String(e?.message))) throw e;
|
||||
testLogger.warn(
|
||||
'Manual alert trigger 500ed; waiting for the scheduler instead. This is the known ' +
|
||||
'trigger-route defect for an on-call alert with no notification destinations — the ' +
|
||||
'alert itself saved fine and the scheduler fires it.',
|
||||
{ alertId },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- pages
|
||||
|
||||
/**
|
||||
@@ -866,20 +816,39 @@ async function listResponses(page, {
|
||||
}
|
||||
|
||||
async function getResponse(page, responseId) {
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/responses/${encodeURIComponent(responseId)}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/responses/${encodeURIComponent(responseId)}`,
|
||||
`Reading page ${responseId}`,
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
/** The routing evidence on a page that landed on the wrong team, for the failure message. */
|
||||
async function describeMisroutedPage(page, record, expectTeamId) {
|
||||
const lines = [
|
||||
`Page ${record.id} opened on team ${record.team_id ?? 'NONE (unrouted)'}; ` +
|
||||
`expected ${expectTeamId ?? 'NONE (unrouted)'}.`,
|
||||
];
|
||||
try {
|
||||
const detail = await getResponse(page, record.id);
|
||||
for (const e of detail?.events ?? []) {
|
||||
lines.push(` event ${e.kind} by ${e.actor}: ${String(e.body ?? '').slice(0, 300)}`);
|
||||
}
|
||||
const progress = await getEscalationProgress(page, record.id);
|
||||
if (progress?.stopped_because) lines.push(` escalation stopped because: ${progress.stopped_because}`);
|
||||
} catch (e) {
|
||||
lines.push(` (could not read the record's events: ${e.message})`);
|
||||
}
|
||||
lines.push(
|
||||
'Check the ownership rule claims the dimension the alert\'s rows carry, and that nothing ' +
|
||||
'fired the alert by hand: a manual trigger opens a teamless page that swallows every scheduled firing.',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a page to exist, matching on the alert that opened it.
|
||||
* Wait for the pages an alert opens, matched on `source_id` because each firing is its own record.
|
||||
*
|
||||
* Matching on `source_id` rather than a title: a record is keyed
|
||||
* `(subject_type, source_id, firing)` and each firing is its own record, so the
|
||||
* alert id is the only handle that survives a re-fire.
|
||||
* `expectTeamId` (a team id, or null for an unrouted page) fails at once when any page lands elsewhere.
|
||||
*
|
||||
* @returns {Promise<object[]>} every page opened by this alert, newest first
|
||||
*/
|
||||
@@ -887,8 +856,9 @@ async function waitForPages(page, {
|
||||
alertId,
|
||||
expected = 1,
|
||||
teamId = undefined,
|
||||
expectTeamId = undefined,
|
||||
timeout = PAGE_WAIT_MS,
|
||||
pollMs = 3000,
|
||||
pollMs = 2000,
|
||||
} = {}) {
|
||||
const deadline = Date.now() + timeout;
|
||||
let seen = [];
|
||||
@@ -897,6 +867,10 @@ async function waitForPages(page, {
|
||||
seen = await listResponses(page, {
|
||||
subjectType: 'alert', sourceId: alertId, teamId, includeResolved: true,
|
||||
});
|
||||
if (expectTeamId !== undefined) {
|
||||
const stray = seen.find((r) => (r.team_id ?? null) !== expectTeamId);
|
||||
if (stray) throw new Error(await describeMisroutedPage(page, stray, expectTeamId));
|
||||
}
|
||||
if (seen.length >= expected) {
|
||||
testLogger.info('On-call page(s) observed', { alertId, count: seen.length });
|
||||
return seen;
|
||||
@@ -905,26 +879,27 @@ async function waitForPages(page, {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Alert ${alertId} never opened ${expected} on-call page(s) within ${timeout}ms (saw ${seen.length}).\n` +
|
||||
`Causes, in the order worth checking: the alert has no oncall_team AND no ownership rule matches\n` +
|
||||
`its identity (a teamless page is still opened, so zero pages means the alert never FIRED);\n` +
|
||||
`the alert's window holds no rows (seed the stream first); or the scheduler is not running.`,
|
||||
`Alert ${alertId} opened ${seen.length} of ${expected} on-call page(s) within ${timeout}ms.\n` +
|
||||
'A firing opens a page even when nothing routes it, so a shortfall means the alert never FIRED: ' +
|
||||
'check the stream has rows inside the alert\'s window and that the alert scheduler is running.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the alert, fire it, and wait for the page it opens.
|
||||
* Create the alert and wait for the scheduler to fire it into a page; never fire it by hand.
|
||||
*
|
||||
* The one helper a UI spec that just needs "a page on screen" should call.
|
||||
* `expectTeamId` defaults to the alert's own `teamId`; pass the team an ownership rule should pick otherwise.
|
||||
*
|
||||
* @returns {Promise<{alert: {id: string, name: string}, pages: object[]}>}
|
||||
*/
|
||||
async function firePageAndWait(page, { alertOptions, expected = 1, timeout = PAGE_WAIT_MS } = {}) {
|
||||
async function firePageAndWait(page, {
|
||||
alertOptions,
|
||||
expected = 1,
|
||||
expectTeamId = alertOptions?.teamId,
|
||||
timeout = PAGE_WAIT_MS,
|
||||
} = {}) {
|
||||
const alert = await createPagingAlert(page, alertOptions);
|
||||
await triggerAlertOrLetSchedulerFire(page, alert.id);
|
||||
const pages = await waitForPages(page, {
|
||||
alertId: alert.id, expected, teamId: alertOptions.teamId, timeout,
|
||||
});
|
||||
const pages = await waitForPages(page, { alertId: alert.id, expected, expectTeamId, timeout });
|
||||
return { alert, pages };
|
||||
}
|
||||
|
||||
@@ -953,22 +928,18 @@ async function sendTestPage(page, teamId, { priority = undefined } = {}) {
|
||||
* `EscalationProgress`, which is the trap.
|
||||
*/
|
||||
async function getEscalationProgress(page, responseId) {
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/responses/${encodeURIComponent(responseId)}/escalation`,
|
||||
{ headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/responses/${encodeURIComponent(responseId)}/escalation`,
|
||||
`Reading the escalation progress of page ${responseId}`,
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
/** The delivery ledger — the ground truth `progress.reached` must agree with (§2.1). */
|
||||
async function getDeliveries(page, responseId) {
|
||||
const res = await page.request.get(
|
||||
`${baseUrl()}/api/${orgId()}/oncall/responses/${encodeURIComponent(responseId)}/deliveries`,
|
||||
{ headers: getAuthHeaders() },
|
||||
return await getOrNull(
|
||||
page, `${baseUrl()}/api/${orgId()}/oncall/responses/${encodeURIComponent(responseId)}/deliveries`,
|
||||
`Reading the delivery ledger of page ${responseId}`,
|
||||
);
|
||||
if (!res.ok()) return null;
|
||||
return await res.json().catch(() => null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ teardown
|
||||
@@ -1136,8 +1107,6 @@ module.exports = {
|
||||
waitForStreamSearchable,
|
||||
seedNotificationDestination,
|
||||
createPagingAlert,
|
||||
triggerAlert,
|
||||
triggerAlertOrLetSchedulerFire,
|
||||
listResponses,
|
||||
getResponse,
|
||||
waitForPages,
|
||||
|
||||
@@ -470,6 +470,44 @@ describe("ScheduledPipeline Component", () => {
|
||||
"SELECT max(_timestamp), count(*) FROM \"stream1\" WHERE level='error' GROUP BY histogram(_timestamp)",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an include", "operation_name='GET /'"],
|
||||
["a multi-value exclude", "(operation_name!='GET /' and operation_name!='POST /')"],
|
||||
])("adds %s next to a field whose name ends with the same name", async (_, filter) => {
|
||||
const base = `SELECT * FROM "qa" WHERE db_operation_name='SELECT'`;
|
||||
const mockEditorRef = {
|
||||
getCursorIndex: vi.fn().mockReturnValue(-1),
|
||||
getValue: vi.fn().mockReturnValue(base),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
child.pipelineEditorRef = mockEditorRef;
|
||||
child.tab = "sql";
|
||||
|
||||
await child.handleSidebarEvent("add-field", filter);
|
||||
|
||||
expect(mockEditorRef.setValue).toHaveBeenCalledWith(`${base} AND ${filter}`);
|
||||
});
|
||||
|
||||
it("still replaces the exact field's value next to the longer field", async () => {
|
||||
const mockEditorRef = {
|
||||
getCursorIndex: vi.fn().mockReturnValue(-1),
|
||||
getValue: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
`SELECT * FROM "qa" WHERE db_operation_name='SELECT' AND operation_name='POST /'`,
|
||||
),
|
||||
setValue: vi.fn(),
|
||||
};
|
||||
child.pipelineEditorRef = mockEditorRef;
|
||||
child.tab = "sql";
|
||||
|
||||
await child.handleSidebarEvent("add-field", "operation_name='GET /'");
|
||||
|
||||
expect(mockEditorRef.setValue).toHaveBeenCalledWith(
|
||||
`SELECT * FROM "qa" WHERE db_operation_name='SELECT' AND operation_name='GET /'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Function Management", () => {
|
||||
|
||||
@@ -13,25 +13,40 @@
|
||||
// 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, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
|
||||
vi.mock("@/services/query_history", () => ({
|
||||
default: { list: vi.fn().mockResolvedValue({ data: [] }) },
|
||||
}));
|
||||
|
||||
import RecentSearchesButton from "./RecentSearchesButton.vue";
|
||||
|
||||
const mountButton = (attrs: Record<string, unknown> = {}) =>
|
||||
mount(RecentSearchesButton, {
|
||||
props: { dataTest: "clock" },
|
||||
attrs,
|
||||
global: { plugins: [store], stubs: { OTooltip: true } },
|
||||
});
|
||||
|
||||
describe("RecentSearchesButton", () => {
|
||||
it("puts a caller's class on the clock button, since the popover renders no element", () => {
|
||||
const wrapper = mount(RecentSearchesButton, {
|
||||
props: { source: "logs", dataTest: "clock" },
|
||||
attrs: { class: "me-1" },
|
||||
global: { plugins: [store], stubs: { RecentSearchesPopover: true, OTooltip: true } },
|
||||
});
|
||||
const button = wrapper.find('[data-test="clock"]');
|
||||
afterEach(() => {
|
||||
store.state.zoConfig.query_history_enabled = true;
|
||||
});
|
||||
|
||||
it("puts a caller's class on the button", () => {
|
||||
const button = mountButton({ class: "me-1" }).find('[data-test="clock"]');
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.classes()).toContain("me-1");
|
||||
});
|
||||
|
||||
it("asks for the full Recent searches drawer on click", async () => {
|
||||
const wrapper = mountButton();
|
||||
await wrapper.find('[data-test="clock"]').trigger("click");
|
||||
expect(wrapper.emitted("view-all")).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it("is hidden unless history is enabled", () => {
|
||||
store.state.zoConfig.query_history_enabled = false;
|
||||
expect(mountButton().find('[data-test="clock"]').exists()).toBe(false);
|
||||
delete store.state.zoConfig.query_history_enabled;
|
||||
expect(mountButton().find('[data-test="clock"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,98 +15,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<OPopover
|
||||
<OButton
|
||||
v-if="enabled"
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
align="end"
|
||||
variant="outline"
|
||||
size="icon-toolbar"
|
||||
icon-left="manage-search"
|
||||
:aria-label="t('recentSearches.title')"
|
||||
@open-auto-focus="focusList"
|
||||
@pointer-down-outside="keepForDetails"
|
||||
@focus-outside="keepForDetails"
|
||||
:data-test="dataTest"
|
||||
@click="emit('view-all', null)"
|
||||
>
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon-toolbar"
|
||||
icon-left="history"
|
||||
v-bind="$attrs"
|
||||
:aria-label="t('recentSearches.title')"
|
||||
:data-test="dataTest"
|
||||
@keydown="onTriggerKeydown"
|
||||
>
|
||||
<OTooltip :content="t('recentSearches.title')" />
|
||||
</OButton>
|
||||
</template>
|
||||
<RecentSearchesPopover
|
||||
ref="popoverRef"
|
||||
:source="source"
|
||||
:active="open"
|
||||
:examples="examples"
|
||||
@run="(entry, when) => close(() => emit('run', entry, when))"
|
||||
@load="(entry, when) => close(() => emit('load', entry, when))"
|
||||
@view-all="(status) => close(() => emit('view-all', status))"
|
||||
@example="(query) => close(() => emit('example', query))"
|
||||
@close="open = false"
|
||||
/>
|
||||
</OPopover>
|
||||
<OTooltip :content="t('recentSearches.title')" />
|
||||
</OButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useRecentSearches } from "@/composables/queryHistory/useRecentSearches";
|
||||
import type { HistorySource, HistoryStatus, QueryHistoryEntry } from "@/services/query_history";
|
||||
import type { RestoreTime } from "@/utils/queryHistory/recentSearches";
|
||||
import RecentSearchesPopover from "./RecentSearchesPopover.vue";
|
||||
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
|
||||
import type { HistoryStatus } from "@/services/query_history";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
|
||||
// OPopover renders no element of its own, so a caller's class and attributes go on the trigger.
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
source: HistorySource;
|
||||
examples?: string[];
|
||||
dataTest?: string;
|
||||
}>(),
|
||||
{ examples: () => [], dataTest: "recent-searches-btn" },
|
||||
);
|
||||
withDefaults(defineProps<{ dataTest?: string }>(), { dataTest: "recent-searches-btn" });
|
||||
|
||||
const emit = defineEmits<{
|
||||
run: [entry: QueryHistoryEntry, when: RestoreTime];
|
||||
load: [entry: QueryHistoryEntry, when: RestoreTime];
|
||||
"view-all": [status: HistoryStatus | null];
|
||||
example: [query: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { enabled } = useRecentSearches();
|
||||
const open = ref(false);
|
||||
const popoverRef = ref<InstanceType<typeof RecentSearchesPopover> | null>(null);
|
||||
|
||||
// Focus can stay on the clock button after opening, and the list's keys must still reach the rows.
|
||||
const onTriggerKeydown = (event: KeyboardEvent) => {
|
||||
if (open.value && popoverRef.value?.handleOutsideKey(event)) event.preventDefault();
|
||||
};
|
||||
|
||||
// The details pane is teleported outside the popover's content, so reka counts a click in it as outside.
|
||||
const keepForDetails = (event: Event) => {
|
||||
const original = (event as CustomEvent<{ originalEvent?: Event }>).detail?.originalEvent;
|
||||
const target = (original?.target ?? event.target) as Node | null;
|
||||
if (popoverRef.value?.ownsNode(target)) event.preventDefault();
|
||||
};
|
||||
|
||||
// OPopover would focus the info button, where Enter opens nothing and the hovered row never runs.
|
||||
const focusList = (event: Event) => {
|
||||
event.preventDefault();
|
||||
popoverRef.value?.focusRoot();
|
||||
};
|
||||
|
||||
const close = (then: () => void) => {
|
||||
open.value = false;
|
||||
then();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,23 +37,153 @@ const mountPicker = (modelValue?: string, zone?: string) =>
|
||||
const cell = (iso: string) =>
|
||||
document.querySelector<HTMLElement>(`[data-test="daterangecalendar-cell-${iso}"]`);
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)).then(flushPromises);
|
||||
|
||||
const openCalendar = async (wrapper: ReturnType<typeof mountPicker>) => {
|
||||
await wrapper.find('[data-test="ran-on-trigger"]').trigger("click");
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
describe("RecentSearchesDayPicker", () => {
|
||||
it("reads as a date field: the dd/mm/yyyy placeholder, then the chosen day in that format", () => {
|
||||
const empty = mountPicker();
|
||||
const emptyValue = empty.find('[data-test="ran-on-value"]');
|
||||
expect(emptyValue.text()).toBe("dd/mm/yyyy");
|
||||
expect(emptyValue.classes()).toContain("text-input-placeholder");
|
||||
expect(empty.find('[data-test="ran-on-trigger"]').attributes("aria-label")).toBe(
|
||||
"Filter by date run",
|
||||
it("is a text field with the dd/mm/yyyy placeholder that shows the chosen day in that format", () => {
|
||||
const empty = mountPicker().find<HTMLInputElement>('[data-test="ran-on-field"]');
|
||||
expect(empty.element.tagName).toBe("INPUT");
|
||||
expect(empty.attributes("placeholder")).toBe("dd/mm/yyyy");
|
||||
expect(empty.element.value).toBe("");
|
||||
const picked = mountPicker("2026-10-29").find<HTMLInputElement>('[data-test="ran-on-field"]');
|
||||
expect(picked.element.value).toBe("29/10/2026");
|
||||
});
|
||||
|
||||
it("applies a typed day on Enter and on leaving the field", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("05/10/2026");
|
||||
await field.trigger("keydown", { key: "Enter" });
|
||||
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["2026-10-05"]);
|
||||
await field.setValue("6/10/2026");
|
||||
await field.trigger("focusout");
|
||||
await settle();
|
||||
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["2026-10-06"]);
|
||||
});
|
||||
|
||||
it("refuses a typed day that is not a date or is after today, and marks the field", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("31/02/2026");
|
||||
await field.trigger("keydown", { key: "Enter" });
|
||||
await field.setValue(DateTime.local().plus({ days: 1 }).toFormat("dd/MM/yyyy"));
|
||||
await field.trigger("keydown", { key: "Enter" });
|
||||
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
||||
expect(field.attributes("aria-invalid")).toBe("true");
|
||||
});
|
||||
|
||||
it("drops the error mark when an invalid entry is cleared with no day applied", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("abc");
|
||||
await field.trigger("focusout");
|
||||
await settle();
|
||||
expect(field.attributes("aria-invalid")).toBe("true");
|
||||
await wrapper.find('[data-test="ran-on-clear"]').trigger("click");
|
||||
expect(field.attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the applied day again when the calendar picks it over invalid text", async () => {
|
||||
const today = DateTime.local();
|
||||
const wrapper = mountPicker(today.toISODate()!);
|
||||
const field = wrapper.find<HTMLInputElement>('[data-test="ran-on-field"]');
|
||||
await field.setValue("99/99");
|
||||
await field.trigger("keydown", { key: "Enter" });
|
||||
await openCalendar(wrapper);
|
||||
cell(today.toISODate()!)!.click();
|
||||
await flushPromises();
|
||||
expect(field.element.value).toBe(today.toFormat("dd/MM/yyyy"));
|
||||
expect(field.attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rewrites the applied day retyped in another shape", async () => {
|
||||
const wrapper = mountPicker("2026-02-01");
|
||||
const field = wrapper.find<HTMLInputElement>('[data-test="ran-on-field"]');
|
||||
await field.setValue("1-2-2026");
|
||||
await field.trigger("keydown", { key: "Enter" });
|
||||
expect(field.element.value).toBe("01/02/2026");
|
||||
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not judge a half-typed day when focus moves to its calendar button", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("3/1");
|
||||
const trigger = wrapper.find<HTMLElement>('[data-test="ran-on-trigger"]');
|
||||
trigger.element.focus();
|
||||
await field.trigger("focusout", { relatedTarget: trigger.element });
|
||||
await settle();
|
||||
expect(field.attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not judge a half-typed day when a press on the calendar button leaves focus nowhere", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("3/1");
|
||||
await wrapper.find('[data-test="ran-on-trigger"]').trigger("pointerdown");
|
||||
await field.trigger("focusout");
|
||||
await settle();
|
||||
expect(field.attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not judge a half-typed day when Escape closes the calendar back onto its button", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("29/10");
|
||||
await openCalendar(wrapper);
|
||||
document.activeElement!.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
const picked = mountPicker("2026-10-29").find('[data-test="ran-on-value"]');
|
||||
expect(picked.text()).toBe("29/10/2026");
|
||||
expect(picked.classes()).toContain("text-input-text");
|
||||
await settle();
|
||||
expect(document.querySelector('[data-test="ran-on-calendar"]')).toBeNull();
|
||||
expect(field.attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("judges the text once focus leaves the field from its calendar button", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("abc");
|
||||
const trigger = wrapper.find<HTMLElement>('[data-test="ran-on-trigger"]');
|
||||
trigger.element.focus();
|
||||
await settle();
|
||||
trigger.element.blur();
|
||||
await trigger.trigger("focusout", { relatedTarget: document.body });
|
||||
await settle();
|
||||
expect(field.attributes("aria-invalid")).toBe("true");
|
||||
});
|
||||
|
||||
it("sends a calendar pick once", async () => {
|
||||
const wrapper = mountPicker();
|
||||
await openCalendar(wrapper);
|
||||
cell(DateTime.local().toISODate()!)!.click();
|
||||
await settle();
|
||||
expect(wrapper.emitted("update:modelValue")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops invalid text on reset", async () => {
|
||||
const wrapper = mountPicker();
|
||||
const field = wrapper.find<HTMLInputElement>('[data-test="ran-on-field"]');
|
||||
await field.setValue("99/99/2026");
|
||||
await field.trigger("keydown", { key: "Enter" });
|
||||
expect(field.attributes("aria-invalid")).toBe("true");
|
||||
(wrapper.vm as unknown as { reset: () => void }).reset();
|
||||
await flushPromises();
|
||||
expect(field.element.value).toBe("");
|
||||
expect(field.attributes("aria-invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears the day when the typed text is emptied", async () => {
|
||||
const wrapper = mountPicker("2026-10-27");
|
||||
const field = wrapper.find('[data-test="ran-on-field"]');
|
||||
await field.setValue("");
|
||||
await field.trigger("focusout");
|
||||
await settle();
|
||||
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("opens the shared range calendar and picks one day with a single click", async () => {
|
||||
@@ -107,21 +237,7 @@ describe("RecentSearchesDayPicker", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("joins the field and its clear button in a button group, without overriding the buttons' box", () => {
|
||||
const wrapper = mountPicker("2026-10-27");
|
||||
const group = wrapper.find('[data-test="ran-on"]');
|
||||
expect(group.attributes("role")).toBe("group");
|
||||
const buttons = group.findAll(":scope > button");
|
||||
expect(buttons.map((b) => b.attributes("data-test"))).toEqual([
|
||||
"ran-on-trigger",
|
||||
"ran-on-clear",
|
||||
]);
|
||||
const forced = /^(h|rounded|border)(-\S*)?!$/;
|
||||
buttons.forEach((b) => expect(b.classes().filter((c) => forced.test(c))).toEqual([]));
|
||||
});
|
||||
|
||||
it("clears the day with its own button, shown only while a day is set", async () => {
|
||||
expect(mountPicker().find('[data-test="ran-on-clear"]').exists()).toBe(false);
|
||||
it("clears the day with the field's clear button", async () => {
|
||||
const wrapper = mountPicker("2026-10-27");
|
||||
await wrapper.find('[data-test="ran-on-clear"]').trigger("click");
|
||||
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([undefined]);
|
||||
|
||||
@@ -15,71 +15,65 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<OButtonGroup
|
||||
class="border-input-border bg-input-bg hover:border-input-border-hover border"
|
||||
:data-test="dataTest"
|
||||
>
|
||||
<OPopover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
align="start"
|
||||
<span ref="fieldRef" class="contents" @focusout="judgeLater">
|
||||
<OInput
|
||||
v-model="text"
|
||||
size="sm"
|
||||
width="sm"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="fieldLabel"
|
||||
content-class="p-1 shadow-lg!"
|
||||
@open-auto-focus="focusDay"
|
||||
:error="invalid"
|
||||
clearable
|
||||
:data-test="dataTest"
|
||||
@keydown.enter.prevent="commit"
|
||||
@clear="apply(undefined)"
|
||||
>
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="min-w-36"
|
||||
<template #icon-right>
|
||||
<OPopover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
align="end"
|
||||
:aria-label="fieldLabel"
|
||||
:data-test="`${dataTest}-trigger`"
|
||||
content-class="p-1 shadow-lg!"
|
||||
@open-auto-focus="focusDay"
|
||||
>
|
||||
<span
|
||||
:class="modelValue ? 'text-input-text' : 'text-input-placeholder'"
|
||||
class="ms-0.5 flex-1 text-left text-sm font-normal tabular-nums"
|
||||
:data-test="`${dataTest}-value`"
|
||||
>{{ label }}</span
|
||||
>
|
||||
<template #icon-right>
|
||||
<OIcon name="calendar-month" size="sm" class="text-input-hint ms-auto me-0.5" />
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
@pointerdown="notePress"
|
||||
icon-left="calendar-month"
|
||||
class="text-input-hint"
|
||||
:aria-label="t('components.date.openCalendar')"
|
||||
:data-test="`${dataTest}-trigger`"
|
||||
/>
|
||||
</template>
|
||||
</OButton>
|
||||
<div
|
||||
ref="calendarRef"
|
||||
class="flex justify-center px-3 py-2"
|
||||
:data-test="`${dataTest}-calendar`"
|
||||
>
|
||||
<ODateRangeCalendar
|
||||
:start-date="selectedDay"
|
||||
:end-date="selectedDay"
|
||||
:min-date="MIN_DAY"
|
||||
:max-date="today"
|
||||
@update:pending-date="pick"
|
||||
/>
|
||||
</div>
|
||||
</OPopover>
|
||||
</template>
|
||||
<div
|
||||
ref="calendarRef"
|
||||
class="flex justify-center px-3 py-2"
|
||||
:data-test="`${dataTest}-calendar`"
|
||||
>
|
||||
<ODateRangeCalendar
|
||||
:start-date="selectedDay"
|
||||
:end-date="selectedDay"
|
||||
:min-date="MIN_DAY"
|
||||
:max-date="today"
|
||||
@update:pending-date="pick"
|
||||
/>
|
||||
</div>
|
||||
</OPopover>
|
||||
<OButton
|
||||
v-if="modelValue"
|
||||
variant="ghost"
|
||||
size="icon-xs-sq"
|
||||
icon-left="close"
|
||||
:aria-label="t('common.clearFilter')"
|
||||
:data-test="`${dataTest}-clear`"
|
||||
@click="emit('update:modelValue', undefined)"
|
||||
/>
|
||||
</OButtonGroup>
|
||||
</OInput>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { DateTime } from "luxon";
|
||||
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import { useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import ODateRangeCalendar from "@/lib/forms/DateTimeRange/ODateRangeCalendar.vue";
|
||||
|
||||
const MIN_DAY = "1999/01/01";
|
||||
@@ -112,10 +106,67 @@ watch(open, (isOpen) => {
|
||||
});
|
||||
// The calendar speaks the app's YYYY/MM/DD; the filter keeps YYYY-MM-DD.
|
||||
const selectedDay = computed(() => props.modelValue?.replace(/-/g, "/"));
|
||||
const label = computed(() => {
|
||||
if (!props.modelValue) return props.placeholder;
|
||||
return raw(DateTime.fromISO(props.modelValue).toFormat("dd/MM/yyyy"));
|
||||
});
|
||||
const shown = (day?: string) => (day ? DateTime.fromISO(day).toFormat("dd/MM/yyyy") : "");
|
||||
const text = ref(shown(props.modelValue));
|
||||
const invalid = ref(false);
|
||||
const fieldRef = ref<HTMLElement | null>(null);
|
||||
// The day last sent; the parent's v-model lags a tick, so comparing to it would send a pick twice.
|
||||
const appliedDay = ref(props.modelValue);
|
||||
let pressingTrigger = false;
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(day) => {
|
||||
appliedDay.value = day;
|
||||
text.value = shown(day);
|
||||
invalid.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
// The field shows the applied day even when the day itself did not change, e.g. one retyped in another shape.
|
||||
const apply = (day: string | undefined) => {
|
||||
text.value = shown(day);
|
||||
invalid.value = false;
|
||||
if (day === appliedDay.value) return;
|
||||
appliedDay.value = day;
|
||||
emit("update:modelValue", day);
|
||||
};
|
||||
|
||||
// Typed days read day-first, the order the field shows; a day outside the calendar's range is refused.
|
||||
const parseTyped = (value: string): string | null => {
|
||||
for (const format of ["d/M/yyyy", "d-M-yyyy", "d.M.yyyy"]) {
|
||||
const day = DateTime.fromFormat(value, format);
|
||||
if (!day.isValid) continue;
|
||||
const iso = day.toISODate();
|
||||
const last = todayText().replace(/\//g, "-");
|
||||
return iso && iso >= MIN_DAY.replace(/\//g, "-") && iso <= last ? iso : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const value = String(text.value ?? "").trim();
|
||||
if (!value) return apply(undefined);
|
||||
const day = parseTyped(value);
|
||||
if (day) apply(day);
|
||||
else invalid.value = true;
|
||||
};
|
||||
|
||||
// Judged once focus settles outside the text, its calendar button and the open calendar; Safari does not focus a pressed button.
|
||||
const judgeLater = () =>
|
||||
setTimeout(() => {
|
||||
const pressed = pressingTrigger;
|
||||
pressingTrigger = false;
|
||||
if (pressed || open.value || fieldRef.value?.contains(document.activeElement)) return;
|
||||
commit();
|
||||
});
|
||||
|
||||
const notePress = () => {
|
||||
pressingTrigger = true;
|
||||
};
|
||||
|
||||
const reset = () => apply(undefined);
|
||||
|
||||
defineExpose({ reset });
|
||||
|
||||
// Arrow keys and Enter work only from a day cell, so open on the chosen day, else today.
|
||||
const focusDay = (event: Event) => {
|
||||
@@ -127,10 +178,9 @@ const focusDay = (event: Event) => {
|
||||
day.focus();
|
||||
};
|
||||
|
||||
// A first click on the range calendar is one complete day for this filter.
|
||||
// The calendar clears a one-day range clicked again and reports null; here that is re-picking the applied day.
|
||||
const pick = (day: string | null) => {
|
||||
if (!day) return;
|
||||
emit("update:modelValue", day.replace(/\//g, "-"));
|
||||
apply(day ? day.replace(/\//g, "-") : props.modelValue);
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -73,6 +73,8 @@ const input = (name: string) => ({
|
||||
template: `<input data-stub="${name}" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
|
||||
});
|
||||
|
||||
const dayReset = vi.fn();
|
||||
|
||||
const mountDrawer = (open = true) =>
|
||||
mount(RecentSearchesDrawer, {
|
||||
props: { source: "logs", open },
|
||||
@@ -97,7 +99,7 @@ const mountDrawer = (open = true) =>
|
||||
},
|
||||
OSearchInput: input("search"),
|
||||
OSelect: input("select"),
|
||||
RecentSearchesDayPicker: input("date"),
|
||||
RecentSearchesDayPicker: { ...input("date"), methods: { reset: dayReset } },
|
||||
OSwitch: {
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
@@ -198,6 +200,7 @@ describe("RecentSearchesDrawer", () => {
|
||||
expect((selects[0].element as HTMLInputElement).value).toBe("");
|
||||
expect((selects[1].element as HTMLInputElement).value).toBe("all");
|
||||
expect((wrapper.find('[data-stub="date"]').element as HTMLInputElement).value).toBe("");
|
||||
expect(dayReset).toHaveBeenCalled();
|
||||
expect(
|
||||
(wrapper.find('[data-test="recent-searches-starred-only"]').element as HTMLInputElement)
|
||||
.checked,
|
||||
|
||||
@@ -74,6 +74,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="recent-searches-status"
|
||||
/>
|
||||
<RecentSearchesDayPicker
|
||||
ref="dayPickerRef"
|
||||
v-model="ranOn"
|
||||
:placeholder="t('recentSearches.datePlaceholder')"
|
||||
:field-label="t('recentSearches.filterByDateRun')"
|
||||
@@ -174,7 +175,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:icon-left="entry.starred ? 'star' : 'star-outline'"
|
||||
:class="entry.starred ? 'text-accent' : ''"
|
||||
:class="entry.starred ? 'text-favorite' : ''"
|
||||
:aria-label="
|
||||
entry.starred ? t('recentSearches.unstar') : t('recentSearches.star')
|
||||
"
|
||||
@@ -438,6 +439,7 @@ const scope = ref<"page" | "all">("page");
|
||||
const stream = ref<string | null>(null);
|
||||
const status = ref<"all" | HistoryStatus>("all");
|
||||
const ranOn = ref<string | undefined>(undefined);
|
||||
const dayPickerRef = ref<InstanceType<typeof RecentSearchesDayPicker> | null>(null);
|
||||
const starredOnly = ref(false);
|
||||
const clearOpen = ref(false);
|
||||
const includeStarred = ref(false);
|
||||
@@ -469,6 +471,7 @@ const resetFilters = () => {
|
||||
stream.value = null;
|
||||
status.value = "all";
|
||||
ranOn.value = undefined;
|
||||
dayPickerRef.value?.reset();
|
||||
starredOnly.value = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ const clipboard = vi.hoisted(() => ({ copyToClipboard: vi.fn().mockResolvedValue
|
||||
vi.mock("@/utils/clipboard", () => clipboard);
|
||||
|
||||
import RecentSearchesPopover from "./RecentSearchesPopover.vue";
|
||||
import RecentSearchesButton from "./RecentSearchesButton.vue";
|
||||
import RecentSearchesFocusDropdown from "./RecentSearchesFocusDropdown.vue";
|
||||
import { placeCaret } from "@/test/unit/helpers/monacoCaret";
|
||||
import {
|
||||
@@ -853,13 +852,7 @@ describe("long queries in the details pane", () => {
|
||||
|
||||
it("leaves PageDown to its owner when no row is active", async () => {
|
||||
const wrapper = await mountAttached();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
handleOutsideKey: (event: KeyboardEvent) => boolean;
|
||||
scrollDetails: (step: 1 | -1) => boolean;
|
||||
};
|
||||
const event = new KeyboardEvent("keydown", { key: "PageDown", cancelable: true });
|
||||
expect(vm.handleOutsideKey(event)).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
const vm = wrapper.vm as unknown as { scrollDetails: (step: 1 | -1) => boolean };
|
||||
expect(vm.scrollDetails(1)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -913,14 +906,6 @@ describe("when history is disabled", () => {
|
||||
delete store.state.zoConfig.query_history_enabled;
|
||||
});
|
||||
|
||||
it("hides the clock button", () => {
|
||||
const wrapper = mount(RecentSearchesButton, {
|
||||
props: { source: "logs" },
|
||||
global: { plugins: [store] },
|
||||
});
|
||||
expect(wrapper.find('[data-test="recent-searches-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("never opens the focus dropdown and never fetches", async () => {
|
||||
const wrapper = mount(RecentSearchesFocusDropdown, {
|
||||
props: { source: "logs", anchor: null, open: true },
|
||||
@@ -948,25 +933,23 @@ describe("keyboard: arrows then Enter or Shift+Enter", () => {
|
||||
delete store.state.zoConfig.query_history_enabled;
|
||||
});
|
||||
|
||||
const openFromButton = async () => {
|
||||
const wrapper = mount(RecentSearchesButton, {
|
||||
props: { source: "logs" },
|
||||
const openPopover = async () => {
|
||||
const wrapper = mount(RecentSearchesPopover, {
|
||||
props: { source: "logs", active: true },
|
||||
global: { plugins: [store], stubs: { OTimeCell: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const trigger = wrapper.find('[data-test="recent-searches-btn"]');
|
||||
await trigger.trigger("click");
|
||||
await flushPromises();
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('[data-test="recent-searches-row-e1"]')).not.toBeNull(),
|
||||
);
|
||||
return { wrapper, trigger: trigger.element as HTMLElement };
|
||||
const root = document.querySelector<HTMLElement>('[data-test="recent-searches-popover"]')!;
|
||||
root.focus();
|
||||
return { wrapper, root };
|
||||
};
|
||||
|
||||
it("loads the row Shift+Enter is pressed on, even when focus was left on the clock button", async () => {
|
||||
const { wrapper, trigger } = await openFromButton();
|
||||
trigger.focus();
|
||||
press(trigger, "ArrowDown");
|
||||
it("loads the row Shift+Enter is pressed on after arrowing in from the list", async () => {
|
||||
const { wrapper, root } = await openPopover();
|
||||
press(root, "ArrowDown");
|
||||
press(document.activeElement!, "ArrowDown");
|
||||
expect(activeTest()).toBe("recent-searches-row-e1");
|
||||
|
||||
@@ -977,9 +960,8 @@ describe("keyboard: arrows then Enter or Shift+Enter", () => {
|
||||
});
|
||||
|
||||
it("gives the keyboard-focused row, or the hovered one, the menu's active state", async () => {
|
||||
const { trigger } = await openFromButton();
|
||||
trigger.focus();
|
||||
press(trigger, "ArrowDown");
|
||||
const { root } = await openPopover();
|
||||
press(root, "ArrowDown");
|
||||
press(document.activeElement!, "ArrowDown");
|
||||
await nextTick();
|
||||
const row = (id: string) =>
|
||||
@@ -1008,7 +990,7 @@ describe("keyboard: arrows then Enter or Shift+Enter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("Enter on a hovered row after opening from the clock button", () => {
|
||||
describe("Enter on a hovered row with focus on the list itself", () => {
|
||||
const hoverRow = async (id: string) => {
|
||||
document
|
||||
.querySelector(`[data-test="recent-searches-row-${id}"]`)!
|
||||
@@ -1026,15 +1008,14 @@ describe("keyboard: arrows then Enter or Shift+Enter", () => {
|
||||
return event;
|
||||
};
|
||||
|
||||
it("focuses the list itself, so Enter runs the hovered row and never reaches the page", async () => {
|
||||
it("runs the hovered row on Enter, which never reaches the page", async () => {
|
||||
const pageSaw = vi.fn();
|
||||
document.addEventListener("keydown", pageSaw);
|
||||
try {
|
||||
const { wrapper } = await openFromButton();
|
||||
const list = document.querySelector('[data-test="recent-searches-popover"]')!;
|
||||
expect(document.activeElement).toBe(list);
|
||||
const { wrapper, root } = await openPopover();
|
||||
expect(document.activeElement).toBe(root);
|
||||
await hoverRow("e1");
|
||||
const event = enter(list);
|
||||
const event = enter(root);
|
||||
await flushPromises();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(pageSaw).not.toHaveBeenCalled();
|
||||
@@ -1045,37 +1026,26 @@ describe("keyboard: arrows then Enter or Shift+Enter", () => {
|
||||
});
|
||||
|
||||
it("loads the hovered row on Shift+Enter", async () => {
|
||||
const { wrapper } = await openFromButton();
|
||||
const { wrapper, root } = await openPopover();
|
||||
await hoverRow("e1");
|
||||
enter(document.querySelector('[data-test="recent-searches-popover"]')!, true);
|
||||
enter(root, true);
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("load")?.[0]?.[0]).toMatchObject({ id: "e1" });
|
||||
expect(wrapper.emitted("run")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on Enter when no row is hovered", async () => {
|
||||
const { wrapper } = await openFromButton();
|
||||
const event = enter(document.querySelector('[data-test="recent-searches-popover"]')!);
|
||||
const { wrapper, root } = await openPopover();
|
||||
const event = enter(root);
|
||||
await flushPromises();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(wrapper.emitted("run")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs the hovered row on Enter even with focus left on the clock button", async () => {
|
||||
const { wrapper, trigger } = await openFromButton();
|
||||
trigger.focus();
|
||||
await hoverRow("e1");
|
||||
const event = enter(trigger);
|
||||
await flushPromises();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(wrapper.emitted("run")?.[0]?.[0]).toMatchObject({ id: "e1" });
|
||||
});
|
||||
});
|
||||
|
||||
it("runs the row plain Enter is pressed on", async () => {
|
||||
const { wrapper, trigger } = await openFromButton();
|
||||
trigger.focus();
|
||||
press(trigger, "ArrowDown");
|
||||
const { wrapper, root } = await openPopover();
|
||||
press(root, "ArrowDown");
|
||||
press(document.activeElement!, "Enter");
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("run")?.[0]?.[0]).toMatchObject({ id: "e0" });
|
||||
@@ -1186,35 +1156,33 @@ describe("keyboard: arrows then Enter or Shift+Enter", () => {
|
||||
expect(wrapper.emitted("update:open")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the clock popover open for a click in the details pane, and closes it for a click elsewhere", async () => {
|
||||
const { wrapper } = await openFromButton();
|
||||
document
|
||||
.querySelector('[data-test="recent-searches-row-e0"]')!
|
||||
.dispatchEvent(new PointerEvent("pointerenter"));
|
||||
await flushPromises();
|
||||
it("keeps the focus dropdown open for a click in the details pane, and closes it for a click elsewhere", async () => {
|
||||
const { wrapper } = await mountWithEditor();
|
||||
await hoverRow("e0");
|
||||
const copy = document.querySelector<HTMLElement>('[data-test="recent-searches-details-copy"]')!;
|
||||
copy.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
copy.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("update:open")).toBeUndefined();
|
||||
expect(document.querySelector('[data-test="recent-searches-popover"]')).not.toBeNull();
|
||||
|
||||
const elsewhere = document.createElement("button");
|
||||
document.body.appendChild(elsewhere);
|
||||
elsewhere.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
await flushPromises();
|
||||
expect(document.querySelector('[data-test="recent-searches-popover"]')).toBeNull();
|
||||
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("keeps the clock popover open while Tab takes a keyboard-selected row to its details' Copy", async () => {
|
||||
const { wrapper, trigger } = await openFromButton();
|
||||
trigger.focus();
|
||||
press(trigger, "ArrowDown");
|
||||
it("keeps the focus dropdown open while Tab takes a keyboard-selected row to its details' Copy", async () => {
|
||||
const { wrapper } = await mountWithEditor();
|
||||
expect((wrapper.vm as unknown as { focusFirst: () => boolean }).focusFirst()).toBe(true);
|
||||
await flushPromises();
|
||||
expect(activeTest()).toBe("recent-searches-row-e0");
|
||||
press(document.activeElement!, "Tab");
|
||||
await flushPromises();
|
||||
expect(activeTest()).toBe("recent-searches-details-copy");
|
||||
expect(wrapper.emitted("update:open")).toBeUndefined();
|
||||
expect(document.querySelector('[data-test="recent-searches-popover"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-test="recent-searches-details-query"]')?.textContent).toBe(
|
||||
"q0",
|
||||
|
||||
@@ -161,7 +161,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OIcon
|
||||
:name="row.starred ? 'star' : 'history'"
|
||||
size="xs"
|
||||
:class="row.starred ? 'text-accent' : 'text-select-item-selected-text'"
|
||||
:class="row.starred ? 'text-favorite' : 'text-select-item-selected-text'"
|
||||
/>
|
||||
</span>
|
||||
<OTruncatedText :tooltip="false" class="flex-1 font-mono text-xs">
|
||||
@@ -416,20 +416,5 @@ const pickActive = (event: KeyboardEvent) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Handles a key pressed while focus is still outside the list; true when it was used. */
|
||||
const handleOutsideKey = (event: KeyboardEvent) => {
|
||||
if (isPlainEnter(event)) return hoveredId.value !== null && pickActive(event);
|
||||
if (event.key === "PageDown" || event.key === "PageUp") {
|
||||
onKeydown(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
if (!["ArrowDown", "ArrowUp", "Escape"].includes(event.key)) return false;
|
||||
onKeydown(event);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Focuses the list itself rather than its first control, so Enter reaches the hovered row. */
|
||||
const focusRoot = () => rootRef.value?.focus();
|
||||
|
||||
defineExpose({ focusFirst, focusRoot, pickActive, handleOutsideKey, scrollDetails, ownsNode });
|
||||
defineExpose({ focusFirst, pickActive, scrollDetails, ownsNode });
|
||||
</script>
|
||||
|
||||
@@ -470,9 +470,24 @@ describe("SyntheticsLocationForm", () => {
|
||||
|
||||
// ── Provider options ───────────────────────────────────────────────────────
|
||||
|
||||
it("exposes the four provider options in the computed", () => {
|
||||
it("offers only AWS when creating a location", () => {
|
||||
wrapper = createWrapper();
|
||||
const options = wrapper.vm.providerOptions;
|
||||
expect(options.map((o: { value: string }) => o.value)).toEqual(["aws"]);
|
||||
});
|
||||
|
||||
it("keeps all provider options in edit mode to label existing locations", () => {
|
||||
wrapper = createWrapper({
|
||||
isEdit: true,
|
||||
data: {
|
||||
id: "gcp-us-east1",
|
||||
provider: "gcp",
|
||||
region: "us-east1",
|
||||
label: "GCP",
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
const options = wrapper.vm.providerOptions;
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options.map((o: { value: string }) => o.value)).toEqual([
|
||||
"aws",
|
||||
|
||||
@@ -141,13 +141,19 @@ export default defineComponent({
|
||||
const locationFormRef = ref<any>(null);
|
||||
const locationFormSchema = makeSyntheticsLocationFormSchema(t);
|
||||
|
||||
// Provider options for the select dropdown.
|
||||
const providerOptions = computed(() => [
|
||||
{ label: raw("AWS"), value: "aws" },
|
||||
{ label: raw("GCP"), value: "gcp" },
|
||||
{ label: raw("Azure"), value: "azure" },
|
||||
{ label: t("synthetics.locations.providerOther"), value: "custom" },
|
||||
]);
|
||||
// Provider options for the select dropdown. Only AWS probes are supported,
|
||||
// so create offers AWS alone. Edit keeps the full list so the (disabled)
|
||||
// select still labels locations saved with another provider.
|
||||
const providerOptions = computed(() =>
|
||||
props.isEdit
|
||||
? [
|
||||
{ label: raw("AWS"), value: "aws" },
|
||||
{ label: raw("GCP"), value: "gcp" },
|
||||
{ label: raw("Azure"), value: "azure" },
|
||||
{ label: t("synthetics.locations.providerOther"), value: "custom" },
|
||||
]
|
||||
: [{ label: raw("AWS"), value: "aws" }],
|
||||
);
|
||||
|
||||
// Edit-prefill defaults. OForm reads `:default-values` once at mount; ODrawer
|
||||
// (reka-ui `lazy`) unmounts/remounts its body on close/open so this re-seeds
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("loadLogsData — requests the URL search waits on", () => {
|
||||
expect(prefetchStreamSchema).toHaveBeenCalledWith("app", "logs");
|
||||
expect(prefetchStreamSchema).toHaveBeenCalledWith("web", "logs");
|
||||
expect(calls.indexOf("schema:logs/app")).toBeLessThan(calls.indexOf("streams"));
|
||||
finishStreams({ list: [] });
|
||||
finishStreams({ list: [{ name: "app" }, { name: "web" }] });
|
||||
await loaded;
|
||||
});
|
||||
|
||||
|
||||
@@ -117,6 +117,130 @@ describe("useStreamFields Composable", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refetches a cached stream list that lacks a stream the URL names", async () => {
|
||||
queryClient.clear();
|
||||
const { searchObj } = searchState();
|
||||
searchObj.data.stream.streamType = "logs";
|
||||
searchObj.data.stream.selectedStream = ["fresh"];
|
||||
searchObj.data.filterErrMsg = "";
|
||||
const fetchQuery = vi
|
||||
.spyOn(queryClient, "fetchQuery")
|
||||
.mockResolvedValueOnce([{ name: "old", stream_type: "logs" }] as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "old", stream_type: "logs" },
|
||||
{ name: "fresh", stream_type: "logs" },
|
||||
] as any);
|
||||
|
||||
try {
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(2);
|
||||
expect(searchObj.data.stream.selectedStream).toEqual(["fresh"]);
|
||||
expect(searchObj.data.filterErrMsg).toBe("");
|
||||
} finally {
|
||||
fetchQuery.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still reports a URL stream that a fresh list does not have", async () => {
|
||||
queryClient.clear();
|
||||
const { searchObj } = searchState();
|
||||
searchObj.data.stream.streamType = "logs";
|
||||
searchObj.data.stream.selectedStream = ["gone"];
|
||||
const fetchQuery = vi
|
||||
.spyOn(queryClient, "fetchQuery")
|
||||
.mockResolvedValue([{ name: "old", stream_type: "logs" }] as any);
|
||||
|
||||
try {
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(2);
|
||||
expect(searchObj.data.stream.selectedStream).toEqual([]);
|
||||
expect(searchObj.data.filterErrMsg).toContain("gone");
|
||||
} finally {
|
||||
fetchQuery.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the cached list when the refetch fails", async () => {
|
||||
queryClient.clear();
|
||||
const { searchObj } = searchState();
|
||||
searchObj.data.stream.streamType = "logs";
|
||||
searchObj.data.stream.selectedStream = ["flaky"];
|
||||
const fetchQuery = vi
|
||||
.spyOn(queryClient, "fetchQuery")
|
||||
.mockResolvedValueOnce([{ name: "old", stream_type: "logs" }] as any)
|
||||
.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
try {
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
expect(searchObj.data.streamResults.list.map((s: any) => s.name)).toEqual(["old"]);
|
||||
expect(searchObj.data.filterErrMsg).toContain("flaky");
|
||||
} finally {
|
||||
fetchQuery.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries the refetch on the next load after one failed", async () => {
|
||||
queryClient.clear();
|
||||
const { searchObj } = searchState();
|
||||
searchObj.data.stream.streamType = "logs";
|
||||
const fetchQuery = vi
|
||||
.spyOn(queryClient, "fetchQuery")
|
||||
.mockResolvedValueOnce([{ name: "old", stream_type: "logs" }] as any)
|
||||
.mockRejectedValueOnce(new Error("network"))
|
||||
.mockResolvedValueOnce([{ name: "old", stream_type: "logs" }] as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "old", stream_type: "logs" },
|
||||
{ name: "late", stream_type: "logs" },
|
||||
] as any);
|
||||
|
||||
try {
|
||||
searchObj.data.stream.selectedStream = ["late"];
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
searchObj.data.stream.selectedStream = ["late"];
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(4);
|
||||
expect(searchObj.data.stream.selectedStream).toEqual(["late"]);
|
||||
} finally {
|
||||
fetchQuery.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("refetches once for a stream a fresh list already lacked", async () => {
|
||||
queryClient.clear();
|
||||
const { searchObj } = searchState();
|
||||
searchObj.data.stream.streamType = "logs";
|
||||
const fetchQuery = vi
|
||||
.spyOn(queryClient, "fetchQuery")
|
||||
.mockResolvedValue([{ name: "old", stream_type: "logs" }] as any);
|
||||
|
||||
try {
|
||||
searchObj.data.stream.selectedStream = ["deleted"];
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
searchObj.data.stream.selectedStream = ["deleted"];
|
||||
await wrapper.vm.getStreamList(true, { fromUrl: true });
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
fetchQuery.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not refetch when the streams are not from the URL", async () => {
|
||||
queryClient.clear();
|
||||
const { searchObj } = searchState();
|
||||
searchObj.data.stream.streamType = "logs";
|
||||
searchObj.data.stream.selectedStream = ["fresh"];
|
||||
const fetchQuery = vi
|
||||
.spyOn(queryClient, "fetchQuery")
|
||||
.mockResolvedValue([{ name: "old", stream_type: "logs" }] as any);
|
||||
|
||||
try {
|
||||
await wrapper.vm.getStreamList(false);
|
||||
expect(fetchQuery).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fetchQuery.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should have loadStreamFields function", () => {
|
||||
expect(typeof wrapper.vm.loadStreamFields).toBe("function");
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ import config from "@/aws-exports";
|
||||
import { searchState } from "@/composables/useLogs/searchState";
|
||||
import { columnsFromUrl } from "@/composables/useLogs/useLogPermalink";
|
||||
import useStreams from "@/composables/useStreams";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { streamKeys } from "@/services/stream.querykeys";
|
||||
import useSqlSuggestions from "@/composables/useSuggestions";
|
||||
import { captureFromSearchHits } from "@/composables/fieldValueStore";
|
||||
|
||||
@@ -63,6 +65,8 @@ export const FIELD_GROUPS_WAIT_MS = 1000;
|
||||
let correlationWatcherUsers = 0;
|
||||
let correlationWatcherScope: EffectScope | null = null;
|
||||
let fieldGroupsHoldId = 0;
|
||||
// Streams a fresh list already lacked: re-asking for them on every load would only repeat the miss.
|
||||
const refetchedMissing = new Set<string>();
|
||||
|
||||
export interface InitialStreamInput {
|
||||
list: StreamStatsEntry[];
|
||||
@@ -1012,7 +1016,8 @@ export const useStreamFields = () => {
|
||||
// resetStreamData();
|
||||
const streamType = searchObj.data.stream.streamType || "logs";
|
||||
const requestOrg = store.state.selectedOrganization.identifier;
|
||||
const streamData: any = await getStreams(streamType, false);
|
||||
const cached: any = await getStreams(streamType, false);
|
||||
const streamData = await withUnlistedRefetched(cached, streamType, requestOrg, options);
|
||||
if (store.state.selectedOrganization.identifier !== requestOrg) return;
|
||||
if (options.isCurrent && !options.isCurrent()) return;
|
||||
searchObj.data.streamResults = {
|
||||
@@ -1028,15 +1033,54 @@ export const useStreamFields = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// The list is cached for minutes, so a URL stream created since then would read as missing.
|
||||
const withUnlistedRefetched = async (
|
||||
cached: any,
|
||||
streamType: string,
|
||||
org: string,
|
||||
options: StreamListOptions,
|
||||
) => {
|
||||
if (!options.fromUrl) return cached;
|
||||
const { missing } = pickInitialLogsStreams({
|
||||
list: cached?.list ?? [],
|
||||
current: requestedStreams(options),
|
||||
currentFromUrl: true,
|
||||
persisted: [],
|
||||
allowLatest: false,
|
||||
});
|
||||
const key = (name: string) => `${org}/${streamType}/${name}`;
|
||||
const unseen = missing.filter((name) => !refetchedMissing.has(key(name)));
|
||||
if (!unseen.length) return cached;
|
||||
try {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: streamKeys.nameList(org, streamType),
|
||||
exact: true,
|
||||
});
|
||||
const fresh: any = await getStreams(streamType, false);
|
||||
// Marked only once a fresh list confirms the miss, so a failed or concurrent load still retries.
|
||||
const listed = new Set((fresh?.list ?? []).map((entry: { name: string }) => entry.name));
|
||||
unseen.filter((name) => !listed.has(name)).forEach((name) => refetchedMissing.add(key(name)));
|
||||
return fresh;
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof CancelledError) throw e;
|
||||
return cached;
|
||||
}
|
||||
};
|
||||
|
||||
const requestedStreams = (options: StreamListOptions): string[] => {
|
||||
const selected: string[] = Array.isArray(searchObj.data.stream.selectedStream)
|
||||
? [...searchObj.data.stream.selectedStream]
|
||||
: [];
|
||||
if (options.fromUrl && !selected.length && searchObj.meta.sqlMode && searchObj.data.query) {
|
||||
return sqlSources(searchObj.data.query).sources;
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
const initialStreamInput = (options: StreamListOptions): InitialStreamInput => {
|
||||
const zoConfig = store.state.zoConfig ?? {};
|
||||
const streamType = searchObj.data.stream.streamType || "logs";
|
||||
let current: string[] = Array.isArray(searchObj.data.stream.selectedStream)
|
||||
? [...searchObj.data.stream.selectedStream]
|
||||
: [];
|
||||
if (options.fromUrl && !current.length && searchObj.meta.sqlMode && searchObj.data.query) {
|
||||
current = sqlSources(searchObj.data.query).sources;
|
||||
}
|
||||
const current = requestedStreams(options);
|
||||
const legacy =
|
||||
zoConfig.query_on_stream_selection === false ||
|
||||
router?.currentRoute?.value?.query?.type === "stream_explorer";
|
||||
|
||||
@@ -112,6 +112,63 @@ describe("SearchBar — adding a filter term to the query", () => {
|
||||
expect(wrapper.vm.searchObj.data.stream.addToFilter).toBe("");
|
||||
});
|
||||
|
||||
describe("a field whose name ends another field's name", () => {
|
||||
const base = `SELECT * FROM "qa" WHERE db_operation_name = 'SELECT'`;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper.vm.searchObj.meta.sqlMode = true;
|
||||
});
|
||||
|
||||
it("adds an include and an exclude in SQL mode without touching the longer field", async () => {
|
||||
wrapper.vm.searchObj.data.query = base;
|
||||
expect(await addFilterTerm("operation_name = 'GET /'")).toBe(
|
||||
`${base} AND operation_name = 'GET /'`,
|
||||
);
|
||||
|
||||
wrapper.vm.searchObj.data.query = base;
|
||||
expect(await addFilterTerm("operation_name != 'GET /'")).toBe(
|
||||
`${base} AND operation_name != 'GET /'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a multi-value group in SQL mode without touching the longer field's group", async () => {
|
||||
const group = "(db_operation_name = 'SELECT' OR db_operation_name = 'INSERT')";
|
||||
const added = "(operation_name = 'GET /' OR operation_name = 'POST /')";
|
||||
wrapper.vm.searchObj.data.query = `SELECT * FROM "qa" WHERE ${group}`;
|
||||
|
||||
expect(await addFilterTerm(added)).toBe(`SELECT * FROM "qa" WHERE ${group} AND ${added}`);
|
||||
});
|
||||
|
||||
it("still replaces the exact field's condition, plain or stream-qualified", async () => {
|
||||
wrapper.vm.searchObj.data.query = `${base} AND operation_name = 'POST /'`;
|
||||
expect(await addFilterTerm("operation_name = 'GET /'")).toBe(
|
||||
`${base} AND operation_name = 'GET /'`,
|
||||
);
|
||||
|
||||
wrapper.vm.searchObj.data.query = `${base} AND "qa".operation_name = 'POST /'`;
|
||||
expect(await addFilterTerm(`"qa".operation_name = 'GET /'`)).toBe(
|
||||
`${base} AND "qa".operation_name = 'GET /'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("still replaces a quoted exact field and leaves a quoted longer field alone", async () => {
|
||||
wrapper.vm.searchObj.data.query = `${base} AND "db_operation_name" != 'INSERT' AND "operation_name" = 'POST /' AND operation_name = 'PUT /'`;
|
||||
|
||||
expect(await addFilterTerm("operation_name = 'GET /'")).toBe(
|
||||
`${base} AND "db_operation_name" != 'INSERT' AND operation_name = 'GET /' AND operation_name = 'GET /'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the longer field in non-SQL mode", async () => {
|
||||
wrapper.vm.searchObj.meta.sqlMode = false;
|
||||
wrapper.vm.searchObj.data.query = "db_operation_name = 'SELECT'";
|
||||
|
||||
expect(await addFilterTerm("operation_name = 'GET /'")).toBe(
|
||||
"db_operation_name = 'SELECT' and operation_name = 'GET /'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free text (item 1 AC6.9)", () => {
|
||||
const stream = (name: string, fts: boolean) => ({
|
||||
name,
|
||||
|
||||
@@ -941,13 +941,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</ODropdown>
|
||||
</OButtonGroup>
|
||||
<RecentSearchesButton
|
||||
source="logs"
|
||||
:examples="recentExamples"
|
||||
class="me-1 size-[2.125rem]!"
|
||||
data-test="logs-search-bar-recent-searches-btn"
|
||||
@run="(entry, when) => restoreRecent(entry, when, true)"
|
||||
@load="(entry, when) => restoreRecent(entry, when, false)"
|
||||
@view-all="openRecentSearches"
|
||||
@example="applyRecentExample"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
@@ -1875,7 +1871,8 @@ const replaceExistingFieldCondition = (
|
||||
const valPat = `(?:'(?:[^']|'')*'|null|\\d+(?:\\.\\d+)?|true|false)`;
|
||||
const opPat = `(?:=|!=|is(?:\\s+not)?)`;
|
||||
const fieldPat = `(?:"${esc}"|${esc})`;
|
||||
const condPat = `(?:"[^"]+"\\.)?${fieldPat}\\s*${opPat}\\s*${valPat}`;
|
||||
// The lookbehind stops `operation_name` from matching inside `db_operation_name`.
|
||||
const condPat = `(?<!\\w)(?:"[^"]+"\\.)?${fieldPat}\\s*${opPat}\\s*${valPat}`;
|
||||
|
||||
// Try parenthesized multi-value group first: (field = 'x' OR/AND field = 'y')
|
||||
const multiRegex = new RegExp(`\\(\\s*${condPat}(?:\\s+(?:OR|AND)\\s+${condPat})*\\s*\\)`, "gi");
|
||||
|
||||
@@ -384,3 +384,88 @@ describe("removeFieldCondition with AND inside a quoted value (issue 11966)", ()
|
||||
expect(removeFieldCondition("a = 'x and y' AND b = '1'", "a")).toBe("b = '1'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a field whose name ends another field's name", () => {
|
||||
it("does not see a condition on db_operation_name as one on operation_name", () => {
|
||||
expect(hasFieldCondition("db_operation_name = 'SELECT'", "operation_name")).toBe(false);
|
||||
expect(hasFieldCondition(`"qa".db_operation_name = 'SELECT'`, "operation_name")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds an include next to the longer field's condition", () => {
|
||||
expect(applyFilterToQuery("db_operation_name = 'SELECT'", "operation_name = 'GET /'")).toBe(
|
||||
"db_operation_name = 'SELECT' and operation_name = 'GET /'",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds an exclude next to the longer field's condition", () => {
|
||||
expect(applyFilterToQuery("db_operation_name = 'SELECT'", "operation_name != 'GET /'")).toBe(
|
||||
"db_operation_name = 'SELECT' and operation_name != 'GET /'",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a multi-value include and exclude group next to the longer field's condition", () => {
|
||||
const include = "(operation_name = 'GET /' OR operation_name = 'POST /')";
|
||||
const exclude = "(operation_name != 'GET /' AND operation_name != 'POST /')";
|
||||
expect(applyFilterToQuery("db_operation_name = 'SELECT'", include)).toBe(
|
||||
`db_operation_name = 'SELECT' and ${include}`,
|
||||
);
|
||||
expect(applyFilterToQuery("db_operation_name = 'SELECT'", exclude)).toBe(
|
||||
`db_operation_name = 'SELECT' and ${exclude}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the longer field's multi-value group intact", () => {
|
||||
const group = "(db_operation_name = 'SELECT' OR db_operation_name = 'INSERT')";
|
||||
expect(applyFilterToQuery(group, "operation_name = 'GET /'")).toBe(
|
||||
`${group} and operation_name = 'GET /'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("still replaces the exact field's condition and keeps the longer field's", () => {
|
||||
expect(
|
||||
applyFilterToQuery(
|
||||
"db_operation_name = 'SELECT' and operation_name = 'POST /'",
|
||||
"operation_name = 'GET /'",
|
||||
),
|
||||
).toBe("db_operation_name = 'SELECT' and operation_name = 'GET /'");
|
||||
expect(
|
||||
applyFilterToQuery(
|
||||
"(db_operation_name = 'SELECT' OR db_operation_name = 'INSERT') and operation_name = 'POST /'",
|
||||
"(operation_name = 'GET /' OR operation_name = 'PUT /')",
|
||||
),
|
||||
).toBe(
|
||||
"(db_operation_name = 'SELECT' OR db_operation_name = 'INSERT') and (operation_name = 'GET /' OR operation_name = 'PUT /')",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles stream-qualified fields", () => {
|
||||
expect(
|
||||
applyFilterToQuery(`"qa".db_operation_name = 'SELECT'`, `"qa".operation_name = 'GET /'`),
|
||||
).toBe(`"qa".db_operation_name = 'SELECT' and "qa".operation_name = 'GET /'`);
|
||||
expect(
|
||||
applyFilterToQuery(
|
||||
`"qa".db_operation_name = 'SELECT' and "qa".operation_name = 'POST /'`,
|
||||
`"qa".operation_name = 'GET /'`,
|
||||
),
|
||||
).toBe(`"qa".db_operation_name = 'SELECT' and "qa".operation_name = 'GET /'`);
|
||||
});
|
||||
|
||||
it("keeps null, number and boolean conditions on the longer field", () => {
|
||||
expect(applyFilterToQuery("http_status = 500 and status is null", "status = 'ok'")).toBe(
|
||||
"http_status = 500 and status = 'ok'",
|
||||
);
|
||||
expect(applyFilterToQuery("is_enabled = true and enabled = true", "enabled = false")).toBe(
|
||||
"is_enabled = true and enabled = false",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces only the exact field when called directly", () => {
|
||||
expect(
|
||||
replaceExistingFieldCondition(
|
||||
"username = 'bob' and name = 'al'",
|
||||
"name",
|
||||
"(name = 'al' OR name = 'eve')",
|
||||
),
|
||||
).toBe("username = 'bob' and (name = 'al' OR name = 'eve')");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user