-
Notifications
You must be signed in to change notification settings - Fork 50
442 lines (417 loc) · 20.1 KB
/
Copy pathtestbot.yaml
File metadata and controls
442 lines (417 loc) · 20.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# Testbot: analyzes coverage gaps, generates tests via Claude Code,
# validates them, and opens a PR for human review. Runs hourly on weekdays.
name: Testbot
on:
schedule:
- cron: '0 * * * 1-5' # Weekdays hourly
workflow_dispatch:
inputs:
max_targets:
description: 'Max files to target'
default: '3'
max_uncovered:
description: 'Max uncovered lines per target (0 = no cap)'
default: '500'
max_turns:
description: 'Max Claude Code agent turns'
# Matches the schedule-trigger fallback below. Scales with
# max_targets — the generator runs the full read/write/verify
# loop per target, and runs/26536045087 hit max_turns at 101/100
# on a single target after Claude's context auto-compacted,
# wiping most of the exploration state mid-run.
default: '400'
timeout_minutes:
description: 'Workflow timeout in minutes'
default: '60'
model:
description: 'LLM model name on NVIDIA gateway'
default: 'aws/anthropic/bedrock-claude-opus-5'
dry_run:
description: 'Generate but do not create PR'
type: boolean
default: false
slack_channel:
description: 'Slack channel for review request (empty = no notification)'
default: '#osmo-code-reviews'
force_run:
description: 'Bypass the open-PR preflight (useful when testing workflow changes from a branch)'
type: boolean
default: false
force_create_pr:
description: 'Bypass create_pr.py has_unapproved_testbot_pr check (useful for branch verification when an unapproved testbot PR already exists)'
type: boolean
default: false
skip_slack:
description: 'Skip the Slack review request even when the channel/token are configured (useful for ad-hoc dispatches)'
type: boolean
default: false
permissions:
contents: write
pull-requests: write
concurrency:
group: testbot
cancel-in-progress: true
jobs:
preflight:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.open-testbot-prs.outputs.should_run }}
steps:
- name: Check open testbot PRs
id: open-testbot-prs
run: |
set -euo pipefail
# workflow_dispatch force_run bypass — useful when verifying
# testbot changes from a feature branch without waiting for
# all open ai-generated PRs to be approved/merged.
if [[ "$FORCE_RUN" == "true" ]]; then
echo "force_run=true; bypassing open-PR preflight check."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! unapproved_prs="$(
gh pr list \
--repo "$REPOSITORY" \
--label ai-generated \
--state open \
--author svc-osmo-ci \
--json number,reviewDecision,url \
--jq '.[] | select(.reviewDecision != "APPROVED") | "#\(.number) \(.reviewDecision // "UNKNOWN") \(.url)"'
)"; then
echo "Failed to list open testbot PRs; skipping generation."
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -n "$unapproved_prs" ]]; then
echo "Open unapproved testbot PR(s) exist; skipping generation:"
echo "$unapproved_prs"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "No open unapproved testbot PRs; continuing."
echo "should_run=true" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
FORCE_RUN: ${{ inputs.force_run || 'false' }}
generate-tests:
needs: preflight
if: needs.preflight.outputs.should_run == 'true'
environment: nim-env
timeout-minutes: ${{ fromJSON(inputs.timeout_minutes || '60') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
lfs: true
fetch-depth: 0
token: ${{ secrets.SVC_OSMO_CI_TOKEN }}
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.14.7'
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: src/ui/package.json
- name: Install UI dependencies
working-directory: src/ui
run: pnpm install --frozen-lockfile
# bazel-contrib/setup-bazel uses GitHub's listReleases API to find the
# bazelisk binary, but as of 2026-04-27 that endpoint returns an empty
# array for bazelbuild/bazelisk while /releases/tags/v<X> still works.
# Install bazelisk directly from the asset URL to bypass the broken path.
# Bazelisk doesn't publish official checksums for v1.27.0 (issue #306),
# so we pin the SHA256 we observed and verify before sudo-installing.
- name: Install Bazelisk
env:
BAZELISK_VERSION: v1.27.0
BAZELISK_SHA256: e1508323f347ad1465a887bc5d2bfb91cffc232d11e8e997b623227c6b32fb76
run: |
curl -fsSL -o "$RUNNER_TEMP/bazelisk" \
"https://github.com/bazelbuild/bazelisk/releases/download/${BAZELISK_VERSION}/bazelisk-linux-amd64"
echo "${BAZELISK_SHA256} $RUNNER_TEMP/bazelisk" | sha256sum -c -
sudo install -m 0755 "$RUNNER_TEMP/bazelisk" /usr/local/bin/bazelisk
sudo ln -sf /usr/local/bin/bazelisk /usr/local/bin/bazel
- name: Setup Bazel
uses: bazel-contrib/setup-bazel@4fd964a13a440a8aeb0be47350db2fc640f19ca8
with:
bazelisk-cache: true
disk-cache: ${{ github.workflow }}
repository-cache: true
external-cache: |
manifest:
osmo_python_deps: src/locked_requirements.txt
osmo_tests_python_deps: src/tests/locked_requirements.txt
osmo_mypy_deps: bzl/mypy/locked_requirements.txt
pylint_python_deps: bzl/linting/locked_requirements.txt
io_bazel_rules_go: src/runtime/go.mod
bazel_gazelle: src/runtime/go.sum
- name: Configure git
run: |
git config user.name "testbot[bot]"
git config user.email "testbot[bot]@users.noreply.github.com"
# Two-stage target selection: a heuristic scorer narrows the candidate
# pool to ~20 critical-but-undertested files (using fan-in, churn, and
# path-tier signals), then a small Claude subagent reads each candidate
# and picks the 1-3 best test targets by ROI.
- name: Score criticality (Stage 1)
run: |
# PYTHONPATH=. so the namespace-package import
# `from src.scripts.testbot.coverage_targets import ...` resolves.
# criticality_scorer logs the ranked shortlist itself; we just
# dump the JSON in a foldable group so reviewers can see the full
# per-signal breakdown when debugging a pick.
PYTHONPATH=. python src/scripts/testbot/criticality_scorer.py \
--repo-root . \
--shortlist-size 20 \
--max-uncovered ${{ inputs.max_uncovered || '500' }} \
--output "$RUNNER_TEMP/shortlist.json"
echo "::group::Shortlist JSON (full per-signal breakdown)"
cat "$RUNNER_TEMP/shortlist.json"
echo "::endgroup::"
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Pick targets via subagent (Stage 2)
run: |
PYTHONPATH=. python src/scripts/testbot/select_targets_agent.py \
--shortlist "$RUNNER_TEMP/shortlist.json" \
--max-targets ${{ inputs.max_targets || '3' }} \
--output "$RUNNER_TEMP/targets.txt" \
--meta-output "$RUNNER_TEMP/targets_meta.json"
echo "::group::Selected targets"
cat "$RUNNER_TEMP/targets.txt"
echo "::endgroup::"
env:
ANTHROPIC_API_KEY: ${{ secrets.NVIDIA_NIM_KEY }}
ANTHROPIC_BASE_URL: https://inference-api.nvidia.com
ANTHROPIC_MODEL: ${{ inputs.model || 'aws/anthropic/bedrock-claude-opus-5' }}
DISABLE_PROMPT_CACHING: "1"
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1"
- name: Generate tests
run: |
# Validate workflow_dispatch input before splicing it into the
# shell command (defense in depth — the input is interpolated
# by GitHub Actions, not the shell, so quoting alone isn't enough).
if ! [[ "$MAX_TURNS" =~ ^[0-9]+$ ]]; then
echo "max_turns must be a non-negative integer, got: $MAX_TURNS" >&2
exit 2
fi
TARGETS=$(cat "$RUNNER_TEMP/targets.txt")
# Include the rules inline rather than pointing at them.
PROMPT="$(cat src/scripts/testbot/TESTBOT_PROMPT.md)
$(cat src/scripts/testbot/TESTBOT_RULES.md)
Coverage targets:
$TARGETS"
STREAM_LOG="$RUNNER_TEMP/claude-stream.jsonl"
# Stream every turn and tool use into a foldable group so the
# diagnostics summary stays visible by default; the verbose
# stream is one click away. We disable -e so the diagnostics
# block runs even when Claude exits non-zero.
set +e
echo "::group::Claude Code stream (click to expand turn-by-turn log)"
npx @anthropic-ai/claude-code@2.1.116 --print \
--model "$ANTHROPIC_MODEL" \
--output-format stream-json --verbose \
--allowedTools "Read,Edit,Write,Glob,Grep,Bash(cd *),Bash(mv *),Bash(rm *),Bash(bazel test *),Bash(bazel build *),Bash(bazel coverage *),Bash(bazel query *),Bash(python *),Bash(python3 *),Bash(pnpm *),Bash(npx vitest *),Bash(npx tsc *),Bash(./node_modules/.bin/vitest *),Bash(./node_modules/.bin/tsc *)" \
--max-turns "$MAX_TURNS" \
"$PROMPT" | tee "$STREAM_LOG"
# PIPESTATUS[0] is the npx exit code; $? would be tee's, masking
# Claude failures as success.
status=${PIPESTATUS[0]}
echo "::endgroup::"
# Wrap untrusted Claude result text in ::stop-commands:: so it can't
# inject workflow commands (::warning::, ::add-mask::, etc.).
STOP_TOKEN=$(python3 -c 'import secrets; print(secrets.token_hex(16))')
echo "::group::Claude Code diagnostics"
echo "::stop-commands::$STOP_TOKEN"
python3 - "$STREAM_LOG" "$status" <<'PY'
import json, sys
path, status = sys.argv[1], sys.argv[2]
# Result message is the final stream-json event; iterate in reverse
# and stop at the first match instead of parsing every assistant
# turn and tool-use line.
final = None
with open(path) as f:
for line in reversed(f.readlines()):
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("type") == "result":
final = msg
break
print(f"exit_status: {status}")
if final is None:
print("no final result message captured")
sys.exit(0)
for key in ("subtype", "is_error", "num_turns", "duration_ms",
"duration_api_ms", "total_cost_usd"):
if key in final:
print(f"{key}: {final[key]}")
result = final.get("result")
if isinstance(result, str) and result:
# Save the full generator summary so create_pr.py can
# embed it in the PR body — reviewers see the same
# "what I did / what's still uncovered / files changed"
# block the LLM produced, not just the workflow log.
import os
summary_path = os.path.join(
os.environ["RUNNER_TEMP"], "generate_summary.md"
)
with open(summary_path, "w", encoding="utf-8") as out:
out.write(result)
print("result:")
print(result[:2000])
PY
echo "::$STOP_TOKEN::"
echo "::endgroup::"
exit $status
env:
ANTHROPIC_API_KEY: ${{ secrets.NVIDIA_NIM_KEY }}
ANTHROPIC_BASE_URL: https://inference-api.nvidia.com
ANTHROPIC_MODEL: ${{ inputs.model || 'aws/anthropic/bedrock-claude-opus-5' }}
MAX_TURNS: ${{ inputs.max_turns || '400' }}
DISABLE_PROMPT_CACHING: "1" # NVIDIA gateway rejects cache_control.ephemeral.scope
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" # NVIDIA gateway rejects context_management
# Independently measure how many of the picker's listed uncovered
# lines were actually exercised by the new tests, then attach the
# report to the PR. The generator is expected to have already
# self-iterated against this same script (see TESTBOT_PROMPT.md);
# this step gives the human reviewer the same numbers and acts as
# the source of truth in case the LLM skipped the loop.
# Fail-soft: a non-zero exit here would block PR creation, so we
# log and continue — the PR body will note the missing report.
# always() so the verifier still runs when Generate fails (e.g.,
# max_turns hit, transient API error). Even partial test files
# produce a useful coverage number for the workflow log —
# diagnosing run/26536045087 was harder than it needed to be
# because this step was skipped. Create PR stays gated on
# success so half-written tests don't open a PR.
- name: Verify coverage
if: always() && inputs.dry_run != true
continue-on-error: true
run: |
set +e
# bazel coverage prints the LCOV path on success; the
# combined_report=lcov setting in .bazelrc routes it to
# bazel-out/_coverage/_coverage_report.dat.
#
# Do NOT use --config=ci here. That config sets
# DOCKER_HOST=tcp://docker:2375 and TESTCONTAINERS_HOST_OVERRIDE=docker
# for the self-hosted DinD environment that coverage.yaml uses,
# but testbot.yaml runs on ubuntu-latest with the runner's
# local docker socket. With --config=ci, testcontainers-based
# integration tests fail to reach the daemon and produce zero
# DA hits — that's how PR #1058 ended up with the verifier
# reporting 0/97 while Codecov's later pr-checks run (which
# has DinD wired up properly) confirmed 93/97 hit.
# Scope to test targets in the directories the picker
# actually chose. `bazel coverage //...` ran 211 tests on
# run/26871947281 and took 10 minutes; the LLM's earlier
# in-Generate coverage call (scoped to the same package)
# produced the same numbers in 48 seconds. Same-directory
# patterns work across languages:
# Go src/utils/roles/user_role_sync.go → //src/utils/roles/...
# (same-package roles_test + roles_integration_test)
# Python src/utils/job/jobs.py → //src/utils/job/...
# (picks up the tests/ subdir convention)
# TS src/ui/.../foo.ts → //src/ui/.../...
patterns=$(python3 -c '
import json, os, sys
path = os.path.join(os.environ["RUNNER_TEMP"], "targets_meta.json")
try:
meta = json.load(open(path))
except (OSError, json.JSONDecodeError):
# fail-open to //... so a stale/missing meta still produces
# a (slower) report rather than skipping coverage entirely
print("//...")
sys.exit(0)
dirs = sorted({
entry["file_path"].rsplit("/", 1)[0]
for entry in meta
if isinstance(entry, dict) and entry.get("file_path") and "/" in entry["file_path"]
})
print(" ".join(f"//{d}/..." for d in dirs) if dirs else "//...")
')
echo "::group::bazel coverage $patterns"
bazel coverage $patterns 2>&1 | tail -50
coverage_status=${PIPESTATUS[0]}
echo "::endgroup::"
echo "bazel coverage exit_status: $coverage_status"
PYTHONPATH=. python src/scripts/testbot/verify_coverage.py \
--targets-meta "$RUNNER_TEMP/targets_meta.json" \
--lcov bazel-out/_coverage/_coverage_report.dat \
--json-output "$RUNNER_TEMP/coverage_report.json" \
--markdown-output "$RUNNER_TEMP/coverage_report.md"
echo "::group::Coverage report (markdown)"
cat "$RUNNER_TEMP/coverage_report.md" || true
echo "::endgroup::"
- name: Create PR
if: inputs.dry_run != true
run: |
# Pass --coverage-report when the verify step produced one;
# missing report is non-fatal so the PR still opens.
coverage_flag=""
if [[ -s "$RUNNER_TEMP/coverage_report.json" ]]; then
coverage_flag="--coverage-report $RUNNER_TEMP/coverage_report.json"
fi
# --generate-summary is similarly fail-soft: when the file is
# missing (LLM produced no final result text), the PR body
# still renders without the section.
summary_flag=""
if [[ -s "$RUNNER_TEMP/generate_summary.md" ]]; then
summary_flag="--generate-summary $RUNNER_TEMP/generate_summary.md"
fi
PYTHONPATH=src/scripts python -m testbot.create_pr \
--targets-meta "$RUNNER_TEMP/targets_meta.json" \
$coverage_flag \
$summary_flag
env:
GH_TOKEN: ${{ secrets.SVC_OSMO_CI_TOKEN }}
# FORCE_CREATE_PR bypasses the create_pr.py script-level guard
# against stacking on top of an already-open ai-generated PR.
# Pairs with force_run (which bypasses the *job-level*
# preflight) for end-to-end branch verification when an
# unapproved testbot PR is already open.
FORCE_CREATE_PR: ${{ inputs.force_create_pr || 'false' }}
# SKIP_SLACK uses the same env-var pattern as FORCE_CREATE_PR
# (script checks the string at runtime). The earlier YAML-
# expression form `inputs.skip_slack && '' || <secret>`
# didn't work because GHA expressions treat empty string as
# falsy: `'' || X` collapses to `X`, so the secret always
# reached create_pr.py. Run #26870328623 verified that
# failure mode — Slack still posted despite skip_slack=true.
SKIP_SLACK: ${{ inputs.skip_slack || 'false' }}
TESTBOT_SLACK_BOT_TOKEN: ${{ secrets.TESTBOT_SLACK_BOT_TOKEN }}
# workflow_dispatch override wins when present. For schedule
# triggers inputs.slack_channel is null, so we fall back to
# the org/repo var. The literal #osmo-slack-test is the
# safety-net default for forks / dev repos where the var
# isn't set — prod has vars.TESTBOT_SLACK_CHANNEL configured
# to #osmo-code-reviews and the workflow_dispatch input
# default mirrors that.
TESTBOT_SLACK_CHANNEL: ${{ inputs.slack_channel == null && (vars.TESTBOT_SLACK_CHANNEL || '#osmo-slack-test') || inputs.slack_channel }}