tinybeeai 0.0.0-stage → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.railway/railway.ts +83 -0
- package/.railway/tsconfig.json +13 -0
- package/AGENTS.md +102 -0
- package/Dockerfile +108 -0
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/bin/localdost.js +29 -0
- package/bin/tinybeeai.js +28 -0
- package/compose.yml +63 -0
- package/demo/payloads/ci_failure.json +46 -0
- package/demo/payloads/outage.json +18 -0
- package/demo/payloads/pr_comment_edited.json +50 -0
- package/demo/payloads/pr_merged.json +80 -0
- package/demo/payloads/pr_opened.json +69 -0
- package/demo/payloads/pr_synchronize.json +69 -0
- package/demo/payloads/ticket_created.json +138 -0
- package/demo/sample-app/playwright.config.ts +20 -0
- package/demo/sample-app/server.ts +163 -0
- package/demo/sample-app/tests/checkout.spec.ts +13 -0
- package/demo/sample-app/tests/smoke.spec.ts +8 -0
- package/dist/agent/approval-retries.js +59 -0
- package/dist/agent/approval-retries.js.map +1 -0
- package/dist/agent/approvals.js +390 -0
- package/dist/agent/approvals.js.map +1 -0
- package/dist/agent/chat-bridge.js +154 -0
- package/dist/agent/chat-bridge.js.map +1 -0
- package/dist/agent/chat-lead.js +475 -0
- package/dist/agent/chat-lead.js.map +1 -0
- package/dist/agent/chat-loop.js +106 -0
- package/dist/agent/chat-loop.js.map +1 -0
- package/dist/agent/chat-mcp.js +49 -0
- package/dist/agent/chat-mcp.js.map +1 -0
- package/dist/agent/chat-tool-blocks.js +453 -0
- package/dist/agent/chat-tool-blocks.js.map +1 -0
- package/dist/agent/chat-view.js +94 -0
- package/dist/agent/chat-view.js.map +1 -0
- package/dist/agent/chat-views.js +781 -0
- package/dist/agent/chat-views.js.map +1 -0
- package/dist/agent/chat.js +1703 -0
- package/dist/agent/chat.js.map +1 -0
- package/dist/agent/destinations.js +150 -0
- package/dist/agent/destinations.js.map +1 -0
- package/dist/agent/llm-cli.js +451 -0
- package/dist/agent/llm-cli.js.map +1 -0
- package/dist/agent/llm-codex.js +493 -0
- package/dist/agent/llm-codex.js.map +1 -0
- package/dist/agent/llm-openai.js +211 -0
- package/dist/agent/llm-openai.js.map +1 -0
- package/dist/agent/llm.js +1464 -0
- package/dist/agent/llm.js.map +1 -0
- package/dist/agent/memory.js +33 -0
- package/dist/agent/memory.js.map +1 -0
- package/dist/agent/mock-views.js +93 -0
- package/dist/agent/mock-views.js.map +1 -0
- package/dist/agent/model-availability.js +207 -0
- package/dist/agent/model-availability.js.map +1 -0
- package/dist/agent/model-errors.js +188 -0
- package/dist/agent/model-errors.js.map +1 -0
- package/dist/agent/model-guard.js +311 -0
- package/dist/agent/model-guard.js.map +1 -0
- package/dist/agent/prompts.js +374 -0
- package/dist/agent/prompts.js.map +1 -0
- package/dist/agent/qa-lenses/qa-auth.md +32 -0
- package/dist/agent/qa-lenses/qa-check-then-act.md +33 -0
- package/dist/agent/qa-lenses/qa-control-has-no-effect.md +32 -0
- package/dist/agent/qa-lenses/qa-dates.md +31 -0
- package/dist/agent/qa-lenses/qa-double-submit.md +34 -0
- package/dist/agent/qa-lenses/qa-feature-flag.md +29 -0
- package/dist/agent/qa-lenses/qa-migration.md +35 -0
- package/dist/agent/qa-lenses/qa-money.md +38 -0
- package/dist/agent/qa-lenses/qa-response-shape-break.md +33 -0
- package/dist/agent/qa-lenses/qa-validation.md +29 -0
- package/dist/agent/qa-prompts.js +660 -0
- package/dist/agent/qa-prompts.js.map +1 -0
- package/dist/agent/reasoning.js +55 -0
- package/dist/agent/reasoning.js.map +1 -0
- package/dist/agent/redact.js +48 -0
- package/dist/agent/redact.js.map +1 -0
- package/dist/agent/rules.js +927 -0
- package/dist/agent/rules.js.map +1 -0
- package/dist/agent/runs.js +440 -0
- package/dist/agent/runs.js.map +1 -0
- package/dist/agent/send-retry.js +62 -0
- package/dist/agent/send-retry.js.map +1 -0
- package/dist/agent/team-notes.js +288 -0
- package/dist/agent/team-notes.js.map +1 -0
- package/dist/agent/token-meter.js +31 -0
- package/dist/agent/token-meter.js.map +1 -0
- package/dist/agent/triage.js +506 -0
- package/dist/agent/triage.js.map +1 -0
- package/dist/api.js +1047 -0
- package/dist/api.js.map +1 -0
- package/dist/approval-targets.js +107 -0
- package/dist/approval-targets.js.map +1 -0
- package/dist/bee/cloudflared.js +108 -0
- package/dist/bee/cloudflared.js.map +1 -0
- package/dist/bee/health.js +99 -0
- package/dist/bee/health.js.map +1 -0
- package/dist/bee/hive-client.js +75 -0
- package/dist/bee/hive-client.js.map +1 -0
- package/dist/bee/identity.js +39 -0
- package/dist/bee/identity.js.map +1 -0
- package/dist/bee/models.js +510 -0
- package/dist/bee/models.js.map +1 -0
- package/dist/bee/pairing.js +351 -0
- package/dist/bee/pairing.js.map +1 -0
- package/dist/bee/relay-client.js +50 -0
- package/dist/bee/relay-client.js.map +1 -0
- package/dist/bee/routes.js +214 -0
- package/dist/bee/routes.js.map +1 -0
- package/dist/bee/setup-page.js +223 -0
- package/dist/bee/setup-page.js.map +1 -0
- package/dist/bee/state.js +41 -0
- package/dist/bee/state.js.map +1 -0
- package/dist/bee/status.js +117 -0
- package/dist/bee/status.js.map +1 -0
- package/dist/bee/tunnel-manager.js +245 -0
- package/dist/bee/tunnel-manager.js.map +1 -0
- package/dist/bee/updater.js +268 -0
- package/dist/bee/updater.js.map +1 -0
- package/dist/boot.js +41 -0
- package/dist/boot.js.map +1 -0
- package/dist/call/index.js +145 -0
- package/dist/call/index.js.map +1 -0
- package/dist/case-folders.js +294 -0
- package/dist/case-folders.js.map +1 -0
- package/dist/cases.js +554 -0
- package/dist/cases.js.map +1 -0
- package/dist/chat-blocks.js +709 -0
- package/dist/chat-blocks.js.map +1 -0
- package/dist/chat-parse-util.js +38 -0
- package/dist/chat-parse-util.js.map +1 -0
- package/dist/chat-results.js +96 -0
- package/dist/chat-results.js.map +1 -0
- package/dist/chat-stats.js +193 -0
- package/dist/chat-stats.js.map +1 -0
- package/dist/chat-view-specs.js +186 -0
- package/dist/chat-view-specs.js.map +1 -0
- package/dist/cli/doctor-ops.js +361 -0
- package/dist/cli/doctor-ops.js.map +1 -0
- package/dist/cli/doctor.js +289 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/dogfood.js +70 -0
- package/dist/cli/dogfood.js.map +1 -0
- package/dist/cli/env-file.js +34 -0
- package/dist/cli/env-file.js.map +1 -0
- package/dist/cli/eval-chat-cases.js +359 -0
- package/dist/cli/eval-chat-cases.js.map +1 -0
- package/dist/cli/eval-chat-results.js +85 -0
- package/dist/cli/eval-chat-results.js.map +1 -0
- package/dist/cli/eval-chat.js +508 -0
- package/dist/cli/eval-chat.js.map +1 -0
- package/dist/cli/eval-designer.js +67 -0
- package/dist/cli/eval-designer.js.map +1 -0
- package/dist/cli/eval-qa-review.js +284 -0
- package/dist/cli/eval-qa-review.js.map +1 -0
- package/dist/cli/golden-export.js +35 -0
- package/dist/cli/golden-export.js.map +1 -0
- package/dist/cli/index.js +90 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.js +746 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/scan-repo.js +199 -0
- package/dist/cli/scan-repo.js.map +1 -0
- package/dist/cli/seed.js +108 -0
- package/dist/cli/seed.js.map +1 -0
- package/dist/cli/tinybee.js +247 -0
- package/dist/cli/tinybee.js.map +1 -0
- package/dist/config.js +138 -0
- package/dist/config.js.map +1 -0
- package/dist/connect/aivision-local.js +833 -0
- package/dist/connect/aivision-local.js.map +1 -0
- package/dist/connect/aivision-token.js +84 -0
- package/dist/connect/aivision-token.js.map +1 -0
- package/dist/connect/crypto.js +65 -0
- package/dist/connect/crypto.js.map +1 -0
- package/dist/connect/deps.js +110 -0
- package/dist/connect/deps.js.map +1 -0
- package/dist/connect/engines.js +144 -0
- package/dist/connect/engines.js.map +1 -0
- package/dist/connect/github-access.js +74 -0
- package/dist/connect/github-access.js.map +1 -0
- package/dist/connect/github-app.js +129 -0
- package/dist/connect/github-app.js.map +1 -0
- package/dist/connect/index.js +9 -0
- package/dist/connect/index.js.map +1 -0
- package/dist/connect/model-choices.js +201 -0
- package/dist/connect/model-choices.js.map +1 -0
- package/dist/connect/providers.js +900 -0
- package/dist/connect/providers.js.map +1 -0
- package/dist/connect/readiness.js +232 -0
- package/dist/connect/readiness.js.map +1 -0
- package/dist/connect/redact.js +16 -0
- package/dist/connect/redact.js.map +1 -0
- package/dist/connect/registry.js +406 -0
- package/dist/connect/registry.js.map +1 -0
- package/dist/connect/store.js +283 -0
- package/dist/connect/store.js.map +1 -0
- package/dist/connect/tunnel.js +134 -0
- package/dist/connect/tunnel.js.map +1 -0
- package/dist/connect/types.js +8 -0
- package/dist/connect/types.js.map +1 -0
- package/dist/connections/index.js +5 -0
- package/dist/connections/index.js.map +1 -0
- package/dist/connections/manager.js +223 -0
- package/dist/connections/manager.js.map +1 -0
- package/dist/connections/presets.js +264 -0
- package/dist/connections/presets.js.map +1 -0
- package/dist/connections/store.js +132 -0
- package/dist/connections/store.js.map +1 -0
- package/dist/connections/tools.js +206 -0
- package/dist/connections/tools.js.map +1 -0
- package/dist/db-schema.js +355 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.js +379 -0
- package/dist/db.js.map +1 -0
- package/dist/delivery.js +49 -0
- package/dist/delivery.js.map +1 -0
- package/dist/deploy-wait.js +701 -0
- package/dist/deploy-wait.js.map +1 -0
- package/dist/disk.js +243 -0
- package/dist/disk.js.map +1 -0
- package/dist/dot-health.js +966 -0
- package/dist/dot-health.js.map +1 -0
- package/dist/dot-remove.js +246 -0
- package/dist/dot-remove.js.map +1 -0
- package/dist/events.js +38 -0
- package/dist/events.js.map +1 -0
- package/dist/evidence-pack.js +423 -0
- package/dist/evidence-pack.js.map +1 -0
- package/dist/flows/answers.js +289 -0
- package/dist/flows/answers.js.map +1 -0
- package/dist/flows/change.js +811 -0
- package/dist/flows/change.js.map +1 -0
- package/dist/flows/checkStatus.js +524 -0
- package/dist/flows/checkStatus.js.map +1 -0
- package/dist/flows/ci.js +236 -0
- package/dist/flows/ci.js.map +1 -0
- package/dist/flows/ciFailure.js +76 -0
- package/dist/flows/ciFailure.js.map +1 -0
- package/dist/flows/common.js +522 -0
- package/dist/flows/common.js.map +1 -0
- package/dist/flows/default-branch.js +29 -0
- package/dist/flows/default-branch.js.map +1 -0
- package/dist/flows/defectDraft.js +447 -0
- package/dist/flows/defectDraft.js.map +1 -0
- package/dist/flows/defects.js +623 -0
- package/dist/flows/defects.js.map +1 -0
- package/dist/flows/digest.js +295 -0
- package/dist/flows/digest.js.map +1 -0
- package/dist/flows/impact.js +82 -0
- package/dist/flows/impact.js.map +1 -0
- package/dist/flows/index.js +23 -0
- package/dist/flows/index.js.map +1 -0
- package/dist/flows/intent-sources.js +72 -0
- package/dist/flows/intent-sources.js.map +1 -0
- package/dist/flows/mergeCases.js +169 -0
- package/dist/flows/mergeCases.js.map +1 -0
- package/dist/flows/nightlyRun.js +82 -0
- package/dist/flows/nightlyRun.js.map +1 -0
- package/dist/flows/outage.js +64 -0
- package/dist/flows/outage.js.map +1 -0
- package/dist/flows/postMerge.js +5 -0
- package/dist/flows/postMerge.js.map +1 -0
- package/dist/flows/prCheck.js +5 -0
- package/dist/flows/prCheck.js.map +1 -0
- package/dist/flows/prTarget.js +66 -0
- package/dist/flows/prTarget.js.map +1 -0
- package/dist/flows/qa.js +363 -0
- package/dist/flows/qa.js.map +1 -0
- package/dist/flows/questions.js +160 -0
- package/dist/flows/questions.js.map +1 -0
- package/dist/flows/recovery.js +64 -0
- package/dist/flows/recovery.js.map +1 -0
- package/dist/flows/rerun.js +50 -0
- package/dist/flows/rerun.js.map +1 -0
- package/dist/flows/testDesign.js +294 -0
- package/dist/flows/testDesign.js.map +1 -0
- package/dist/flows/testDesignJob.js +46 -0
- package/dist/flows/testDesignJob.js.map +1 -0
- package/dist/flows/testRepo.js +228 -0
- package/dist/flows/testRepo.js.map +1 -0
- package/dist/flows/testSavings.js +381 -0
- package/dist/flows/testSavings.js.map +1 -0
- package/dist/flows/testStatus.js +343 -0
- package/dist/flows/testStatus.js.map +1 -0
- package/dist/flows/ticketContext.js +425 -0
- package/dist/flows/ticketContext.js.map +1 -0
- package/dist/flows/ticketReview.js +249 -0
- package/dist/flows/ticketReview.js.map +1 -0
- package/dist/flows/ticks.js +73 -0
- package/dist/flows/ticks.js.map +1 -0
- package/dist/hive/admin.js +122 -0
- package/dist/hive/admin.js.map +1 -0
- package/dist/hive/auth.js +130 -0
- package/dist/hive/auth.js.map +1 -0
- package/dist/hive/billing.js +278 -0
- package/dist/hive/billing.js.map +1 -0
- package/dist/hive/config.js +63 -0
- package/dist/hive/config.js.map +1 -0
- package/dist/hive/db.js +186 -0
- package/dist/hive/db.js.map +1 -0
- package/dist/hive/embed.js +76 -0
- package/dist/hive/embed.js.map +1 -0
- package/dist/hive/errors.js +17 -0
- package/dist/hive/errors.js.map +1 -0
- package/dist/hive/google.js +75 -0
- package/dist/hive/google.js.map +1 -0
- package/dist/hive/index.js +25 -0
- package/dist/hive/index.js.map +1 -0
- package/dist/hive/jwt.js +57 -0
- package/dist/hive/jwt.js.map +1 -0
- package/dist/hive/keys.js +61 -0
- package/dist/hive/keys.js.map +1 -0
- package/dist/hive/mail.js +27 -0
- package/dist/hive/mail.js.map +1 -0
- package/dist/hive/native-push.js +255 -0
- package/dist/hive/native-push.js.map +1 -0
- package/dist/hive/pairing.js +342 -0
- package/dist/hive/pairing.js.map +1 -0
- package/dist/hive/presence.js +121 -0
- package/dist/hive/presence.js.map +1 -0
- package/dist/hive/push.js +185 -0
- package/dist/hive/push.js.map +1 -0
- package/dist/hive/relay.js +205 -0
- package/dist/hive/relay.js.map +1 -0
- package/dist/hive/routes.js +228 -0
- package/dist/hive/routes.js.map +1 -0
- package/dist/hive/server.js +12 -0
- package/dist/hive/server.js.map +1 -0
- package/dist/hive/session.js +28 -0
- package/dist/hive/session.js.map +1 -0
- package/dist/hive/tenants.js +287 -0
- package/dist/hive/tenants.js.map +1 -0
- package/dist/hive/tunnel/cloudflare.js +160 -0
- package/dist/hive/tunnel/cloudflare.js.map +1 -0
- package/dist/hive/tunnel/mock.js +52 -0
- package/dist/hive/tunnel/mock.js.map +1 -0
- package/dist/hive/tunnel/provider.js +19 -0
- package/dist/hive/tunnel/provider.js.map +1 -0
- package/dist/hive/updates.js +61 -0
- package/dist/hive/updates.js.map +1 -0
- package/dist/http-error.js +11 -0
- package/dist/http-error.js.map +1 -0
- package/dist/impact/classify.js +226 -0
- package/dist/impact/classify.js.map +1 -0
- package/dist/impact/find.js +259 -0
- package/dist/impact/find.js.map +1 -0
- package/dist/impact/pr.js +54 -0
- package/dist/impact/pr.js.map +1 -0
- package/dist/impact/store.js +365 -0
- package/dist/impact/store.js.map +1 -0
- package/dist/impact/text.js +158 -0
- package/dist/impact/text.js.map +1 -0
- package/dist/index.js +288 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/adf.js +397 -0
- package/dist/integrations/adf.js.map +1 -0
- package/dist/integrations/bot-marker.js +103 -0
- package/dist/integrations/bot-marker.js.map +1 -0
- package/dist/integrations/github-issues.js +705 -0
- package/dist/integrations/github-issues.js.map +1 -0
- package/dist/integrations/github.js +1284 -0
- package/dist/integrations/github.js.map +1 -0
- package/dist/integrations/issue-create.js +60 -0
- package/dist/integrations/issue-create.js.map +1 -0
- package/dist/integrations/jira.js +279 -0
- package/dist/integrations/jira.js.map +1 -0
- package/dist/integrations/linear.js +273 -0
- package/dist/integrations/linear.js.map +1 -0
- package/dist/integrations/own-identity-warm.js +47 -0
- package/dist/integrations/own-identity-warm.js.map +1 -0
- package/dist/integrations/own-identity.js +39 -0
- package/dist/integrations/own-identity.js.map +1 -0
- package/dist/integrations/status-change.js +42 -0
- package/dist/integrations/status-change.js.map +1 -0
- package/dist/lead-defects.js +202 -0
- package/dist/lead-defects.js.map +1 -0
- package/dist/lead-digest.js +333 -0
- package/dist/lead-digest.js.map +1 -0
- package/dist/lead-release.js +286 -0
- package/dist/lead-release.js.map +1 -0
- package/dist/lead.js +328 -0
- package/dist/lead.js.map +1 -0
- package/dist/legacy-names.js +51 -0
- package/dist/legacy-names.js.map +1 -0
- package/dist/limits.js +44 -0
- package/dist/limits.js.map +1 -0
- package/dist/link-trust.js +224 -0
- package/dist/link-trust.js.map +1 -0
- package/dist/links.js +219 -0
- package/dist/links.js.map +1 -0
- package/dist/loopback.js +35 -0
- package/dist/loopback.js.map +1 -0
- package/dist/model-cap.js +60 -0
- package/dist/model-cap.js.map +1 -0
- package/dist/model-usage.js +173 -0
- package/dist/model-usage.js.map +1 -0
- package/dist/monitor/backfill.js +39 -0
- package/dist/monitor/backfill.js.map +1 -0
- package/dist/monitor/browser-probe.js +198 -0
- package/dist/monitor/browser-probe.js.map +1 -0
- package/dist/monitor/catch-up.js +31 -0
- package/dist/monitor/catch-up.js.map +1 -0
- package/dist/monitor/pollers.js +1606 -0
- package/dist/monitor/pollers.js.map +1 -0
- package/dist/monitor/probes.js +178 -0
- package/dist/monitor/probes.js.map +1 -0
- package/dist/monitor/self-health.js +228 -0
- package/dist/monitor/self-health.js.map +1 -0
- package/dist/monitor/statuspage.js +105 -0
- package/dist/monitor/statuspage.js.map +1 -0
- package/dist/nightly-scheduler.js +127 -0
- package/dist/nightly-scheduler.js.map +1 -0
- package/dist/nightly.js +141 -0
- package/dist/nightly.js.map +1 -0
- package/dist/notify/headline.js +114 -0
- package/dist/notify/headline.js.map +1 -0
- package/dist/notify/push.js +174 -0
- package/dist/notify/push.js.map +1 -0
- package/dist/object-cards.js +585 -0
- package/dist/object-cards.js.map +1 -0
- package/dist/object-links.js +47 -0
- package/dist/object-links.js.map +1 -0
- package/dist/onboarding-checks.js +141 -0
- package/dist/onboarding-checks.js.map +1 -0
- package/dist/onboarding.js +401 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/path-filters.js +62 -0
- package/dist/path-filters.js.map +1 -0
- package/dist/phone/alerts.js +67 -0
- package/dist/phone/alerts.js.map +1 -0
- package/dist/phone/auth.js +240 -0
- package/dist/phone/auth.js.map +1 -0
- package/dist/phone/pairing.js +442 -0
- package/dist/phone/pairing.js.map +1 -0
- package/dist/phone/prefs.js +27 -0
- package/dist/phone/prefs.js.map +1 -0
- package/dist/phone/tailscale.js +161 -0
- package/dist/phone/tailscale.js.map +1 -0
- package/dist/pr-scope.js +441 -0
- package/dist/pr-scope.js.map +1 -0
- package/dist/proc.js +156 -0
- package/dist/proc.js.map +1 -0
- package/dist/profile/cache.js +108 -0
- package/dist/profile/cache.js.map +1 -0
- package/dist/profile/ci.js +216 -0
- package/dist/profile/ci.js.map +1 -0
- package/dist/profile/index.js +8 -0
- package/dist/profile/index.js.map +1 -0
- package/dist/profile/job.js +135 -0
- package/dist/profile/job.js.map +1 -0
- package/dist/profile/queries/go-tags.scm +47 -0
- package/dist/profile/queries/java-tags.scm +25 -0
- package/dist/profile/queries/javascript-tags.scm +104 -0
- package/dist/profile/queries/python-tags.scm +19 -0
- package/dist/profile/queries/typescript-tags.scm +28 -0
- package/dist/profile/scan-thread.js +42 -0
- package/dist/profile/scan-thread.js.map +1 -0
- package/dist/profile/scan-worker.js +16 -0
- package/dist/profile/scan-worker.js.map +1 -0
- package/dist/profile/scan.js +351 -0
- package/dist/profile/scan.js.map +1 -0
- package/dist/profile/stack.js +528 -0
- package/dist/profile/stack.js.map +1 -0
- package/dist/profile/store.js +35 -0
- package/dist/profile/store.js.map +1 -0
- package/dist/profile/tests.js +399 -0
- package/dist/profile/tests.js.map +1 -0
- package/dist/queue.js +350 -0
- package/dist/queue.js.map +1 -0
- package/dist/refs.js +756 -0
- package/dist/refs.js.map +1 -0
- package/dist/release-notes.js +272 -0
- package/dist/release-notes.js.map +1 -0
- package/dist/reports/build.js +243 -0
- package/dist/reports/build.js.map +1 -0
- package/dist/reports/index.js +211 -0
- package/dist/reports/index.js.map +1 -0
- package/dist/reports/store.js +150 -0
- package/dist/reports/store.js.map +1 -0
- package/dist/reports/ticket-titles.js +124 -0
- package/dist/reports/ticket-titles.js.map +1 -0
- package/dist/reports/trace-map.js +1278 -0
- package/dist/reports/trace-map.js.map +1 -0
- package/dist/reports/traceability.js +422 -0
- package/dist/reports/traceability.js.map +1 -0
- package/dist/repos/mirror.js +356 -0
- package/dist/repos/mirror.js.map +1 -0
- package/dist/repos-github.js +108 -0
- package/dist/repos-github.js.map +1 -0
- package/dist/repos.js +891 -0
- package/dist/repos.js.map +1 -0
- package/dist/review/backend-anthropic.js +90 -0
- package/dist/review/backend-anthropic.js.map +1 -0
- package/dist/review/backend-cli.js +248 -0
- package/dist/review/backend-cli.js.map +1 -0
- package/dist/review/backend-codex.js +81 -0
- package/dist/review/backend-codex.js.map +1 -0
- package/dist/review/backend-mock.js +686 -0
- package/dist/review/backend-mock.js.map +1 -0
- package/dist/review/backend-only.js +168 -0
- package/dist/review/backend-only.js.map +1 -0
- package/dist/review/backend.js +105 -0
- package/dist/review/backend.js.map +1 -0
- package/dist/review/baseline/render-v1.js +483 -0
- package/dist/review/baseline/render-v1.js.map +1 -0
- package/dist/review/change-summary.js +197 -0
- package/dist/review/change-summary.js.map +1 -0
- package/dist/review/changelog.js +40 -0
- package/dist/review/changelog.js.map +1 -0
- package/dist/review/check.js +494 -0
- package/dist/review/check.js.map +1 -0
- package/dist/review/checkout.js +185 -0
- package/dist/review/checkout.js.map +1 -0
- package/dist/review/classify.js +178 -0
- package/dist/review/classify.js.map +1 -0
- package/dist/review/coverage.js +347 -0
- package/dist/review/coverage.js.map +1 -0
- package/dist/review/decision-eval.js +121 -0
- package/dist/review/decision-eval.js.map +1 -0
- package/dist/review/deliver.js +104 -0
- package/dist/review/deliver.js.map +1 -0
- package/dist/review/designer-eval.js +280 -0
- package/dist/review/designer-eval.js.map +1 -0
- package/dist/review/dogfood-digest.js +136 -0
- package/dist/review/dogfood-digest.js.map +1 -0
- package/dist/review/dogfood-eval.js +486 -0
- package/dist/review/dogfood-eval.js.map +1 -0
- package/dist/review/environment.js +109 -0
- package/dist/review/environment.js.map +1 -0
- package/dist/review/eval.js +405 -0
- package/dist/review/eval.js.map +1 -0
- package/dist/review/glob.js +110 -0
- package/dist/review/glob.js.map +1 -0
- package/dist/review/golden-export.js +219 -0
- package/dist/review/golden-export.js.map +1 -0
- package/dist/review/impact.js +406 -0
- package/dist/review/impact.js.map +1 -0
- package/dist/review/imports.js +542 -0
- package/dist/review/imports.js.map +1 -0
- package/dist/review/index.js +512 -0
- package/dist/review/index.js.map +1 -0
- package/dist/review/intent.js +128 -0
- package/dist/review/intent.js.map +1 -0
- package/dist/review/lenses.js +167 -0
- package/dist/review/lenses.js.map +1 -0
- package/dist/review/linked-tickets.js +293 -0
- package/dist/review/linked-tickets.js.map +1 -0
- package/dist/review/merge.js +370 -0
- package/dist/review/merge.js.map +1 -0
- package/dist/review/on-demand.js +218 -0
- package/dist/review/on-demand.js.map +1 -0
- package/dist/review/prompt-eval.js +299 -0
- package/dist/review/prompt-eval.js.map +1 -0
- package/dist/review/prompt-variants.js +37 -0
- package/dist/review/prompt-variants.js.map +1 -0
- package/dist/review/readability-eval.js +217 -0
- package/dist/review/readability-eval.js.map +1 -0
- package/dist/review/readability.js +295 -0
- package/dist/review/readability.js.map +1 -0
- package/dist/review/render.js +2409 -0
- package/dist/review/render.js.map +1 -0
- package/dist/review/repo.js +194 -0
- package/dist/review/repo.js.map +1 -0
- package/dist/review/report-plan.js +90 -0
- package/dist/review/report-plan.js.map +1 -0
- package/dist/review/routes.js +195 -0
- package/dist/review/routes.js.map +1 -0
- package/dist/review/run-summary.js +34 -0
- package/dist/review/run-summary.js.map +1 -0
- package/dist/review/run.js +245 -0
- package/dist/review/run.js.map +1 -0
- package/dist/review/scope.js +454 -0
- package/dist/review/scope.js.map +1 -0
- package/dist/review/security.js +432 -0
- package/dist/review/security.js.map +1 -0
- package/dist/review/signals.js +550 -0
- package/dist/review/signals.js.map +1 -0
- package/dist/review/specs.js +150 -0
- package/dist/review/specs.js.map +1 -0
- package/dist/review/ste.js +133 -0
- package/dist/review/ste.js.map +1 -0
- package/dist/review/test-repo-file.js +18 -0
- package/dist/review/test-repo-file.js.map +1 -0
- package/dist/review/test-repo-lines.js +53 -0
- package/dist/review/test-repo-lines.js.map +1 -0
- package/dist/review/ticket-on-demand.js +232 -0
- package/dist/review/ticket-on-demand.js.map +1 -0
- package/dist/review/yaml.js +298 -0
- package/dist/review/yaml.js.map +1 -0
- package/dist/room.js +361 -0
- package/dist/room.js.map +1 -0
- package/dist/routes/activity.js +219 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/call.js +29 -0
- package/dist/routes/call.js.map +1 -0
- package/dist/routes/cards.js +40 -0
- package/dist/routes/cards.js.map +1 -0
- package/dist/routes/cases.js +133 -0
- package/dist/routes/cases.js.map +1 -0
- package/dist/routes/connect.js +268 -0
- package/dist/routes/connect.js.map +1 -0
- package/dist/routes/connection-presets.js +32 -0
- package/dist/routes/connection-presets.js.map +1 -0
- package/dist/routes/connections.js +177 -0
- package/dist/routes/connections.js.map +1 -0
- package/dist/routes/disk.js +19 -0
- package/dist/routes/disk.js.map +1 -0
- package/dist/routes/docs.js +71 -0
- package/dist/routes/docs.js.map +1 -0
- package/dist/routes/dot-suggest.js +1002 -0
- package/dist/routes/dot-suggest.js.map +1 -0
- package/dist/routes/evidence-pack.js +16 -0
- package/dist/routes/evidence-pack.js.map +1 -0
- package/dist/routes/health.js +121 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/impact.js +48 -0
- package/dist/routes/impact.js.map +1 -0
- package/dist/routes/lead.js +50 -0
- package/dist/routes/lead.js.map +1 -0
- package/dist/routes/links.js +13 -0
- package/dist/routes/links.js.map +1 -0
- package/dist/routes/markdown.js +62 -0
- package/dist/routes/markdown.js.map +1 -0
- package/dist/routes/onboarding.js +47 -0
- package/dist/routes/onboarding.js.map +1 -0
- package/dist/routes/phone.js +231 -0
- package/dist/routes/phone.js.map +1 -0
- package/dist/routes/release-notes.js +30 -0
- package/dist/routes/release-notes.js.map +1 -0
- package/dist/routes/reports.js +102 -0
- package/dist/routes/reports.js.map +1 -0
- package/dist/routes/repos.js +91 -0
- package/dist/routes/repos.js.map +1 -0
- package/dist/routes/review.js +283 -0
- package/dist/routes/review.js.map +1 -0
- package/dist/routes/roles.js +114 -0
- package/dist/routes/roles.js.map +1 -0
- package/dist/routes/room.js +21 -0
- package/dist/routes/room.js.map +1 -0
- package/dist/routes/runner.js +156 -0
- package/dist/routes/runner.js.map +1 -0
- package/dist/routes/runs.js +84 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/routes/setup.js +18 -0
- package/dist/routes/setup.js.map +1 -0
- package/dist/routes/test-runs.js +220 -0
- package/dist/routes/test-runs.js.map +1 -0
- package/dist/routes/ticket-status.js +17 -0
- package/dist/routes/ticket-status.js.map +1 -0
- package/dist/routes/traceability.js +143 -0
- package/dist/routes/traceability.js.map +1 -0
- package/dist/routes/true.js +142 -0
- package/dist/routes/true.js.map +1 -0
- package/dist/routes/updates.js +70 -0
- package/dist/routes/updates.js.map +1 -0
- package/dist/runner/adapters/index.js +12 -0
- package/dist/runner/adapters/index.js.map +1 -0
- package/dist/runner/adapters/playwright.js +68 -0
- package/dist/runner/adapters/playwright.js.map +1 -0
- package/dist/runner/aivision/cases.js +172 -0
- package/dist/runner/aivision/cases.js.map +1 -0
- package/dist/runner/aivision/cli.js +201 -0
- package/dist/runner/aivision/cli.js.map +1 -0
- package/dist/runner/aivision/index.js +281 -0
- package/dist/runner/aivision/index.js.map +1 -0
- package/dist/runner/aivision/server.js +394 -0
- package/dist/runner/aivision/server.js.map +1 -0
- package/dist/runner/aivision/shared.js +140 -0
- package/dist/runner/aivision/shared.js.map +1 -0
- package/dist/runner/artifact-cap.js +189 -0
- package/dist/runner/artifact-cap.js.map +1 -0
- package/dist/runner/artifacts.js +433 -0
- package/dist/runner/artifacts.js.map +1 -0
- package/dist/runner/command.js +564 -0
- package/dist/runner/command.js.map +1 -0
- package/dist/runner/copyfiles.js +221 -0
- package/dist/runner/copyfiles.js.map +1 -0
- package/dist/runner/engines.js +32 -0
- package/dist/runner/engines.js.map +1 -0
- package/dist/runner/evidence.js +114 -0
- package/dist/runner/evidence.js.map +1 -0
- package/dist/runner/host-env.js +160 -0
- package/dist/runner/host-env.js.map +1 -0
- package/dist/runner/index.js +1221 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/runner/observe.js +603 -0
- package/dist/runner/observe.js.map +1 -0
- package/dist/runner/playwright-json.js +108 -0
- package/dist/runner/playwright-json.js.map +1 -0
- package/dist/runner/pwgen/exec.js +192 -0
- package/dist/runner/pwgen/exec.js.map +1 -0
- package/dist/runner/pwgen/explore.js +111 -0
- package/dist/runner/pwgen/explore.js.map +1 -0
- package/dist/runner/pwgen/index.js +423 -0
- package/dist/runner/pwgen/index.js.map +1 -0
- package/dist/runner/pwgen/propose.js +69 -0
- package/dist/runner/pwgen/propose.js.map +1 -0
- package/dist/runner/pwgen/source.js +632 -0
- package/dist/runner/pwgen/source.js.map +1 -0
- package/dist/runner/setup.js +222 -0
- package/dist/runner/setup.js.map +1 -0
- package/dist/runner/step-reporter.js +107 -0
- package/dist/runner/step-reporter.js.map +1 -0
- package/dist/runner/test-repo.js +326 -0
- package/dist/runner/test-repo.js.map +1 -0
- package/dist/runner/typed-value.js +46 -0
- package/dist/runner/typed-value.js.map +1 -0
- package/dist/runner/verify.js +133 -0
- package/dist/runner/verify.js.map +1 -0
- package/dist/runner/worktree.js +349 -0
- package/dist/runner/worktree.js.map +1 -0
- package/dist/sandbox.js +61 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/server.js +262 -0
- package/dist/server.js.map +1 -0
- package/dist/shadow-week.js +126 -0
- package/dist/shadow-week.js.map +1 -0
- package/dist/shutdown.js +92 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/slack/index.js +645 -0
- package/dist/slack/index.js.map +1 -0
- package/dist/stages/config.js +147 -0
- package/dist/stages/config.js.map +1 -0
- package/dist/stages/index.js +41 -0
- package/dist/stages/index.js.map +1 -0
- package/dist/suites.js +293 -0
- package/dist/suites.js.map +1 -0
- package/dist/target-url.js +116 -0
- package/dist/target-url.js.map +1 -0
- package/dist/test-runs.js +662 -0
- package/dist/test-runs.js.map +1 -0
- package/dist/ticket-keys.js +97 -0
- package/dist/ticket-keys.js.map +1 -0
- package/dist/ticket-supersede.js +105 -0
- package/dist/ticket-supersede.js.map +1 -0
- package/dist/tinybee/ed25519.js +99 -0
- package/dist/tinybee/ed25519.js.map +1 -0
- package/dist/tinybee/models-routes.js +19 -0
- package/dist/tinybee/models-routes.js.map +1 -0
- package/dist/tinybee/pair-payload.js +41 -0
- package/dist/tinybee/pair-payload.js.map +1 -0
- package/dist/tinybee/update-manifest.js +77 -0
- package/dist/tinybee/update-manifest.js.map +1 -0
- package/dist/tinybee/well-known.js +64 -0
- package/dist/tinybee/well-known.js.map +1 -0
- package/dist/tinybee/workflows/activity.js +23 -0
- package/dist/tinybee/workflows/activity.js.map +1 -0
- package/dist/tinybee/workflows/cron.js +210 -0
- package/dist/tinybee/workflows/cron.js.map +1 -0
- package/dist/tinybee/workflows/engine.js +371 -0
- package/dist/tinybee/workflows/engine.js.map +1 -0
- package/dist/tinybee/workflows/from-text.js +203 -0
- package/dist/tinybee/workflows/from-text.js.map +1 -0
- package/dist/tinybee/workflows/routes.js +189 -0
- package/dist/tinybee/workflows/routes.js.map +1 -0
- package/dist/tinybee/workflows/scheduler.js +100 -0
- package/dist/tinybee/workflows/scheduler.js.map +1 -0
- package/dist/tinybee/workflows/skills.js +227 -0
- package/dist/tinybee/workflows/skills.js.map +1 -0
- package/dist/tinybee/workflows/store.js +205 -0
- package/dist/tinybee/workflows/store.js.map +1 -0
- package/dist/tinybee/workflows/types.js +180 -0
- package/dist/tinybee/workflows/types.js.map +1 -0
- package/dist/tinybee/workflows/yaml.js +323 -0
- package/dist/tinybee/workflows/yaml.js.map +1 -0
- package/dist/true/api.js +495 -0
- package/dist/true/api.js.map +1 -0
- package/dist/true/client.js +292 -0
- package/dist/true/client.js.map +1 -0
- package/dist/true/clock.js +36 -0
- package/dist/true/clock.js.map +1 -0
- package/dist/true/events.js +125 -0
- package/dist/true/events.js.map +1 -0
- package/dist/true/hosts.js +98 -0
- package/dist/true/hosts.js.map +1 -0
- package/dist/true/index.js +29 -0
- package/dist/true/index.js.map +1 -0
- package/dist/true/key.js +54 -0
- package/dist/true/key.js.map +1 -0
- package/dist/true/line.js +69 -0
- package/dist/true/line.js.map +1 -0
- package/dist/true/mask.js +31 -0
- package/dist/true/mask.js.map +1 -0
- package/dist/true/names.js +114 -0
- package/dist/true/names.js.map +1 -0
- package/dist/true/outbox.js +233 -0
- package/dist/true/outbox.js.map +1 -0
- package/dist/true/replay.js +164 -0
- package/dist/true/replay.js.map +1 -0
- package/dist/true/settings.js +123 -0
- package/dist/true/settings.js.map +1 -0
- package/dist/true/status.js +189 -0
- package/dist/true/status.js.map +1 -0
- package/dist/true/sync-cases.js +222 -0
- package/dist/true/sync-cases.js.map +1 -0
- package/dist/true/sync-manual.js +295 -0
- package/dist/true/sync-manual.js.map +1 -0
- package/dist/true/sync-runs.js +109 -0
- package/dist/true/sync-runs.js.map +1 -0
- package/dist/true/worker.js +414 -0
- package/dist/true/worker.js.map +1 -0
- package/dist/types.js +32 -0
- package/dist/types.js.map +1 -0
- package/dist/updates/asks.js +38 -0
- package/dist/updates/asks.js.map +1 -0
- package/dist/updates/index.js +835 -0
- package/dist/updates/index.js.map +1 -0
- package/dist/updates/owner.js +24 -0
- package/dist/updates/owner.js.map +1 -0
- package/dist/updates/prefs.js +118 -0
- package/dist/updates/prefs.js.map +1 -0
- package/dist/updates/route.js +78 -0
- package/dist/updates/route.js.map +1 -0
- package/dist/updates/sources.js +345 -0
- package/dist/updates/sources.js.map +1 -0
- package/dist/updates/words.js +452 -0
- package/dist/updates/words.js.map +1 -0
- package/dist/version.js +53 -0
- package/dist/version.js.map +1 -0
- package/dist/view-compute.js +382 -0
- package/dist/view-compute.js.map +1 -0
- package/dist/view-spec.js +894 -0
- package/dist/view-spec.js.map +1 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/ApprovalCard-Br_z5z7G.css +1 -0
- package/dist/web/assets/ApprovalCard-CfGhGFo_.js +3 -0
- package/dist/web/assets/CardFromRef-BaTGmZ9c.js +2 -0
- package/dist/web/assets/CardFromRef-DO7i2uDd.css +1 -0
- package/dist/web/assets/Cases-8l_6UcLh.js +2 -0
- package/dist/web/assets/Cases-CpfED0uB.css +1 -0
- package/dist/web/assets/Chat-BKDiR0qt.js +3 -0
- package/dist/web/assets/Chat-DxjdyYUu.css +1 -0
- package/dist/web/assets/ConfirmDialog-CIMveD-b.js +2 -0
- package/dist/web/assets/ConnectSheet-Cm5vbZdR.js +4 -0
- package/dist/web/assets/Docs-HKGuY0WE.js +2 -0
- package/dist/web/assets/Docs-fodKx8S7.css +1 -0
- package/dist/web/assets/DotDetail-DskS3NWe.js +2 -0
- package/dist/web/assets/DotDetail-OWRSD_1x.css +1 -0
- package/dist/web/assets/DotStage-BZDclfzV.css +1 -0
- package/dist/web/assets/DotStage-CtMNzCN3.js +2 -0
- package/dist/web/assets/Dots-BqCsS34m.js +2 -0
- package/dist/web/assets/EvidencePack-DQ3kCJX4.js +2 -0
- package/dist/web/assets/EvidencePack-DV_9ETsA.css +1 -0
- package/dist/web/assets/FailedDeliveries-1F2kFcs9.js +2 -0
- package/dist/web/assets/FailedDeliveries-DXa2_VvI.css +1 -0
- package/dist/web/assets/Feed-5wiHRTFK.js +2 -0
- package/dist/web/assets/GfmView-C-NYaduC.js +4 -0
- package/dist/web/assets/ImpactList-C-RNuDB0.js +2 -0
- package/dist/web/assets/ImpactList-C612dFdw.css +1 -0
- package/dist/web/assets/Inbox-kc1bKVL_.js +2 -0
- package/dist/web/assets/JoinScreen-C3L4wmP3.js +2 -0
- package/dist/web/assets/JoinScreen-COeAUv5v.css +1 -0
- package/dist/web/assets/LeadDialog-N3Zul9Rw.js +8 -0
- package/dist/web/assets/LeadDialog-xyWjAuAS.css +1 -0
- package/dist/web/assets/ListeningLine-BRFNaczY.js +2 -0
- package/dist/web/assets/ListeningLine-pN8CgyiK.css +1 -0
- package/dist/web/assets/Mascots-pIyGpxtD.js +2 -0
- package/dist/web/assets/ModelChoices-BxO1zyyr.js +2 -0
- package/dist/web/assets/ModelChoices-DQJnVxfs.css +1 -0
- package/dist/web/assets/NewDotDialog-CDPUWmgN.js +2 -0
- package/dist/web/assets/NewDotDialog-DrPPqkSZ.css +1 -0
- package/dist/web/assets/NewTestRunDialog-BQBthVHE.js +2 -0
- package/dist/web/assets/NoBreakNames-Csxdqdt6.js +2 -0
- package/dist/web/assets/Pair-omex_K7H.js +2 -0
- package/dist/web/assets/PushSection-CmqHlE_2.js +2 -0
- package/dist/web/assets/ReleaseNotes-B6VqFewP.js +2 -0
- package/dist/web/assets/ReleaseNotes-DBJUL7Vm.css +1 -0
- package/dist/web/assets/ReportPage-BHS1xWEt.js +162 -0
- package/dist/web/assets/Reports-BEHnDIb1.js +2 -0
- package/dist/web/assets/ReviewChangeDialog-BVSt-cw-.js +2 -0
- package/dist/web/assets/ReviewChangeDialog-BbIlHRx0.css +1 -0
- package/dist/web/assets/Room-BoG_uge4.js +2 -0
- package/dist/web/assets/Room-evAfiW_e.css +1 -0
- package/dist/web/assets/RunActions-ZY7yf1AF.js +2 -0
- package/dist/web/assets/RunDetail-C8St20GQ.css +1 -0
- package/dist/web/assets/RunDetail-qADXjBvd.js +9 -0
- package/dist/web/assets/RunRow-BKEJv8ib.js +2 -0
- package/dist/web/assets/RunnerTry-Dgpp67-s.js +6 -0
- package/dist/web/assets/RunnerTry-DsweWOfO.css +1 -0
- package/dist/web/assets/Settings-fs-QJwNM.js +2 -0
- package/dist/web/assets/Setup-DI6hBEP-.js +2 -0
- package/dist/web/assets/TestRows-wh03T5Li.js +4 -0
- package/dist/web/assets/TestRun-DTOyF-Pv.js +2 -0
- package/dist/web/assets/TestRuns-ggDjm1Q8.js +2 -0
- package/dist/web/assets/TinyBeeAddLaptop-bc3pmh3T.js +2 -0
- package/dist/web/assets/TinyBeeAi-CkyOzhm5.js +2 -0
- package/dist/web/assets/TinyBeeConnections-B2K_P38u.js +2 -0
- package/dist/web/assets/TinyBeeHome-DSpxX5KS.js +3 -0
- package/dist/web/assets/TinyBeePlan-BamiLziu.js +2 -0
- package/dist/web/assets/TinyBeeSignup-vHcz0Wen.js +2 -0
- package/dist/web/assets/TinyBeeSkills-By9RLUse.js +2 -0
- package/dist/web/assets/TinyBeeWorkflow-DeNO7Jsh.js +2 -0
- package/dist/web/assets/TinyBeeWorkflowEditor-B6OGC2OT.js +2 -0
- package/dist/web/assets/TinyBeeWorkflows-BN4-h6lt.js +2 -0
- package/dist/web/assets/Trace-BBCI1aIB.css +1 -0
- package/dist/web/assets/Trace-aF92UWaH.js +2 -0
- package/dist/web/assets/UpdatesSection-CCWEbkue.js +2 -0
- package/dist/web/assets/Welcome-CCcy558Q.js +2 -0
- package/dist/web/assets/activity-C3qilrDm.css +1 -0
- package/dist/web/assets/activity-CBfTSxqw.js +2 -0
- package/dist/web/assets/authedMedia-BTYt_tyu.js +2 -0
- package/dist/web/assets/beeApi-BHW8Tx1s.js +2 -0
- package/dist/web/assets/data-CmXvx3Mj.js +2 -0
- package/dist/web/assets/engines-CfdJODFj.css +1 -0
- package/dist/web/assets/format-BBZ8vFyT.js +2 -0
- package/dist/web/assets/forms-Coc7p2UF.css +1 -0
- package/dist/web/assets/hivePush-DZJDokj7.js +2 -0
- package/dist/web/assets/impactModel-MY1OFuiP.js +2 -0
- package/dist/web/assets/index-B6qKm1NW.js +13 -0
- package/dist/web/assets/index-BvKyT1X7.css +1 -0
- package/dist/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/web/assets/lead-BL_P_kri.css +1 -0
- package/dist/web/assets/model-BIQy5IVG.js +2 -0
- package/dist/web/assets/modelsApi-PUSySRQw.js +2 -0
- package/dist/web/assets/newDotModel-BaLLyw57.js +2 -0
- package/dist/web/assets/onboarding-bfKqnM4Z.css +1 -0
- package/dist/web/assets/phone-Rs7FoyG0.css +1 -0
- package/dist/web/assets/phoneApi-BG_1-M-D.js +3 -0
- package/dist/web/assets/releaseModel-EynYMVOC.js +2 -0
- package/dist/web/assets/releaseNotesModel-A1Rtv3cV.js +2 -0
- package/dist/web/assets/reports-BWaA1HhM.js +37 -0
- package/dist/web/assets/reports-DXwdWQsB.css +1 -0
- package/dist/web/assets/router-W6nZsQ33.js +2 -0
- package/dist/web/assets/runs-BY0Owv93.css +1 -0
- package/dist/web/assets/shadowWeekModel-BUUiww09.js +2 -0
- package/dist/web/assets/storage-yvL36AWt.js +2 -0
- package/dist/web/assets/store-IqrqHuFR.js +2 -0
- package/dist/web/assets/testruns-BzIZ0cR0.css +1 -0
- package/dist/web/assets/testruns-DyaqX4ji.js +2 -0
- package/dist/web/assets/true-9Q68cs5b.js +2 -0
- package/dist/web/assets/true-DPGBpoir.css +1 -0
- package/dist/web/assets/ui-DcmhYyYp.js +13 -0
- package/dist/web/assets/ui-DxEDgbIx.css +1 -0
- package/dist/web/assets/usePauseDot-BV60ZVy1.js +2 -0
- package/dist/web/assets/usePauseDot-NrSzMI1C.css +1 -0
- package/dist/web/assets/usePush-Dsm-TjlG.js +2 -0
- package/dist/web/assets/useRefChips-CbafNx59.js +68 -0
- package/dist/web/assets/useRestoreDot-D0B6W82H.js +2 -0
- package/dist/web/assets/view-worker-pyeVAhUT.js +2 -0
- package/dist/web/assets/workflowUi-QYsd3n9J.js +2 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/index.html +45 -0
- package/dist/web/manifest.webmanifest +23 -0
- package/dist/web/mascots/argus-attention.svg +7 -0
- package/dist/web/mascots/argus-ok.svg +7 -0
- package/dist/web/mascots/argus-outage.svg +7 -0
- package/dist/web/mascots/argus-running.svg +7 -0
- package/dist/web/mascots/bolt-attention.svg +7 -0
- package/dist/web/mascots/bolt-ok.svg +7 -0
- package/dist/web/mascots/bolt-outage.svg +7 -0
- package/dist/web/mascots/bolt-running.svg +7 -0
- package/dist/web/mascots/echo-attention.svg +7 -0
- package/dist/web/mascots/echo-ok.svg +7 -0
- package/dist/web/mascots/echo-outage.svg +7 -0
- package/dist/web/mascots/echo-running.svg +7 -0
- package/dist/web/mascots/lens-attention.svg +7 -0
- package/dist/web/mascots/lens-ok.svg +7 -0
- package/dist/web/mascots/lens-outage.svg +7 -0
- package/dist/web/mascots/lens-running.svg +7 -0
- package/dist/web/mascots/moss-attention.svg +7 -0
- package/dist/web/mascots/moss-ok.svg +7 -0
- package/dist/web/mascots/moss-outage.svg +7 -0
- package/dist/web/mascots/moss-running.svg +7 -0
- package/dist/web/mascots/nit-attention.svg +7 -0
- package/dist/web/mascots/nit-ok.svg +7 -0
- package/dist/web/mascots/nit-outage.svg +7 -0
- package/dist/web/mascots/nit-running.svg +7 -0
- package/dist/web/mascots/nova-attention.svg +7 -0
- package/dist/web/mascots/nova-ok.svg +7 -0
- package/dist/web/mascots/nova-outage.svg +7 -0
- package/dist/web/mascots/nova-running.svg +7 -0
- package/dist/web/mascots/penn-attention.svg +7 -0
- package/dist/web/mascots/penn-ok.svg +7 -0
- package/dist/web/mascots/penn-outage.svg +7 -0
- package/dist/web/mascots/penn-running.svg +7 -0
- package/dist/web/mascots/pip-attention.svg +7 -0
- package/dist/web/mascots/pip-ok.svg +7 -0
- package/dist/web/mascots/pip-outage.svg +7 -0
- package/dist/web/mascots/pip-running.svg +7 -0
- package/dist/web/mascots/ward-attention.svg +7 -0
- package/dist/web/mascots/ward-ok.svg +7 -0
- package/dist/web/mascots/ward-outage.svg +7 -0
- package/dist/web/mascots/ward-running.svg +7 -0
- package/dist/web/sw.js +98 -0
- package/dist/webhook-seen.js +53 -0
- package/dist/webhook-seen.js.map +1 -0
- package/dist/webhooks.js +625 -0
- package/dist/webhooks.js.map +1 -0
- package/dist/zip.js +63 -0
- package/dist/zip.js.map +1 -0
- package/docs/API-V2.md +532 -0
- package/docs/CHAT-EVAL.md +130 -0
- package/docs/CHAT-UI.md +416 -0
- package/docs/CONNECTIONS.md +89 -0
- package/docs/DATA-MODEL.md +518 -0
- package/docs/DEPLOY.md +577 -0
- package/docs/DOGFOOD-LEDGER.md +81 -0
- package/docs/GITHUB.md +148 -0
- package/docs/INTEGRATION-MODEL.md +200 -0
- package/docs/LEAD-DOT.md +553 -0
- package/docs/LIST-PATTERNS.md +52 -0
- package/docs/MULTI-REPO.md +766 -0
- package/docs/PARITY.md +276 -0
- package/docs/PR-REVIEW-QA.md +1266 -0
- package/docs/PR-REVIEW.md +357 -0
- package/docs/SANDBOX.md +320 -0
- package/docs/SETUP.md +776 -0
- package/docs/SLACK.md +80 -0
- package/docs/STAGES.md +619 -0
- package/docs/TRACE-MAP.md +89 -0
- package/docs/TRACKERS.md +225 -0
- package/docs/VOICE.md +36 -0
- package/docs/tinybee/00-README.md +45 -0
- package/docs/tinybee/01-PRD.md +216 -0
- package/docs/tinybee/02-architecture-pairing.md +199 -0
- package/docs/tinybee/03-local-agent.md +222 -0
- package/docs/tinybee/04-local-setup-ui.md +128 -0
- package/docs/tinybee/05-mobile-app-ux.md +199 -0
- package/docs/tinybee/06-environments-and-delivery.md +148 -0
- package/docs/tinybee/07-coding-agent-handoff.md +116 -0
- package/docs/tinybee/OPEN-QUESTIONS.md +26 -0
- package/docs/tinybee/PROGRESS.md +134 -0
- package/package.json +111 -4
|
@@ -0,0 +1,2409 @@
|
|
|
1
|
+
// PR review for QA, rendering (docs/PR-REVIEW-QA.md section 5, plain wording from KAT-96): the
|
|
2
|
+
// sticky PR comment, the check-run title, text and annotations, the <qa_review> block for the verdict model, and the
|
|
3
|
+
// release-notes line for the digest. Pure functions: no I/O, no model call.
|
|
4
|
+
//
|
|
5
|
+
// Every model-written string goes through cell(), inline(), quote() or code(). Escaping
|
|
6
|
+
// follows OpenQodex render/markdown.ts (control characters removed, Markdown and HTML
|
|
7
|
+
// escaped, line breaks to <br> in tables, code spans without backticks), plus one localdost
|
|
8
|
+
// rule: "@" becomes "@" + U+200B so the bot never mentions a user.
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { isTestRepoFile, splitTestRepoFile } from './test-repo-file.js';
|
|
11
|
+
import { skippedSentence, testRepoDetail, testRepoSentence, testsRan } from './test-repo-lines.js';
|
|
12
|
+
import { RISK_WORD, SIZE_WORD } from './change-summary.js';
|
|
13
|
+
import { safeName } from '../runner/artifacts.js';
|
|
14
|
+
import { APP_DID_NOT_ANSWER, appDidNotAnswer, environmentSignature } from './environment.js';
|
|
15
|
+
import { intentWhere, matchIntent } from './intent.js';
|
|
16
|
+
import { isSecurityPassRisk } from './security.js';
|
|
17
|
+
export { skippedSentence, testsRan };
|
|
18
|
+
export const PR_MARKER = '<!-- localdost:pr-summary -->';
|
|
19
|
+
export const QA_MARKER_RE = /<!-- (?:localdost|qadots):qa-review head=([0-9a-f]{0,40}) -->/i;
|
|
20
|
+
export const COMMENT_LIMIT = 65000;
|
|
21
|
+
export const MAX_CHECKLIST = 8;
|
|
22
|
+
export const MAX_BUGS = 6;
|
|
23
|
+
export const MAX_UNTESTED = 6;
|
|
24
|
+
export const MAX_SUGGESTIONS = 6;
|
|
25
|
+
export const MAX_RISK_ROWS = 10;
|
|
26
|
+
export const MAX_TESTS_AT_RISK = 10;
|
|
27
|
+
export const MAX_ANNOTATIONS = 50;
|
|
28
|
+
const SEV = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
29
|
+
const PRIORITY = { must: 0, should: 1, could: 2 };
|
|
30
|
+
const ZWSP = '\u200b';
|
|
31
|
+
// ---------- escaping ----------
|
|
32
|
+
// C0 and C1 controls except tab, line feed and carriage return; zero-width and bidi controls.
|
|
33
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
|
|
34
|
+
function clean(s) {
|
|
35
|
+
return String(s ?? '')
|
|
36
|
+
.replace(CONTROL, '')
|
|
37
|
+
.replace(/\r\n?/g, '\n');
|
|
38
|
+
}
|
|
39
|
+
function escapeText(s) {
|
|
40
|
+
return (s
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/([\\`*_[\]|~])/g, '\\$1')
|
|
45
|
+
.replace(/@/g, `@${ZWSP}`)
|
|
46
|
+
// Escapes what would start a heading or a list. "3." becomes "3\." (CommonMark has no "\3").
|
|
47
|
+
.replace(/^(\s*)([#+-])/, '$1\\$2')
|
|
48
|
+
.replace(/^(\s*)(\d+)\./, '$1$2\\.'));
|
|
49
|
+
}
|
|
50
|
+
/** One line of prose outside a table: escaped, line breaks become spaces. */
|
|
51
|
+
export function inline(s) {
|
|
52
|
+
return escapeText(clean(s)
|
|
53
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
54
|
+
.trim());
|
|
55
|
+
}
|
|
56
|
+
/** One line of prose with its square brackets dropped before escaping, so "[UI] Guest" reads "UI Guest" and no stray backslash is left. */
|
|
57
|
+
export function noBrackets(s) {
|
|
58
|
+
return inline(clean(s).replace(/[[\]]/g, ''));
|
|
59
|
+
}
|
|
60
|
+
/** A table cell: escaped, line breaks become <br>. */
|
|
61
|
+
export function cell(s) {
|
|
62
|
+
return clean(s)
|
|
63
|
+
.trim()
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((l) => escapeText(l.trim()))
|
|
66
|
+
.join('<br>');
|
|
67
|
+
}
|
|
68
|
+
/** A code span. Backticks and line breaks are removed, so the span cannot be closed early. */
|
|
69
|
+
export function code(s) {
|
|
70
|
+
const t = clean(s)
|
|
71
|
+
.replace(/`/g, "'")
|
|
72
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
73
|
+
.trim();
|
|
74
|
+
return `\`${t || ' '}\``;
|
|
75
|
+
}
|
|
76
|
+
/** A block quote. Each line is escaped and prefixed. */
|
|
77
|
+
export function quote(s) {
|
|
78
|
+
const t = clean(s).trim();
|
|
79
|
+
if (!t)
|
|
80
|
+
return '';
|
|
81
|
+
return t
|
|
82
|
+
.split('\n')
|
|
83
|
+
.map((l) => `> ${escapeText(l.trim())}`)
|
|
84
|
+
.join('\n');
|
|
85
|
+
}
|
|
86
|
+
/** Plain text for check-run annotations (not Markdown): controls removed, one line per break. */
|
|
87
|
+
export function plain(s, max = 2000) {
|
|
88
|
+
const t = clean(s).trim();
|
|
89
|
+
return t.length > max ? `${t.slice(0, max - 3)}...` : t;
|
|
90
|
+
}
|
|
91
|
+
export function refText(r) {
|
|
92
|
+
if (!r)
|
|
93
|
+
return '';
|
|
94
|
+
return `${r.file}:${r.line}${r.lineEnd && r.lineEnd !== r.line ? `-${r.lineEnd}` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
// ---------- checklist ids and ticks ----------
|
|
97
|
+
export function normalizeScenario(s) {
|
|
98
|
+
return clean(s)
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
101
|
+
.trim();
|
|
102
|
+
}
|
|
103
|
+
/** Stable checklist id: first 6 hex of sha1(areaId + normalized scenario). */
|
|
104
|
+
export function checklistId(item) {
|
|
105
|
+
return createHash('sha1')
|
|
106
|
+
.update(`${item.areaId ?? ''}${normalizeScenario(item.scenario)}`)
|
|
107
|
+
.digest('hex')
|
|
108
|
+
.slice(0, 6);
|
|
109
|
+
}
|
|
110
|
+
const ITEM_RE = /^(\s*[-*] \[)([ xX])(\].*?<!-- qa:t:([0-9a-f]{6}) -->)\s*$/;
|
|
111
|
+
/** Checklist lines with ids, in order, from a rendered comment. */
|
|
112
|
+
export function checklistLines(body) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const line of (body ?? '').split('\n')) {
|
|
115
|
+
const m = ITEM_RE.exec(line);
|
|
116
|
+
if (m)
|
|
117
|
+
out.push({ id: m[4], ticked: m[2].toLowerCase() === 'x', line });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
export function tickedIds(body) {
|
|
122
|
+
return new Set(checklistLines(body)
|
|
123
|
+
.filter((l) => l.ticked)
|
|
124
|
+
.map((l) => l.id));
|
|
125
|
+
}
|
|
126
|
+
/** The review head the comment was rendered for, as written in its marker. */
|
|
127
|
+
export function commentHead(body) {
|
|
128
|
+
const m = QA_MARKER_RE.exec(body ?? '');
|
|
129
|
+
return m ? m[1] : null;
|
|
130
|
+
}
|
|
131
|
+
const EARLIER_OPEN = '<details><summary>Checked earlier';
|
|
132
|
+
/** The fold that holds everything a developer needs. Tick items and the footer sit above it. */
|
|
133
|
+
export const DETAILS_SUMMARY = 'Details for developers';
|
|
134
|
+
const DETAILS_OPEN = `<details><summary>${DETAILS_SUMMARY}</summary>`;
|
|
135
|
+
/**
|
|
136
|
+
* Ticks again every item of `next` that was ticked in `previous`. Ticked items whose id is
|
|
137
|
+
* gone move to a collapsed "Checked earlier" list with their tick kept. Without this, every
|
|
138
|
+
* new push wipes the tester's ticks.
|
|
139
|
+
*/
|
|
140
|
+
export function carryTicks(next, previous) {
|
|
141
|
+
const before = checklistLines(previous);
|
|
142
|
+
const ticked = new Set(before.filter((l) => l.ticked).map((l) => l.id));
|
|
143
|
+
if (!ticked.size)
|
|
144
|
+
return next;
|
|
145
|
+
const present = new Set(checklistLines(next).map((l) => l.id));
|
|
146
|
+
let body = next
|
|
147
|
+
.split('\n')
|
|
148
|
+
.map((line) => {
|
|
149
|
+
const m = ITEM_RE.exec(line);
|
|
150
|
+
return m && ticked.has(m[4]) ? `${m[1]}x${m[3]}` : line;
|
|
151
|
+
})
|
|
152
|
+
.join('\n');
|
|
153
|
+
// Items that disappeared, including ones already in an earlier "Checked earlier" list.
|
|
154
|
+
const gone = before.filter((l) => l.ticked && !present.has(l.id));
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
const lines = gone.filter((l) => (seen.has(l.id) ? false : (seen.add(l.id), true))).map((l) => l.line.replace(/^\s*[-*] \[[ xX]\]/, '- [x]'));
|
|
157
|
+
if (!lines.length)
|
|
158
|
+
return body;
|
|
159
|
+
// Drop a "Checked earlier" block the new body may already carry, then add the merged one.
|
|
160
|
+
body = body.replace(/\n?<details><summary>Checked earlier[\s\S]*?<\/details>\n?/, '\n');
|
|
161
|
+
const block = `${EARLIER_OPEN} (${lines.length})</summary>\n\n${lines.join('\n')}\n\n</details>`;
|
|
162
|
+
// The block goes above the developer details of the new layout, else above the footer of the older one.
|
|
163
|
+
const at = Math.max(body.lastIndexOf(`\n${DETAILS_OPEN}`), body.lastIndexOf('\n<sub>localdost'));
|
|
164
|
+
return at >= 0 ? `${body.slice(0, at)}\n${block}\n${body.slice(at)}` : `${body}\n\n${block}`;
|
|
165
|
+
}
|
|
166
|
+
/** Lead lines that mark a comment as out of date, old and new wording. */
|
|
167
|
+
const STALE_TEXT = ['This report is out of date', 'This review is out of date', 'is out of date, a new review is running'];
|
|
168
|
+
/** How long a reader waits for the new report before asking for one. */
|
|
169
|
+
export const STALE_WAIT_MINUTES = 15;
|
|
170
|
+
const STALE_SUMMARY = 'Earlier report, for the previous push';
|
|
171
|
+
/**
|
|
172
|
+
* Marks a rendered comment as out of date while a new run tests the latest push. The comment becomes three things: the
|
|
173
|
+
* hidden markers (the old head stays in its marker), the lead "This report is out of date." with what to do when no new
|
|
174
|
+
* report lands, and the old report folded away, so old advice is never read as current. The old body keeps its tick items,
|
|
175
|
+
* so ticks carry over to the new report. Comments in the format before KAT-96 have their "Head `sha` ·" line replaced instead.
|
|
176
|
+
* `recheckByReply` says whether a reply of `/localdost review` reaches this server; without it the reader pushes a commit.
|
|
177
|
+
*/
|
|
178
|
+
export function markStale(body, newHead, o = {}) {
|
|
179
|
+
const old = commentHead(body);
|
|
180
|
+
if (!old || STALE_TEXT.some((t) => body.includes(t)))
|
|
181
|
+
return body;
|
|
182
|
+
if (newHead && old && newHead.startsWith(old))
|
|
183
|
+
return body;
|
|
184
|
+
const lines = body.split('\n');
|
|
185
|
+
const legacy = lines.findIndex((l) => /^Head `[0-9a-f]+` · /.test(l));
|
|
186
|
+
if (legacy >= 0) {
|
|
187
|
+
lines[legacy] = `**Review of \`${old.slice(0, 12)}\` is out of date, a new review is running** for \`${(newHead || 'a new head').slice(0, 12)}\`.`;
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
const markers = lines.filter((l) => l.startsWith('<!--') && l.trim().endsWith('-->') && !/<!-- qa:t:/.test(l) && (l.includes('localdost:pr-summary') || QA_MARKER_RE.test(l)));
|
|
191
|
+
const rest = lines.filter((l) => !markers.includes(l));
|
|
192
|
+
const ask = o.recheckByReply === false ? 'push a new commit' : 'reply `/localdost review`';
|
|
193
|
+
return [
|
|
194
|
+
...markers,
|
|
195
|
+
'**This report is out of date.** localdost is checking your latest push now.',
|
|
196
|
+
`If no new report appears within ${STALE_WAIT_MINUTES} minutes, ${ask}.`,
|
|
197
|
+
'',
|
|
198
|
+
`<details><summary>${STALE_SUMMARY}</summary>`,
|
|
199
|
+
'',
|
|
200
|
+
...rest,
|
|
201
|
+
'',
|
|
202
|
+
'</details>',
|
|
203
|
+
]
|
|
204
|
+
.join('\n')
|
|
205
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
206
|
+
}
|
|
207
|
+
/** A spec title as a link to the file at the head sha. Without a repo or a head, the title alone: the path sits in Details. */
|
|
208
|
+
function specTitleLink(o, head, n) {
|
|
209
|
+
const link = fileLink(o, head, { file: n.file });
|
|
210
|
+
const url = /\]\((https?:[^)]+)\)$/.exec(link)?.[1];
|
|
211
|
+
const title = noBrackets(n.title);
|
|
212
|
+
return url ? `[${title}](${url})` : title;
|
|
213
|
+
}
|
|
214
|
+
/** The Details part that names the file of each test to update, so the default view can show the title alone. */
|
|
215
|
+
function impactNoteFiles(notes) {
|
|
216
|
+
const list = (notes ?? []).slice(0, MAX_UNTESTED);
|
|
217
|
+
return list.length ? [`**Tests to update, with files**\n\n${list.map((n) => `- ${noBrackets(n.title)}: ${code(n.file)}`).join('\n')}`] : [];
|
|
218
|
+
}
|
|
219
|
+
function tickLines(item, ticked) {
|
|
220
|
+
return [`- [${ticked.has(item.id) ? 'x' : ' '}] ${item.first} <!-- qa:t:${item.id} -->`, ...item.more.map((l) => ` ${l}`)];
|
|
221
|
+
}
|
|
222
|
+
/** The tick items for specs that a ticket changes and the PR leaves alone (KAT-304). */
|
|
223
|
+
function impactNoteItems(notes, o, head) {
|
|
224
|
+
return (notes ?? []).map((n) => {
|
|
225
|
+
const ticket = n.ticketTitle ? `${n.ticket} (${inline(n.ticketTitle)})` : n.ticket;
|
|
226
|
+
return {
|
|
227
|
+
id: n.id,
|
|
228
|
+
first: `Update the test ${specTitleLink(o, head, n)} for ${ticket}: ${inline(sentence(n.why))}`,
|
|
229
|
+
more: ['This PR does not change that test. Open it, change what it checks, then run it.'],
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** A ticket key with its title chip: "PROD-123 (Guest checkout)". The key links to the ticket when the tracker gave a URL. */
|
|
234
|
+
function ticketChip(t) {
|
|
235
|
+
const ref = noBrackets(t.ref);
|
|
236
|
+
const url = t.url && /^https?:\/\//i.test(t.url) ? t.url.replace(/[()\s]/g, (c) => encodeURIComponent(c)) : '';
|
|
237
|
+
const key = url ? `[${ref}](${url})` : ref;
|
|
238
|
+
const title = clipText(t.title, 70);
|
|
239
|
+
const shown = noBrackets(title);
|
|
240
|
+
return shown && title.toLowerCase() !== t.ref.toLowerCase() ? `${key} (${shown})` : key;
|
|
241
|
+
}
|
|
242
|
+
/** The lines of "Not covered by this PR": a criterion of a linked ticket that no changed line touches. A question, never a bug. */
|
|
243
|
+
function notCoveredLines(review) {
|
|
244
|
+
return (review.criteriaNotCovered ?? []).slice(0, MAX_BUGS).map((c) => {
|
|
245
|
+
const t = review.linkedTickets?.find((x) => x.ref === c.ticket);
|
|
246
|
+
return `${ticketChip({ ref: c.ticket, title: '', ...(t?.url ? { url: t.url } : {}) })}: "${inline(clipText(c.text, 160))}" Does another PR cover this, or is it left out on purpose?`;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Details parts for the tickets the review read, and for the ones it could not read (KAT-388). */
|
|
250
|
+
function linkedTicketParts(review) {
|
|
251
|
+
const parts = [];
|
|
252
|
+
const read = review.linkedTickets ?? [];
|
|
253
|
+
if (read.length) {
|
|
254
|
+
const lines = read.map((t) => {
|
|
255
|
+
const facts = [
|
|
256
|
+
t.state,
|
|
257
|
+
t.labels?.length ? `labels ${t.labels.map((l) => inline(l)).join(', ')}` : '',
|
|
258
|
+
t.milestone ? `milestone ${inline(t.milestone)}` : '',
|
|
259
|
+
`${t.criteria} acceptance ${t.criteria === 1 ? 'criterion' : 'criteria'}`,
|
|
260
|
+
];
|
|
261
|
+
return `- ${ticketChip(t)}: ${facts.filter(Boolean).join(', ')}.`;
|
|
262
|
+
});
|
|
263
|
+
parts.push(`**Linked tickets read**\n\n${lines.join('\n')}`);
|
|
264
|
+
}
|
|
265
|
+
const unread = review.ticketsNotRead ?? [];
|
|
266
|
+
if (unread.length)
|
|
267
|
+
parts.push(`**Linked tickets not read**\n\n${unread.map((n) => `- ${inline(n.ref)}: ${inline(n.reason)}.`).join('\n')}\n\nThe review ran without ${unread.length === 1 ? 'it' : 'them'}.`);
|
|
268
|
+
return parts;
|
|
269
|
+
}
|
|
270
|
+
function areaName(review, areaId) {
|
|
271
|
+
return review.impactAreas.find((a) => a.id === areaId)?.area ?? '';
|
|
272
|
+
}
|
|
273
|
+
export function shownRisks(review, showFrom = 'medium') {
|
|
274
|
+
return review.risks
|
|
275
|
+
.filter((r) => r.verifier.status !== 'refuted' && SEV[r.severity] >= SEV[showFrom])
|
|
276
|
+
.sort((a, b) => SEV[b.severity] - SEV[a.severity] || statusRank(a.verifier.status) - statusRank(b.verifier.status));
|
|
277
|
+
}
|
|
278
|
+
function statusRank(s) {
|
|
279
|
+
return s === 'confirmed' ? 0 : s === 'uncertain' ? 1 : s === 'unverified' ? 2 : 3;
|
|
280
|
+
}
|
|
281
|
+
export function orderedTests(review) {
|
|
282
|
+
const sevOf = (t) => Math.max(-1, ...t.riskIds.map((id) => SEV[review.risks.find((r) => r.id === id)?.severity ?? 'low']));
|
|
283
|
+
return review.whatToTest
|
|
284
|
+
.filter((t) => t.verifier.status !== 'refuted')
|
|
285
|
+
.slice()
|
|
286
|
+
.sort((a, b) => PRIORITY[a.priority] - PRIORITY[b.priority] || sevOf(b) - sevOf(a) || a.id.localeCompare(b.id, 'en', { numeric: true }));
|
|
287
|
+
}
|
|
288
|
+
function verifierPass(review) {
|
|
289
|
+
return review.passes.find((p) => p.role === 'verifier');
|
|
290
|
+
}
|
|
291
|
+
/** "verifier confirmed 2, dropped 1", "verifier unavailable" or "not verified". Details only. */
|
|
292
|
+
export function verifierLine(review) {
|
|
293
|
+
const v = verifierPass(review);
|
|
294
|
+
const all = [...review.risks, ...review.whatToTest, ...review.testsAtRisk, ...review.testGaps].map((c) => c.verifier.status);
|
|
295
|
+
const unavailable = all.length > 0 && all.every((s) => s === 'unverified');
|
|
296
|
+
if (!v)
|
|
297
|
+
return unavailable ? 'not verified' : 'verifier did not run';
|
|
298
|
+
if (unavailable)
|
|
299
|
+
return 'Verifier unavailable';
|
|
300
|
+
const confirmed = review.risks.filter((r) => r.verifier.status === 'confirmed').length;
|
|
301
|
+
const uncertain = review.risks.filter((r) => r.verifier.status === 'uncertain').length;
|
|
302
|
+
return `verifier confirmed ${confirmed}${uncertain ? `, uncertain ${uncertain}` : ''}, dropped ${review.droppedByVerifier.length}`;
|
|
303
|
+
}
|
|
304
|
+
/** The line of the PR comment and the check when a fork pull request got a review and no test run (KAT-384). */
|
|
305
|
+
export const FORK_NOT_RUN_LINE = 'Tests did not run: this PR comes from a fork. Ask a maintainer to check it again.';
|
|
306
|
+
/**
|
|
307
|
+
* The facts behind the bookkeeping lines of a draft, for the auto-reviewer (KAT-94). Counts and shas
|
|
308
|
+
* only: nothing here comes from untrusted PR, ticket, or log text.
|
|
309
|
+
*/
|
|
310
|
+
export function reviewEvidence(x) {
|
|
311
|
+
const out = [];
|
|
312
|
+
const r = x.report;
|
|
313
|
+
if (r?.notRun?.stage === 'skipped')
|
|
314
|
+
out.push(`Tests skipped: ${r.notRun.reason}`);
|
|
315
|
+
else if (r?.notRun)
|
|
316
|
+
out.push(`Tests did not run (stage ${r.notRun.stage}); there is no test report.`);
|
|
317
|
+
else if (r)
|
|
318
|
+
out.push(`Test report: ${r.passed} passed, ${r.failed} failed, ${r.flaky} flaky, ${r.skipped} skipped.${r.skipped ? ` ${plural(testsRan(r), 'test')} ran: a skipped test did not run.` : ''}`);
|
|
319
|
+
const q = x.review;
|
|
320
|
+
if (q) {
|
|
321
|
+
const risks = (st) => q.risks.filter((i) => i.verifier.status === st).length;
|
|
322
|
+
out.push(`Reviewed head ${(q.head || 'unknown').slice(0, 12)}.`);
|
|
323
|
+
out.push(`Verifier on the ${q.risks.length} risk${q.risks.length === 1 ? '' : 's'}: ${risks('confirmed')} confirmed, ${risks('uncertain')} uncertain, ${risks('unverified')} unverified; ${q.droppedByVerifier.length} refuted and dropped. "confirmed" means the verifier re-read the cited code and agreed the risk is worth testing, not that a bug was reproduced.`);
|
|
324
|
+
const c = q.coverage;
|
|
325
|
+
out.push(`Reviewer input: ${c.hunksDelivered} of ${c.hunks} changed ranges delivered, ${c.signalsDisposed} of ${c.signalsShown} signals disposed; review ${c.status}.`);
|
|
326
|
+
const ciRuns = q.ci?.jobs.length
|
|
327
|
+
? ` CI at the reviewed head: ${q.ci.jobs.map((j) => `${j.name} ${j.status === 'completed' ? (j.conclusion ?? 'completed') : j.status}`).join(', ')}; ${q.ci.passed} test lines passed, ${q.ci.failed} failed.`
|
|
328
|
+
: '';
|
|
329
|
+
if (ciRuns)
|
|
330
|
+
out.push(ciRuns.trim());
|
|
331
|
+
const notRun = q.whatToTest.filter((t) => t.result === 'not_run').length;
|
|
332
|
+
out.push(`Checklist: ${q.whatToTest.length} test${q.whatToTest.length === 1 ? '' : 's'}, ${notRun} marked not run. Test gaps are a separate list of tests nobody has written: ${q.testGaps.length}.`);
|
|
333
|
+
for (const p of q.passes)
|
|
334
|
+
out.push(`${p.role} pass: ${p.model} (${p.backend}), ${p.rounds} round${p.rounds === 1 ? '' : 's'}.`);
|
|
335
|
+
}
|
|
336
|
+
if (x.verdict)
|
|
337
|
+
out.push(`Confidence ${Math.round(x.verdict.confidence * 100)}% is the analysis model's own score for this verdict.`);
|
|
338
|
+
if (x.newerHead)
|
|
339
|
+
out.push(`A review of the newer head ${x.newerHead.slice(0, 12)} has started${x.runId ? ` in run ${x.runId}` : ''}. This draft is the previous comment with only its head line changed to say so; its other lines describe the older head.`);
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* "85 passed, 53 skipped, 0 failed": passed, skipped and failed always, flaky when there were any. "85 of 138
|
|
344
|
+
* tests passed" hid 53 skipped tests and read as 53 failures (dogfood ledger).
|
|
345
|
+
*/
|
|
346
|
+
export function testCounts(r) {
|
|
347
|
+
const parts = [`${r.passed} passed`, `${r.skipped} skipped`, `${r.failed} failed`];
|
|
348
|
+
if (r.flaky)
|
|
349
|
+
parts.push(`${r.flaky} flaky`);
|
|
350
|
+
return parts.join(', ');
|
|
351
|
+
}
|
|
352
|
+
function testsLine(report) {
|
|
353
|
+
if (!report)
|
|
354
|
+
return 'No tests ran.';
|
|
355
|
+
if (report.notRun?.stage === 'skipped')
|
|
356
|
+
return 'The tests were skipped.';
|
|
357
|
+
if (report.notRun)
|
|
358
|
+
return 'The tests did not run.';
|
|
359
|
+
return `${testCounts(report)}.`;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* What the automated tests did, in plain words, with skipped tests kept out of "ran" (KAT-402): "85 automated tests ran and
|
|
363
|
+
* passed. 53 were skipped (they do not apply to this project)." A failure or a retry keeps the counts: "3 automated tests ran:
|
|
364
|
+
* 2 passed, 1 failed." A run of skipped tests only says that all of them were skipped.
|
|
365
|
+
*/
|
|
366
|
+
function ranSentences(r) {
|
|
367
|
+
const ran = testsRan(r);
|
|
368
|
+
if (!ran) {
|
|
369
|
+
return r.skipped === 1
|
|
370
|
+
? 'The automated test was skipped (it does not apply to this project).'
|
|
371
|
+
: `All ${r.skipped} automated tests were skipped (they do not apply to this project).`;
|
|
372
|
+
}
|
|
373
|
+
const parts = [`${r.passed} passed`];
|
|
374
|
+
if (r.failed)
|
|
375
|
+
parts.push(`${r.failed} failed`);
|
|
376
|
+
if (r.flaky)
|
|
377
|
+
parts.push(`${r.flaky} passed on retry`);
|
|
378
|
+
const head = !r.failed && !r.flaky ? `${plural(ran, 'automated test')} ran and passed.` : `${plural(ran, 'automated test')} ran: ${parts.join(', ')}.`;
|
|
379
|
+
return r.skipped ? `${head} ${skippedSentence(r.skipped)}` : head;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The line of the Details under "What I tested", without its bullet. Skipped tests are not part of "ran" (KAT-402):
|
|
383
|
+
* "Ran 85 selected tests: 85 passed, 0 failed. 53 were skipped (they do not apply to this project)." A run with no skipped test
|
|
384
|
+
* keeps the full count: "Ran 3 selected tests: 3 passed, 0 skipped, 0 failed."
|
|
385
|
+
*/
|
|
386
|
+
function ranDetail(r) {
|
|
387
|
+
const ran = testsRan(r);
|
|
388
|
+
if (!r.skipped)
|
|
389
|
+
return `Ran ${plural(ran, 'selected test')}: ${testCounts(r)}.`;
|
|
390
|
+
if (!ran)
|
|
391
|
+
return `${r.skipped === 1 ? 'The selected test was' : `All ${r.skipped} selected tests were`} skipped (${r.skipped === 1 ? 'it does' : 'they do'} not apply to this project).`;
|
|
392
|
+
const parts = [`${r.passed} passed`, `${r.failed} failed`];
|
|
393
|
+
if (r.flaky)
|
|
394
|
+
parts.push(`${r.flaky} flaky`);
|
|
395
|
+
return `Ran ${plural(ran, 'selected test')}: ${parts.join(', ')}. ${skippedSentence(r.skipped)}`;
|
|
396
|
+
}
|
|
397
|
+
/** A sentence that says the tests did not run ("no tests ran", "the tests were not run"). A scoped one ("no tests ran in Safari") is a coverage note and stays. */
|
|
398
|
+
const NO_TESTS_RAN_RE = /\bno (?:automated |selected )?tests (?:ran|were run|were executed|have been run|have run)\b(?!\s+(?:in|on|for|against|under|with|across)\b(?!\s+(?:this|the|that)\s+(?:commit|pr|pull request|change|branch|head|run)\b))|\b(?:the )?tests? (?:did not|didn't|were not|was not|have not been|has not been) (?:run|ran|been run|executed)\b(?!\s+(?:in|on|for|against|under|with|across)\b(?!\s+(?:this|the|that)\s+(?:commit|pr|pull request|change|branch|head|run)\b))/i;
|
|
399
|
+
/** The line of a Tests repo batch (testRepoSentence): the ran count, the counts that ran, and the skipped sentence when there is one. */
|
|
400
|
+
const TEST_REPO_LINE_RE = /\bRan (\d+) (?:[^\n.:]*? )?tests? from [^\n:]+: ([^\n]*?)\.(?: (\d+) (?:was|were) skipped \([^)\n]*\)\.)?(?= |$|\n)/g;
|
|
401
|
+
const QUOTE_PAIRS = { '"': '"', "'": "'", '`': '`', '\u201c': '\u201d', '\u2018': '\u2019' };
|
|
402
|
+
const QUOTE_CLOSERS = new Set(Object.values(QUOTE_PAIRS));
|
|
403
|
+
const MASK_OPEN = '\uE000';
|
|
404
|
+
const MASK_CLOSE = '\uE001';
|
|
405
|
+
/** Quoted text and link texts are not the dot's own claim (KAT-337). Each such span becomes a placeholder; `unmask` puts it back. */
|
|
406
|
+
function maskQuoted(text) {
|
|
407
|
+
const spans = [];
|
|
408
|
+
const hold = (span) => {
|
|
409
|
+
spans.push(span);
|
|
410
|
+
return `${MASK_OPEN}${spans.length - 1}${MASK_CLOSE}`;
|
|
411
|
+
};
|
|
412
|
+
let out = text;
|
|
413
|
+
out = out.replace(/\[[^\]\n]*\]\([^)\n]*\)/g, hold);
|
|
414
|
+
// A test-plan row (`- `t2` should ...`) describes a check to run, and "claims that no tests ran" names a claim the change removes: neither says the tests did not run.
|
|
415
|
+
out = out.replace(/^[ \t]*[-*] `t\d+` should\b.*$/gm, hold);
|
|
416
|
+
out = out.replace(/(\bclaims?\s+(?:that\s+)?)(no\b[^.;\n]*)/gi, (_m, pre, claim) => pre + hold(claim));
|
|
417
|
+
// Quotes: an opener follows a space or an open bracket and precedes a word; a closer follows a word or punctuation and precedes a space or a close bracket. Nested quotes are counted.
|
|
418
|
+
const chars = [...out];
|
|
419
|
+
const res = [];
|
|
420
|
+
let depth = 0;
|
|
421
|
+
let start = -1;
|
|
422
|
+
const stack = [];
|
|
423
|
+
for (let i = 0; i < chars.length; i++) {
|
|
424
|
+
const c = chars[i];
|
|
425
|
+
const prev = i ? chars[i - 1] : ' ';
|
|
426
|
+
const next = i + 1 < chars.length ? chars[i + 1] : ' ';
|
|
427
|
+
const isOpenCh = c in QUOTE_PAIRS;
|
|
428
|
+
const isCloseCh = QUOTE_CLOSERS.has(c);
|
|
429
|
+
// Markdown emphasis (**, _, ~) around a quote counts as a boundary, so **"no tests ran"** is a quote too.
|
|
430
|
+
const prevOpens = /[\s([{:;,>*_~-]/.test(prev) || prev === MASK_CLOSE;
|
|
431
|
+
const nextCloses = /[\s)\]}.,:;!?<*_~-]/.test(next);
|
|
432
|
+
const opens = isOpenCh && (c === '\u201c' || c === '\u2018' || (prevOpens && !/\s/.test(next)));
|
|
433
|
+
const closes = isCloseCh && (c === '\u201d' || c === '\u2019' || !/\s/.test(prev)) && nextCloses && stack.length > 0;
|
|
434
|
+
if (depth > 0 && closes && QUOTE_PAIRS[stack[stack.length - 1]] === c) {
|
|
435
|
+
stack.pop();
|
|
436
|
+
depth--;
|
|
437
|
+
if (depth === 0) {
|
|
438
|
+
res.push(hold(chars.slice(start, i + 1).join('')));
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (opens) {
|
|
443
|
+
if (depth === 0)
|
|
444
|
+
start = i;
|
|
445
|
+
stack.push(c);
|
|
446
|
+
depth++;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (depth === 0)
|
|
450
|
+
res.push(c);
|
|
451
|
+
}
|
|
452
|
+
// An unclosed quote is plain text.
|
|
453
|
+
if (depth > 0)
|
|
454
|
+
res.push(...chars.slice(start));
|
|
455
|
+
const unmask = (t) => {
|
|
456
|
+
let cur = t;
|
|
457
|
+
for (let k = 0; k < 5 && cur.includes(MASK_OPEN); k++)
|
|
458
|
+
cur = cur.replace(new RegExp(`${MASK_OPEN}(\\d+)${MASK_CLOSE}`, 'g'), (_m, n) => spans[Number(n)] ?? '');
|
|
459
|
+
return cur;
|
|
460
|
+
};
|
|
461
|
+
return { masked: res.join(''), unmask };
|
|
462
|
+
}
|
|
463
|
+
/** True when the text says the tests did not run in the dot's own voice: quoted text and link texts do not count. */
|
|
464
|
+
export function claimsNoTestsRan(text) {
|
|
465
|
+
return NO_TESTS_RAN_RE.test(maskQuoted(text).masked);
|
|
466
|
+
}
|
|
467
|
+
/** True when the run has results: tests ran, and the report lists them. */
|
|
468
|
+
export function testsHaveResults(report) {
|
|
469
|
+
return !!report && !report.notRun && report.passed + report.failed + report.flaky + report.skipped > 0;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Test facts come from one source: the run's results (KAT-330). The review text is written before the run,
|
|
473
|
+
* so once the run has results, any sentence or clause that says the tests did not run is dropped.
|
|
474
|
+
* A run with no results keeps the text as it is.
|
|
475
|
+
*/
|
|
476
|
+
export function withoutNoTestsClaims(text, report) {
|
|
477
|
+
if (!testsHaveResults(report))
|
|
478
|
+
return text;
|
|
479
|
+
const { masked, unmask } = maskQuoted(text);
|
|
480
|
+
if (!NO_TESTS_RAN_RE.test(masked))
|
|
481
|
+
return text;
|
|
482
|
+
return unmask(masked
|
|
483
|
+
.split('\n')
|
|
484
|
+
.map((line) => NO_TESTS_RAN_RE.test(line)
|
|
485
|
+
? line
|
|
486
|
+
.split(/(?<=[.!?])\s+/)
|
|
487
|
+
.map((sent) => {
|
|
488
|
+
if (!NO_TESTS_RAN_RE.test(sent))
|
|
489
|
+
return sent;
|
|
490
|
+
const kept = sent
|
|
491
|
+
.replace(/[.!?]+$/, '')
|
|
492
|
+
.split(/\s*[;:]\s+/)
|
|
493
|
+
.filter((c) => !NO_TESTS_RAN_RE.test(c));
|
|
494
|
+
return kept.length ? `${kept.join('; ')}.` : '';
|
|
495
|
+
})
|
|
496
|
+
.filter(Boolean)
|
|
497
|
+
.join(' ')
|
|
498
|
+
: line)
|
|
499
|
+
.filter((line, i, all) => line.trim() !== '' || (i > 0 && all[i - 1].trim() !== ''))
|
|
500
|
+
.filter((line) => !/^\s*[-*]\s*$/.test(line))
|
|
501
|
+
.join('\n')
|
|
502
|
+
.trim());
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* A sentence in the model's own words that gives a test count the run results already give: "Ran 138 tests from the e2e suite:
|
|
506
|
+
* 85 passed, 53 skipped." or "All 12 automated tests ran against staging." The counts have one source, the run (KAT-402), and
|
|
507
|
+
* the report check reads the counts line, never the model's prose. The wording is the same wording reportConsistency reads.
|
|
508
|
+
*/
|
|
509
|
+
const MODEL_TEST_COUNT_RE = [
|
|
510
|
+
/\bRan \d+ (?:[^\n.:]*? )?tests? from [^\n:]+:/,
|
|
511
|
+
/\b\d+ automated tests? ran\b/,
|
|
512
|
+
/\bRan \d+ selected tests?:/,
|
|
513
|
+
/\b\d+ (?:was|were) skipped \(/,
|
|
514
|
+
];
|
|
515
|
+
/** Drops the sentences of model prose that restate the run's test counts, once the run has results. Other sentences stay as written. */
|
|
516
|
+
export function withoutTestCountClaims(text, report) {
|
|
517
|
+
if (!testsHaveResults(report))
|
|
518
|
+
return text;
|
|
519
|
+
const { masked, unmask } = maskQuoted(text);
|
|
520
|
+
const says = (t) => MODEL_TEST_COUNT_RE.some((re) => re.test(t));
|
|
521
|
+
if (!says(masked))
|
|
522
|
+
return text;
|
|
523
|
+
return unmask(masked
|
|
524
|
+
.split('\n')
|
|
525
|
+
.map((line) => says(line)
|
|
526
|
+
? line
|
|
527
|
+
.split(/(?<=[.!?])\s+/)
|
|
528
|
+
.filter((sent) => !says(sent))
|
|
529
|
+
.join(' ')
|
|
530
|
+
: line)
|
|
531
|
+
.filter((line, i, all) => line.trim() !== '' || (i > 0 && all[i - 1].trim() !== ''))
|
|
532
|
+
.filter((line) => !/^\s*[-*]\s*$/.test(line))
|
|
533
|
+
.join('\n')
|
|
534
|
+
.trim());
|
|
535
|
+
}
|
|
536
|
+
function more(n, what) {
|
|
537
|
+
return n > 0 ? `And ${n} more ${what} in the run.` : '';
|
|
538
|
+
}
|
|
539
|
+
/** Ends a model sentence with a full stop, so one-line items read as sentences. */
|
|
540
|
+
function sentence(s) {
|
|
541
|
+
const t = s.trim();
|
|
542
|
+
return !t || /[.!?:]$/.test(t) ? t : `${t}.`;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Model scenarios written before KAT-96 often open with "Verify" or "Ensure". A tester reads
|
|
546
|
+
* "Check" faster, so the lead word changes; the rest of the sentence stays as written.
|
|
547
|
+
*/
|
|
548
|
+
export function plainScenario(s) {
|
|
549
|
+
return s.trim().replace(/^(?:verify|ensure|validate|confirm)(?:\s+that)?\s+(\S)/i, (_m, c) => `Check ${c}`);
|
|
550
|
+
}
|
|
551
|
+
/** A test idea in a tester's verbs: "assert the total" is "check the total", and the ticket says what is wanted, not "the spec" (KAT-377). */
|
|
552
|
+
export function testerIdea(s) {
|
|
553
|
+
return plainScenario(s)
|
|
554
|
+
.replace(/^asserts?\b/i, 'Check')
|
|
555
|
+
.replace(/\basserts?\b/gi, (w) => (w.toLowerCase() === 'asserts' ? 'checks' : 'check'))
|
|
556
|
+
.replace(/\bthe spec\b/gi, 'the ticket');
|
|
557
|
+
}
|
|
558
|
+
// ---------- file links ----------
|
|
559
|
+
function basename(p) {
|
|
560
|
+
return p.split('/').pop() || p;
|
|
561
|
+
}
|
|
562
|
+
/** A file reference as a GitHub link at the head sha, or a code span when there is no repo. */
|
|
563
|
+
export function fileLink(o, head, ref) {
|
|
564
|
+
const lines = ref.line && ref.line > 0 ? `:${ref.line}${ref.lineEnd && ref.lineEnd !== ref.line ? `-${ref.lineEnd}` : ''}` : '';
|
|
565
|
+
// A spec of a Tests repo links to that repo at the commit the tests ran at; without the commit it is a code span, never a link into the app repo.
|
|
566
|
+
const inTests = splitTestRepoFile(ref.file);
|
|
567
|
+
if (inTests.repo) {
|
|
568
|
+
const ran = (o.testRepos ?? o.report?.testRepos)?.find((t) => t.repo === inTests.repo && t.sha);
|
|
569
|
+
if (!ran)
|
|
570
|
+
return code(`${ref.file}${lines}`);
|
|
571
|
+
const base = (o.serverUrl ?? 'https://github.com').replace(/\/$/, '');
|
|
572
|
+
const path = clean(inTests.path)
|
|
573
|
+
.split('/')
|
|
574
|
+
.map((x) => encodeURIComponent(x))
|
|
575
|
+
.join('/');
|
|
576
|
+
const anchor = ref.line && ref.line > 0 ? `#L${ref.line}${ref.lineEnd && ref.lineEnd !== ref.line ? `-L${ref.lineEnd}` : ''}` : '';
|
|
577
|
+
return `[${code(`${basename(inTests.path)}${lines}`)}](${base}/${inTests.repo.split('/').map(encodeURIComponent).join('/')}/blob/${ran.sha}/${path}${anchor})`;
|
|
578
|
+
}
|
|
579
|
+
if (!o.repo || !head || ref.file === 'PR_BODY' || !/^[0-9a-f]{7,40}$/i.test(head))
|
|
580
|
+
return code(`${ref.file === 'PR_BODY' ? 'PR description' : ref.file}${lines}`);
|
|
581
|
+
const base = (o.serverUrl ?? 'https://github.com').replace(/\/$/, '');
|
|
582
|
+
const path = clean(ref.file)
|
|
583
|
+
.split('/')
|
|
584
|
+
.map((s) => encodeURIComponent(s))
|
|
585
|
+
.join('/');
|
|
586
|
+
const anchor = ref.line && ref.line > 0 ? `#L${ref.line}${ref.lineEnd && ref.lineEnd !== ref.line ? `-L${ref.lineEnd}` : ''}` : '';
|
|
587
|
+
const url = `${base}/${encodeURIComponent(o.repo.owner)}/${encodeURIComponent(o.repo.name)}/blob/${head}/${path}${anchor}`;
|
|
588
|
+
return `[${code(`${basename(ref.file)}${lines}`)}](${url})`;
|
|
589
|
+
}
|
|
590
|
+
/** Where a checklist item points: its existing test, else its first risk's line, else its area's first file. */
|
|
591
|
+
function itemRef(review, t) {
|
|
592
|
+
if (t.existingTest)
|
|
593
|
+
return { file: t.existingTest };
|
|
594
|
+
for (const id of t.riskIds) {
|
|
595
|
+
const ev = review.risks.find((r) => r.id === id)?.evidence[0];
|
|
596
|
+
if (ev)
|
|
597
|
+
return ev;
|
|
598
|
+
}
|
|
599
|
+
const file = review.impactAreas.find((a) => a.id === t.areaId)?.files[0];
|
|
600
|
+
return file ? { file } : null;
|
|
601
|
+
}
|
|
602
|
+
const SEV_WORD = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low' };
|
|
603
|
+
export function clipText(s, n) {
|
|
604
|
+
const t = clean(s).replace(/\s+/g, ' ').trim();
|
|
605
|
+
return t.length > n ? `${t.slice(0, n - 3).trimEnd()}...` : t;
|
|
606
|
+
}
|
|
607
|
+
function hasPassingTest(review, r) {
|
|
608
|
+
return review.whatToTest.some((t) => t.riskIds.includes(r.id) && t.result === 'passed');
|
|
609
|
+
}
|
|
610
|
+
/** The label a defect found by reading the code carries in the report, the Inbox card and the tracker. */
|
|
611
|
+
export const READING_LABEL = 'found by reading the code';
|
|
612
|
+
/** Words that say "some input might break it" without naming one. */
|
|
613
|
+
const VAGUE_INPUT = /\b(?:some|certain|various|several|many|other|any|unusual|unexpected|unknown|invalid|bad|wrong|malformed|odd|edge|corner)[- ](?:inputs?|cases?|values?|data|conditions?|scenarios?|situations?|combinations?|requests?|users?)\b|\b(?:might|could|possibly|potentially|perhaps|unclear|not sure|n\/a|tbd)\b|^(?:none|unknown|various|anything|n\/a|tbd)\b/i;
|
|
614
|
+
/** "may" as a hedge, never the month: "may fail" is vague, "May 31, 2026" names an input. */
|
|
615
|
+
const HEDGE_MAY = /\bmay\b(?!\s+\d)|\bMay\b(?!\s+\d)(?=\s+(?:be|break|fail|happen|cause|not|return|throw|crash|lose|show)\b)/;
|
|
616
|
+
/** A value a tester can type: a digit, a quoted or bracketed literal, a code span, or a named literal such as empty or null. */
|
|
617
|
+
const INPUT_VALUE = /\d|["'`[\]{}()=<>]|\b(?:empty|blank|null|undefined|zero|negative|nan|true|false|missing|duplicate|twice|same)\b/i;
|
|
618
|
+
/**
|
|
619
|
+
* True when a failing input names something a tester can try: "a cart with two items and the coupon SAVE10 twice",
|
|
620
|
+
* "an empty list". "Some edge cases" and "unexpected values" are vague, not concrete.
|
|
621
|
+
*/
|
|
622
|
+
export function concreteInput(input) {
|
|
623
|
+
const t = clean(input ?? '')
|
|
624
|
+
.replace(/\s+/g, ' ')
|
|
625
|
+
.trim();
|
|
626
|
+
return t.length >= 6 && !VAGUE_INPUT.test(t) && !HEDGE_MAY.test(t) && INPUT_VALUE.test(t);
|
|
627
|
+
}
|
|
628
|
+
/** The line a risk cites, when it is a real file and line (not the PR description). */
|
|
629
|
+
function citedLine(r) {
|
|
630
|
+
const e = r.evidence[0];
|
|
631
|
+
return e && e.file && e.file !== 'PR_BODY' && Number.isInteger(e.line) && e.line > 0 ? e : null;
|
|
632
|
+
}
|
|
633
|
+
export function bugKey(parts) {
|
|
634
|
+
return createHash('sha1')
|
|
635
|
+
.update(parts.map((p) => normalizeScenario(p)).join('\n'))
|
|
636
|
+
.digest('hex')
|
|
637
|
+
.slice(0, 10);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* The same bug across runs, PRs and merges: no PR or merge scope in it, unlike bugKey (dogfood ledger: the same
|
|
641
|
+
* defect filed again from a later PR or commit). A failing test by file and title; a review finding by its title
|
|
642
|
+
* and the file it cites.
|
|
643
|
+
*/
|
|
644
|
+
export function bugSignature(bug) {
|
|
645
|
+
const parts = bug.test ? ['test', bug.test.file, bug.test.title] : ['review', bug.title, bug.ref?.file ?? ''];
|
|
646
|
+
// The same test or finding in two repos of one dot is two bugs. No repo (a single-repo dot) keeps the older signature.
|
|
647
|
+
if (bug.repo)
|
|
648
|
+
parts.push(bug.repo.toLowerCase());
|
|
649
|
+
return createHash('sha256')
|
|
650
|
+
.update(parts.map((p) => normalizeScenario(p)).join('\n'))
|
|
651
|
+
.digest('hex')
|
|
652
|
+
.slice(0, 12);
|
|
653
|
+
}
|
|
654
|
+
/** The signatures of a bug in the other repo shapes (KAT-326), for a lookup that must survive a repo added or removed. */
|
|
655
|
+
export function bugSignatureAlts(bug, altRepos) {
|
|
656
|
+
return altRepos.map((a) => bugSignature({ ...bug, repo: a ?? undefined }));
|
|
657
|
+
}
|
|
658
|
+
/** Playwright errors carry "Expected: ..." and "Received: ..." lines; they are the expected and actual values. */
|
|
659
|
+
function expectedActual(error) {
|
|
660
|
+
// Playwright colors the values in the error text; the color codes are not part of them.
|
|
661
|
+
const e = clean(String(error ?? '').replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ''));
|
|
662
|
+
const exp = /Expected[^:\n]*:\s*(.+)/.exec(e)?.[1];
|
|
663
|
+
const rec = /(?:Received|Actual)[^:\n]*:\s*(.+)/.exec(e)?.[1];
|
|
664
|
+
const first = e.split('\n').find((l) => l.trim()) ?? 'The test failed.';
|
|
665
|
+
return { expected: clipText(exp ?? 'The test passes.', 160), actual: clipText(rec ?? first, 160) };
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* True when the runner cases of a check failed and every one of them shows an environment signature. A check is a repo
|
|
669
|
+
* spec, a generated Playwright spec or an AI-Vision case: each has its own runner case, found by file (KAT-325).
|
|
670
|
+
*/
|
|
671
|
+
function failedOnlyByEnvironment(item, report, ctx) {
|
|
672
|
+
if (!report)
|
|
673
|
+
return false;
|
|
674
|
+
const { existingTest, id } = item;
|
|
675
|
+
const generated = `pwgen/${safeName(id, 40)}.spec.ts`;
|
|
676
|
+
const aiVision = `aivision/${id}.yaml`;
|
|
677
|
+
const failed = report.cases.filter((c) => (c.status === 'failed' || c.status === 'timedOut') &&
|
|
678
|
+
((!!existingTest && (c.file.endsWith(existingTest) || existingTest.endsWith(c.file))) || c.file === generated || c.file === aiVision));
|
|
679
|
+
return failed.length > 0 && failed.every((c) => environmentSignature(c, ctx));
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Bugs, most severe first: every failed test case, then every confirmed risk at or above showFrom
|
|
683
|
+
* that no passing test clears. `scope` keeps keys apart per PR (or per merge sha).
|
|
684
|
+
*/
|
|
685
|
+
export function findBugs(review, report, o = {}) {
|
|
686
|
+
const scope = o.scope ?? '';
|
|
687
|
+
const repo = o.repo?.toLowerCase();
|
|
688
|
+
const repoPart = repo ? [repo] : [];
|
|
689
|
+
const altOf = (parts) => {
|
|
690
|
+
const keys = (o.altRepos ?? []).map((a) => bugKey(parts(a ? [a.toLowerCase()] : [])));
|
|
691
|
+
return keys.length ? { altKeys: keys } : {};
|
|
692
|
+
};
|
|
693
|
+
const out = [];
|
|
694
|
+
// A failure the verdict blames on the test or the environment is not a product bug: no defect for it.
|
|
695
|
+
const testBugs = o.outcome !== 'test_failure' && o.outcome !== 'environment_failure';
|
|
696
|
+
// An environment signature (full disk, cancelled job, load timeout, a new test's first run) is never a
|
|
697
|
+
// product bug, whatever the verdict model said (dogfood ledger).
|
|
698
|
+
const envCtx = { addedFiles: o.addedFiles, ...o.environment };
|
|
699
|
+
const failed = (testBugs ? (report?.cases ?? []) : []).filter((x) => (x.status === 'failed' || x.status === 'timedOut') && !environmentSignature(x, envCtx));
|
|
700
|
+
for (const c of failed.slice(0, 10)) {
|
|
701
|
+
const linked = review?.whatToTest.find((t) => t.existingTest && (c.file.endsWith(t.existingTest) || t.existingTest.endsWith(c.file)));
|
|
702
|
+
const ea = expectedActual(c.error);
|
|
703
|
+
// A connection or load-timeout failure the default branch does not show may be the change's own doing: say so.
|
|
704
|
+
const unanswered = appDidNotAnswer(c, envCtx);
|
|
705
|
+
out.push({
|
|
706
|
+
key: bugKey([scope, ...repoPart, 'test', c.file, c.title]),
|
|
707
|
+
...altOf((r) => [scope, ...r, 'test', c.file, c.title]),
|
|
708
|
+
...(repo ? { repo } : {}),
|
|
709
|
+
// An older build fails a new spec of the PR as well as a bad change does: say where it failed, and rank it as a risk.
|
|
710
|
+
title: `"${clipText(c.title, 80)}" ${o.sharedBuild ? 'failed against the shared QA build' : 'fails'}`,
|
|
711
|
+
severity: o.sharedBuild ? 'medium' : 'high',
|
|
712
|
+
source: 'test',
|
|
713
|
+
steps: linked?.steps?.length ? linked.steps : c.steps?.length ? c.steps : [`Run ${c.file}${c.title ? ` › "${clipText(c.title, 80)}"` : ''}.`],
|
|
714
|
+
...(linked?.steps?.length || c.steps?.length ? {} : { runOnly: true }),
|
|
715
|
+
expected: linked?.expected ?? ea.expected,
|
|
716
|
+
actual: unanswered ? clipText(`${unanswered[0].toUpperCase()}${unanswered.slice(1)}. ${ea.actual}`, 200) : ea.actual,
|
|
717
|
+
ref: { file: c.file },
|
|
718
|
+
test: { file: c.file, title: c.title },
|
|
719
|
+
reproduced: !o.sharedBuild,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (review) {
|
|
723
|
+
// A security and robustness risk is a risk to test: it becomes a bug only through a failed test, listed above.
|
|
724
|
+
for (const r of shownRisks(review, o.showFrom ?? 'medium').filter((x) => x.verifier.status === 'confirmed' && !hasPassingTest(review, x) && !isSecurityPassRisk(x))) {
|
|
725
|
+
const t = orderedTests(review).find((x) => x.riskIds.includes(r.id) && (x.steps?.length || x.expected));
|
|
726
|
+
// A linked check that failed (a spec, a generated test, an AI-Vision step, a CI job) reproduces the risk.
|
|
727
|
+
// An environment failure (a full disk, a cancelled job) never reproduces a risk (KAT-321). A check the dot ran against the
|
|
728
|
+
// shared build does not either (KAT-363): only the PR's own CI does.
|
|
729
|
+
const reproduced = o.outcome !== 'environment_failure' &&
|
|
730
|
+
review.whatToTest.some((x) => x.riskIds.includes(r.id) && x.result === 'failed' && !(o.sharedBuild && x.resultSource === 'runner') && !failedOnlyByEnvironment(x, report, envCtx));
|
|
731
|
+
// Not reproduced, but one cited line and one input that breaks it: a code-path risk, filed as a defect.
|
|
732
|
+
const byReading = !reproduced && o.outcome !== 'environment_failure' && r.kind !== 'prompt_injection' && !!citedLine(r) && concreteInput(r.failingInput);
|
|
733
|
+
out.push({
|
|
734
|
+
reproduced,
|
|
735
|
+
...(byReading ? { foundByReading: true, failingInput: clipText(r.failingInput ?? '', 200) } : {}),
|
|
736
|
+
key: bugKey([scope, ...repoPart, 'risk', r.title, r.evidence[0]?.file ?? '']),
|
|
737
|
+
...altOf((p) => [scope, ...p, 'risk', r.title, r.evidence[0]?.file ?? '']),
|
|
738
|
+
...(repo ? { repo } : {}),
|
|
739
|
+
title: r.title,
|
|
740
|
+
severity: r.verifier.severity ?? r.severity,
|
|
741
|
+
source: 'review',
|
|
742
|
+
steps: t?.steps?.length ? t.steps : t ? [t.scenario] : [],
|
|
743
|
+
expected: t?.expected ?? '',
|
|
744
|
+
actual: r.userImpact,
|
|
745
|
+
why: r.why,
|
|
746
|
+
ref: r.evidence[0],
|
|
747
|
+
riskId: r.id,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
function defectLink(bug, links) {
|
|
754
|
+
const d = links?.[bug.key];
|
|
755
|
+
if (!d)
|
|
756
|
+
return '';
|
|
757
|
+
if (d.state === 'filed')
|
|
758
|
+
return d.url ? ` Defect: [filed](${d.url}).` : ' Defect: filed.';
|
|
759
|
+
if (d.state === 'rejected')
|
|
760
|
+
return ' Defect: not filed (rejected in the Inbox).';
|
|
761
|
+
const drafted = d.where ? `drafted for ${d.where}` : 'drafted';
|
|
762
|
+
return d.url ? ` Defect: [${drafted}, waiting in the Inbox](${d.url}).` : ` Defect: ${drafted}, waiting in the localdost Inbox.`;
|
|
763
|
+
}
|
|
764
|
+
function bugLines(bug, head, o) {
|
|
765
|
+
const reading = bug.foundByReading ? ` Found by reading the code, not reproduced (lower confidence).` : '';
|
|
766
|
+
const out = [`- **${inline(sentence(bug.title))}** ${SEV_WORD[bug.severity]}.${reading}${defectLink(bug, o.defects)}`];
|
|
767
|
+
if (bug.steps.length)
|
|
768
|
+
out.push(` - Steps: ${bug.steps
|
|
769
|
+
.slice(0, 4)
|
|
770
|
+
.map((s, i) => `${i + 1}) ${inline(clipText(s, 110)).replace(/[.;]+$/, '')}`)
|
|
771
|
+
.join('; ')}.`);
|
|
772
|
+
const ea = [bug.expected ? `Expected: ${sentence(inline(bug.expected))}` : '', bug.actual ? `Actual: ${sentence(inline(bug.actual))}` : ''].filter(Boolean);
|
|
773
|
+
if (ea.length)
|
|
774
|
+
out.push(` - ${ea.join(' ')}`);
|
|
775
|
+
if (bug.foundByReading && bug.failingInput)
|
|
776
|
+
out.push(` - Input that breaks it: ${inline(sentence(bug.failingInput))}`);
|
|
777
|
+
const ev = [];
|
|
778
|
+
if (bug.test)
|
|
779
|
+
ev.push(`failing test ${fileLink(o, head, { file: bug.test.file })}`);
|
|
780
|
+
else if (bug.ref)
|
|
781
|
+
ev.push(`the code at ${fileLink(o, head, bug.ref)}`);
|
|
782
|
+
if (o.runUrl)
|
|
783
|
+
ev.push(`[the run](${o.runUrl}) with logs${bug.test ? ', screenshots and traces' : ''}`);
|
|
784
|
+
if (ev.length)
|
|
785
|
+
out.push(` - Evidence: ${ev.join(', ')}.`);
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* The PR as the defect names it: `#12` in the repo the defect is filed in, `owner/name#12` in any other, so a defect
|
|
790
|
+
* filed in a shared tracker repo points at the PR of the code repo and not at issue 12 of its own repo (KAT-355).
|
|
791
|
+
*/
|
|
792
|
+
export function prRefIn(prNumber, repo, filedIn) {
|
|
793
|
+
if (!repo || !filedIn || filedIn.toLowerCase() === `${repo.owner}/${repo.name}`.toLowerCase())
|
|
794
|
+
return `#${prNumber}`;
|
|
795
|
+
return `${repo.owner}/${repo.name}#${prNumber}`;
|
|
796
|
+
}
|
|
797
|
+
/** Does the tracker show the For developers fold? GitHub renders <details>; Jira gets it as an expand node (markdownToAdf); Linear shows the tags as text. */
|
|
798
|
+
export const foldsDefect = (tracker) => tracker !== 'linear';
|
|
799
|
+
/** The label a defect gets when no one wrote steps a person can follow. */
|
|
800
|
+
export const NEEDS_MANUAL_STEPS_LABEL = 'needs-manual-steps';
|
|
801
|
+
const DEFAULT_EXPECTED = 'The test passes.';
|
|
802
|
+
const unquote = (s) => s.replace(/^(["'`])(.*)\1$/, '$2').trim();
|
|
803
|
+
/** The two values a failed assertion compared, when both are short enough to read in a title. Null for a thrown error or a timeout. */
|
|
804
|
+
function defectValues(bug) {
|
|
805
|
+
const expected = unquote(clean(bug.expected).trim());
|
|
806
|
+
const actual = unquote(clean(bug.actual).trim());
|
|
807
|
+
if (!expected || !actual || expected === DEFAULT_EXPECTED)
|
|
808
|
+
return null;
|
|
809
|
+
if (expected.length > 40 || actual.length > 40 || /\n/.test(`${expected}${actual}`))
|
|
810
|
+
return null;
|
|
811
|
+
if (/\bexpect\(|^\w*Error\b|\btimeout\b|\bwaiting for\b|\bstrict mode\b/i.test(`${expected} ${actual}`))
|
|
812
|
+
return null;
|
|
813
|
+
return { expected, actual };
|
|
814
|
+
}
|
|
815
|
+
const upperFirst = (s) => (s ? `${s[0].toUpperCase()}${s.slice(1)}` : s);
|
|
816
|
+
/** What the reader sees in a tracker list: what went wrong, with both values when the test compared two. */
|
|
817
|
+
export function defectTitle(bug) {
|
|
818
|
+
if (bug.source === 'test' && bug.test) {
|
|
819
|
+
const name = upperFirst(clipText(bug.test.title.replace(/^\[[^\]]+\]\s*/, '').replace(/\s*›\s*/g, ' › '), 80));
|
|
820
|
+
const v = defectValues(bug);
|
|
821
|
+
return clipText(v ? `${name}: ${v.actual} instead of ${v.expected}` : `${name} fails`, 140);
|
|
822
|
+
}
|
|
823
|
+
return clipText(upperFirst(clean(bug.title)), 140);
|
|
824
|
+
}
|
|
825
|
+
/** The command a developer runs to see the test fail. */
|
|
826
|
+
function runCommand(test) {
|
|
827
|
+
const name = test.title.replace(/^\[[^\]]+\]\s*/, '').replace(/\s*›\s*/g, ' ');
|
|
828
|
+
const pattern = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/"/g, '\\"');
|
|
829
|
+
return `npx playwright test ${splitTestRepoFile(test.file).path} -g "${pattern}"`;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* The defect body for the tracker, for a person who reads the issue first and the code later (KAT-377): what it costs
|
|
833
|
+
* and when it was found, the severity, steps to reproduce, expected and actual, the evidence, the likely cause, and what
|
|
834
|
+
* it relates to. The run command, the error text, the commit and the bug signature sit under For developers. When the test
|
|
835
|
+
* names no steps and no test case has any, the steps say to run the automated test and `needsManualSteps` is true, so the
|
|
836
|
+
* caller labels the issue.
|
|
837
|
+
*/
|
|
838
|
+
export function renderDefect(bug, o) {
|
|
839
|
+
const reading = !!bug.foundByReading;
|
|
840
|
+
const values = bug.source === 'test' ? defectValues(bug) : null;
|
|
841
|
+
// A connection or load failure on a change that touches app code says the change may be the cause (KAT-308).
|
|
842
|
+
const note = `${APP_DID_NOT_ANSWER[0].toUpperCase()}${APP_DID_NOT_ANSWER.slice(1)}.`;
|
|
843
|
+
const unanswered = bug.source === 'test' && bug.actual.startsWith(note) ? note : '';
|
|
844
|
+
const pr = o.prNumber ? prRefIn(o.prNumber, o.repo, o.filedIn) : '';
|
|
845
|
+
// The first line says what this costs a person. Expected and Actual below hold the two values, so they are not said again here.
|
|
846
|
+
const feature = clipText((bug.test?.title ?? bug.title)
|
|
847
|
+
.replace(/^\[[^\]]+\]\s*/, '')
|
|
848
|
+
.split(/\s*›\s*/)
|
|
849
|
+
.pop() ?? '', 80);
|
|
850
|
+
const impact = bug.source === 'test'
|
|
851
|
+
? values
|
|
852
|
+
? `People who use "${inline(feature)}" get a wrong result.`
|
|
853
|
+
: `"${inline(feature)}" does not work.${unanswered ? ` ${unanswered}` : ''}`
|
|
854
|
+
: inline(sentence(bug.actual || bug.title));
|
|
855
|
+
const found = reading
|
|
856
|
+
? 'Found by reading the code, not by running it.'
|
|
857
|
+
: pr
|
|
858
|
+
? o.task === 'post_merge'
|
|
859
|
+
? `Found after PR ${pr} merged.`
|
|
860
|
+
: `Found while ${bug.source === 'review' ? 'reviewing' : 'testing'} PR ${pr}.`
|
|
861
|
+
: bug.source === 'review'
|
|
862
|
+
? 'Found by reading the change.'
|
|
863
|
+
: 'Found in a test run.';
|
|
864
|
+
const lines = [`${impact} ${found}`, ''];
|
|
865
|
+
const attempts = o.attempts && o.attempts > 0 ? ` Failed ${o.attempts} of ${o.attempts} times, so it is not flaky.` : '';
|
|
866
|
+
lines.push(`Severity: ${SEV_WORD[bug.severity]}.${attempts}`, '');
|
|
867
|
+
if (reading)
|
|
868
|
+
lines.push('Confidence: lower. Nobody ran this. It comes from reading the code.', '');
|
|
869
|
+
// Steps a person can follow: the test's own step names, a review's steps, or the steps of the linked test case.
|
|
870
|
+
let needsManualSteps = false;
|
|
871
|
+
const steps = bug.runOnly ? (o.testCase?.steps.length ? o.testCase.steps : []) : bug.steps;
|
|
872
|
+
if (steps.length)
|
|
873
|
+
lines.push('**Steps to reproduce**', '', ...steps.slice(0, 8).map((x, i) => `${i + 1}. ${inline(x)}`), '');
|
|
874
|
+
else if (bug.runOnly) {
|
|
875
|
+
needsManualSteps = true;
|
|
876
|
+
lines.push('**Steps to reproduce**', '', 'Run the automated test in For developers.', '');
|
|
877
|
+
}
|
|
878
|
+
// A failed test with no two values has only an error text, which is for developers.
|
|
879
|
+
const showValues = bug.source !== 'test' || !!values;
|
|
880
|
+
// Two short values read as values ("Expected: $108.00"); a review writes sentences.
|
|
881
|
+
const say = (x) => (values ? inline(x) : inline(sentence(x)));
|
|
882
|
+
// One paragraph per fact: a renderer that joins single line breaks (the Inbox, Linear) keeps them apart, and Jira gets one paragraph each.
|
|
883
|
+
if (showValues && bug.expected)
|
|
884
|
+
lines.push(`Expected: ${say(values?.expected ?? bug.expected)}`, '');
|
|
885
|
+
if (showValues && bug.actual)
|
|
886
|
+
lines.push(`Actual: ${say(values?.actual ?? bug.actual)}`, '');
|
|
887
|
+
const evidence = [
|
|
888
|
+
...(o.evidence?.screenshot ? [`[screenshot](${o.evidence.screenshot})`] : []),
|
|
889
|
+
...(o.evidence?.video ? [`[video](${o.evidence.video})`] : []),
|
|
890
|
+
...(o.runUrl ? [`[the check](${o.runUrl})`] : []),
|
|
891
|
+
];
|
|
892
|
+
const facts = [];
|
|
893
|
+
if (evidence.length)
|
|
894
|
+
facts.push(`Evidence: ${evidence.join(' · ')}`);
|
|
895
|
+
if (bug.ref && !bug.test) {
|
|
896
|
+
// A bug found by reading the code always shows the plain file:line, even when the link text is only the file name.
|
|
897
|
+
const link = fileLink(o, o.head, bug.ref);
|
|
898
|
+
const plainRef = code(refText(bug.ref));
|
|
899
|
+
facts.push(`Code: ${reading && link !== plainRef ? `${plainRef} (${link})` : link}`);
|
|
900
|
+
}
|
|
901
|
+
if (reading && bug.failingInput)
|
|
902
|
+
facts.push(`Input that breaks it: ${inline(sentence(bug.failingInput))}`);
|
|
903
|
+
// The suspect holds a PR title, which anyone can write: no mention, no tag, no hidden marker gets through.
|
|
904
|
+
if (o.suspected)
|
|
905
|
+
facts.push(`Likely cause: ${noMentions(o.suspected.replace(/</g, '<'))}`);
|
|
906
|
+
if (o.testCase?.line)
|
|
907
|
+
facts.push(o.testCase.line);
|
|
908
|
+
for (const r of o.relates ?? [])
|
|
909
|
+
facts.push(`${r}.`);
|
|
910
|
+
lines.push(...facts.flatMap((f) => [f, '']));
|
|
911
|
+
while (lines[lines.length - 1] === '')
|
|
912
|
+
lines.pop();
|
|
913
|
+
const dev = [];
|
|
914
|
+
if (bug.test)
|
|
915
|
+
dev.push(`- Run${isTestRepoFile(bug.test.file) ? ` in ${code(splitTestRepoFile(bug.test.file).repo ?? '')}` : ''}: ${code(runCommand(bug.test))}`, `- Test: ${fileLink(o, o.head, { file: bug.test.file })} › "${inline(bug.test.title)}"`);
|
|
916
|
+
if (o.evidence?.trace)
|
|
917
|
+
dev.push(`- Trace: ${o.evidence.trace}`);
|
|
918
|
+
if (o.head)
|
|
919
|
+
dev.push(`- Commit: ${code(o.head.slice(0, 7))}`);
|
|
920
|
+
if (o.errorLines?.length)
|
|
921
|
+
dev.push('', ...o.errorLines);
|
|
922
|
+
if (o.signature)
|
|
923
|
+
dev.push('', `localdost bug signature: ${code(o.signature)}`);
|
|
924
|
+
if (dev.length) {
|
|
925
|
+
const inner = dev
|
|
926
|
+
.join('\n')
|
|
927
|
+
.replace(/^\n+/, '')
|
|
928
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
929
|
+
.replace(/\n+$/, '');
|
|
930
|
+
lines.push('', o.collapse === false ? `**For developers**\n\n${inner}` : `<details><summary>For developers</summary>\n\n${inner}\n\n</details>`);
|
|
931
|
+
}
|
|
932
|
+
// Every model-written part went through inline() or code() above, which escape mentions and comment markers.
|
|
933
|
+
return { title: defectTitle(bug), body: lines.join('\n').trim(), needsManualSteps };
|
|
934
|
+
}
|
|
935
|
+
// ---------- the report ----------
|
|
936
|
+
const RESULT_WORD = { passed: 'Passed', failed: 'Failed', flaky: 'Flaky (passed on retry)' };
|
|
937
|
+
const ENGINE_LABEL = { spec: 'an existing test', playwright: 'a Playwright e2e test', aivision: 'an AI-Vision browser check' };
|
|
938
|
+
/** " (checked by a Playwright e2e test)" for items no existing test covered and a runner engine ran. */
|
|
939
|
+
function engineNote(t) {
|
|
940
|
+
const engines = (t.coveredBy ?? []).filter((e) => e !== 'spec');
|
|
941
|
+
return engines.length && t.resultSource === 'runner' ? ` (checked by ${engines.map((e) => ENGINE_LABEL[e]).join(' and ')})` : '';
|
|
942
|
+
}
|
|
943
|
+
function testedLines(review, tests, o) {
|
|
944
|
+
const out = [];
|
|
945
|
+
const r = o.report;
|
|
946
|
+
if (r && !r.notRun) {
|
|
947
|
+
out.push(`- ${ranDetail(r)}`);
|
|
948
|
+
}
|
|
949
|
+
if (r?.hint)
|
|
950
|
+
out.push(`- ${inline(r.hint)}`);
|
|
951
|
+
const ci = ciLine(review.ci);
|
|
952
|
+
if (ci)
|
|
953
|
+
out.push(ci);
|
|
954
|
+
for (const t of tests) {
|
|
955
|
+
if (t.result !== 'passed' && t.result !== 'failed' && t.result !== 'flaky')
|
|
956
|
+
continue;
|
|
957
|
+
const ref = itemRef(review, t);
|
|
958
|
+
const by = t.resultSource === 'manual'
|
|
959
|
+
? ' (checked by hand)'
|
|
960
|
+
: t.resultSource === 'ci'
|
|
961
|
+
? ` (${t.result === 'failed' ? 'failed' : 'passed'} in CI job ${inline(t.ciJob ?? 'unknown')})`
|
|
962
|
+
: engineNote(t);
|
|
963
|
+
out.push(`- **${RESULT_WORD[t.result]}:** ${inline(sentence(plainScenario(t.scenario)))}${by}${ref ? ` ${fileLink(o, review.head, ref)}` : ''}`);
|
|
964
|
+
}
|
|
965
|
+
return out;
|
|
966
|
+
}
|
|
967
|
+
/** "Tests in CI: 12 passed (job unit) on abc1234." Lists only jobs that printed a test line. A job still running adds nothing. */
|
|
968
|
+
function ciLine(ci) {
|
|
969
|
+
if (!ci)
|
|
970
|
+
return '';
|
|
971
|
+
const jobs = ci.jobs.filter((j) => j.status === 'completed' && j.passed + j.failed > 0);
|
|
972
|
+
const passed = jobs.reduce((n, j) => n + j.passed, 0);
|
|
973
|
+
const failed = jobs.reduce((n, j) => n + j.failed, 0);
|
|
974
|
+
if (!passed && !failed)
|
|
975
|
+
return '';
|
|
976
|
+
const names = jobs.map((j) => inline(j.name)).join(', ');
|
|
977
|
+
const counts = failed ? `${passed} passed, ${failed} failed` : `${passed} passed`;
|
|
978
|
+
return `- Tests in CI: ${counts} (${jobs.length === 1 ? 'job' : 'jobs'} ${names}) on ${code(ci.head.slice(0, 7))}.`;
|
|
979
|
+
}
|
|
980
|
+
const SETTLED = new Set(['passed', 'failed', 'flaky']);
|
|
981
|
+
function whyNotTested(t, ci) {
|
|
982
|
+
if (t.result === 'skipped')
|
|
983
|
+
return 'the test was skipped';
|
|
984
|
+
if (t.noManualStep)
|
|
985
|
+
return 'no step a tester can run without a screen exists, so a developer test has to cover it';
|
|
986
|
+
if (t.existingTest && t.result === 'not_run' && ci?.jobs.some((j) => j.status !== 'completed'))
|
|
987
|
+
return 'the CI job is still running';
|
|
988
|
+
if (t.existingTest)
|
|
989
|
+
return t.result === 'not_run' ? 'the test runner did not run this file' : 'it was not in the selected tests';
|
|
990
|
+
if (t.type === 'manual' || t.type === 'exploratory')
|
|
991
|
+
return 'it needs a person';
|
|
992
|
+
return 'no test covers it yet';
|
|
993
|
+
}
|
|
994
|
+
/** A check with steps or an expected result and no test of its own is a thing to try by hand. Anything else stays with the developer. */
|
|
995
|
+
function triedByHand(t) {
|
|
996
|
+
return !t.existingTest && !!(t.steps?.length || t.expected);
|
|
997
|
+
}
|
|
998
|
+
/** One manual check as a tick item: the scenario, then its steps, its data and its expected result under it. */
|
|
999
|
+
function handItem(t) {
|
|
1000
|
+
const steps = (t.steps ?? [])
|
|
1001
|
+
.slice(0, 6)
|
|
1002
|
+
.map((x) => inline(clipText(x, 140)).replace(/[.;]+$/, ''))
|
|
1003
|
+
.filter(Boolean);
|
|
1004
|
+
const same = steps.length === 1 && normalizeScenario(t.steps?.[0] ?? '') === normalizeScenario(t.scenario);
|
|
1005
|
+
const more = [];
|
|
1006
|
+
if (steps.length && !same)
|
|
1007
|
+
more.push(`Steps: ${steps.map((x, i) => `${i + 1}) ${x}`).join('; ')}.`);
|
|
1008
|
+
if (t.data)
|
|
1009
|
+
more.push(`Data: ${sentence(inline(clipText(t.data, 160)))}`);
|
|
1010
|
+
if (t.expected)
|
|
1011
|
+
more.push(`Expect: ${sentence(inline(clipText(t.expected, 200)))}`);
|
|
1012
|
+
return { id: checklistId(t), first: inline(sentence(plainScenario(t.scenario))), more };
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* A risk the review could not confirm, worded for the person who tries it. When the PR description, a linked ticket or the
|
|
1016
|
+
* docs say the change does this on purpose, the item says so and asks whether users should see something else.
|
|
1017
|
+
*/
|
|
1018
|
+
function possibleBugItem(review, title, areaId, userImpact, why, failingInput) {
|
|
1019
|
+
const intent = matchIntent({ title, actual: userImpact, expected: '', why, reproduced: false }, review.intents ?? []);
|
|
1020
|
+
const id = checklistId({ areaId: areaId ?? '', scenario: `risk ${title}` });
|
|
1021
|
+
const first = inline(sentence(clipText(title, 160)));
|
|
1022
|
+
const input = failingInput ? [`Try this input: ${sentence(inline(clipText(failingInput, 200)))}`] : [];
|
|
1023
|
+
return intent
|
|
1024
|
+
? { id, first: `${first} ${intentWhere(intent)} this is on purpose.`, more: [...input, 'If users should see something else here, this is a bug.'] }
|
|
1025
|
+
: {
|
|
1026
|
+
id,
|
|
1027
|
+
first,
|
|
1028
|
+
more: [
|
|
1029
|
+
...input,
|
|
1030
|
+
input.length
|
|
1031
|
+
? 'If it breaks, this is a bug.'
|
|
1032
|
+
: review.backendOnly
|
|
1033
|
+
? 'Try it with an API call or a command. If the answer is not what users should get, this is a bug.'
|
|
1034
|
+
: 'Try it. If this is not what users should see, this is a bug.',
|
|
1035
|
+
],
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
function openItems(review, tests, risks, o) {
|
|
1039
|
+
const handTests = [];
|
|
1040
|
+
const other = [];
|
|
1041
|
+
for (const t of tests.filter((x) => !SETTLED.has(x.result ?? ''))) {
|
|
1042
|
+
if (triedByHand(t)) {
|
|
1043
|
+
handTests.push({ test: t, item: handItem(t) });
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
const ref = itemRef(review, t);
|
|
1047
|
+
other.push(`${code(t.id)} ${inline(sentence(plainScenario(t.scenario)))} Not tested: ${whyNotTested(t, review.ci)}${ref ? ` (${fileLink(o, review.head, ref)})` : ''}.`);
|
|
1048
|
+
}
|
|
1049
|
+
const possibleBugs = risks.filter((x) => x.verifier.status !== 'confirmed').map((r) => possibleBugItem(review, r.title, r.areaId, r.userImpact, r.why));
|
|
1050
|
+
return { handTests, possibleBugs, other };
|
|
1051
|
+
}
|
|
1052
|
+
/** "Note: ..." when the change touches product code and the repo's changelog has no new Unreleased line. */
|
|
1053
|
+
export function changelogNoteLine(review) {
|
|
1054
|
+
if (!review.changelog)
|
|
1055
|
+
return '';
|
|
1056
|
+
return `**Note:** this change touches product code, but ${code(review.changelog.file)} has no new line under Unreleased. Add one plain sentence about what changes for the people who use the product.`;
|
|
1057
|
+
}
|
|
1058
|
+
function suggestionLines(review, o) {
|
|
1059
|
+
const out = [];
|
|
1060
|
+
for (const g of review.testGaps.filter((x) => x.verifier.status !== 'refuted')) {
|
|
1061
|
+
const area = areaName(review, g.areaId);
|
|
1062
|
+
// The first sentence names the gap; the full text stays in Details.
|
|
1063
|
+
const gap = clean(g.missing).split(/(?<=[.!?])\s+/)[0] ?? g.missing;
|
|
1064
|
+
out.push(`- Add ${/^[aeiu]/i.test(g.type) ? 'an' : 'a'} ${g.type} test${area ? ` for ${inline(area)}` : ''}: ${inline(sentence(gap.charAt(0).toLowerCase() + gap.slice(1)))}`);
|
|
1065
|
+
}
|
|
1066
|
+
for (const k of review.testsAtRisk.filter((x) => x.verifier.status !== 'refuted')) {
|
|
1067
|
+
out.push(`- Update ${fileLink(o, review.head, { file: k.path, line: k.testLine })}: ${inline(sentence(k.reason))}`);
|
|
1068
|
+
}
|
|
1069
|
+
return out.slice(0, MAX_SUGGESTIONS);
|
|
1070
|
+
}
|
|
1071
|
+
/** The lines without repeats, ignoring case and spacing; the first one stays. */
|
|
1072
|
+
function uniqueLines(lines) {
|
|
1073
|
+
const seen = new Set();
|
|
1074
|
+
return lines.filter((l) => {
|
|
1075
|
+
const k = l.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
1076
|
+
return !seen.has(k) && seen.add(k);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
function plural(n, one, many = `${one}s`) {
|
|
1080
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
1081
|
+
}
|
|
1082
|
+
/** An Expected that hands the decision to someone else, or asks instead of stating a result. */
|
|
1083
|
+
const DESIGN_QUESTION = /\b(?:the )?(?:team|product(?: owner)?|owner|maintainers?|reviewers?|a person|someone|stakeholders?)\s+(?:decides?|chooses?|confirms?|agrees?|to decide|should decide|must decide)\b|\bconfirm (?:that )?(?:this|it|the [\w ]+) is (?:wanted|intended|desired|expected|on purpose)\b|\bdecide (?:whether|if|on)\b|\b(?:tbd|to be decided|to be confirmed|undecided|unclear|not specified|unspecified)\b|\bdepends on (?:the|what|whether)\b|\?\s*$/i;
|
|
1084
|
+
/**
|
|
1085
|
+
* True when an Expected names a result a tester can check. Empty, a design question ("the team decides",
|
|
1086
|
+
* "confirm this is wanted"), or a question mark is not concrete (dogfood ledger: no Expected, no defect).
|
|
1087
|
+
*/
|
|
1088
|
+
export function concreteExpected(expected) {
|
|
1089
|
+
const t = clean(expected ?? '').trim();
|
|
1090
|
+
return t.length > 0 && !DESIGN_QUESTION.test(t);
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Splits findings into bugs (reproduced, with a concrete Expected), risks to test (confirmed in the code, not
|
|
1094
|
+
* reproduced), and questions (an Expected that is a design question, or a reproduced finding with no Expected).
|
|
1095
|
+
*/
|
|
1096
|
+
export function reportFacts(all, intents = []) {
|
|
1097
|
+
// A stated decision wins over every other bucket: the change does what its author said it does.
|
|
1098
|
+
const behaviorChanges = [];
|
|
1099
|
+
const findings = [];
|
|
1100
|
+
for (const b of all) {
|
|
1101
|
+
const intent = matchIntent({ title: b.title, actual: b.actual, expected: b.expected, why: b.why, reproduced: b.reproduced }, intents);
|
|
1102
|
+
if (intent)
|
|
1103
|
+
behaviorChanges.push({ bug: b, intent });
|
|
1104
|
+
else
|
|
1105
|
+
findings.push(b);
|
|
1106
|
+
}
|
|
1107
|
+
const question = (b) => (b.expected.trim() ? !concreteExpected(b.expected) : b.reproduced);
|
|
1108
|
+
return {
|
|
1109
|
+
bugs: findings.filter((b) => b.reproduced && !question(b)),
|
|
1110
|
+
risksToTest: findings.filter((b) => !b.reproduced && !question(b)),
|
|
1111
|
+
questions: findings.filter(question),
|
|
1112
|
+
behaviorChanges,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** The question a finding without a concrete Expected becomes, for the PR comment and the ticket. */
|
|
1116
|
+
export function questionFor(b) {
|
|
1117
|
+
const said = clean(b.expected).trim();
|
|
1118
|
+
return `${sentence(clipText(b.title, 160))} What should happen here?${said ? ` The review could only say: "${clipText(said, 120)}".` : ''}${b.actual ? ` Today: ${sentence(clipText(b.actual, 160))}` : ''}`;
|
|
1119
|
+
}
|
|
1120
|
+
/** What to test items that count (the verifier did not refute them) and have no settled result yet. */
|
|
1121
|
+
export function untestedChecks(review) {
|
|
1122
|
+
return (review?.whatToTest ?? []).filter((t) => t.verifier.status !== 'refuted' && !['passed', 'failed', 'flaky'].includes(t.result ?? '')).length;
|
|
1123
|
+
}
|
|
1124
|
+
export function testSummary(review, report, bugs, untested, risks = 0, questions = 0, behaviorChanges = 0, shared = false) {
|
|
1125
|
+
const names = (review?.impactAreas ?? []).map((a) => clean(a.area)).filter(Boolean);
|
|
1126
|
+
const areas = names.length > 1 ? `${names[0]} and ${names.length - 1} more area${names.length === 2 ? '' : 's'}` : (names[0] ?? '');
|
|
1127
|
+
const notRun = !!report?.notRun;
|
|
1128
|
+
const total = report && !notRun ? report.passed + report.failed + report.flaky + report.skipped : 0;
|
|
1129
|
+
const passed = report && !notRun ? report.passed : 0;
|
|
1130
|
+
const parts = [bugs ? `${plural(bugs, 'bug')} found` : 'no bugs found'];
|
|
1131
|
+
if (risks)
|
|
1132
|
+
parts.push(`${plural(risks, 'risk')} to test`);
|
|
1133
|
+
if (questions)
|
|
1134
|
+
parts.push(`${plural(questions, 'question')} for the team`);
|
|
1135
|
+
if (behaviorChanges)
|
|
1136
|
+
parts.push(`${plural(behaviorChanges, 'behavior change')} to confirm`);
|
|
1137
|
+
if (untested)
|
|
1138
|
+
parts.push(`${plural(untested, 'check')} not tested yet`);
|
|
1139
|
+
if (total && report)
|
|
1140
|
+
parts.push(`tests: ${testCounts(report)}`);
|
|
1141
|
+
const what = areas
|
|
1142
|
+
? `${notRun ? 'Read the code of' : report ? 'Tested' : 'Reviewed'} ${areas}${shared && report && !notRun ? ' on the shared QA build' : ''}`
|
|
1143
|
+
: notRun
|
|
1144
|
+
? 'Read the change'
|
|
1145
|
+
: report
|
|
1146
|
+
? shared
|
|
1147
|
+
? 'Tested the shared QA build'
|
|
1148
|
+
: 'Tested this change'
|
|
1149
|
+
: 'Reviewed this change';
|
|
1150
|
+
const lead = notRun
|
|
1151
|
+
? report?.notRun?.fork
|
|
1152
|
+
? `${FORK_NOT_RUN_LINE} `
|
|
1153
|
+
: report?.notRun?.stage === 'skipped'
|
|
1154
|
+
? 'Tests skipped. '
|
|
1155
|
+
: report?.notRun?.stage === 'checkout'
|
|
1156
|
+
? 'No tests have run for this commit yet. '
|
|
1157
|
+
: 'The tests did not run. '
|
|
1158
|
+
: '';
|
|
1159
|
+
const counted = report && !notRun;
|
|
1160
|
+
const skipped = counted ? report.skipped : 0;
|
|
1161
|
+
const failed = counted ? report.failed : 0;
|
|
1162
|
+
const flaky = counted ? report.flaky : 0;
|
|
1163
|
+
return {
|
|
1164
|
+
areas,
|
|
1165
|
+
bugs,
|
|
1166
|
+
risks,
|
|
1167
|
+
questions,
|
|
1168
|
+
behaviorChanges,
|
|
1169
|
+
untested,
|
|
1170
|
+
passed,
|
|
1171
|
+
skipped,
|
|
1172
|
+
failed,
|
|
1173
|
+
flaky,
|
|
1174
|
+
total,
|
|
1175
|
+
notRun,
|
|
1176
|
+
...(report?.notRun?.stage === 'skipped' ? { testsSkipped: true } : {}),
|
|
1177
|
+
line: clipText(`${lead}${what}: ${parts.join(', ')}.`, 200),
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
/** One line for a lock screen or a run step, under 120 characters. Never merge advice. */
|
|
1181
|
+
export function summaryHeadline(s, prNumber) {
|
|
1182
|
+
const parts = [s.bugs ? `${plural(s.bugs, 'bug')} found` : 'no bugs found'];
|
|
1183
|
+
if (s.risks)
|
|
1184
|
+
parts.push(`${plural(s.risks, 'risk')} to test`);
|
|
1185
|
+
if (s.questions)
|
|
1186
|
+
parts.push(`${plural(s.questions, 'question')} for the team`);
|
|
1187
|
+
if (s.behaviorChanges)
|
|
1188
|
+
parts.push(`${plural(s.behaviorChanges, 'behavior change')} to confirm`);
|
|
1189
|
+
if (s.untested)
|
|
1190
|
+
parts.push(`${s.untested} not tested yet`);
|
|
1191
|
+
if (s.total)
|
|
1192
|
+
parts.push(`tests: ${s.passed} passed, ${s.skipped} skipped, ${s.failed} failed${s.flaky ? `, ${s.flaky} flaky` : ''}`);
|
|
1193
|
+
if (s.notRun)
|
|
1194
|
+
parts.unshift(s.testsSkipped ? 'tests skipped' : 'tests did not run');
|
|
1195
|
+
return clipText(`${prNumber ? `PR #${prNumber}` : 'Change'}: ${parts.join(', ')}.`, 120);
|
|
1196
|
+
}
|
|
1197
|
+
// ---------- consistency ----------
|
|
1198
|
+
/** The counts a summary line or headline states, read back from its text. Null when the text states no counts. */
|
|
1199
|
+
export function statedCounts(text) {
|
|
1200
|
+
const t = clean(text);
|
|
1201
|
+
const bugs = /\bno bugs found\b/i.test(t) ? 0 : Number(/\b(\d+) bugs? found\b/i.exec(t)?.[1] ?? NaN);
|
|
1202
|
+
if (!Number.isFinite(bugs))
|
|
1203
|
+
return null;
|
|
1204
|
+
const risks = Number(/\b(\d+) risks? to test\b/i.exec(t)?.[1] ?? 0);
|
|
1205
|
+
const questions = Number(/\b(\d+) questions? for the team\b/i.exec(t)?.[1] ?? 0);
|
|
1206
|
+
const behaviorChanges = Number(/\b(\d+) behavior changes? to confirm\b/i.exec(t)?.[1] ?? 0);
|
|
1207
|
+
const untested = Number(/\b(\d+) (?:checks? )?not tested yet\b/i.exec(t)?.[1] ?? 0);
|
|
1208
|
+
return { bugs, risks, questions, behaviorChanges, untested };
|
|
1209
|
+
}
|
|
1210
|
+
/** Items listed under a "### Title" section of a report body, plus its "And N more ... in the run." line. */
|
|
1211
|
+
function listedUnder(body, title, item) {
|
|
1212
|
+
const lines = body.split('\n');
|
|
1213
|
+
const at = lines.findIndex((l) => l.trim() === `### ${title}`);
|
|
1214
|
+
if (at < 0)
|
|
1215
|
+
return 0;
|
|
1216
|
+
let n = 0;
|
|
1217
|
+
for (const l of lines.slice(at + 1)) {
|
|
1218
|
+
if (/^### |^<details>/.test(l))
|
|
1219
|
+
break;
|
|
1220
|
+
if (item.test(l))
|
|
1221
|
+
n++;
|
|
1222
|
+
const more = /^And (\d+) more \w+ in the run\.$/.exec(l.trim());
|
|
1223
|
+
if (more)
|
|
1224
|
+
n += Number(more[1]);
|
|
1225
|
+
}
|
|
1226
|
+
return n;
|
|
1227
|
+
}
|
|
1228
|
+
/** The counts the first line of the tester report states ("2 things need a person to try", "1 test failed"), read back from its text. */
|
|
1229
|
+
function testerCounts(verdict) {
|
|
1230
|
+
const n = (re) => Number(re.exec(verdict)?.[1] ?? 0);
|
|
1231
|
+
return {
|
|
1232
|
+
failed: n(/\b(\d+) tests? failed\b/i),
|
|
1233
|
+
bugs: n(/\b(\d+) bugs? found\b/i),
|
|
1234
|
+
hand: n(/\b(\d+) things? needs? a person to try\b/i),
|
|
1235
|
+
questions: n(/\b(\d+) questions? for the team\b/i),
|
|
1236
|
+
behaviorChanges: n(/\b(\d+) behavior changes? to confirm\b/i),
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
/** A check title in the tester wording: "1 test failed: ...", "1 bug found: ...", "Passed. 2 things to try by hand". Null for the older title. */
|
|
1240
|
+
function testerTitleCounts(title) {
|
|
1241
|
+
if (!/^(?:Passed\b|Nothing failed\b|\d+ tests? failed\b|\d+ bugs? found\b|Tests (?:did not run|skipped)\b|Needs a look\b)/.test(title))
|
|
1242
|
+
return null;
|
|
1243
|
+
const n = (re) => Number(re.exec(title)?.[1] ?? 0);
|
|
1244
|
+
return { failed: n(/^(\d+) tests? failed\b/), bugs: n(/^(\d+) bugs? found\b/), hand: n(/\b(\d+) things? to try by hand\b/) };
|
|
1245
|
+
}
|
|
1246
|
+
/** The "Bug:" lines of a check summary, plus its "And N more bugs in the run." line. */
|
|
1247
|
+
function bugLinesIn(text) {
|
|
1248
|
+
let n = 0;
|
|
1249
|
+
for (const l of text.split('\n')) {
|
|
1250
|
+
if (/^Bug: /.test(l))
|
|
1251
|
+
n++;
|
|
1252
|
+
const more = /^And (\d+) more bugs in the run\.$/.exec(l.trim());
|
|
1253
|
+
if (more)
|
|
1254
|
+
n += Number(more[1]);
|
|
1255
|
+
}
|
|
1256
|
+
return n;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Every number a report states against the one structured summary it was built from (dogfood ledger: "N checks
|
|
1260
|
+
* not tested yet" that did not match the list below it). Returns one sentence per difference; empty when the
|
|
1261
|
+
* headline, the check title, and each body agree with the summary. `bodies` are reports in the older layout,
|
|
1262
|
+
* `comments` the PR comment in the tester layout, `checks` the visible text of a check in that layout.
|
|
1263
|
+
*/
|
|
1264
|
+
export function reportConsistency(s, parts) {
|
|
1265
|
+
const out = [];
|
|
1266
|
+
const want = { bugs: s.bugs, risks: s.risks, questions: s.questions ?? 0, behaviorChanges: s.behaviorChanges ?? 0, untested: s.untested };
|
|
1267
|
+
const word = {
|
|
1268
|
+
bugs: 'bugs',
|
|
1269
|
+
risks: 'risks',
|
|
1270
|
+
questions: 'questions',
|
|
1271
|
+
behaviorChanges: 'behavior changes',
|
|
1272
|
+
untested: 'not tested yet',
|
|
1273
|
+
};
|
|
1274
|
+
const compare = (what, got) => {
|
|
1275
|
+
if (!got)
|
|
1276
|
+
return;
|
|
1277
|
+
for (const k of Object.keys(want))
|
|
1278
|
+
if (got[k] !== want[k])
|
|
1279
|
+
out.push(`${what} says ${got[k]} ${word[k]}, the summary has ${want[k]}.`);
|
|
1280
|
+
};
|
|
1281
|
+
const same = (what, label, got, expected) => {
|
|
1282
|
+
if (got !== expected)
|
|
1283
|
+
out.push(`${what} says ${got} ${label}, the summary has ${expected}.`);
|
|
1284
|
+
};
|
|
1285
|
+
// A skipped test did not run (KAT-402): "N automated tests ran" and "Ran N selected tests" count passed, failed and flaky tests,
|
|
1286
|
+
// and "N were skipped" counts the skipped ones. A text that adds the skipped tests to the ran count says more than the run did.
|
|
1287
|
+
const ran = s.passed !== undefined && s.failed !== undefined && s.flaky !== undefined ? s.passed + s.failed + s.flaky : undefined;
|
|
1288
|
+
const testWords = (what, whole) => {
|
|
1289
|
+
// A Tests repo line is one batch of the run: "Ran 85 tests from e2e: 85 passed. 53 were skipped (...)". Its counts are part of the
|
|
1290
|
+
// run's counts, never more, and a skipped test never sits in its ran count ("..., 53 skipped") (KAT-402).
|
|
1291
|
+
let repoRan = 0;
|
|
1292
|
+
let repoSkipped = 0;
|
|
1293
|
+
const text = whole.replace(TEST_REPO_LINE_RE, (_all, n, parts, skipped) => {
|
|
1294
|
+
repoRan += Number(n);
|
|
1295
|
+
repoSkipped += skipped ? Number(skipped) : 0;
|
|
1296
|
+
if (/\b\d+ skipped\b/.test(parts))
|
|
1297
|
+
out.push(`${what} counts skipped tests as ran in a Tests repo line.`);
|
|
1298
|
+
return ' ';
|
|
1299
|
+
});
|
|
1300
|
+
if (repoRan && ran !== undefined && repoRan > ran)
|
|
1301
|
+
out.push(`${what} says ${repoRan} tests ran from the Tests repos, the summary has ${ran}.`);
|
|
1302
|
+
if (repoSkipped && s.skipped !== undefined && repoSkipped > s.skipped)
|
|
1303
|
+
out.push(`${what} says ${repoSkipped} tests were skipped in the Tests repos, the summary has ${s.skipped}.`);
|
|
1304
|
+
const said = /\b(\d+) automated tests? ran\b/.exec(text) ?? /^- Ran (\d+) selected tests?:/m.exec(text);
|
|
1305
|
+
if (said && ran !== undefined)
|
|
1306
|
+
same(what, 'automated tests ran', Number(said[1]), ran);
|
|
1307
|
+
// A later line in the old wording, "138 automated tests ran: 85 passed, 53 skipped", adds the skipped tests to ran. The counts line
|
|
1308
|
+
// comes first and `said` reads only that one, so every line that lists skipped tests under a ran count is read too (KAT-402).
|
|
1309
|
+
if (ran !== undefined)
|
|
1310
|
+
for (const m of text.matchAll(/(?:\b(\d+) automated tests? ran|^- Ran (\d+) selected tests?): [^\n.]*\b[1-9]\d* skipped\b/gm)) {
|
|
1311
|
+
const n = Number(m[1] ?? m[2]);
|
|
1312
|
+
if (n !== ran)
|
|
1313
|
+
same(what, 'automated tests ran', n, ran);
|
|
1314
|
+
else
|
|
1315
|
+
out.push(`${what} counts skipped tests as ran.`);
|
|
1316
|
+
}
|
|
1317
|
+
const skipped = /\b(\d+) (?:was|were) skipped \(/.exec(text);
|
|
1318
|
+
if (skipped && s.skipped !== undefined)
|
|
1319
|
+
same(what, 'tests skipped', Number(skipped[1]), s.skipped);
|
|
1320
|
+
};
|
|
1321
|
+
compare('The summary line', statedCounts(s.line));
|
|
1322
|
+
if (parts.headline)
|
|
1323
|
+
compare('The headline', statedCounts(parts.headline));
|
|
1324
|
+
// A clipped title may have lost its last count, so only a whole one is read.
|
|
1325
|
+
if (parts.checkTitle && !parts.checkTitle.endsWith('...')) {
|
|
1326
|
+
const t = testerTitleCounts(parts.checkTitle);
|
|
1327
|
+
if (!t)
|
|
1328
|
+
compare('The check title', statedCounts(parts.checkTitle));
|
|
1329
|
+
else {
|
|
1330
|
+
if (/^\d+ tests? failed/.test(parts.checkTitle) && s.failed !== undefined)
|
|
1331
|
+
same('The check title', 'tests failed', t.failed, s.failed);
|
|
1332
|
+
if (/^\d+ bugs? found/.test(parts.checkTitle))
|
|
1333
|
+
same('The check title', 'bugs', t.bugs, s.bugs);
|
|
1334
|
+
if (/things? to try by hand/.test(parts.checkTitle) && s.hand !== undefined)
|
|
1335
|
+
same('The check title', 'things to try by hand', t.hand, s.hand);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
for (const body of parts.bodies ?? []) {
|
|
1339
|
+
testWords('The report body', body);
|
|
1340
|
+
// Test facts have one source, the run's results: a report that lists results cannot also say no tests ran.
|
|
1341
|
+
if (claimsNoTestsRan(body) && (/^- Ran \d+ selected tests?:/m.test(body) || /\btests: \d+ passed/.test(s.line)))
|
|
1342
|
+
out.push('The report body says no tests ran, and the summary lists test results.');
|
|
1343
|
+
compare('The report body', {
|
|
1344
|
+
bugs: listedUnder(body, 'Bugs found', /^- \*\*/),
|
|
1345
|
+
risks: listedUnder(body, 'Risks to test', /^- \*\*/),
|
|
1346
|
+
questions: listedUnder(body, 'Questions for the team', /^- /),
|
|
1347
|
+
behaviorChanges: listedUnder(body, 'Behavior changes to confirm', /^- \*\*/),
|
|
1348
|
+
untested: listedUnder(body, 'Not tested yet', /^- \[[ xX]\] /),
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
const tick = /^- \[[ xX]\] /;
|
|
1352
|
+
for (const body of parts.comments ?? []) {
|
|
1353
|
+
testWords('The report', body);
|
|
1354
|
+
const lead = /^\*\*localdost: (.*)\*\*$/m.exec(body)?.[1] ?? '';
|
|
1355
|
+
const said = testerCounts(lead);
|
|
1356
|
+
// Only a failed check names the failed tests; a failure on the shared build leaves the check neutral and says nothing of it.
|
|
1357
|
+
const failedCheck = lead.startsWith('the check failed.');
|
|
1358
|
+
if (failedCheck && s.failed !== undefined)
|
|
1359
|
+
same('The first line', 'tests failed', said.failed, s.failed);
|
|
1360
|
+
if (!said.failed)
|
|
1361
|
+
same('The first line', 'bugs', said.bugs, s.bugs);
|
|
1362
|
+
if (s.hand !== undefined)
|
|
1363
|
+
same('The first line', 'things to try by hand', said.hand, s.hand);
|
|
1364
|
+
same('The first line', 'questions', said.questions, want.questions);
|
|
1365
|
+
same('The first line', 'behavior changes', said.behaviorChanges, want.behaviorChanges);
|
|
1366
|
+
same('The report body', 'bugs', listedUnder(body, 'Bugs found', /^- \*\*/), s.bugs);
|
|
1367
|
+
same('The report body', 'questions', listedUnder(body, 'Questions for the team', /^- /), want.questions);
|
|
1368
|
+
same('The report body', 'behavior changes', listedUnder(body, 'Behavior changes to confirm', /^- \*\*/), want.behaviorChanges);
|
|
1369
|
+
if (s.hand !== undefined)
|
|
1370
|
+
same('The report body', 'things to try by hand', listedUnder(body, 'Please try by hand', tick), s.hand);
|
|
1371
|
+
if (s.update !== undefined)
|
|
1372
|
+
same('The report body', 'tests to update', listedUnder(body, 'Tests to update', tick), s.update);
|
|
1373
|
+
}
|
|
1374
|
+
for (const text of parts.checks ?? []) {
|
|
1375
|
+
same('The check text', 'bugs', bugLinesIn(text), s.bugs);
|
|
1376
|
+
testWords('The check text', text);
|
|
1377
|
+
}
|
|
1378
|
+
return [...new Set(out)];
|
|
1379
|
+
}
|
|
1380
|
+
/** GitHub cuts a check title longer than this. */
|
|
1381
|
+
export const CHECK_TITLE_MAX = 60;
|
|
1382
|
+
/** A check title that fits: cut at a word, with "..." when it was cut. */
|
|
1383
|
+
export function clipCheckTitle(s) {
|
|
1384
|
+
const t = clean(s).replace(/\s+/g, ' ').trim();
|
|
1385
|
+
if (t.length <= CHECK_TITLE_MAX)
|
|
1386
|
+
return t;
|
|
1387
|
+
const cut = t.slice(0, CHECK_TITLE_MAX - 3);
|
|
1388
|
+
const at = cut.lastIndexOf(' ');
|
|
1389
|
+
return `${(at > 30 ? cut.slice(0, at) : cut).replace(/[\s,;:.]+$/, '')}...`;
|
|
1390
|
+
}
|
|
1391
|
+
/** "Passed" for a verdict outcome that passed, a failure for the failed ones, neutral for the rest. The test conclusion before reviews add bugs. */
|
|
1392
|
+
export function verdictConclusion(v) {
|
|
1393
|
+
if (!v || v.outcome === 'pass')
|
|
1394
|
+
return 'success';
|
|
1395
|
+
return v.outcome === 'product_failure' || v.outcome === 'test_failure' ? 'failure' : 'neutral';
|
|
1396
|
+
}
|
|
1397
|
+
function listOf(names) {
|
|
1398
|
+
return names.length < 2 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
1399
|
+
}
|
|
1400
|
+
/** Tests the PR's own CI jobs ran at the reviewed head. */
|
|
1401
|
+
function ciCounts(ci) {
|
|
1402
|
+
const jobs = (ci?.jobs ?? []).filter((j) => j.status === 'completed' && j.passed + j.failed > 0);
|
|
1403
|
+
return { passed: jobs.reduce((n, j) => n + j.passed, 0), failed: jobs.reduce((n, j) => n + j.failed, 0) };
|
|
1404
|
+
}
|
|
1405
|
+
/** Words of the test tool, not of the product: the default view never shows them (simplicity spec, word map). */
|
|
1406
|
+
const TOOL_WORDS = /\b(?:smoke|spec|specs|e2e|ci|runner|engine|probe|playwright|selector|fixture|sha)\b|@\w+|\.(?:spec|test)\.\w+|\w+\/\w+\.\w{2,4}\b/i;
|
|
1407
|
+
/**
|
|
1408
|
+
* What a passing test checked, for the counts line: the scenario's own words with a leading imperative taken off
|
|
1409
|
+
* ("Run the smoke spec to confirm the app still loads" becomes "the app still loads"). Empty when what is left
|
|
1410
|
+
* still holds a tool word or still reads as a command, so a tester never reads "smoke spec" in the first lines.
|
|
1411
|
+
*/
|
|
1412
|
+
export function testedWhat(scenario) {
|
|
1413
|
+
let t = clean(plainScenario(scenario))
|
|
1414
|
+
.replace(/\s+/g, ' ')
|
|
1415
|
+
.trim()
|
|
1416
|
+
.replace(/[.!?]+$/, '');
|
|
1417
|
+
const run = /^(?:run|execute|start)\b.*?\b(?:to|and) (?:confirm|check|verify|make sure|ensure)(?: that)?\s+(.+)$/i.exec(t);
|
|
1418
|
+
if (run)
|
|
1419
|
+
t = run[1];
|
|
1420
|
+
else
|
|
1421
|
+
t = t.replace(/^(?:check|confirm|verify|make sure|ensure)(?: that)?\s+(?=\S)/i, '');
|
|
1422
|
+
t = clipText(t, 70).replace(/[.!?]+$/, '');
|
|
1423
|
+
if (!t || TOOL_WORDS.test(t) || /^(?:run|execute|start|open|click|type|go to)\b/i.test(t))
|
|
1424
|
+
return '';
|
|
1425
|
+
return t;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* The one line with the counts, in plain words: what the automated tests did, what a person checked by hand, and what
|
|
1429
|
+
* has no automated test. The numbers appear here once; the Details fold holds the rest.
|
|
1430
|
+
*/
|
|
1431
|
+
function countsLine(review, tests, o) {
|
|
1432
|
+
const r = o.report;
|
|
1433
|
+
const ci = ciCounts(review.ci);
|
|
1434
|
+
const out = [];
|
|
1435
|
+
if (r?.notRun)
|
|
1436
|
+
out.push(r.notRun.fork
|
|
1437
|
+
? FORK_NOT_RUN_LINE
|
|
1438
|
+
: r.notRun.stage === 'skipped'
|
|
1439
|
+
? 'Automated tests were skipped for this change.'
|
|
1440
|
+
: r.notRun.stage === 'checkout'
|
|
1441
|
+
? 'No tests have run for this commit yet.'
|
|
1442
|
+
: 'The automated tests did not run.');
|
|
1443
|
+
else if (r) {
|
|
1444
|
+
const total = r.passed + r.failed + r.flaky + r.skipped;
|
|
1445
|
+
// A run whose every test is from a Tests repo has that repo's line below, which says the same with the repo named.
|
|
1446
|
+
const onlyTestRepos = !!r.testRepos?.length && total > 0 && r.cases.length > 0 && r.cases.every((c) => isTestRepoFile(c.file));
|
|
1447
|
+
if (!total)
|
|
1448
|
+
out.push('No automated tests were selected for this change.');
|
|
1449
|
+
else if (onlyTestRepos) {
|
|
1450
|
+
// The Tests repo line below says the same with the repo named.
|
|
1451
|
+
}
|
|
1452
|
+
else if (!r.failed && !r.flaky && !r.skipped) {
|
|
1453
|
+
const only = r.passed === 1 ? tests.filter((t) => t.result === 'passed' && t.resultSource === 'runner') : [];
|
|
1454
|
+
const what = only.length === 1 ? testedWhat(only[0].scenario) : '';
|
|
1455
|
+
out.push(`${plural(r.passed, 'automated test')} passed${what ? ` (${inline(what.charAt(0).toLowerCase() + what.slice(1))})` : ''}.`);
|
|
1456
|
+
}
|
|
1457
|
+
else
|
|
1458
|
+
out.push(ranSentences(r));
|
|
1459
|
+
if (o.sharedBuild && testsRan(r))
|
|
1460
|
+
out.push('They ran on the shared QA build, not on a build of this PR.');
|
|
1461
|
+
}
|
|
1462
|
+
else if (!ci.passed && !ci.failed)
|
|
1463
|
+
out.push('No automated tests ran.');
|
|
1464
|
+
// The Tests repos of the run say what they ran in one line each, with the repo named (KAT-86).
|
|
1465
|
+
for (const t of r?.testRepos ?? [])
|
|
1466
|
+
out.push(testRepoSentence(t));
|
|
1467
|
+
if (ci.passed || ci.failed)
|
|
1468
|
+
out.push(`The build ran ${plural(ci.passed + ci.failed, 'test')}: ${ci.passed} passed${ci.failed ? `, ${ci.failed} failed` : ''}.`);
|
|
1469
|
+
const byHand = tests.filter((t) => t.resultSource === 'manual' && t.result === 'passed').length;
|
|
1470
|
+
if (byHand)
|
|
1471
|
+
out.push(`${plural(byHand, 'thing')} checked by hand.`);
|
|
1472
|
+
const gaps = [
|
|
1473
|
+
...new Set(review.testGaps
|
|
1474
|
+
.filter((g) => g.verifier.status !== 'refuted')
|
|
1475
|
+
.map((g) => clean(areaName(review, g.areaId)).trim())
|
|
1476
|
+
.filter(Boolean)),
|
|
1477
|
+
];
|
|
1478
|
+
if (review.testGaps.some((g) => g.verifier.status !== 'refuted'))
|
|
1479
|
+
out.push(gaps.length
|
|
1480
|
+
? `No automated test covers ${listOf(gaps.slice(0, 3).map(inline))}${gaps.length > 3 ? ` and ${gaps.length - 3} more` : ''} yet.`
|
|
1481
|
+
: 'No automated test covers part of this change yet.');
|
|
1482
|
+
// KAT-388: the tickets the review read next to the diff, each with its title chip.
|
|
1483
|
+
if (review.linkedTickets?.length)
|
|
1484
|
+
out.push(`Checked against ${review.linkedTickets.map(ticketChip).join(', ')}.`);
|
|
1485
|
+
return out.join(' ');
|
|
1486
|
+
}
|
|
1487
|
+
/** The first line of the report: whether anything blocks, then what needs a person. */
|
|
1488
|
+
function verdictText(a) {
|
|
1489
|
+
const what = a.task === 'post_merge' ? 'this change' : 'this PR';
|
|
1490
|
+
const parts = [];
|
|
1491
|
+
let lead;
|
|
1492
|
+
let readingBug = false;
|
|
1493
|
+
if (a.conclusion === 'failure') {
|
|
1494
|
+
const cause = a.outcome === 'environment_failure' ? 'a test setup problem' : a.outcome === 'test_failure' ? 'a broken test' : '';
|
|
1495
|
+
// A check that failed on a bug found by reading the code says so once, with the count; the bug is not counted again below.
|
|
1496
|
+
readingBug = a.failedTests === 0 && !a.testRunFailed;
|
|
1497
|
+
lead = `the check failed. ${a.failedTests > 0
|
|
1498
|
+
? `${plural(a.failedTests, 'test')} failed.${cause ? ` localdost reads this as ${cause}, not a bug in this PR.` : ''}`
|
|
1499
|
+
: a.testRunFailed
|
|
1500
|
+
? 'The test run found a failure.'
|
|
1501
|
+
: a.bugs > 0
|
|
1502
|
+
? `${plural(a.bugs, 'bug')} found by reading the code.`
|
|
1503
|
+
: 'localdost found a bug by reading the code.'}`;
|
|
1504
|
+
}
|
|
1505
|
+
else {
|
|
1506
|
+
lead = a.notRun ? `the tests ${a.notRun.stage === 'skipped' ? 'were skipped' : 'did not run'}. Nothing blocks ${what}.` : `nothing blocks ${what}.`;
|
|
1507
|
+
}
|
|
1508
|
+
if (a.bugs > 0 && a.failedTests === 0 && !readingBug)
|
|
1509
|
+
parts.push(`${plural(a.bugs, 'bug')} found.`);
|
|
1510
|
+
if (a.hand > 0)
|
|
1511
|
+
parts.push(`${plural(a.hand, 'thing')} ${a.hand === 1 ? 'needs' : 'need'} a person to try.`);
|
|
1512
|
+
if (a.questions > 0)
|
|
1513
|
+
parts.push(`${plural(a.questions, 'question')} for the team.`);
|
|
1514
|
+
if (a.behaviorChanges > 0)
|
|
1515
|
+
parts.push(`${plural(a.behaviorChanges, 'behavior change')} to confirm.`);
|
|
1516
|
+
if (!parts.length && a.conclusion !== 'failure' && !a.notRun)
|
|
1517
|
+
parts.push('Nothing is left to try by hand.');
|
|
1518
|
+
return [lead, ...parts].join(' ');
|
|
1519
|
+
}
|
|
1520
|
+
/** The GitHub check title: the result in a few words, under CHECK_TITLE_MAX characters. */
|
|
1521
|
+
function checkTitleFor(a) {
|
|
1522
|
+
const r = a.report;
|
|
1523
|
+
if (r?.notRun)
|
|
1524
|
+
return r.notRun.stage === 'skipped' ? 'Tests skipped' : 'Tests did not run';
|
|
1525
|
+
const failedCases = (r?.cases ?? []).filter((c) => c.status === 'failed' || c.status === 'timedOut');
|
|
1526
|
+
const failed = r?.failed ?? 0;
|
|
1527
|
+
if (failed > 0) {
|
|
1528
|
+
const name = failedCases[0]?.title ?? a.bugs.find((b) => b.test)?.test?.title;
|
|
1529
|
+
return clipCheckTitle(`${plural(failed, 'test')} failed${a.sharedBuild ? ' on the shared QA build' : ''}${name && !a.sharedBuild ? `: ${clipText(name, 80)}` : ''}`);
|
|
1530
|
+
}
|
|
1531
|
+
if (a.bugs.length)
|
|
1532
|
+
return clipCheckTitle(`${plural(a.bugs.length, 'bug')} found: ${clipText(a.bugs[0].title, 80)}`);
|
|
1533
|
+
const lead = a.conclusion === 'neutral' ? 'Nothing failed' : 'Passed';
|
|
1534
|
+
const tail = [
|
|
1535
|
+
a.hand > 0 ? `${plural(a.hand, 'thing')} to try by hand` : '',
|
|
1536
|
+
a.questions > 0 ? `${plural(a.questions, 'question')} for the team` : '',
|
|
1537
|
+
a.behaviorChanges > 0 ? `${plural(a.behaviorChanges, 'behavior change')} to confirm` : '',
|
|
1538
|
+
].filter(Boolean);
|
|
1539
|
+
if (tail.length)
|
|
1540
|
+
return clipCheckTitle(`${lead}. ${tail.join(', ')}`);
|
|
1541
|
+
if (a.conclusion === 'neutral') {
|
|
1542
|
+
if (r && r.passed + r.flaky + r.skipped > 0)
|
|
1543
|
+
return clipCheckTitle(`Nothing failed. ${[`${r.passed} passed`, r.flaky ? `${r.flaky} flaky` : '', r.skipped ? `${r.skipped} skipped` : ''].filter(Boolean).join(', ')}`);
|
|
1544
|
+
return 'Nothing failed. Needs a look';
|
|
1545
|
+
}
|
|
1546
|
+
return 'Passed';
|
|
1547
|
+
}
|
|
1548
|
+
/** What the visible sections say about each bug, in tester words: expected and got, how often it failed, and what happened to its bug report. */
|
|
1549
|
+
function bugItem(bug, o) {
|
|
1550
|
+
const out = [`- **${inline(sentence(bug.title))}**${bug.foundByReading ? ' Found by reading the code. Nobody reproduced it.' : ''}`];
|
|
1551
|
+
const times = bug.test ? failedTimes(bug.test, o.report) : '';
|
|
1552
|
+
const ea = [bug.expected ? `Expected: ${sentence(inline(bug.expected))}` : '', bug.actual ? `Got: ${sentence(inline(bug.actual))}` : '', times].filter(Boolean);
|
|
1553
|
+
if (ea.length)
|
|
1554
|
+
out.push(` ${ea.join(' ')}`);
|
|
1555
|
+
if (bug.foundByReading && bug.failingInput)
|
|
1556
|
+
out.push(` Try this input: ${sentence(inline(bug.failingInput))}`);
|
|
1557
|
+
const d = defectSentence(bug, o.defects);
|
|
1558
|
+
if (d)
|
|
1559
|
+
out.push(` ${d}`);
|
|
1560
|
+
return out;
|
|
1561
|
+
}
|
|
1562
|
+
/** "Failed 3 of 3 times." from the runner's case, when it ran the test more than once. */
|
|
1563
|
+
function failedTimes(test, report) {
|
|
1564
|
+
const c = report?.cases.find((x) => x.file === test.file && x.title === test.title && (x.status === 'failed' || x.status === 'timedOut'));
|
|
1565
|
+
return c && c.retries > 0 ? `Failed ${c.retries + 1} of ${c.retries + 1} times.` : '';
|
|
1566
|
+
}
|
|
1567
|
+
/** What became of the bug report for a bug, in plain words. Empty when the dot made none. */
|
|
1568
|
+
function defectSentence(bug, links) {
|
|
1569
|
+
const d = links?.[bug.key];
|
|
1570
|
+
if (!d)
|
|
1571
|
+
return '';
|
|
1572
|
+
if (d.state === 'filed')
|
|
1573
|
+
return d.url ? `Bug report filed: [open it](${d.url}).` : 'Bug report filed.';
|
|
1574
|
+
if (d.state === 'rejected')
|
|
1575
|
+
return 'No bug report filed yet.';
|
|
1576
|
+
return d.url ? `A bug report is ready to review: [open it](${d.url}).` : 'A bug report is ready to review in localdost.';
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The reader-facing report (KAT-96, KAT-376): written for a manual tester. It opens with what happened and whether anything
|
|
1580
|
+
* blocks, then one line of counts, then what a person does: bugs, questions, and tick items to try by hand. Everything a
|
|
1581
|
+
* developer needs sits in `details`. It gives no merge advice: the team decides the merge.
|
|
1582
|
+
*/
|
|
1583
|
+
export function renderQaSection(review, o = {}) {
|
|
1584
|
+
const showFrom = o.showFrom ?? 'medium';
|
|
1585
|
+
const ticked = o.ticked ?? new Set();
|
|
1586
|
+
const risks = shownRisks(review, showFrom);
|
|
1587
|
+
const tests = orderedTests(review);
|
|
1588
|
+
const facts = reportFacts(o.bugs ?? findBugs(review, o.report, { showFrom, ...(o.sharedBuild ? { sharedBuild: true } : {}) }), review.intents);
|
|
1589
|
+
const bugs = facts.bugs;
|
|
1590
|
+
const open = openItems(review, tests, risks, o);
|
|
1591
|
+
// Specs a ticket changes and this PR leaves alone come first: the list is cut at MAX_UNTESTED, and these must not be the lines that drop.
|
|
1592
|
+
const update = impactNoteItems(o.impactNotes, o, review.head);
|
|
1593
|
+
// A confirmed risk that no test reproduced is worth a try too, unless a check with steps already covers it.
|
|
1594
|
+
const covered = new Set(open.handTests.flatMap((h) => h.test.riskIds));
|
|
1595
|
+
const riskItems = facts.risksToTest
|
|
1596
|
+
.filter((b) => !(b.riskId && covered.has(b.riskId)))
|
|
1597
|
+
.map((b) => {
|
|
1598
|
+
const r = b.riskId ? review.risks.find((x) => x.id === b.riskId) : undefined;
|
|
1599
|
+
return b.source === 'test'
|
|
1600
|
+
? {
|
|
1601
|
+
id: checklistId({ areaId: '', scenario: `risk ${b.title}` }),
|
|
1602
|
+
first: inline(sentence(clipText(b.title, 160))),
|
|
1603
|
+
more: ['Run it on a build of this PR. If it fails there too, this is a bug.'],
|
|
1604
|
+
}
|
|
1605
|
+
: possibleBugItem(review, b.title, r?.areaId, b.actual, b.why ?? '', b.foundByReading ? b.failingInput : undefined);
|
|
1606
|
+
});
|
|
1607
|
+
const hand = [...open.handTests.map((h) => h.item), ...open.possibleBugs, ...riskItems];
|
|
1608
|
+
const untested = update.length + open.handTests.length + open.possibleBugs.length + open.other.length;
|
|
1609
|
+
const summary = {
|
|
1610
|
+
...testSummary(review, o.report, bugs.length, untested, facts.risksToTest.length, facts.questions.length, facts.behaviorChanges.length, o.sharedBuild),
|
|
1611
|
+
hand: hand.length,
|
|
1612
|
+
update: update.length,
|
|
1613
|
+
};
|
|
1614
|
+
const questionLines = facts.questions.slice(0, MAX_BUGS).map((b) => inline(questionFor(b)));
|
|
1615
|
+
const behaviorLines = facts.behaviorChanges
|
|
1616
|
+
.slice(0, MAX_BUGS)
|
|
1617
|
+
.map(({ bug, intent }) => `**${inline(sentence(bug.title))}** ${intentWhere(intent)}: "${inline(intent.text)}" Confirm this is the behavior you want.`);
|
|
1618
|
+
const body = [];
|
|
1619
|
+
if (bugs.length) {
|
|
1620
|
+
body.push('', '### Bugs found', '');
|
|
1621
|
+
body.push(...bugs.slice(0, MAX_BUGS).flatMap((b) => bugItem(b, o)));
|
|
1622
|
+
const rest = more(bugs.length - MAX_BUGS, 'bugs');
|
|
1623
|
+
if (rest)
|
|
1624
|
+
body.push('', rest);
|
|
1625
|
+
}
|
|
1626
|
+
if (facts.questions.length) {
|
|
1627
|
+
// No concrete Expected, so no bug and no defect: the team or the ticket answers it.
|
|
1628
|
+
body.push('', '### Questions for the team', '');
|
|
1629
|
+
body.push(...questionLines.map((l) => `- ${l}`));
|
|
1630
|
+
const rest = more(facts.questions.length - MAX_BUGS, 'questions');
|
|
1631
|
+
if (rest)
|
|
1632
|
+
body.push('', rest);
|
|
1633
|
+
}
|
|
1634
|
+
const notCovered = notCoveredLines(review);
|
|
1635
|
+
if (notCovered.length) {
|
|
1636
|
+
// A criterion of a linked ticket that no changed line touches: a question for the team, no bug and no defect.
|
|
1637
|
+
body.push('', '### Not covered by this PR', '');
|
|
1638
|
+
body.push(...notCovered.map((l) => `- ${l}`));
|
|
1639
|
+
const rest = more((review.criteriaNotCovered?.length ?? 0) - MAX_BUGS, 'criteria');
|
|
1640
|
+
if (rest)
|
|
1641
|
+
body.push('', rest);
|
|
1642
|
+
}
|
|
1643
|
+
if (facts.behaviorChanges.length) {
|
|
1644
|
+
// The author says the change does this on purpose: a note for the team to confirm, never a defect.
|
|
1645
|
+
body.push('', '### Behavior changes to confirm', '');
|
|
1646
|
+
body.push(...behaviorLines.map((l) => `- ${l}`));
|
|
1647
|
+
const rest = more(facts.behaviorChanges.length - MAX_BUGS, 'behavior changes');
|
|
1648
|
+
if (rest)
|
|
1649
|
+
body.push('', rest);
|
|
1650
|
+
}
|
|
1651
|
+
if (hand.length) {
|
|
1652
|
+
body.push('', '### Please try by hand', '');
|
|
1653
|
+
body.push(...hand.slice(0, MAX_UNTESTED).flatMap((i) => tickLines(i, ticked)));
|
|
1654
|
+
const rest = more(hand.length - MAX_UNTESTED, 'things');
|
|
1655
|
+
if (rest)
|
|
1656
|
+
body.push('', rest);
|
|
1657
|
+
}
|
|
1658
|
+
if (update.length) {
|
|
1659
|
+
body.push('', '### Tests to update', '');
|
|
1660
|
+
body.push(...update.slice(0, MAX_UNTESTED).flatMap((i) => tickLines(i, ticked)));
|
|
1661
|
+
const rest = more(update.length - MAX_UNTESTED, 'tests');
|
|
1662
|
+
if (rest)
|
|
1663
|
+
body.push('', rest);
|
|
1664
|
+
}
|
|
1665
|
+
const conclusion = checkConclusionFor(o.testConclusion ?? verdictConclusion(o.verdict), o.report, [...bugs, ...facts.risksToTest, ...facts.questions], o.blockOn, o.sharedBuild);
|
|
1666
|
+
const failedTests = o.report && !o.report.notRun ? o.report.failed : 0;
|
|
1667
|
+
const verdict = verdictText({
|
|
1668
|
+
conclusion,
|
|
1669
|
+
...(o.task ? { task: o.task } : {}),
|
|
1670
|
+
failedTests,
|
|
1671
|
+
testRunFailed: (o.testConclusion ?? verdictConclusion(o.verdict)) === 'failure',
|
|
1672
|
+
bugs: bugs.length,
|
|
1673
|
+
hand: hand.length,
|
|
1674
|
+
questions: facts.questions.length,
|
|
1675
|
+
behaviorChanges: facts.behaviorChanges.length,
|
|
1676
|
+
...(o.verdict ? { outcome: o.verdict.outcome } : {}),
|
|
1677
|
+
...(o.report?.notRun ? { notRun: o.report.notRun } : {}),
|
|
1678
|
+
});
|
|
1679
|
+
const checkTitle = checkTitleFor({
|
|
1680
|
+
conclusion,
|
|
1681
|
+
...(o.report ? { report: o.report } : {}),
|
|
1682
|
+
bugs,
|
|
1683
|
+
hand: hand.length,
|
|
1684
|
+
questions: facts.questions.length,
|
|
1685
|
+
behaviorChanges: facts.behaviorChanges.length,
|
|
1686
|
+
...(o.sharedBuild ? { sharedBuild: true } : {}),
|
|
1687
|
+
...(o.verdict ? { outcome: o.verdict.outcome } : {}),
|
|
1688
|
+
});
|
|
1689
|
+
return {
|
|
1690
|
+
bugs,
|
|
1691
|
+
facts,
|
|
1692
|
+
summary,
|
|
1693
|
+
head: inline(summary.line),
|
|
1694
|
+
body: body.join('\n'),
|
|
1695
|
+
details: [
|
|
1696
|
+
...impactNoteFiles(o.impactNotes),
|
|
1697
|
+
...linkedTicketParts(review),
|
|
1698
|
+
...developerParts(review, o, tests, bugs, facts.risksToTest, open.other),
|
|
1699
|
+
...detailParts(review, o, risks, tests),
|
|
1700
|
+
],
|
|
1701
|
+
verdict,
|
|
1702
|
+
counts: countsLine(review, tests, o),
|
|
1703
|
+
conclusion,
|
|
1704
|
+
checkTitle,
|
|
1705
|
+
ticks: hand.length + update.length,
|
|
1706
|
+
handLines: hand.map((i) => i.first),
|
|
1707
|
+
questionLines,
|
|
1708
|
+
behaviorLines,
|
|
1709
|
+
updateLines: update.map((i) => i.first),
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
/** What a developer wants first in Details: bugs with steps and evidence, risks, what ran, suggestions, and the open checks nobody does by hand. */
|
|
1713
|
+
function developerParts(review, o, tests, bugs, risksToTest, other) {
|
|
1714
|
+
const parts = [];
|
|
1715
|
+
const listed = (list, what) => {
|
|
1716
|
+
const rest = more(list.length - MAX_BUGS, what);
|
|
1717
|
+
return `${list
|
|
1718
|
+
.slice(0, MAX_BUGS)
|
|
1719
|
+
.flatMap((b) => bugLines(b, review.head, o))
|
|
1720
|
+
.join('\n')}${rest ? `\n\n${rest}` : ''}`;
|
|
1721
|
+
};
|
|
1722
|
+
if (bugs.length)
|
|
1723
|
+
parts.push(`**Bugs, with steps and evidence**\n\n${listed(bugs, 'bugs')}`);
|
|
1724
|
+
if (risksToTest.length)
|
|
1725
|
+
parts.push(`**Risks to test**\n\n${listed(risksToTest, 'risks')}`);
|
|
1726
|
+
const tested = testedLines(review, tests, o);
|
|
1727
|
+
if (tested.length)
|
|
1728
|
+
parts.push(`**What I tested**\n\n${tested.join('\n')}`);
|
|
1729
|
+
if (other.length) {
|
|
1730
|
+
const rest = more(other.length - MAX_UNTESTED, 'checks');
|
|
1731
|
+
parts.push(`**Not tested yet, for developers**\n\n${other
|
|
1732
|
+
.slice(0, MAX_UNTESTED)
|
|
1733
|
+
.map((l) => `- ${l}`)
|
|
1734
|
+
.join('\n')}${rest ? `\n\n${rest}` : ''}`);
|
|
1735
|
+
}
|
|
1736
|
+
const suggestions = suggestionLines(review, o);
|
|
1737
|
+
if (suggestions.length)
|
|
1738
|
+
parts.push(`**Suggestions**\n\n${suggestions.join('\n')}`);
|
|
1739
|
+
const note = changelogNoteLine(review);
|
|
1740
|
+
if (note)
|
|
1741
|
+
parts.push(note);
|
|
1742
|
+
return parts;
|
|
1743
|
+
}
|
|
1744
|
+
function seconds(ms) {
|
|
1745
|
+
return ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.max(1, Math.round(ms / 1000))} s`;
|
|
1746
|
+
}
|
|
1747
|
+
/** Everything internal: ids, verifier counts, scope, head sha, engines, timings. */
|
|
1748
|
+
function detailParts(review, o, risks, tests) {
|
|
1749
|
+
const parts = [];
|
|
1750
|
+
parts.push(`**Test run:** ${testsLine(o.report)}`);
|
|
1751
|
+
if (o.report?.testRepos?.length)
|
|
1752
|
+
parts.push(`**Tests repo**\n\n${o.report.testRepos.map((t) => `- ${inline(testRepoDetail(t))}`).join('\n')}`);
|
|
1753
|
+
if (review.ci?.jobs.length) {
|
|
1754
|
+
const lines = review.ci.jobs.map((j) => `- ${inline(j.name)}: ${j.status === 'completed' ? (j.conclusion ?? 'completed') : j.status}${j.passed + j.failed ? `, ${j.passed} passed, ${j.failed} failed` : ''}`);
|
|
1755
|
+
parts.push(`**CI at ${code(review.ci.head.slice(0, 7))}**\n\n${lines.join('\n')}`);
|
|
1756
|
+
}
|
|
1757
|
+
const v = o.verdict;
|
|
1758
|
+
const notes = v?.summary ? withoutTestCountClaims(withoutNoTestsClaims(withoutMergeAdvice(safeSummaryMarkdown(v.summary.trim())), o.report), o.report) : '';
|
|
1759
|
+
if (notes)
|
|
1760
|
+
parts.push(`**Test notes**\n\n${noHiddenMarkers(noMentions(notes))}`);
|
|
1761
|
+
const changeSummary = withoutTestCountClaims(withoutNoTestsClaims(inline(review.summary ?? ''), o.report), o.report);
|
|
1762
|
+
if (changeSummary)
|
|
1763
|
+
parts.push(`**Change summary:** ${changeSummary}`);
|
|
1764
|
+
if (risks.length) {
|
|
1765
|
+
const lines = risks.slice(0, MAX_RISK_ROWS).map((r) => {
|
|
1766
|
+
const out = [
|
|
1767
|
+
`- ${code(r.id)} ${inline(r.title)} · ${r.severity}, ${r.likelihood} · verifier ${r.verifier.status}${r.verifier.severity ? `, lowered to ${r.verifier.severity}` : ''} · ${code(refText(r.evidence[0]))}`,
|
|
1768
|
+
` Why: ${inline(r.why)} User impact: ${inline(r.userImpact)}`,
|
|
1769
|
+
];
|
|
1770
|
+
if (r.verifier.status === 'uncertain' && r.verifier.missing)
|
|
1771
|
+
out.push(` Verifier could not confirm: ${inline(r.verifier.missing)}`);
|
|
1772
|
+
return out.join('\n');
|
|
1773
|
+
});
|
|
1774
|
+
parts.push(`**Risks**\n\n${lines.join('\n')}`);
|
|
1775
|
+
}
|
|
1776
|
+
if (tests.length) {
|
|
1777
|
+
const lines = tests.slice(0, MAX_CHECKLIST).map((t) => {
|
|
1778
|
+
const extra = [
|
|
1779
|
+
t.existingTitle ? `test "${inline(t.existingTitle)}"` : '',
|
|
1780
|
+
t.steps?.length ? `steps: ${t.steps.map((x) => inline(x).replace(/[.;]+$/, '')).join('; ')}` : '',
|
|
1781
|
+
t.expected ? `expected: ${inline(t.expected)}` : '',
|
|
1782
|
+
t.data ? `data: ${inline(t.data)}` : '',
|
|
1783
|
+
t.riskIds.length ? `risks ${t.riskIds.join(', ')}` : '',
|
|
1784
|
+
].filter(Boolean);
|
|
1785
|
+
const ref = itemRef(review, t);
|
|
1786
|
+
if (ref)
|
|
1787
|
+
extra.push(`start at ${fileLink(o, review.head, ref)}`);
|
|
1788
|
+
return `- ${code(t.id)} ${t.priority} · ${t.type}${areaName(review, t.areaId) ? ` · ${inline(areaName(review, t.areaId))}` : ''}${extra.length ? ` · ${extra.join(' · ')}` : ''}`;
|
|
1789
|
+
});
|
|
1790
|
+
parts.push(`**Checks**\n\n${lines.join('\n')}`);
|
|
1791
|
+
}
|
|
1792
|
+
const atRisk = review.testsAtRisk.filter((k) => k.verifier.status !== 'refuted');
|
|
1793
|
+
if (atRisk.length) {
|
|
1794
|
+
const lines = atRisk
|
|
1795
|
+
.slice(0, MAX_TESTS_AT_RISK)
|
|
1796
|
+
.map((k) => `- ${code(`${k.path}${k.testLine ? `:${k.testLine}` : ''}`)}${k.title ? ` ${inline(k.title)}` : ''}: ${inline(k.reason)}`);
|
|
1797
|
+
const rest = more(atRisk.length - MAX_TESTS_AT_RISK, 'tests at risk');
|
|
1798
|
+
parts.push(`**Tests the change may break**\n\n${lines.join('\n')}${rest ? `\n\n${rest}` : ''}`);
|
|
1799
|
+
}
|
|
1800
|
+
const scope = review.regressionScope;
|
|
1801
|
+
const scopeAreas = scope.areaIds.map((id) => areaName(review, id)).filter(Boolean);
|
|
1802
|
+
const scopeReason = scope.reason ? withoutTestCountClaims(withoutNoTestsClaims(inline(scope.reason), o.report), o.report) : '';
|
|
1803
|
+
parts.push(`**Regression scope:** ${scope.level}${scopeAreas.length ? ` (${scopeAreas.map(inline).join(', ')})` : ''}.${scopeReason ? ` ${scopeReason}` : ''}`);
|
|
1804
|
+
const c = review.coverage;
|
|
1805
|
+
const head = review.head ? fileLinkCommit(o, review.head) : code('unknown');
|
|
1806
|
+
const review_ = [
|
|
1807
|
+
`Head ${head}${review.staleFor ? `, out of date: newer head ${code(review.staleFor.slice(0, 12))}` : ''}.`,
|
|
1808
|
+
`Coverage ${c.status}: ${c.hunksDelivered} of ${c.hunks} changed ranges, signals ${c.signalsDisposed} of ${c.signalsShown} disposed${c.signalsNotShown ? `, ${c.signalsNotShown} not shown` : ''}.`,
|
|
1809
|
+
`${sentence(verifierLine(review).replace(/^v/, 'V'))}`,
|
|
1810
|
+
];
|
|
1811
|
+
if (c.status === 'incomplete')
|
|
1812
|
+
for (const m of c.missing.slice(0, 3))
|
|
1813
|
+
review_.push(`Incomplete: ${inline(m)}`);
|
|
1814
|
+
if (review.droppedByVerifier.length) {
|
|
1815
|
+
const d = review.droppedByVerifier
|
|
1816
|
+
.slice(0, 5)
|
|
1817
|
+
.map((x) => `"${inline(x.title)}": ${inline(x.reason)}${x.evidence ? ` at ${code(refText(x.evidence))}` : x.namedTest ? ` (${code(x.namedTest)})` : ''}`);
|
|
1818
|
+
review_.push(`Dropped by verifier: ${review.droppedByVerifier.length} (${d.join('; ')}).`);
|
|
1819
|
+
}
|
|
1820
|
+
if (review.belowFloor.length)
|
|
1821
|
+
review_.push(`Below the confidence floor: ${review.belowFloor.length}.`);
|
|
1822
|
+
if (review.belowThreshold)
|
|
1823
|
+
review_.push(`${review.belowThreshold} lower risk${review.belowThreshold === 1 ? '' : 's'} below the display threshold.`);
|
|
1824
|
+
parts.push(`**Review:** ${review_.join(' ')}`);
|
|
1825
|
+
if (review.passes.length) {
|
|
1826
|
+
const engines = review.passes.map((p) => `${p.role === 'reviewer' ? 'Reviewer' : 'Verifier'} ${inline(p.model)} (${p.backend}), ${p.rounds} round${p.rounds === 1 ? '' : 's'}${p.durationMs ? `, ${seconds(p.durationMs)}` : ''}${p.errors.length ? `, ${p.errors.length} check error${p.errors.length === 1 ? '' : 's'} left` : ''}.`);
|
|
1827
|
+
const run = o.report?.durationMs ? ` Tests took ${seconds(o.report.durationMs)}.` : '';
|
|
1828
|
+
parts.push(`**Engines and timings:** ${engines.join(' ')}${run}`);
|
|
1829
|
+
}
|
|
1830
|
+
if (v)
|
|
1831
|
+
parts.push(`**Model confidence:** ${Math.round(v.confidence * 100)}%.`);
|
|
1832
|
+
const gaps = review.testGaps.filter((g) => g.verifier.status !== 'refuted');
|
|
1833
|
+
if (review.impactAreas.length || gaps.length) {
|
|
1834
|
+
const lines = review.impactAreas.map((a) => `- ${code(a.id)} ${inline(a.area)}: ${a.files.slice(0, 6).map(code).join(', ')}${a.files.length > 6 ? `, and ${a.files.length - 6} more` : ''}${a.userFlows.length ? ` · flows: ${a.userFlows.map(inline).join(', ')}` : ''}`);
|
|
1835
|
+
for (const g of gaps)
|
|
1836
|
+
lines.push(`- Gap ${code(g.id)} · ${inline(areaName(review, g.areaId) || 'unmapped')} · ${inline(g.missing)}`);
|
|
1837
|
+
parts.push(`**Impact areas and test gaps**\n\n${lines.join('\n')}`);
|
|
1838
|
+
}
|
|
1839
|
+
const rn = review.releaseNotesImpact;
|
|
1840
|
+
parts.push(rn.userVisible && rn.note
|
|
1841
|
+
? `**Release notes:** user-visible${rn.breaking ? ', breaking' : ''}. "${inline(rn.note)}"`
|
|
1842
|
+
: '**Release notes:** not user-visible.');
|
|
1843
|
+
return parts;
|
|
1844
|
+
}
|
|
1845
|
+
function fileLinkCommit(o, head) {
|
|
1846
|
+
const short = code(head.slice(0, 12));
|
|
1847
|
+
if (!o.repo || !/^[0-9a-f]{7,40}$/i.test(head))
|
|
1848
|
+
return short;
|
|
1849
|
+
const base = (o.serverUrl ?? 'https://github.com').replace(/\/$/, '');
|
|
1850
|
+
return `[${short}](${base}/${encodeURIComponent(o.repo.owner)}/${encodeURIComponent(o.repo.name)}/commit/${head})`;
|
|
1851
|
+
}
|
|
1852
|
+
/** Merge advice the dot does not give (KAT-96): the team decides the merge. */
|
|
1853
|
+
const MERGE_ADVICE_RE = /safe to (merge|release)|merge (readiness|safety)|\b(do not|don't|ok to|okay to|ready to) merge\b|\bhold (the|this) (pr|merge)\b|\bblocks? the merge\b|\bbefore (you )?merge\b/i;
|
|
1854
|
+
/** Model text with every line and sentence that gives merge advice removed. */
|
|
1855
|
+
export function withoutMergeAdvice(md) {
|
|
1856
|
+
return md
|
|
1857
|
+
.split('\n')
|
|
1858
|
+
.map((line) => MERGE_ADVICE_RE.test(line)
|
|
1859
|
+
? line
|
|
1860
|
+
.split(/(?<=[.!?])\s+/)
|
|
1861
|
+
.filter((x) => !MERGE_ADVICE_RE.test(x))
|
|
1862
|
+
.join(' ')
|
|
1863
|
+
: line)
|
|
1864
|
+
.filter((line, i, all) => line.trim() !== '' || (i > 0 && all[i - 1].trim() !== ''))
|
|
1865
|
+
.filter((line) => !/^\s*[-*]\s*$/.test(line))
|
|
1866
|
+
.join('\n')
|
|
1867
|
+
.trim();
|
|
1868
|
+
}
|
|
1869
|
+
/** Wraps Details parts in one collapsed section. */
|
|
1870
|
+
export function detailsBlock(parts, summary = DETAILS_SUMMARY) {
|
|
1871
|
+
return parts.length ? `<details><summary>${summary}</summary>\n\n${parts.join('\n\n')}\n\n</details>` : '';
|
|
1872
|
+
}
|
|
1873
|
+
// ---------- the change summary (KAT-144) ----------
|
|
1874
|
+
export const MAX_SUMMARY_AREAS = 6;
|
|
1875
|
+
export const TEST_RESULT_LABEL = '**Test result:**';
|
|
1876
|
+
/** "What this change does" and one table row per area: size with files and lines, and risk. No file names: the run page lists them. */
|
|
1877
|
+
export function changeSummaryLines(c) {
|
|
1878
|
+
const out = [`**What this change does:** ${inline(c.whatItDoes)}`];
|
|
1879
|
+
if (!c.areas.length)
|
|
1880
|
+
return out;
|
|
1881
|
+
out.push('', '| Area | Size | Risk |', '|---|---|---|');
|
|
1882
|
+
for (const a of c.areas.slice(0, MAX_SUMMARY_AREAS))
|
|
1883
|
+
out.push(`| ${cell(a.area)} | ${SIZE_WORD[a.size]}: ${plural(a.files.length, 'file')}, +${a.additions} -${a.deletions} | ${RISK_WORD[a.risk]} |`);
|
|
1884
|
+
const rest = c.areas.length - MAX_SUMMARY_AREAS;
|
|
1885
|
+
if (rest > 0)
|
|
1886
|
+
out.push(`| ${plural(rest, 'more area')} | | |`);
|
|
1887
|
+
return out;
|
|
1888
|
+
}
|
|
1889
|
+
/** Breaks every @mention with a zero-width space; the rest of the Markdown stays as written. */
|
|
1890
|
+
export function noMentions(s) {
|
|
1891
|
+
return s.replace(/@(?=[A-Za-z0-9_-])/g, `@${ZWSP}`);
|
|
1892
|
+
}
|
|
1893
|
+
/** Shows `<!--` as text, so model-written Markdown cannot add a hidden checklist id or marker. */
|
|
1894
|
+
export function noHiddenMarkers(s) {
|
|
1895
|
+
return s.replace(/<!--/g, '<!--');
|
|
1896
|
+
}
|
|
1897
|
+
/** The older footer of ticket and CI comments. */
|
|
1898
|
+
export function footer(runUrl) {
|
|
1899
|
+
return `<sub>localdost${runUrl ? ` · [Open the run](${runUrl})` : ''}</sub>`;
|
|
1900
|
+
}
|
|
1901
|
+
/** Who ticks the boxes, and what a tick does. Shown only above a list that has tick items. */
|
|
1902
|
+
export const WHO_TICKS = 'Who ticks: the PR author or QA (anyone with write access). localdost keeps each tick.';
|
|
1903
|
+
/**
|
|
1904
|
+
* The last lines of the report above its Details: the link to the full check, and how to ask for a new one. The link shows
|
|
1905
|
+
* only when the server has a public address. A reply of `/localdost review` works only where GitHub sends events to the server.
|
|
1906
|
+
*/
|
|
1907
|
+
export function reportFooter(o) {
|
|
1908
|
+
// A fork PR gets the same "tests did not run" after a new push, so the report does not offer a new check (KAT-384).
|
|
1909
|
+
// Its own line already says who can lift that.
|
|
1910
|
+
if (o.report?.notRun?.fork)
|
|
1911
|
+
return o.runUrl ? `[Open the full check](${o.runUrl})` : '';
|
|
1912
|
+
const again = o.recheckByReply === false ? 'Push a new commit to check again.' : 'Reply `/localdost review` to check again.';
|
|
1913
|
+
return o.runUrl ? `[Open the full check](${o.runUrl}) · ${again}` : again;
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* The verdict summary keeps light Markdown (bold, lists, code) but loses everything that can point
|
|
1917
|
+
* elsewhere or render: raw HTML, images, and links (kept as their text). Model output can echo PR text.
|
|
1918
|
+
*/
|
|
1919
|
+
export function safeSummaryMarkdown(s) {
|
|
1920
|
+
return s
|
|
1921
|
+
.replace(/<[^>]*>/g, '')
|
|
1922
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
1923
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
1924
|
+
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
|
|
1925
|
+
.replace(/^\s*\[[^\]]+\]:\s*\S+.*$/gm, '')
|
|
1926
|
+
.split(/(`[^`\n]*`)/)
|
|
1927
|
+
.map((part, i) => (i % 2 ? part : codeBareUrls(part)))
|
|
1928
|
+
.join('');
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Wraps bare URLs in inline code so GitHub does not autolink them. URLs already inside a code span
|
|
1932
|
+
* are left alone, and trailing punctuation stays outside the span: "at http://x:4400, so" becomes
|
|
1933
|
+
* "at `http://x:4400`, so" (KAT-70, seen in the recorded demo as "``http://localhost:4400,`").
|
|
1934
|
+
*/
|
|
1935
|
+
function codeBareUrls(s) {
|
|
1936
|
+
return s.replace(/\bhttps?:\/\/[^\s`<>]*[^\s`<>.,;:!?'")\]]/g, (u) => `\`${u}\``);
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* The sticky PR comment (KAT-96, KAT-376), written for a manual tester: the verdict and whether anything blocks, one line of
|
|
1940
|
+
* counts, the bugs and questions, tick items to try by hand, who ticks, and how to check again. Everything else sits in one
|
|
1941
|
+
* collapsed section for developers. Stays under COMMENT_LIMIT by dropping parts of that section from the end before truncating.
|
|
1942
|
+
* A change in two or more areas keeps its areas table above the sections; a change in one area explains itself in Details.
|
|
1943
|
+
*/
|
|
1944
|
+
export function renderStickyComment(x) {
|
|
1945
|
+
const v = x.verdict;
|
|
1946
|
+
const top = [PR_MARKER];
|
|
1947
|
+
if (!x.review) {
|
|
1948
|
+
const ticked = new Set([...(x.ticked ?? []), ...tickedIds(x.previous)]);
|
|
1949
|
+
top.push(renderPlainComment(v, {
|
|
1950
|
+
ticked,
|
|
1951
|
+
runUrl: x.runUrl,
|
|
1952
|
+
kind: x.task ?? 'pr_check',
|
|
1953
|
+
report: x.report,
|
|
1954
|
+
bugs: x.bugs,
|
|
1955
|
+
defects: x.defects,
|
|
1956
|
+
repo: x.repo,
|
|
1957
|
+
serverUrl: x.serverUrl,
|
|
1958
|
+
impactNotes: x.impactNotes,
|
|
1959
|
+
...(x.recheckByReply === false ? { recheckByReply: false } : {}),
|
|
1960
|
+
}).replace(`${PR_MARKER}\n`, ''));
|
|
1961
|
+
return carryTicks(top.join('\n'), x.previous);
|
|
1962
|
+
}
|
|
1963
|
+
top.push(`<!-- localdost:qa-review head=${(x.review.head || '').slice(0, 12)} -->`);
|
|
1964
|
+
const ticked = new Set([...(x.ticked ?? []), ...tickedIds(x.previous)]);
|
|
1965
|
+
const qa = renderQaSection(x.review, { ...x, ticked });
|
|
1966
|
+
const cs = x.review.changeSummary;
|
|
1967
|
+
const areas = !!cs && cs.areas.length >= 2;
|
|
1968
|
+
const foot = reportFooter(x);
|
|
1969
|
+
const who = qa.ticks ? [WHO_TICKS] : [];
|
|
1970
|
+
const details = [...(cs && !areas ? [`**What this change does:** ${inline(cs.whatItDoes)}`] : []), ...qa.details];
|
|
1971
|
+
const lead = [`**localdost: ${qa.verdict}**`, qa.counts, ...(cs && areas ? ['', ...changeSummaryLines(cs)] : [])];
|
|
1972
|
+
const build = () => [...top, ...lead, qa.body, '', ...who, ...(who.length ? [''] : []), foot, '', detailsBlock(details)].join('\n').replace(/\n{3,}/g, '\n\n');
|
|
1973
|
+
let body = carryTicks(build(), x.previous);
|
|
1974
|
+
while (body.length > COMMENT_LIMIT && details.length) {
|
|
1975
|
+
details.pop();
|
|
1976
|
+
body = carryTicks(build(), x.previous);
|
|
1977
|
+
}
|
|
1978
|
+
if (body.length > COMMENT_LIMIT)
|
|
1979
|
+
body = `${body.slice(0, COMMENT_LIMIT - 60)}\n\nComment truncated. Open the run for the rest.`;
|
|
1980
|
+
return body;
|
|
1981
|
+
}
|
|
1982
|
+
const CI_CAUSE = {
|
|
1983
|
+
product_failure: 'a bug in the change',
|
|
1984
|
+
test_failure: 'a broken test',
|
|
1985
|
+
environment_failure: 'a CI setup problem',
|
|
1986
|
+
flaky: 'a flaky test',
|
|
1987
|
+
};
|
|
1988
|
+
/** Lower-case words and digits only, so a bullet and the line it repeats compare equal whatever the markup. */
|
|
1989
|
+
function plainWords(s) {
|
|
1990
|
+
return s
|
|
1991
|
+
.toLowerCase()
|
|
1992
|
+
.replace(/&(?:amp|lt|gt);/g, ' ')
|
|
1993
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
1994
|
+
.trim();
|
|
1995
|
+
}
|
|
1996
|
+
/** Bold labels the comment already has as headings. Any other label names a list of its own and stays with its bullets. */
|
|
1997
|
+
const SHOWN_LABEL = /^(?:risks?|open questions?|questions?|findings?|suggestions?|(?:test )?ideas|what qa will test)$/i;
|
|
1998
|
+
/**
|
|
1999
|
+
* The model summary of a ticket review without what the comment already lists: headings, labels such as **Risks**, and
|
|
2000
|
+
* bullets whose words start with a question, a risk or a test idea shown above. Prose and bullets that explain stay,
|
|
2001
|
+
* and so does a label of its own (**Existing code and tests at risk**) with the bullets under it. A label whose bullets
|
|
2002
|
+
* all repeat something shown goes too, so no label hangs over nothing.
|
|
2003
|
+
*/
|
|
2004
|
+
function withoutRepeats(summary, shown) {
|
|
2005
|
+
const keep = [];
|
|
2006
|
+
// Index in `keep` of a label of its own, until a line of content follows it.
|
|
2007
|
+
let open = -1;
|
|
2008
|
+
const closeLabel = () => {
|
|
2009
|
+
if (open >= 0)
|
|
2010
|
+
keep.splice(open, 1);
|
|
2011
|
+
open = -1;
|
|
2012
|
+
};
|
|
2013
|
+
for (const line of summary.split('\n')) {
|
|
2014
|
+
const t = line.trim();
|
|
2015
|
+
if (!t) {
|
|
2016
|
+
keep.push('');
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
const label = /^\*\*([^*]+?):?\*\*:?$/.exec(t);
|
|
2020
|
+
if (/^#{1,6}\s/.test(t) || label) {
|
|
2021
|
+
closeLabel();
|
|
2022
|
+
if (label && !SHOWN_LABEL.test(label[1].trim())) {
|
|
2023
|
+
open = keep.length;
|
|
2024
|
+
keep.push(line);
|
|
2025
|
+
}
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
const bullet = /^(?:[-*+]|\d+[.)])\s+(?:\[[ x]\]\s+)?(.*)$/.exec(t);
|
|
2029
|
+
if (bullet) {
|
|
2030
|
+
const words = plainWords(bullet[1]);
|
|
2031
|
+
if (shown.some((x) => words === x || words.startsWith(`${x} `) || x.startsWith(`${words} `)))
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
open = -1;
|
|
2035
|
+
keep.push(line);
|
|
2036
|
+
}
|
|
2037
|
+
closeLabel();
|
|
2038
|
+
return keep
|
|
2039
|
+
.join('\n')
|
|
2040
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
2041
|
+
.trim();
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* A comment from a verdict alone: CI triage, ticket review, and PR or merge checks without a QA
|
|
2045
|
+
* review. Same report shape as the sticky comment: a summary line, Bugs found, What I tested, Not
|
|
2046
|
+
* tested yet, Questions, then Details. Trackers do not render <details>, so `collapse: false`
|
|
2047
|
+
* gives a plain Details heading.
|
|
2048
|
+
*/
|
|
2049
|
+
export function renderPlainComment(v, o) {
|
|
2050
|
+
const safe = (s) => noHiddenMarkers(noMentions(s));
|
|
2051
|
+
const ideas = (v.testIdeas ?? []).slice(0, MAX_CHECKLIST);
|
|
2052
|
+
const questions = v.openQuestions ?? [];
|
|
2053
|
+
const findings = [...(v.findings ?? [])]
|
|
2054
|
+
.sort((a, b) => ['high', 'medium', 'low'].indexOf(a.severity) - ['high', 'medium', 'low'].indexOf(b.severity))
|
|
2055
|
+
.slice(0, MAX_RISK_ROWS);
|
|
2056
|
+
const ro = { repo: o.repo, serverUrl: o.serverUrl, defects: o.defects, runUrl: o.runUrl };
|
|
2057
|
+
const out = [PR_MARKER];
|
|
2058
|
+
const details = [];
|
|
2059
|
+
const finding = (f) => `- **${SEV_WORD[f.severity]}:** ${inline(sentence(f.title))}${f.file ? ` ${code(`${f.file}${f.line ? `:${f.line}` : ''}`)}` : ''}`;
|
|
2060
|
+
// The Details of a ticket review hold explanations only. Lines of the summary that repeat a question, a risk or a test idea go.
|
|
2061
|
+
const ticketDetails = [];
|
|
2062
|
+
let hasWhy = false;
|
|
2063
|
+
if (o.kind === 'ticket_review') {
|
|
2064
|
+
// The summary counts the lines that are rendered below, after the cap and after repeated lines are dropped,
|
|
2065
|
+
// never the model's own count (KAT-371).
|
|
2066
|
+
const questionTexts = uniqueLines(questions.map((q) => inline(sentence(q)))).slice(0, 8);
|
|
2067
|
+
const riskRows = uniqueLines(findings.map((f) => `${SEV_WORD[f.severity]}: ${inline(sentence(f.title))}`));
|
|
2068
|
+
const ideaTexts = uniqueLines((v.testIdeas ?? []).map((t) => inline(sentence(testerIdea(t))))).slice(0, MAX_CHECKLIST);
|
|
2069
|
+
const subject = o.ticket?.key ? inline(o.ticket.key) : 'This ticket';
|
|
2070
|
+
const q = questionTexts.length;
|
|
2071
|
+
// A review after an answer (KAT-393) says which questions of the last review are answered now, and who answered.
|
|
2072
|
+
const answered = (v.answeredQuestions ?? []).slice(0, 8);
|
|
2073
|
+
if (!q && !riskRows.length && !ideaTexts.length)
|
|
2074
|
+
out.push(`**${inline(sentence(v.headline || `${subject} was reviewed`))}**`);
|
|
2075
|
+
else if (v.outcome === 'needs_attention')
|
|
2076
|
+
out.push(`**${subject} is not ready to build yet.${q ? ` ${plural(q, 'question')} ${q === 1 ? 'needs' : 'need'} an answer first.` : ` ${plural(riskRows.length, 'risk')} to look at.`}**`);
|
|
2077
|
+
else
|
|
2078
|
+
out.push(`**${subject} is testable as written.${q ? ` ${plural(q, 'question')} ${q === 1 ? 'is' : 'are'} still open.` : ''}**`);
|
|
2079
|
+
if (answered.length) {
|
|
2080
|
+
out.push('', `${plural(answered.length, 'question')} from the last review ${answered.length === 1 ? 'is' : 'are'} answered now.`, '', '### Answered since the last review', '', ...answered.map((a) => `- ${inline(sentence(a.question))} ${inline(a.by)} answered${a.quote.trim() ? `: "${inline(a.quote).replace(/"/g, "'")}"` : '.'}`));
|
|
2081
|
+
}
|
|
2082
|
+
if (q)
|
|
2083
|
+
out.push('', `${inline(o.ticket?.reporter || 'Reporter')}, answering these now saves a round of rework.`);
|
|
2084
|
+
if (q)
|
|
2085
|
+
out.push('', '### Questions', '', ...questionTexts.map((t, i) => `${i + 1}. ${t}`));
|
|
2086
|
+
if (riskRows.length)
|
|
2087
|
+
out.push('', '### Risks', '', ...riskRows.map((t) => `- ${t}`));
|
|
2088
|
+
if (ideaTexts.length)
|
|
2089
|
+
out.push('', '### What QA will test', '', ...ideaTexts.map((t) => `- ${t}`));
|
|
2090
|
+
// KAT-304: the existing tests this ticket changes. Specs have their own heading, as titled links.
|
|
2091
|
+
const impact = o.impact;
|
|
2092
|
+
if (impact?.cases.length)
|
|
2093
|
+
out.push('', '### Existing test cases to review', '', ...impact.cases.slice(0, 8).map((c) => `- ${c.key} ${inline(c.title)}: ${c.word}. ${inline(sentence(c.why))}`));
|
|
2094
|
+
if (impact?.specs.length) {
|
|
2095
|
+
out.push('', '### Automated tests to update', '', ...impact.specs.slice(0, 8).map((x) => {
|
|
2096
|
+
const title = noBrackets(x.title);
|
|
2097
|
+
const link = x.url && /^https?:\/\//.test(x.url) ? `[${title}](${x.url})` : title;
|
|
2098
|
+
return `- ${link}: ${inline(sentence(x.why))}`;
|
|
2099
|
+
}));
|
|
2100
|
+
const unlinked = impact.specs.slice(0, 8).filter((x) => !(x.url && /^https?:\/\//.test(x.url)) && x.file);
|
|
2101
|
+
if (unlinked.length)
|
|
2102
|
+
ticketDetails.push(`**Automated test files:** ${unlinked.map((x) => code(x.file)).join(', ')}`);
|
|
2103
|
+
}
|
|
2104
|
+
// The explanation of each risk, then whatever prose the model wrote that is not one of the lines above.
|
|
2105
|
+
const why = findings.filter((f) => f.detail?.trim());
|
|
2106
|
+
hasWhy = why.length > 0;
|
|
2107
|
+
if (why.length)
|
|
2108
|
+
ticketDetails.push(why.map((f) => `- ${inline(f.title)}: ${inline(f.detail)}`).join('\n'));
|
|
2109
|
+
// The tests the change may break are listed in Details, so a bullet that only names one of them is not said twice.
|
|
2110
|
+
const shown = [
|
|
2111
|
+
...questionTexts,
|
|
2112
|
+
...answered.map((a) => a.question),
|
|
2113
|
+
...riskRows,
|
|
2114
|
+
...ideaTexts,
|
|
2115
|
+
...findings.map((f) => f.title),
|
|
2116
|
+
...(v.testsAtRisk ?? []).slice(0, 10),
|
|
2117
|
+
]
|
|
2118
|
+
.map(plainWords)
|
|
2119
|
+
.filter(Boolean);
|
|
2120
|
+
const notes = v.summary?.trim() ? withoutTestCountClaims(withoutMergeAdvice(safeSummaryMarkdown(v.summary.trim())), o.report) : '';
|
|
2121
|
+
const rest = withoutRepeats(notes, shown);
|
|
2122
|
+
if (rest)
|
|
2123
|
+
ticketDetails.unshift(safe(rest));
|
|
2124
|
+
if (o.contextNote)
|
|
2125
|
+
ticketDetails.push(safe(o.contextNote));
|
|
2126
|
+
}
|
|
2127
|
+
else if (o.kind === 'ci_failure') {
|
|
2128
|
+
const bugs = reportFacts(o.bugs ?? findBugs(undefined, o.report, { outcome: v.outcome })).bugs;
|
|
2129
|
+
out.push(`CI failed because of ${CI_CAUSE[v.outcome] ?? 'something localdost could not pin down'}. ${inline(sentence(v.headline ?? ''))}`);
|
|
2130
|
+
if (bugs.length)
|
|
2131
|
+
out.push('', '### Bugs found', '', ...bugs.slice(0, MAX_BUGS).flatMap((b) => bugLines(b, undefined, ro)));
|
|
2132
|
+
const r = o.report;
|
|
2133
|
+
if (r && !r.notRun)
|
|
2134
|
+
out.push('', '### What I tested', '', `- ${ranDetail(r)}`);
|
|
2135
|
+
if (r?.hint)
|
|
2136
|
+
out.push('', inline(r.hint));
|
|
2137
|
+
const notes = impactNoteItems(o.impactNotes, o, undefined);
|
|
2138
|
+
details.push(...impactNoteFiles(o.impactNotes));
|
|
2139
|
+
if (ideas.length || notes.length)
|
|
2140
|
+
out.push('', '### Not tested yet', '', ...notes.flatMap((i) => tickLines(i, new Set())), ...ideas.map((t) => `- [ ] ${inline(sentence(t))}`));
|
|
2141
|
+
if (findings.length)
|
|
2142
|
+
out.push('', '### Findings', '', ...findings.map(finding));
|
|
2143
|
+
if (questions.length)
|
|
2144
|
+
out.push('', '### Questions', '', ...questions.slice(0, 8).map((q) => `- ${inline(sentence(q))}`));
|
|
2145
|
+
}
|
|
2146
|
+
else {
|
|
2147
|
+
// A PR or merge report without a QA review: the same tester layout as the sticky comment.
|
|
2148
|
+
const bugs = reportFacts(o.bugs ?? findBugs(undefined, o.report, { outcome: v.outcome })).bugs;
|
|
2149
|
+
const r = o.report;
|
|
2150
|
+
const asks = questions.slice(0, 8);
|
|
2151
|
+
const update = impactNoteItems(o.impactNotes, o, undefined);
|
|
2152
|
+
const conclusion = checkConclusionFor(verdictConclusion(v), r, bugs);
|
|
2153
|
+
out.push(`**localdost: ${verdictText({
|
|
2154
|
+
conclusion,
|
|
2155
|
+
task: o.kind,
|
|
2156
|
+
failedTests: r && !r.notRun ? r.failed : 0,
|
|
2157
|
+
testRunFailed: verdictConclusion(v) === 'failure',
|
|
2158
|
+
bugs: bugs.length,
|
|
2159
|
+
hand: ideas.length,
|
|
2160
|
+
questions: asks.length,
|
|
2161
|
+
behaviorChanges: 0,
|
|
2162
|
+
outcome: v.outcome,
|
|
2163
|
+
...(r?.notRun ? { notRun: r.notRun } : {}),
|
|
2164
|
+
})}**`, countsLine({ ci: undefined, testGaps: [], impactAreas: [] }, [], { report: r, ...(o.sharedBuild ? { sharedBuild: true } : {}) }));
|
|
2165
|
+
if (bugs.length)
|
|
2166
|
+
out.push('', '### Bugs found', '', ...bugs.slice(0, MAX_BUGS).flatMap((b) => bugItem(b, { defects: o.defects, report: r })));
|
|
2167
|
+
if (asks.length)
|
|
2168
|
+
out.push('', '### Questions for the team', '', ...asks.map((q) => `- ${inline(sentence(q))}`));
|
|
2169
|
+
// Each idea has a hidden id, so its tick survives the next rewrite, and the comment says who ticks.
|
|
2170
|
+
const ticked = o.ticked ?? new Set();
|
|
2171
|
+
const idea = (t) => ({ id: checklistId({ areaId: '', scenario: `hand ${t}` }), first: inline(sentence(t)), more: [] });
|
|
2172
|
+
if (ideas.length)
|
|
2173
|
+
out.push('', '### Please try by hand', '', ...ideas.flatMap((t) => tickLines(idea(t), ticked)));
|
|
2174
|
+
if (update.length)
|
|
2175
|
+
out.push('', '### Tests to update', '', ...update.flatMap((i) => tickLines(i, ticked)));
|
|
2176
|
+
out.push('', ...(ideas.length || update.length ? [WHO_TICKS, ''] : []), reportFooter({ runUrl: o.runUrl, recheckByReply: o.recheckByReply, report: o.report }));
|
|
2177
|
+
details.push(...impactNoteFiles(o.impactNotes));
|
|
2178
|
+
if (bugs.length)
|
|
2179
|
+
details.push(`**Bugs, with steps and evidence**\n\n${bugs
|
|
2180
|
+
.slice(0, MAX_BUGS)
|
|
2181
|
+
.flatMap((b) => bugLines(b, undefined, ro))
|
|
2182
|
+
.join('\n')}`);
|
|
2183
|
+
if (r && !r.notRun)
|
|
2184
|
+
details.push(`**What I tested**\n\n- ${ranDetail(r)}${r.hint ? `\n- ${inline(r.hint)}` : ''}`);
|
|
2185
|
+
if (r?.testRepos?.length)
|
|
2186
|
+
details.push(`**Tests repo**\n\n${r.testRepos.map((t) => `- ${inline(testRepoDetail(t))}`).join('\n')}`);
|
|
2187
|
+
if (findings.length)
|
|
2188
|
+
details.push(`**Suggestions**\n\n${findings.map(finding).join('\n')}`);
|
|
2189
|
+
}
|
|
2190
|
+
if (o.kind === 'ticket_review') {
|
|
2191
|
+
details.push(...ticketDetails);
|
|
2192
|
+
if (v.testsAtRisk?.length)
|
|
2193
|
+
details.push(`**Tests the change may break:** ${v.testsAtRisk.slice(0, 10).map(code).join(', ')}`);
|
|
2194
|
+
if (v.suspectedCause)
|
|
2195
|
+
details.push(`**Suspected cause:** ${inline(v.suspectedCause)}`);
|
|
2196
|
+
const fold = hasWhy ? 'Why these risks' : 'Details';
|
|
2197
|
+
if (details.length)
|
|
2198
|
+
out.push('', o.collapse === false ? `**${fold}**\n\n${details.join('\n\n')}` : detailsBlock(details, fold));
|
|
2199
|
+
// A tracker shows <sub> as text, so the ticket comment keeps the italic footer even when Details fold.
|
|
2200
|
+
out.push('', `_localdost${o.runUrl ? ` · [Open the check](${o.runUrl})` : ''}_`);
|
|
2201
|
+
return out.join('\n');
|
|
2202
|
+
}
|
|
2203
|
+
const notes = v.summary?.trim() ? withoutTestCountClaims(withoutMergeAdvice(safeSummaryMarkdown(v.summary.trim())), o.report) : '';
|
|
2204
|
+
if (notes)
|
|
2205
|
+
details.push(safe(notes));
|
|
2206
|
+
if (o.extra)
|
|
2207
|
+
details.push(o.extra);
|
|
2208
|
+
if (findings.length)
|
|
2209
|
+
details.push(findings.map((f) => `- ${inline(f.title)}: ${inline(f.detail)}`).join('\n'));
|
|
2210
|
+
if (v.testsAtRisk?.length)
|
|
2211
|
+
details.push(`**Tests the change may break:** ${v.testsAtRisk.slice(0, 10).map(code).join(', ')}`);
|
|
2212
|
+
if (v.suspectedCause)
|
|
2213
|
+
details.push(`**Suspected cause:** ${inline(v.suspectedCause)}`);
|
|
2214
|
+
if (Number.isFinite(v.confidence))
|
|
2215
|
+
details.push(`**Model confidence:** ${Math.round(v.confidence * 100)}%.`);
|
|
2216
|
+
// A PR or merge report ends above its Details; the older kinds keep their footer last.
|
|
2217
|
+
const tester = o.kind === 'pr_check' || o.kind === 'post_merge';
|
|
2218
|
+
out.push('', o.collapse === false ? `**Details**\n\n${details.join('\n\n')}` : detailsBlock(details, tester ? DETAILS_SUMMARY : 'Details'));
|
|
2219
|
+
if (!tester)
|
|
2220
|
+
out.push('', o.collapse === false ? `_localdost${o.runUrl ? ` · [Open the run](${o.runUrl})` : ''}_` : footer(o.runUrl));
|
|
2221
|
+
return out.join('\n');
|
|
2222
|
+
}
|
|
2223
|
+
/** True when blockOn is set and a confirmed risk is at or above it. */
|
|
2224
|
+
export function blocks(review, blockOn) {
|
|
2225
|
+
if (!blockOn)
|
|
2226
|
+
return false;
|
|
2227
|
+
return review.risks.some((r) => r.verifier.status === 'confirmed' && SEV[r.severity] >= SEV[blockOn]);
|
|
2228
|
+
}
|
|
2229
|
+
/** Check-run annotations from risks and tests at risk (section 5.2). First evidence ref only. */
|
|
2230
|
+
export function annotationsFor(review, o = {}) {
|
|
2231
|
+
const showFrom = o.showFrom ?? 'medium';
|
|
2232
|
+
const out = [];
|
|
2233
|
+
const scenarioFor = (r) => orderedTests(review).find((t) => t.riskIds.includes(r.id))?.scenario;
|
|
2234
|
+
for (const r of review.risks) {
|
|
2235
|
+
const ev = r.evidence[0];
|
|
2236
|
+
if (!ev || ev.file === 'PR_BODY' || ev.line < 1 || r.severity === 'low')
|
|
2237
|
+
continue;
|
|
2238
|
+
const st = r.verifier.status;
|
|
2239
|
+
const test = scenarioFor(r);
|
|
2240
|
+
if (st === 'confirmed') {
|
|
2241
|
+
const level = SEV[r.severity] >= SEV.high ? (o.blockOn && SEV[r.severity] >= SEV[o.blockOn] ? 'failure' : 'warning') : 'notice';
|
|
2242
|
+
out.push({
|
|
2243
|
+
path: ev.file,
|
|
2244
|
+
startLine: ev.line,
|
|
2245
|
+
endLine: ev.lineEnd,
|
|
2246
|
+
level,
|
|
2247
|
+
title: plain(r.title, 255),
|
|
2248
|
+
message: plain(`${r.why}${test ? `\nTest: ${test}` : ''}`),
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
else if ((st === 'uncertain' || st === 'unverified') && SEV[r.severity] >= SEV[showFrom]) {
|
|
2252
|
+
const why = st === 'uncertain' ? `Verifier could not confirm: ${r.verifier.missing ?? r.verifier.reason}` : 'Not verified.';
|
|
2253
|
+
out.push({
|
|
2254
|
+
path: ev.file,
|
|
2255
|
+
startLine: ev.line,
|
|
2256
|
+
endLine: ev.lineEnd,
|
|
2257
|
+
level: 'notice',
|
|
2258
|
+
title: plain(`Check: ${r.title}`, 255),
|
|
2259
|
+
message: plain(`${r.why}\n${why}`),
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
for (const k of review.testsAtRisk) {
|
|
2264
|
+
if (k.verifier.status === 'refuted' || !k.evidence || k.evidence.line < 1)
|
|
2265
|
+
continue;
|
|
2266
|
+
out.push({
|
|
2267
|
+
path: k.evidence.file,
|
|
2268
|
+
startLine: k.evidence.line,
|
|
2269
|
+
endLine: k.evidence.lineEnd,
|
|
2270
|
+
level: 'notice',
|
|
2271
|
+
title: plain(`Test at risk: ${k.path}`, 255),
|
|
2272
|
+
message: plain(k.reason),
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
return out;
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* The check conclusion follows results, never merge advice: failure when a selected test failed;
|
|
2279
|
+
* for bugs only the review found, failure when `blockOn` is set and a bug is at or above it, else
|
|
2280
|
+
* neutral; neutral when the tests could not run; success when everything selected passed and no
|
|
2281
|
+
* bug was found.
|
|
2282
|
+
*/
|
|
2283
|
+
export function checkConclusionFor(testConclusion, report, bugs, blockOn,
|
|
2284
|
+
/** The tests ran against the shared QA build (KAT-363): their failures leave the check neutral, since they may not be the PR's. */
|
|
2285
|
+
sharedBuild = false) {
|
|
2286
|
+
const failedTests = (report?.failed ?? 0) > 0;
|
|
2287
|
+
if ((!sharedBuild && (failedTests || bugs.some((b) => b.source === 'test'))) || testConclusion === 'failure')
|
|
2288
|
+
return 'failure';
|
|
2289
|
+
const reviewBugs = bugs.filter((b) => b.source === 'review');
|
|
2290
|
+
if (blockOn && reviewBugs.some((b) => SEV[b.severity] >= SEV[blockOn]))
|
|
2291
|
+
return 'failure';
|
|
2292
|
+
if (reviewBugs.length || (sharedBuild && failedTests) || (report?.notRun && report.notRun.stage !== 'skipped'))
|
|
2293
|
+
return 'neutral';
|
|
2294
|
+
return testConclusion;
|
|
2295
|
+
}
|
|
2296
|
+
/** Why a user cares about a bug, in the words of the risk it belongs to. Empty when no risk is linked. */
|
|
2297
|
+
function impactOf(review, bug) {
|
|
2298
|
+
const risk = bug.riskId
|
|
2299
|
+
? review.risks.find((r) => r.id === bug.riskId)
|
|
2300
|
+
: review.risks.find((r) => review.whatToTest.some((t) => t.existingTest && bug.test && bug.test.file.endsWith(t.existingTest) && t.riskIds.includes(r.id)));
|
|
2301
|
+
return risk ? clean(risk.userImpact).trim() : '';
|
|
2302
|
+
}
|
|
2303
|
+
/** The areas a bug is in: those of its test, its risks and its file, else every area of the change. */
|
|
2304
|
+
function bugAreas(review, bug) {
|
|
2305
|
+
const linked = bug.test ? review.whatToTest.find((t) => t.existingTest && bug.test.file.endsWith(t.existingTest)) : undefined;
|
|
2306
|
+
const riskIds = new Set([...(linked?.riskIds ?? []), ...(bug.riskId ? [bug.riskId] : [])]);
|
|
2307
|
+
const ids = new Set([
|
|
2308
|
+
...(linked ? [linked.areaId] : []),
|
|
2309
|
+
...review.risks.filter((r) => riskIds.has(r.id) && r.areaId).map((r) => r.areaId),
|
|
2310
|
+
...review.impactAreas.filter((a) => bug.ref && a.files.some((f) => f === bug.ref.file)).map((a) => a.id),
|
|
2311
|
+
]);
|
|
2312
|
+
const hit = review.impactAreas.filter((a) => ids.has(a.id));
|
|
2313
|
+
const named = (hit.length ? hit : review.impactAreas).map((a) => clean(a.area).trim()).filter(Boolean);
|
|
2314
|
+
return [...new Set(named)];
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* What the GitHub check shows first (KAT-376): one sentence on what users may see and whether the check blocks merging, the
|
|
2318
|
+
* counts, each bug with what was expected and what happened, and what is left to try by hand. The developer details follow in `text`.
|
|
2319
|
+
*/
|
|
2320
|
+
function checkSummaryFor(review, qa, o) {
|
|
2321
|
+
const block = qa.conclusion === 'failure' ? 'This check is marked as failed. It blocks merging only if your repo requires it.' : 'This check does not block merging.';
|
|
2322
|
+
const impact = qa.bugs[0] ? impactOf(review, qa.bugs[0]) : '';
|
|
2323
|
+
const out = [`${impact ? `${inline(sentence(impact))} ` : ''}${block}`, '', qa.counts];
|
|
2324
|
+
for (const b of qa.bugs.slice(0, MAX_BUGS)) {
|
|
2325
|
+
const areas = bugAreas(review, b).map(inline);
|
|
2326
|
+
const times = b.test ? failedTimes(b.test, o.report) : '';
|
|
2327
|
+
// The first line already says what users may see: it is not said twice.
|
|
2328
|
+
const said = clean(impactOf(review, b)).trim() === clean(b.actual).trim();
|
|
2329
|
+
const ea = [b.expected ? `Expected: ${sentence(inline(b.expected))}` : '', b.actual && !said ? `Got: ${sentence(inline(b.actual))}` : '', times]
|
|
2330
|
+
.filter(Boolean)
|
|
2331
|
+
.join(' ');
|
|
2332
|
+
const d = defectSentence(b, o.defects);
|
|
2333
|
+
out.push('', `Bug: ${inline(clipText(b.title, 140))}${areas.length ? ` in ${listOf(areas)}` : ''}.`);
|
|
2334
|
+
if (ea)
|
|
2335
|
+
out.push(ea);
|
|
2336
|
+
if (d)
|
|
2337
|
+
out.push(d);
|
|
2338
|
+
}
|
|
2339
|
+
const rest = more(qa.bugs.length - MAX_BUGS, 'bugs');
|
|
2340
|
+
if (rest)
|
|
2341
|
+
out.push('', rest);
|
|
2342
|
+
// The sections of the comment, in the same order, so the check loses none of them.
|
|
2343
|
+
const section = (title, lines, total, cap, what) => {
|
|
2344
|
+
if (!lines.length)
|
|
2345
|
+
return;
|
|
2346
|
+
out.push('', title, ...lines.slice(0, cap).map((l) => `- ${l}`));
|
|
2347
|
+
const rest = more(total - Math.min(lines.length, cap), what);
|
|
2348
|
+
if (rest)
|
|
2349
|
+
out.push('', rest);
|
|
2350
|
+
};
|
|
2351
|
+
section('Questions for the team:', qa.questionLines, qa.facts.questions.length, MAX_BUGS, 'questions');
|
|
2352
|
+
section('Behavior changes to confirm:', qa.behaviorLines, qa.facts.behaviorChanges.length, MAX_BUGS, 'behavior changes');
|
|
2353
|
+
section('Left to try by hand:', qa.handLines, qa.handLines.length, MAX_UNTESTED, 'things');
|
|
2354
|
+
section('Tests to update:', qa.updateLines, qa.updateLines.length, MAX_UNTESTED, 'tests');
|
|
2355
|
+
if (o.runUrl)
|
|
2356
|
+
out.push('', `[Open the check](${o.runUrl})`);
|
|
2357
|
+
return out.join('\n');
|
|
2358
|
+
}
|
|
2359
|
+
/** Check-run output for a review. */
|
|
2360
|
+
export function renderCheckRun(review, testConclusion, o = {}) {
|
|
2361
|
+
const qa = renderQaSection(review, { ...o, testConclusion });
|
|
2362
|
+
const text = detailsBlock(qa.details).replace(/ <!-- qa:t:[0-9a-f]{6} -->/g, '');
|
|
2363
|
+
return {
|
|
2364
|
+
conclusion: qa.conclusion,
|
|
2365
|
+
title: qa.checkTitle,
|
|
2366
|
+
summary: checkSummaryFor(review, qa, o).slice(0, COMMENT_LIMIT),
|
|
2367
|
+
text: text.slice(0, COMMENT_LIMIT),
|
|
2368
|
+
annotations: annotationsFor(review, o),
|
|
2369
|
+
blocked: blocks(review, o.blockOn),
|
|
2370
|
+
bugs: qa.bugs,
|
|
2371
|
+
counts: qa.summary,
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
// ---------- verdict model and digest ----------
|
|
2375
|
+
/** The QaReview for analyze(), as one fenced block: it quotes PR text, so it is data. */
|
|
2376
|
+
export function renderQaReviewBlock(review, showFrom = 'low') {
|
|
2377
|
+
const lines = [];
|
|
2378
|
+
lines.push(`head ${review.head.slice(0, 12)} · coverage ${review.coverage.status} · regression scope ${review.regressionScope.level}`);
|
|
2379
|
+
lines.push(`summary: ${clean(review.summary)}`);
|
|
2380
|
+
for (const r of shownRisks(review, showFrom)) {
|
|
2381
|
+
lines.push(`risk ${r.id} [${r.severity}, ${r.likelihood}, verifier ${r.verifier.status}] ${clean(r.title)} at ${refText(r.evidence[0])}: ${clean(r.why)}`);
|
|
2382
|
+
}
|
|
2383
|
+
for (const t of orderedTests(review)) {
|
|
2384
|
+
lines.push(`test ${t.id} [${t.priority}, ${t.type}${t.existingTest ? `, existing ${t.existingTest}` : ', new'}${t.result ? `, result ${t.result}` : ''}] risks ${t.riskIds.join(',') || 'none'}: ${clean(t.scenario)}`);
|
|
2385
|
+
}
|
|
2386
|
+
for (const k of review.testsAtRisk)
|
|
2387
|
+
lines.push(`test at risk ${k.id} [verifier ${k.verifier.status}] ${k.path}: ${clean(k.reason)}`);
|
|
2388
|
+
const body = lines.join('\n').replace(/<\/?(untrusted|qa_review)/gi, (m) => m.replace('<', '<'));
|
|
2389
|
+
return `<qa_review>\n<untrusted source="qa-review">\n${body}\n</untrusted>\n</qa_review>`;
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* The release note of a review as data, or null when the change is not user-visible or has no note line. The digest line and the
|
|
2393
|
+
* release-note list (src/release-notes.ts) both read it, so a note is user-visible in one place only.
|
|
2394
|
+
*/
|
|
2395
|
+
export function releaseNoteOf(review) {
|
|
2396
|
+
const rn = review.releaseNotesImpact;
|
|
2397
|
+
if (!rn.userVisible || !rn.note)
|
|
2398
|
+
return null;
|
|
2399
|
+
return { ...rn, note: rn.note };
|
|
2400
|
+
}
|
|
2401
|
+
/** One release-notes line for the digest, or null when the change is not user-visible. */
|
|
2402
|
+
export function releaseNotesLine(review, label) {
|
|
2403
|
+
const rn = releaseNoteOf(review);
|
|
2404
|
+
if (!rn)
|
|
2405
|
+
return null;
|
|
2406
|
+
const tags = [rn.breaking ? 'breaking' : '', rn.audience !== 'end_users' && rn.audience !== 'none' ? rn.audience.replace('_', ' ') : ''].filter(Boolean);
|
|
2407
|
+
return `- ${label}: ${inline(rn.note)}${tags.length ? ` (${tags.join(', ')})` : ''}`;
|
|
2408
|
+
}
|
|
2409
|
+
//# sourceMappingURL=render.js.map
|