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,1606 @@
|
|
|
1
|
+
// Poll mode (KAT-31): lets localdost work with no public webhook URL.
|
|
2
|
+
// GitHub: new commits on the default branch and open PRs with a new head sha.
|
|
3
|
+
// GitHub issues: issues opened, edited, or brought into scope in the tracker repos.
|
|
4
|
+
// Linear: issues created in the watched teams.
|
|
5
|
+
// Jira (KAT-98): issues created in the watched projects, and description or acceptance-criteria edits.
|
|
6
|
+
// Each poll builds the same payload the webhook would carry, runs it through the
|
|
7
|
+
// same normalizer, and hands the TriggerEvent to dispatchTrigger, so the events
|
|
8
|
+
// row, job kind, and dedupe follow the webhook path exactly.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { db, findOne, list } from '../db.js';
|
|
11
|
+
import { liveEnv, log } from '../config.js';
|
|
12
|
+
import { sandboxOn, sandboxSkips } from '../sandbox.js';
|
|
13
|
+
import { dispatchTrigger, reviewedHead } from '../webhooks.js';
|
|
14
|
+
import { hasInclusionRule, resolvePrScope } from '../pr-scope.js';
|
|
15
|
+
import { gapText } from './catch-up.js';
|
|
16
|
+
import { describeFetchError, githubMode, normalizeGithub, pullSummary } from '../integrations/github.js';
|
|
17
|
+
import { reconcilePrApprovals } from '../agent/approvals.js';
|
|
18
|
+
import { hasLegacyDefectMarker, hasValidBotMarker } from '../integrations/bot-marker.js';
|
|
19
|
+
import { normalizeLinear } from '../integrations/linear.js';
|
|
20
|
+
import { isOwnWrite, ownIds, rememberOwnId } from '../integrations/own-identity.js';
|
|
21
|
+
import { handleNewComments, hasAnswerWork } from '../flows/answers.js';
|
|
22
|
+
import { bodyHash, issueApi, issueInScope, issueState, normalizeGithubIssue, recordIssue, reviewedSameBody, scopeSig, trackerRepos, } from '../integrations/github-issues.js';
|
|
23
|
+
import { findRepo, primaryRepo, watchRepos } from '../repos.js';
|
|
24
|
+
import { jiraFor, normalizeJira } from '../integrations/jira.js';
|
|
25
|
+
import { backfillLimitOf, explicitBackfillLimit } from './backfill.js';
|
|
26
|
+
import { capState } from '../model-usage.js';
|
|
27
|
+
/** Re-read a few seconds of history each poll so clock skew between us and the API never drops an item. Dedupe absorbs the repeats. */
|
|
28
|
+
const OVERLAP_MS = 30_000;
|
|
29
|
+
const TICK_MS = 5_000;
|
|
30
|
+
const MAX_PR_SHAS = 500;
|
|
31
|
+
/**
|
|
32
|
+
* A stored cursor older than this is treated as missing, so a long outage re-records the
|
|
33
|
+
* cursor instead of replaying hours of history. A short restart catches up from the cursor.
|
|
34
|
+
*/
|
|
35
|
+
export const STALE_CURSOR_MS = 60 * 60_000;
|
|
36
|
+
const isFresh = (iso, now) => !!iso && now.getTime() - Date.parse(iso) <= STALE_CURSOR_MS;
|
|
37
|
+
// liveEnv: tokens saved in Setup count, and a change applies on the next tick.
|
|
38
|
+
const env = () => liveEnv;
|
|
39
|
+
const flag = (v) => v === '1' || v === 'true' || v === 'yes';
|
|
40
|
+
const ghApi = () => (env().GITHUB_API_URL ?? 'https://api.github.com').replace(/\/$/, '');
|
|
41
|
+
const ghServer = () => (env().GITHUB_SERVER_URL ?? 'https://github.com').replace(/\/$/, '');
|
|
42
|
+
const linearApi = () => env().LINEAR_API_URL ?? 'https://api.linear.app/graphql';
|
|
43
|
+
export function githubPollSec() {
|
|
44
|
+
return Math.max(10, Number(env().POLL_GITHUB_SEC) || 60);
|
|
45
|
+
}
|
|
46
|
+
export function linearPollSec() {
|
|
47
|
+
return Math.max(10, Number(env().POLL_LINEAR_SEC) || 60);
|
|
48
|
+
}
|
|
49
|
+
export function jiraPollSec() {
|
|
50
|
+
return Math.max(10, Number(env().POLL_JIRA_SEC) || 60);
|
|
51
|
+
}
|
|
52
|
+
export function wantsJiraPoll(w) {
|
|
53
|
+
return !w.paused && !w.archived && w.tracker?.kind === 'jira' && !!w.tracker.projectKeys?.length && (flag(env().LOCALDOST_POLL) || w.pollJira === true);
|
|
54
|
+
}
|
|
55
|
+
export function wantsGithubPoll(w) {
|
|
56
|
+
return !w.paused && !w.archived && watchRepos(w).length > 0 && (flag(env().LOCALDOST_POLL) || w.pollGithub === true);
|
|
57
|
+
}
|
|
58
|
+
/** GitHub issue polls: a dot with a github tracker, under the same flags as GitHub polls. */
|
|
59
|
+
export function wantsGithubIssuesPoll(w) {
|
|
60
|
+
return !w.paused && !w.archived && w.tracker?.kind === 'github' && trackerRepos(w).length > 0 && (flag(env().LOCALDOST_POLL) || w.pollGithub === true);
|
|
61
|
+
}
|
|
62
|
+
export function wantsLinearPoll(w) {
|
|
63
|
+
return !w.paused && !w.archived && w.tracker?.kind === 'linear' && !!w.tracker.teamKeys?.length && (flag(env().LOCALDOST_POLL) || w.pollLinear === true);
|
|
64
|
+
}
|
|
65
|
+
// Cursor store
|
|
66
|
+
let cursorTableReady = false;
|
|
67
|
+
function ensureCursorTable() {
|
|
68
|
+
if (cursorTableReady)
|
|
69
|
+
return;
|
|
70
|
+
db.exec('CREATE TABLE IF NOT EXISTS poll_cursors (id TEXT PRIMARY KEY, value TEXT NOT NULL, updatedAt TEXT NOT NULL)');
|
|
71
|
+
cursorTableReady = true;
|
|
72
|
+
}
|
|
73
|
+
export function getCursor(key) {
|
|
74
|
+
ensureCursorTable();
|
|
75
|
+
const row = db.prepare('SELECT value FROM poll_cursors WHERE id = ?').get(key);
|
|
76
|
+
if (!row)
|
|
77
|
+
return undefined;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(row.value);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Deletes the poll cursors a dot owns (Jira and Linear are per dot, and so is its first-poll mark and its ticket backfill; GitHub repo
|
|
87
|
+
* cursors belong to the repo and stay). Returns how many went.
|
|
88
|
+
*/
|
|
89
|
+
export function deleteWatchCursors(watchId) {
|
|
90
|
+
ensureCursorTable();
|
|
91
|
+
const seeds = `github-seed:${watchId}:`;
|
|
92
|
+
const backfills = `${BACKFILL_PREFIX}${watchId}:`;
|
|
93
|
+
const res = db
|
|
94
|
+
.prepare('DELETE FROM poll_cursors WHERE id IN (?, ?) OR substr(id, 1, ?) = ? OR substr(id, 1, ?) = ?')
|
|
95
|
+
.run(`jira:${watchId}`, `linear:${watchId}`, seeds.length, seeds, backfills.length, backfills);
|
|
96
|
+
return Number(res.changes);
|
|
97
|
+
}
|
|
98
|
+
export function setCursor(key, value) {
|
|
99
|
+
ensureCursorTable();
|
|
100
|
+
db.prepare('INSERT INTO poll_cursors (id, value, updatedAt) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt').run(key, JSON.stringify(value), new Date().toISOString());
|
|
101
|
+
}
|
|
102
|
+
// Poll health (KAT-363)
|
|
103
|
+
/** Polls in a row that fail before a dot says it is not listening. */
|
|
104
|
+
export const POLL_FAILURES_BEFORE_ALARM = 3;
|
|
105
|
+
/** Per poll key (the cursor key of a repo or of a repo's issues). In memory: a restart starts with no failures and the next polls say again. */
|
|
106
|
+
const pollHealth = new Map();
|
|
107
|
+
/** A poll error in a few plain words: "HTTP 401: Bad credentials" instead of the request path and the JSON body. */
|
|
108
|
+
export function pollErrorText(err) {
|
|
109
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
110
|
+
const http = /failed: HTTP (\d{3})\s*([\s\S]*)$/.exec(raw);
|
|
111
|
+
if (http) {
|
|
112
|
+
let why = '';
|
|
113
|
+
try {
|
|
114
|
+
const m = JSON.parse(http[2]).message;
|
|
115
|
+
if (typeof m === 'string')
|
|
116
|
+
why = m;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
why = http[2].trim();
|
|
120
|
+
}
|
|
121
|
+
return `HTTP ${http[1]}${why ? `: ${why.slice(0, 100)}` : ''}`;
|
|
122
|
+
}
|
|
123
|
+
return raw.replace(/^GitHub GET \S+ failed: /, '').slice(0, 140);
|
|
124
|
+
}
|
|
125
|
+
/** Records how a poll ended: a failure adds to the run of failures, a success ends it. */
|
|
126
|
+
export function recordPollResult(key, err, now = new Date()) {
|
|
127
|
+
if (err === undefined) {
|
|
128
|
+
pollHealth.delete(key);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
pollHealth.set(key, { failures: (pollHealth.get(key)?.failures ?? 0) + 1, error: pollErrorText(err), at: now.toISOString() });
|
|
132
|
+
}
|
|
133
|
+
/** Forgets every recorded poll result. Tests call it between cases. */
|
|
134
|
+
export function clearPollHealth() {
|
|
135
|
+
pollHealth.clear();
|
|
136
|
+
}
|
|
137
|
+
/** The polls a dot depends on: the cursor key of each repo and of each tracker repo's issues, with the name to show. */
|
|
138
|
+
function pollKeysOf(w) {
|
|
139
|
+
const out = [];
|
|
140
|
+
if (wantsGithubPoll(w))
|
|
141
|
+
for (const r of watchRepos(w))
|
|
142
|
+
out.push({ key: githubRepoCursorKey(r), label: `${r.owner}/${r.name}` });
|
|
143
|
+
if (wantsGithubIssuesPoll(w))
|
|
144
|
+
for (const full of trackerRepos(w))
|
|
145
|
+
out.push({ key: githubIssuesCursorKey(full), label: `${full} issues` });
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
/** The poll of this dot that has failed POLL_FAILURES_BEFORE_ALARM times or more in a row (the one with most failures), or null. */
|
|
149
|
+
export function pollFailureFor(w) {
|
|
150
|
+
let worst = null;
|
|
151
|
+
for (const { key, label } of pollKeysOf(w)) {
|
|
152
|
+
const h = pollHealth.get(key);
|
|
153
|
+
if (h && h.failures >= POLL_FAILURES_BEFORE_ALARM && (!worst || h.failures > worst.failures))
|
|
154
|
+
worst = { label, ...h };
|
|
155
|
+
}
|
|
156
|
+
return worst;
|
|
157
|
+
}
|
|
158
|
+
let appAuth = null;
|
|
159
|
+
const installationIds = new Map();
|
|
160
|
+
function loadPrivateKey() {
|
|
161
|
+
const raw = env().GITHUB_PRIVATE_KEY ?? '';
|
|
162
|
+
if (!raw.includes('-----BEGIN') && existsSync(raw))
|
|
163
|
+
return readFileSync(raw, 'utf8');
|
|
164
|
+
return raw.replace(/\\n/g, '\n');
|
|
165
|
+
}
|
|
166
|
+
/** Same token logic as the live GitHub client: PAT, or an App installation token for the repo. */
|
|
167
|
+
async function defaultGithubToken(full, f) {
|
|
168
|
+
if (githubMode() === 'pat')
|
|
169
|
+
return env().GITHUB_TOKEN;
|
|
170
|
+
appAuth ??= import('@octokit/auth-app').then(({ createAppAuth }) => createAppAuth({ appId: env().GITHUB_APP_ID, privateKey: loadPrivateKey() }));
|
|
171
|
+
const auth = await appAuth;
|
|
172
|
+
let id = installationIds.get(full);
|
|
173
|
+
if (!id) {
|
|
174
|
+
const { token: jwt } = await auth({ type: 'app' });
|
|
175
|
+
const res = await f(`${ghApi()}/repos/${full}/installation`, { headers: ghHeaders(jwt) });
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
throw new Error(`GitHub App is not installed on ${full} (HTTP ${res.status})`);
|
|
178
|
+
id = (await res.json()).id;
|
|
179
|
+
installationIds.set(full, id);
|
|
180
|
+
}
|
|
181
|
+
return (await auth({ type: 'installation', installationId: id })).token;
|
|
182
|
+
}
|
|
183
|
+
function ghHeaders(token) {
|
|
184
|
+
return {
|
|
185
|
+
Authorization: `Bearer ${token}`,
|
|
186
|
+
Accept: 'application/vnd.github+json',
|
|
187
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
188
|
+
'User-Agent': 'localdost-poller',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function ghGet(f, token, path) {
|
|
192
|
+
let res;
|
|
193
|
+
try {
|
|
194
|
+
res = await f(`${ghApi()}${path}`, { headers: ghHeaders(token) });
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
// Node's fetch hides the reason (ENOTFOUND, ETIMEDOUT, ECONNRESET, a TLS error) in err.cause.
|
|
198
|
+
throw new Error(`GitHub GET ${path.split('?')[0]} failed: ${describeFetchError(err)}`, { cause: err });
|
|
199
|
+
}
|
|
200
|
+
if (!res.ok) {
|
|
201
|
+
const text = await res.text().catch(() => '');
|
|
202
|
+
throw Object.assign(new Error(`GitHub GET ${path.split('?')[0]} failed: HTTP ${res.status} ${text.slice(0, 200)}`), { status: res.status });
|
|
203
|
+
}
|
|
204
|
+
return (await res.json());
|
|
205
|
+
}
|
|
206
|
+
/** Cursor key of a repo and branch. Shared by every dot that holds the repo, so it is polled once. */
|
|
207
|
+
export function githubRepoCursorKey(r) {
|
|
208
|
+
return `github:${r.owner}/${r.name}:${r.defaultBranch || 'main'}`.toLowerCase();
|
|
209
|
+
}
|
|
210
|
+
/** Cursor key of the watch's first app repo (the repo a single-repo dot watches). */
|
|
211
|
+
export function githubCursorKey(w) {
|
|
212
|
+
const r = primaryRepo(w) ?? watchRepos(w)[0];
|
|
213
|
+
if (!r)
|
|
214
|
+
throw new Error(`${w.name} watches no repo`);
|
|
215
|
+
return githubRepoCursorKey(r);
|
|
216
|
+
}
|
|
217
|
+
/** Ids of every watch that holds the repo `full` (any role, any branch). */
|
|
218
|
+
function watchIdsOnRepo(full) {
|
|
219
|
+
const ids = new Set();
|
|
220
|
+
try {
|
|
221
|
+
for (const o of list('watches')) {
|
|
222
|
+
if (watchRepos(o).some((r) => `${r.owner}/${r.name}`.toLowerCase() === full.toLowerCase()))
|
|
223
|
+
ids.add(o.id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
log.warn(`cannot list watches for ${full}: ${String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
return [...ids];
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The union of repos to poll across these watches, one entry per repo and branch
|
|
233
|
+
* (docs/MULTI-REPO.md section 1.4): a repo shared by three dots is polled once.
|
|
234
|
+
*/
|
|
235
|
+
export function githubPollTargets(watches) {
|
|
236
|
+
const out = new Map();
|
|
237
|
+
for (const w of watches) {
|
|
238
|
+
if (!wantsGithubPoll(w))
|
|
239
|
+
continue;
|
|
240
|
+
for (const r of watchRepos(w)) {
|
|
241
|
+
const t = { owner: r.owner, name: r.name, defaultBranch: r.defaultBranch };
|
|
242
|
+
const key = githubRepoCursorKey(t);
|
|
243
|
+
// A full poll (commits and PRs) of a branch wins over a branch-only poll of the same key.
|
|
244
|
+
if (!out.has(key) || out.get(key).branchOnly)
|
|
245
|
+
out.set(key, t);
|
|
246
|
+
// The test branches of the dot (KAT-292) are polled for new commits too, once per repo and branch.
|
|
247
|
+
if (r.role !== 'app')
|
|
248
|
+
continue;
|
|
249
|
+
for (const b of w.testBranches ?? []) {
|
|
250
|
+
const bt = { owner: r.owner, name: r.name, defaultBranch: b, branchOnly: true };
|
|
251
|
+
const bkey = githubRepoCursorKey(bt);
|
|
252
|
+
if (!out.has(bkey))
|
|
253
|
+
out.set(bkey, bt);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/** One GitHub poll of the watch's first app repo. See pollGithubRepoOnce. */
|
|
260
|
+
export async function pollGithubOnce(w, deps = {}) {
|
|
261
|
+
const r = primaryRepo(w) ?? watchRepos(w)[0];
|
|
262
|
+
if (!r)
|
|
263
|
+
return { baseline: false, triggers: [], results: [] };
|
|
264
|
+
return pollGithubRepoOnce({ owner: r.owner, name: r.name, defaultBranch: r.defaultBranch }, deps);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* One GitHub poll of a repo's default branch and open PRs. With no fresh cursor it only records one.
|
|
268
|
+
* Later calls dispatch one `push` for any new commits on the default branch (before = last seen sha)
|
|
269
|
+
* and one `pr_opened` per open PR whose head sha changed since the last poll. Dispatch matches the
|
|
270
|
+
* events to every dot that holds the repo.
|
|
271
|
+
* No inlineDiff is set, so the flows fetch the diff through githubFor.
|
|
272
|
+
*/
|
|
273
|
+
export async function pollGithubRepoOnce(target, deps = {}) {
|
|
274
|
+
const f = deps.fetch ?? fetch;
|
|
275
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
276
|
+
const { owner, name } = target;
|
|
277
|
+
const branch = target.defaultBranch || 'main';
|
|
278
|
+
const full = `${owner}/${name}`;
|
|
279
|
+
const token = await (deps.githubToken ? deps.githubToken(full) : defaultGithubToken(full, f));
|
|
280
|
+
const key = githubRepoCursorKey(target);
|
|
281
|
+
const stored = getCursor(key) ?? {};
|
|
282
|
+
const baseline = !stored.sha || !isFresh(stored.prsSince, now);
|
|
283
|
+
// A cursor that went stale (KAT-363): the server did not poll since `stored.prsSince`. The commits are re-recorded as before,
|
|
284
|
+
// but the PRs updated since are caught up, told from the ones the dot saw by the PR map of that old cursor.
|
|
285
|
+
const gapSince = baseline && stored.sha && stored.prsSince ? stored.prsSince : undefined;
|
|
286
|
+
// On a baseline poll the PR map starts empty so closed PRs from an old cursor drop out.
|
|
287
|
+
const cur = gapSince
|
|
288
|
+
? {
|
|
289
|
+
sha: stored.sha,
|
|
290
|
+
prsSince: stored.prsSince,
|
|
291
|
+
...(stored.prShas ? { prShas: stored.prShas } : {}),
|
|
292
|
+
...(stored.prDraft ? { prDraft: stored.prDraft } : {}),
|
|
293
|
+
}
|
|
294
|
+
: baseline
|
|
295
|
+
? { sha: stored.sha }
|
|
296
|
+
: stored;
|
|
297
|
+
const repository = { owner: { login: owner }, name, full_name: full, default_branch: branch };
|
|
298
|
+
const triggers = [];
|
|
299
|
+
// Commits on the default branch, newest first.
|
|
300
|
+
// refs/heads/ pins the branch: a bare name could also resolve to a tag of the same name.
|
|
301
|
+
let commits;
|
|
302
|
+
try {
|
|
303
|
+
commits = await ghGet(f, token, `/repos/${full}/commits?sha=${encodeURIComponent(`refs/heads/${branch}`)}&per_page=50`);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
// A listed test branch (KAT-292) exists in some repos of a dot only. Its absence in this repo is no error:
|
|
307
|
+
// nothing to run, nothing to log every cycle. The repo's default branch poll is a separate target.
|
|
308
|
+
if (target.branchOnly && err.status === 404)
|
|
309
|
+
return { baseline: false, triggers: [], results: [] };
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
const head = commits[0]?.sha;
|
|
313
|
+
if (!baseline && head && cur.sha && head !== cur.sha) {
|
|
314
|
+
const ev = normalizeGithub('push', {
|
|
315
|
+
ref: `refs/heads/${branch}`,
|
|
316
|
+
before: cur.sha,
|
|
317
|
+
after: head,
|
|
318
|
+
head_commit: { message: commits[0]?.commit?.message ?? '' },
|
|
319
|
+
compare: `${ghServer()}/${full}/compare/${cur.sha.slice(0, 12)}...${head.slice(0, 12)}`,
|
|
320
|
+
repository,
|
|
321
|
+
});
|
|
322
|
+
if (ev)
|
|
323
|
+
triggers.push(ev);
|
|
324
|
+
}
|
|
325
|
+
// A test branch of a dot is polled for commits only: its pull requests belong to the default branch's poll.
|
|
326
|
+
if (target.branchOnly) {
|
|
327
|
+
setCursor(key, { sha: head ?? cur.sha, prsSince: now.toISOString() });
|
|
328
|
+
if (baseline)
|
|
329
|
+
return { baseline, triggers: [], results: [] };
|
|
330
|
+
return { baseline, triggers, results: await dispatchResolved(triggers) };
|
|
331
|
+
}
|
|
332
|
+
// Open PRs, most recently updated first. Stop at the first one older than the last poll.
|
|
333
|
+
const since = cur.prsSince ? Date.parse(cur.prsSince) - OVERLAP_MS : 0;
|
|
334
|
+
const fresh = baseline && !gapSince;
|
|
335
|
+
const prShas = fresh ? {} : { ...(cur.prShas ?? {}) };
|
|
336
|
+
const drafts = fresh ? {} : { ...(cur.prDraft ?? {}) };
|
|
337
|
+
const listedAt = new Date().toISOString();
|
|
338
|
+
const pulls = await ghGet(f, token, `/repos/${full}/pulls?state=open&sort=updated&direction=desc&per_page=50`);
|
|
339
|
+
// After a gap every PR that changed since is a catch-up event, newest update first; dispatchCatchUp caps what each dot takes.
|
|
340
|
+
const take = (ev) => {
|
|
341
|
+
if (!ev)
|
|
342
|
+
return;
|
|
343
|
+
if (gapSince)
|
|
344
|
+
ev.catchUp = { since: gapSince, gapMs: now.getTime() - Date.parse(gapSince) };
|
|
345
|
+
triggers.push(ev);
|
|
346
|
+
};
|
|
347
|
+
for (const pr of pulls) {
|
|
348
|
+
const n = String(pr.number);
|
|
349
|
+
const sha = String(pr.head?.sha ?? '');
|
|
350
|
+
if (fresh) {
|
|
351
|
+
prShas[n] = sha;
|
|
352
|
+
if (pr.draft === true)
|
|
353
|
+
drafts[n] = true;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (Date.parse(pr.updated_at) < since)
|
|
357
|
+
break;
|
|
358
|
+
const wasDraft = drafts[n] === true;
|
|
359
|
+
if (pr.draft === true)
|
|
360
|
+
drafts[n] = true;
|
|
361
|
+
else
|
|
362
|
+
delete drafts[n];
|
|
363
|
+
if (!sha)
|
|
364
|
+
continue;
|
|
365
|
+
if (prShas[n] === sha) {
|
|
366
|
+
// A draft turning ready keeps its head, so the head check alone would never see it (KAT-354).
|
|
367
|
+
if (wasDraft && pr.draft !== true)
|
|
368
|
+
take(normalizeGithub('pull_request', { action: 'ready_for_review', number: pr.number, pull_request: pr, repository }));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const isNew = !(n in prShas) && Date.parse(pr.created_at) >= since;
|
|
372
|
+
prShas[n] = sha;
|
|
373
|
+
take(normalizeGithub('pull_request', { action: isNew ? 'opened' : 'synchronize', number: pr.number, pull_request: pr, repository }));
|
|
374
|
+
}
|
|
375
|
+
// Pending approvals for a PR that merged, closed, or moved its head expire (KAT-94). Open PRs come
|
|
376
|
+
// from the list above; any other PR with a pending draft is looked up once. Polls are scheduled per
|
|
377
|
+
// repo and branch, so this poll reconciles every watch on the repo, not only the one passed in.
|
|
378
|
+
const open = new Map(pulls.map((p) => [Number(p.number), String(p.head?.sha ?? '')]));
|
|
379
|
+
const looked = new Map();
|
|
380
|
+
const lookup = async (n) => {
|
|
381
|
+
const sha = open.get(n);
|
|
382
|
+
if (sha !== undefined)
|
|
383
|
+
return { state: 'open', headSha: sha };
|
|
384
|
+
let p = looked.get(n);
|
|
385
|
+
if (!p) {
|
|
386
|
+
p = ghGet(f, token, `/repos/${full}/pulls/${n}`).then((raw) => {
|
|
387
|
+
const s = pullSummary(raw);
|
|
388
|
+
return { state: s.state, headSha: s.headSha, ...(s.mergeSha ? { mergeSha: s.mergeSha } : {}) };
|
|
389
|
+
});
|
|
390
|
+
looked.set(n, p);
|
|
391
|
+
}
|
|
392
|
+
return p;
|
|
393
|
+
};
|
|
394
|
+
for (const id of watchIdsOnRepo(full)) {
|
|
395
|
+
try {
|
|
396
|
+
await reconcilePrApprovals(id, lookup, listedAt, full);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
log.warn(`approval check for ${full} (watch ${id}) failed: ${String(err)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const keep = Object.entries(prShas).slice(-MAX_PR_SHAS);
|
|
403
|
+
const keepDrafts = Object.entries(drafts).slice(-MAX_PR_SHAS);
|
|
404
|
+
setCursor(key, {
|
|
405
|
+
sha: head ?? cur.sha,
|
|
406
|
+
prsSince: now.toISOString(),
|
|
407
|
+
prShas: Object.fromEntries(keep),
|
|
408
|
+
...(keepDrafts.length ? { prDraft: Object.fromEntries(keepDrafts) } : {}),
|
|
409
|
+
});
|
|
410
|
+
// A dot's first poll of the repo also takes the PRs that are already open (KAT-352), apart from the ones this poll sends to everyone.
|
|
411
|
+
const first = await seedFirstPoll(full, pulls, repository, now, triggers);
|
|
412
|
+
if (fresh)
|
|
413
|
+
return { baseline, triggers: first.triggers, results: first.results };
|
|
414
|
+
const results = gapSince ? await dispatchCatchUp(triggers, true) : await dispatchResolved(triggers);
|
|
415
|
+
if (gapSince)
|
|
416
|
+
logCatchUp(`poll ${full}`, 'open PR(s)', now.getTime() - Date.parse(gapSince), triggers.length, results);
|
|
417
|
+
return { baseline: baseline && !gapSince, triggers: [...triggers, ...first.triggers], results: [...results, ...first.results] };
|
|
418
|
+
}
|
|
419
|
+
/** Dispatches each event after reading what the dots' PR scope rules need from GitHub (src/pr-scope.ts). */
|
|
420
|
+
async function dispatchResolved(events, opts) {
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const ev of events)
|
|
423
|
+
out.push(dispatchTrigger(await resolvePrScope(ev), opts));
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
/** How many open PRs a dot takes on its first poll of a repo, most recently updated first. */
|
|
427
|
+
export const FIRST_POLL_PRS = 5;
|
|
428
|
+
/** How many PRs, and how many issues, a dot takes from a poll that catches up after a gap, newest first. The same as a first poll. */
|
|
429
|
+
export const CATCH_UP_MAX = FIRST_POLL_PRS;
|
|
430
|
+
/**
|
|
431
|
+
* Dispatches catch-up events (KAT-363), newest first. Each dot takes at most CATCH_UP_MAX of them, counted after its own scope
|
|
432
|
+
* and after the reviews it already has: on a repo shared by several teams the PRs of the others would otherwise fill the
|
|
433
|
+
* cap and leave this dot's own out. A dot that has taken its share gets no more events from this poll.
|
|
434
|
+
*/
|
|
435
|
+
async function dispatchCatchUp(events, scoped) {
|
|
436
|
+
const out = [];
|
|
437
|
+
const dots = list('watches')
|
|
438
|
+
.filter((w) => !w.paused && !w.archived)
|
|
439
|
+
.map((w) => w.id);
|
|
440
|
+
const taken = new Map();
|
|
441
|
+
for (const ev of events) {
|
|
442
|
+
const open = dots.filter((id) => (taken.get(id) ?? 0) < CATCH_UP_MAX);
|
|
443
|
+
if (!open.length)
|
|
444
|
+
break;
|
|
445
|
+
const res = dispatchTrigger(scoped ? await resolvePrScope(ev) : ev, { onlyWatchIds: open });
|
|
446
|
+
out.push(res);
|
|
447
|
+
for (const j of res.jobs)
|
|
448
|
+
taken.set(j.watchId, (taken.get(j.watchId) ?? 0) + 1);
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
function logCatchUp(what, noun, gapMs, found, results) {
|
|
453
|
+
const jobs = results.reduce((n, r) => n + r.jobs.length, 0);
|
|
454
|
+
log.info(`${what}: no poll for ${gapText(gapMs)}; ${found ? `${found} ${noun} changed since, ${jobs} queued` : `no ${noun} changed since`}`);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* First poll of a dot (KAT-352): before this, the poller recorded a cursor and queued nothing, so a dot made in the app
|
|
458
|
+
* stayed quiet about the PRs already open. Each dot that polls the repo and has not had its first poll gets the
|
|
459
|
+
* FIRST_POLL_PRS most recently updated open PRs as `pr_opened` events, sent to that dot only. The mark is stored per
|
|
460
|
+
* dot and repo. A dot older than STALE_CURSOR_MS at its first poll was already running, so it gets the mark and no
|
|
461
|
+
* events (an upgrade or a re-enabled poller never replays PRs). A PR that this poll already sends to every dot, or
|
|
462
|
+
* that the dot reviewed at this head, is left out.
|
|
463
|
+
*
|
|
464
|
+
* A new dot has had no moment to say whose PRs it takes, and a shared repo holds the PRs of other teams. So the first
|
|
465
|
+
* look applies the repo's PR scope like any event, and, unless the scope names authors, a team or linked issue labels,
|
|
466
|
+
* marks each event `firstLook`: its PR comment and check wait for approval (src/agent/rules.ts).
|
|
467
|
+
*/
|
|
468
|
+
async function seedFirstPoll(full, pulls, repository, now, sent) {
|
|
469
|
+
const out = { triggers: [], results: [] };
|
|
470
|
+
const [owner, name] = full.split('/');
|
|
471
|
+
const already = new Set(sent.filter((e) => e.kind === 'pr_opened').map((e) => `${e.pr?.number}@${e.pr?.headSha}`));
|
|
472
|
+
for (const w of list('watches')) {
|
|
473
|
+
const entry = findRepo(w, { owner, name });
|
|
474
|
+
if (!wantsGithubPoll(w) || entry?.role !== 'app')
|
|
475
|
+
continue;
|
|
476
|
+
const mark = `github-seed:${w.id}:${full}`.toLowerCase();
|
|
477
|
+
if (getCursor(mark))
|
|
478
|
+
continue;
|
|
479
|
+
setCursor(mark, { at: now.toISOString() });
|
|
480
|
+
// A dot that was running long before its first poll is not new. A createdAt after the poll clock counts as new.
|
|
481
|
+
if (now.getTime() - Date.parse(w.createdAt) > STALE_CURSOR_MS)
|
|
482
|
+
continue;
|
|
483
|
+
const hold = !hasInclusionRule(entry.prScope);
|
|
484
|
+
let taken = 0;
|
|
485
|
+
for (const pr of pulls.slice(0, FIRST_POLL_PRS)) {
|
|
486
|
+
const ev = normalizeGithub('pull_request', { action: 'opened', number: pr.number, pull_request: pr, repository });
|
|
487
|
+
if (!ev || already.has(`${ev.pr?.number}@${ev.pr?.headSha}`) || reviewedHead(w.id, ev))
|
|
488
|
+
continue;
|
|
489
|
+
taken++;
|
|
490
|
+
const first = hold ? { ...ev, firstLook: true } : ev;
|
|
491
|
+
if (!out.triggers.some((t) => t.dedupeKey === ev.dedupeKey))
|
|
492
|
+
out.triggers.push(first);
|
|
493
|
+
out.results.push(...(await dispatchResolved([first], { onlyWatchIds: [w.id] })));
|
|
494
|
+
}
|
|
495
|
+
log.info(`${w.name}: first poll of ${full} looked at ${taken} open PR(s)`);
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
// GitHub issues
|
|
500
|
+
/** Pages of 100 issues one poll reads at most; the rest waits for the next poll. */
|
|
501
|
+
const ISSUE_PAGES = 5;
|
|
502
|
+
export function githubIssuesCursorKey(full) {
|
|
503
|
+
return `github-issues:${full}`.toLowerCase();
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* A marker counts only on an issue the dot's own account wrote. With a PAT, ask who the token belongs to
|
|
507
|
+
* when an issue carries a valid marker; an App is recognized by the issue itself.
|
|
508
|
+
*/
|
|
509
|
+
async function rememberGithubIdentity(rows, f, token) {
|
|
510
|
+
if (githubMode() === 'pat' && !ownIds('github').length && rows.some((r) => hasValidBotMarker(r.body) || hasLegacyDefectMarker(r.body))) {
|
|
511
|
+
try {
|
|
512
|
+
rememberOwnId('github', ((await ghGet(f, token, '/user')).login ?? '').toLowerCase());
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
/* unknown identity: the issue is reviewed rather than skipped */
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The events of issue rows read from `since` on (oldest first): `opened` for an issue created since, `edited` when the body or
|
|
521
|
+
* title hash differs from issue_state, a scope event when a label, type, or milestone was added. Every row is recorded in
|
|
522
|
+
* issue_state. Any issue seen for the first time that was created before `since` is recorded and queues nothing, since its
|
|
523
|
+
* old text is unknown.
|
|
524
|
+
*/
|
|
525
|
+
async function issueEvents(full, rows, since, f, token) {
|
|
526
|
+
const [owner, name] = full.split('/');
|
|
527
|
+
const repository = { owner: { login: owner }, name, full_name: full };
|
|
528
|
+
await rememberGithubIdentity(rows, f, token);
|
|
529
|
+
const triggers = [];
|
|
530
|
+
const emit = (action, issue, extra = {}) => {
|
|
531
|
+
const ev = normalizeGithubIssue('issues', { action, issue, repository, sender: issue.user, ...extra });
|
|
532
|
+
if (ev)
|
|
533
|
+
triggers.push(ev);
|
|
534
|
+
};
|
|
535
|
+
for (const issue of rows) {
|
|
536
|
+
if (issue.pull_request)
|
|
537
|
+
continue;
|
|
538
|
+
const prev = issueState(full, Number(issue.number));
|
|
539
|
+
if (!prev) {
|
|
540
|
+
if (Date.parse(issue.created_at) >= since)
|
|
541
|
+
emit('opened', issue);
|
|
542
|
+
}
|
|
543
|
+
else if (prev.bodyHash !== bodyHash(issue.title, issue.body)) {
|
|
544
|
+
emit('edited', issue, { changes: { body: { from: '' } } });
|
|
545
|
+
}
|
|
546
|
+
else if (prev.scopeSig !== scopeSig(issue)) {
|
|
547
|
+
const before = JSON.parse(prev.scopeSig);
|
|
548
|
+
const after = JSON.parse(scopeSig(issue));
|
|
549
|
+
for (const label of after.labels.filter((l) => !before.labels.includes(l)))
|
|
550
|
+
emit('labeled', issue, { label: { name: label } });
|
|
551
|
+
if (after.type && after.type !== before.type)
|
|
552
|
+
emit('typed', issue);
|
|
553
|
+
if (after.milestone && after.milestone !== before.milestone)
|
|
554
|
+
emit('milestoned', issue, { milestone: issue.milestone });
|
|
555
|
+
}
|
|
556
|
+
recordIssue(full, issue);
|
|
557
|
+
}
|
|
558
|
+
return triggers;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The poll after a stale cursor (KAT-363): the server did not poll `full` since `cur.polledAt`, so nothing updated in the gap
|
|
562
|
+
* was seen. It reads the 100 most recently updated issues since the cursor, tells what changed from issue_state like a normal poll,
|
|
563
|
+
* and dispatches the events newest first, each dot taking at most CATCH_UP_MAX after its tracker scope (dispatchCatchUp). Each
|
|
564
|
+
* event says so (`catchUp`).
|
|
565
|
+
*/
|
|
566
|
+
async function catchUpIssues(full, cur, now, f, token, key) {
|
|
567
|
+
const since = Date.parse(cur.since) - OVERLAP_MS;
|
|
568
|
+
const rows = await ghGet(f, token, `/repos/${full}/issues?state=all&sort=updated&direction=desc&since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100`);
|
|
569
|
+
// Told from issue_state oldest first, as a normal poll does, then dispatched newest first.
|
|
570
|
+
const events = (await issueEvents(full, [...rows].reverse(), since, f, token)).reverse();
|
|
571
|
+
const gapSince = cur.polledAt ?? cur.since;
|
|
572
|
+
const gapMs = now.getTime() - Date.parse(gapSince);
|
|
573
|
+
for (const ev of events)
|
|
574
|
+
ev.catchUp = { since: gapSince, gapMs };
|
|
575
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
576
|
+
const results = await dispatchCatchUp(events, false);
|
|
577
|
+
logCatchUp(`poll ${full} issues`, 'issue event(s)', gapMs, events.length, results);
|
|
578
|
+
return { baseline: false, triggers: events, results };
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* One issue poll for a repo (shared by every dot whose tracker lists it). With no cursor it records one, and each dot starts its ticket
|
|
582
|
+
* backfill (seedGithubBackfills). Later calls
|
|
583
|
+
* read issues updated since the cursor, oldest first, and dispatch what issueEvents finds. The baseline records the 100 most
|
|
584
|
+
* recently updated open issues. A cursor older than STALE_CURSOR_MS is caught up instead (catchUpIssues).
|
|
585
|
+
*/
|
|
586
|
+
export async function pollGithubIssuesOnce(full, deps = {}) {
|
|
587
|
+
const out = await readGithubIssues(full, deps);
|
|
588
|
+
// A dot that has not picked its backfill yet picks it now, on the first poll of the tracker or on a poll after the dot was made (KAT-381).
|
|
589
|
+
try {
|
|
590
|
+
const started = await seedGithubBackfills(full, deps);
|
|
591
|
+
out.triggers.push(...started.triggers);
|
|
592
|
+
out.results.push(...started.results);
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
log.warn(`poll ${full} issues: ticket backfill failed: ${pollErrorText(err)}`);
|
|
596
|
+
}
|
|
597
|
+
// New comments under tickets the dot reviewed that have open questions can start a second review (KAT-393). A failure only logs: the
|
|
598
|
+
// comment cursor stays where it was, so the next poll reads the same comments again.
|
|
599
|
+
try {
|
|
600
|
+
const t = await pollIssueAnswers(full, deps);
|
|
601
|
+
if (t.queued || t.joined || t.limit)
|
|
602
|
+
log.info(`poll ${full} issues: comments: ${t.queued} review(s) queued, ${t.joined} joined to a waiting review, ${t.limit} over the daily limit`);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
log.warn(`poll ${full} issues: could not read the comments: ${pollErrorText(err)}`);
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
// Answers under reviewed tickets (KAT-393)
|
|
610
|
+
/** Pages of 100 comments one poll reads at most; the rest waits for the next poll. */
|
|
611
|
+
const COMMENT_PAGES = 3;
|
|
612
|
+
/** A comment that GitHub could not be asked about (the issue read or the write-access check failed) is read again by the next poll, for this long. After that the cursor moves on. */
|
|
613
|
+
export const COMMENT_RETRY_MS = 60 * 60_000;
|
|
614
|
+
export function githubCommentsCursorKey(full) {
|
|
615
|
+
return `github-issue-comments:${full}`.toLowerCase();
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Reads the comments of a tracker repo written since the last poll (GET /repos/{full}/issues/comments, oldest first, one request per
|
|
619
|
+
* page for the whole repo) and hands them to src/flows/answers.ts, which decides which ones start a second review of a ticket.
|
|
620
|
+
* The first poll of a repo only records the cursor, and a repo where no dot has a reviewed ticket with open questions costs no request.
|
|
621
|
+
* A comment read twice (the overlap, or a poll that failed after the read) is absorbed by the job key of its comment.
|
|
622
|
+
*/
|
|
623
|
+
export async function pollIssueAnswers(full, deps = {}) {
|
|
624
|
+
const f = deps.fetch ?? fetch;
|
|
625
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
626
|
+
const key = githubCommentsCursorKey(full);
|
|
627
|
+
const cur = getCursor(key);
|
|
628
|
+
const tally = { queued: 0, joined: 0, limit: 0, ignored: 0, retry: 0 };
|
|
629
|
+
if (!cur?.since || !hasAnswerWork(full)) {
|
|
630
|
+
setCursor(key, { since: now.toISOString() });
|
|
631
|
+
return tally;
|
|
632
|
+
}
|
|
633
|
+
const token = await (deps.githubToken ? deps.githubToken(full) : defaultGithubToken(full, f));
|
|
634
|
+
const since = Date.parse(cur.since) - OVERLAP_MS;
|
|
635
|
+
const rows = [];
|
|
636
|
+
let more = false;
|
|
637
|
+
for (let page = 1; page <= COMMENT_PAGES; page++) {
|
|
638
|
+
const batch = await ghGet(f, token, `/repos/${full}/issues/comments?sort=created&direction=asc&since=${encodeURIComponent(new Date(since).toISOString())}&per_page=100&page=${page}`);
|
|
639
|
+
rows.push(...batch);
|
|
640
|
+
more = batch.length === 100;
|
|
641
|
+
if (!more)
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
// The list holds the comments updated since, so an old comment that was edited is in it. Only a comment written since counts.
|
|
645
|
+
const fresh = rows.filter((c) => Date.parse(String(c.created_at)) >= since);
|
|
646
|
+
const out = await handleNewComments(full, fresh, { api: issueApi(full, { fetch: f, token: async () => token }), ...(deps.now ? { now: deps.now } : {}) });
|
|
647
|
+
const lastRead = rows.at(-1)?.created_at;
|
|
648
|
+
let next = more && lastRead ? new Date(Date.parse(lastRead) + OVERLAP_MS).toISOString() : now.toISOString();
|
|
649
|
+
// A comment that GitHub could not judge keeps the cursor where it was, so the next poll reads it again. A comment already queued
|
|
650
|
+
// is absorbed by its job key. The hold ends after COMMENT_RETRY_MS, so a check that always fails cannot stop the poll for good.
|
|
651
|
+
if (out.retry > 0 && now.getTime() - Date.parse(cur.since) < COMMENT_RETRY_MS) {
|
|
652
|
+
next = cur.since;
|
|
653
|
+
log.warn(`poll ${full} issues: ${out.retry} comment(s) could not be checked on GitHub; the next poll reads them again`);
|
|
654
|
+
}
|
|
655
|
+
setCursor(key, { since: next });
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
async function readGithubIssues(full, deps) {
|
|
659
|
+
const f = deps.fetch ?? fetch;
|
|
660
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
661
|
+
const key = githubIssuesCursorKey(full);
|
|
662
|
+
const cur = getCursor(key);
|
|
663
|
+
const token = await (deps.githubToken ? deps.githubToken(full) : defaultGithubToken(full, f));
|
|
664
|
+
if (cur?.since && !isFresh(cur.polledAt ?? cur.since, now))
|
|
665
|
+
return catchUpIssues(full, { ...cur, since: cur.since }, now, f, token, key);
|
|
666
|
+
if (!cur?.since) {
|
|
667
|
+
// Record the recently updated open issues, so a later label or edit on one of them is seen as a change.
|
|
668
|
+
const open = await ghGet(f, token, `/repos/${full}/issues?state=open&sort=updated&direction=desc&per_page=100`);
|
|
669
|
+
for (const issue of open)
|
|
670
|
+
if (!issue.pull_request && !issueState(full, Number(issue.number)))
|
|
671
|
+
recordIssue(full, issue);
|
|
672
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
673
|
+
return { baseline: true, triggers: [], results: [] };
|
|
674
|
+
}
|
|
675
|
+
const since = Date.parse(cur.since) - OVERLAP_MS;
|
|
676
|
+
const sinceIso = new Date(since).toISOString();
|
|
677
|
+
const rows = [];
|
|
678
|
+
let more = false;
|
|
679
|
+
for (let page = 1; page <= ISSUE_PAGES; page++) {
|
|
680
|
+
const batch = await ghGet(f, token, `/repos/${full}/issues?state=all&sort=updated&direction=asc&since=${encodeURIComponent(sinceIso)}&per_page=100&page=${page}`);
|
|
681
|
+
rows.push(...batch);
|
|
682
|
+
more = batch.length === 100;
|
|
683
|
+
if (!more)
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
const triggers = await issueEvents(full, rows, since, f, token);
|
|
687
|
+
// Rows come oldest first. When the page cap cut the read short, the next poll starts at the last
|
|
688
|
+
// row read instead of now, so a burst of updates is read over several polls and none is skipped.
|
|
689
|
+
const lastRead = rows.at(-1)?.updated_at;
|
|
690
|
+
const next = more && lastRead ? new Date(Date.parse(lastRead) + OVERLAP_MS).toISOString() : now.toISOString();
|
|
691
|
+
setCursor(key, { since: next, polledAt: now.toISOString() });
|
|
692
|
+
const results = triggers.map((ev) => dispatchTrigger(ev));
|
|
693
|
+
return { baseline: false, triggers, results };
|
|
694
|
+
}
|
|
695
|
+
/** Pages of 50 issues one Linear poll reads at most; the rest waits for the next poll. */
|
|
696
|
+
const LINEAR_PAGES = 5;
|
|
697
|
+
const VIEWER_QUERY = `query LocaldostViewer { viewer { id } }`;
|
|
698
|
+
/** The issue read of a poll (orderBy createdAt) or of a ticket backfill (orderBy updatedAt, the most recently updated first). */
|
|
699
|
+
const issuesQuery = (orderBy) => `query LocaldostPoll($filter: IssueFilter, $after: String) {
|
|
700
|
+
issues(filter: $filter, first: 50, after: $after, orderBy: ${orderBy}) {
|
|
701
|
+
nodes {
|
|
702
|
+
id identifier title description url priorityLabel createdAt
|
|
703
|
+
creator { id name }
|
|
704
|
+
team { key }
|
|
705
|
+
project { name }
|
|
706
|
+
labels { nodes { name } }
|
|
707
|
+
state { type }
|
|
708
|
+
}
|
|
709
|
+
pageInfo { hasNextPage endCursor }
|
|
710
|
+
}
|
|
711
|
+
}`;
|
|
712
|
+
const ISSUES_QUERY = issuesQuery('createdAt');
|
|
713
|
+
/** Linear 5xx responses are retried this many times, waiting 1 s, 2 s, 4 s between tries. */
|
|
714
|
+
const LINEAR_RETRIES = 3;
|
|
715
|
+
const LINEAR_BACKOFF_MS = 1000;
|
|
716
|
+
/**
|
|
717
|
+
* One Linear GraphQL call. A 5xx response is retried with exponential backoff; when every try fails
|
|
718
|
+
* the call throws, so the poll ends before it moves the cursor and the next poll reads the same window.
|
|
719
|
+
* Other failures (4xx, GraphQL errors, network) are not retried here.
|
|
720
|
+
*/
|
|
721
|
+
async function linearGql(f, apiKey, query, variables, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
722
|
+
for (let attempt = 0;; attempt++) {
|
|
723
|
+
const res = await f(linearApi(), {
|
|
724
|
+
method: 'POST',
|
|
725
|
+
headers: { Authorization: apiKey, 'Content-Type': 'application/json', 'User-Agent': 'localdost-poller' },
|
|
726
|
+
body: JSON.stringify({ query, variables }),
|
|
727
|
+
});
|
|
728
|
+
if (res.status >= 500 && attempt < LINEAR_RETRIES) {
|
|
729
|
+
await res.text().catch(() => '');
|
|
730
|
+
await sleep(LINEAR_BACKOFF_MS * 2 ** attempt);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (!res.ok) {
|
|
734
|
+
const text = await res.text().catch(() => '');
|
|
735
|
+
throw new Error(`Linear GraphQL failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
736
|
+
}
|
|
737
|
+
const body = (await res.json());
|
|
738
|
+
if (body.errors?.length)
|
|
739
|
+
throw new Error(`Linear GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`);
|
|
740
|
+
return body.data;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
export function linearCursorKey(w) {
|
|
744
|
+
return `linear:${w.id}`;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* One Linear poll for a watch. With no fresh cursor it only records one.
|
|
748
|
+
* Later calls dispatch `ticket_created` (job ticket_review, dedupe linear:<identifier>:created,
|
|
749
|
+
* the same key the webhook uses) for each issue created in the watched teams since the last poll.
|
|
750
|
+
*/
|
|
751
|
+
export async function pollLinearOnce(w, deps = {}) {
|
|
752
|
+
const out = await readLinearTickets(w, deps);
|
|
753
|
+
// A dot with no backfill mark picks its open tickets now (KAT-381).
|
|
754
|
+
try {
|
|
755
|
+
const apiKey = env().LINEAR_API_KEY;
|
|
756
|
+
if (apiKey && w.tracker?.kind === 'linear' && w.tracker.teamKeys?.length) {
|
|
757
|
+
const started = await seedLinearBackfill(w, deps.fetch ?? fetch, apiKey, (deps.now ?? (() => new Date()))(), deps.sleep);
|
|
758
|
+
out.triggers.push(...started.triggers);
|
|
759
|
+
out.results.push(...started.results);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch (err) {
|
|
763
|
+
log.warn(`poll linear ${w.name}: ticket backfill failed: ${pollErrorText(err)}`);
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
async function readLinearTickets(w, deps) {
|
|
768
|
+
const apiKey = env().LINEAR_API_KEY;
|
|
769
|
+
const tracker = w.tracker;
|
|
770
|
+
if (!apiKey || tracker?.kind !== 'linear' || !tracker.teamKeys?.length)
|
|
771
|
+
return { baseline: false, triggers: [], results: [] };
|
|
772
|
+
const f = deps.fetch ?? fetch;
|
|
773
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
774
|
+
const key = linearCursorKey(w);
|
|
775
|
+
const cur = getCursor(key);
|
|
776
|
+
if (!isFresh(cur?.polledAt ?? cur?.since, now)) {
|
|
777
|
+
if (cur?.since)
|
|
778
|
+
return catchUpLinear(w.name, tracker, cur.since, cur.polledAt, now, f, apiKey, key, deps.sleep);
|
|
779
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
780
|
+
return { baseline: true, triggers: [], results: [] };
|
|
781
|
+
}
|
|
782
|
+
// A pending window is read on from its page cursor with the same filter; else a fresh read re-reads the overlap.
|
|
783
|
+
const pending = cur.pending;
|
|
784
|
+
const gt = pending?.gt ?? new Date(Date.parse(cur.since) - OVERLAP_MS).toISOString();
|
|
785
|
+
const { nodes, after, more } = await readLinearIssues(tracker, f, apiKey, gt, pending?.after, deps.sleep);
|
|
786
|
+
const triggers = await linearTriggers(nodes, f, apiKey, deps.sleep);
|
|
787
|
+
// Where the next poll starts. Cut short by the page cap, the cursor keeps the window and its page cursor, and
|
|
788
|
+
// `since` stays behind the unread issues; the window is drained before `since` moves on to the time the
|
|
789
|
+
// first cut read started.
|
|
790
|
+
const polledAt = now.toISOString();
|
|
791
|
+
let next;
|
|
792
|
+
if (more) {
|
|
793
|
+
next = { since: cur.since, polledAt, pending: { gt, after: after, resumeAt: pending?.resumeAt ?? polledAt } };
|
|
794
|
+
}
|
|
795
|
+
else if (pending) {
|
|
796
|
+
next = { since: pending.resumeAt, polledAt };
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
next = { since: polledAt, polledAt };
|
|
800
|
+
}
|
|
801
|
+
setCursor(key, next);
|
|
802
|
+
const results = triggers.map((ev) => dispatchTrigger(ev));
|
|
803
|
+
return { baseline: false, triggers, results };
|
|
804
|
+
}
|
|
805
|
+
/** The issues of the dot's Linear teams (and project) created after `gt`, read from the page cursor `after`, at most LINEAR_PAGES pages. Oldest first. */
|
|
806
|
+
async function readLinearIssues(tracker, f, apiKey, gt, start, sleep) {
|
|
807
|
+
const filter = { team: { key: { in: tracker.teamKeys } }, createdAt: { gt } };
|
|
808
|
+
if (tracker.projectName)
|
|
809
|
+
filter.project = { name: { eq: tracker.projectName } };
|
|
810
|
+
const nodes = [];
|
|
811
|
+
let after = start;
|
|
812
|
+
let more = false;
|
|
813
|
+
for (let page = 0; page < LINEAR_PAGES; page++) {
|
|
814
|
+
const data = await linearGql(f, apiKey, ISSUES_QUERY, { filter, after }, sleep);
|
|
815
|
+
nodes.push(...data.issues.nodes);
|
|
816
|
+
more = !!data.issues.pageInfo.hasNextPage && !!data.issues.pageInfo.endCursor;
|
|
817
|
+
if (!more)
|
|
818
|
+
break;
|
|
819
|
+
after = data.issues.pageInfo.endCursor;
|
|
820
|
+
}
|
|
821
|
+
// The API's order is not relied on: it is read from the rows, then the rows are processed oldest first.
|
|
822
|
+
nodes.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
823
|
+
return { nodes, after, more };
|
|
824
|
+
}
|
|
825
|
+
/** `ticket_created` events for Linear issue rows, in the order given. The dot's own filings are skipped. */
|
|
826
|
+
async function linearTriggers(nodes, f, apiKey, sleep) {
|
|
827
|
+
// A marker counts only on content the dot's own account wrote. Ask who the key belongs to just when a
|
|
828
|
+
// description carries a valid marker, so a poll without one makes no extra call.
|
|
829
|
+
if (!ownIds('linear').length && nodes.some((d) => hasValidBotMarker(d.description))) {
|
|
830
|
+
try {
|
|
831
|
+
const me = await linearGql(f, apiKey, VIEWER_QUERY, undefined, sleep);
|
|
832
|
+
rememberOwnId('linear', me.viewer?.id);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
/* unknown identity: the issue is reviewed rather than skipped */
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const triggers = [];
|
|
839
|
+
for (const d of nodes) {
|
|
840
|
+
const ev = normalizeLinear({ type: 'Issue', action: 'create', data: { ...d, creatorId: d.creator?.id } });
|
|
841
|
+
if (ev)
|
|
842
|
+
triggers.push(ev);
|
|
843
|
+
}
|
|
844
|
+
return triggers;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* The poll after a stale cursor (KAT-363): the server did not poll Linear since `polledAt`, so the issues created in the gap were
|
|
848
|
+
* never seen. It reads the dot's teams from the old cursor (`since` stays behind a backlog, so a window left unread is part of it),
|
|
849
|
+
* and dispatches the newest issues first, each dot taking at most CATCH_UP_MAX (dispatchCatchUp). Each event says so (`catchUp`).
|
|
850
|
+
*/
|
|
851
|
+
async function catchUpLinear(dotName, tracker, since, polledAt, now, f, apiKey, key, sleep) {
|
|
852
|
+
const gt = new Date(Date.parse(since) - OVERLAP_MS).toISOString();
|
|
853
|
+
const { nodes } = await readLinearIssues(tracker, f, apiKey, gt, undefined, sleep);
|
|
854
|
+
const events = (await linearTriggers(nodes, f, apiKey, sleep)).reverse();
|
|
855
|
+
const gapSince = polledAt ?? since;
|
|
856
|
+
const gapMs = now.getTime() - Date.parse(gapSince);
|
|
857
|
+
for (const ev of events)
|
|
858
|
+
ev.catchUp = { since: gapSince, gapMs, source: 'Linear' };
|
|
859
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
860
|
+
const results = await dispatchCatchUp(events, false);
|
|
861
|
+
logCatchUp(`poll ${dotName} (Linear)`, 'issue(s)', gapMs, events.length, results);
|
|
862
|
+
return { baseline: false, triggers: events, results };
|
|
863
|
+
}
|
|
864
|
+
/** Issues one Jira poll reads at most (the search pages 100 at a time). A full read means more may be waiting. */
|
|
865
|
+
export const JIRA_POLL_MAX = 1000;
|
|
866
|
+
/** The most issues a poll reads while it works through a window that is larger than JIRA_POLL_MAX. */
|
|
867
|
+
export const JIRA_POLL_HARD_MAX = 3000;
|
|
868
|
+
const jiraSeenKey = (issue) => `${issue.key}|${String((issue.fields ?? {}).updated ?? '')}`;
|
|
869
|
+
export function jiraCursorKey(w) {
|
|
870
|
+
return `jira:${w.id}`;
|
|
871
|
+
}
|
|
872
|
+
const JIRA_KEY = /^[A-Z][A-Z0-9_]{0,19}$/;
|
|
873
|
+
/** True when a changelog item edits the description or the acceptance criteria field. */
|
|
874
|
+
export function isJiraSpecItem(item) {
|
|
875
|
+
const ac = env().JIRA_AC_FIELD;
|
|
876
|
+
return (item.field === 'description' || item.fieldId === 'description' || (!!ac && item.fieldId === ac) || /acceptance criteria/i.test(String(item.field ?? '')));
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* One Jira poll for a watch, through the connected Jira client. With no fresh cursor it only
|
|
880
|
+
* records one. Later calls search `project in (...) AND updated >= <cursor>` and dispatch
|
|
881
|
+
* ticket_created for issues created since the cursor (dedupe jira:<key>:created, the webhook's key)
|
|
882
|
+
* and for description or acceptance-criteria edits since the cursor (dedupe by the edit time).
|
|
883
|
+
* Edits by the bot account (JIRA_BOT_ACCOUNT_ID, else the account that owns the token) are dropped,
|
|
884
|
+
* and an issue whose newest comment is the bot's and came after the change is skipped: the bot has
|
|
885
|
+
* already answered it. Issues come oldest first and are paged, up to JIRA_POLL_MAX per poll. When a window
|
|
886
|
+
* holds more than that (the search is rounded to the minute, so many issues can share one window), the
|
|
887
|
+
* cursor stays at the window start and remembers the issues it handled by key and updated time; the next
|
|
888
|
+
* poll reads that many issues deeper and skips them, so every issue is reached and reviewed once.
|
|
889
|
+
*/
|
|
890
|
+
export async function pollJiraOnce(w, deps = {}) {
|
|
891
|
+
const out = await readJiraTickets(w, deps);
|
|
892
|
+
// A dot with no backfill mark picks its open tickets now (KAT-381).
|
|
893
|
+
try {
|
|
894
|
+
const client = (deps.jira ?? jiraFor)(w);
|
|
895
|
+
if (client.mode === 'live' && client.search) {
|
|
896
|
+
const started = await seedJiraBackfill(w, client, (deps.now ?? (() => new Date()))());
|
|
897
|
+
out.triggers.push(...started.triggers);
|
|
898
|
+
out.results.push(...started.results);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
catch (err) {
|
|
902
|
+
log.warn(`poll jira ${w.name}: ticket backfill failed: ${pollErrorText(err)}`);
|
|
903
|
+
}
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
906
|
+
async function readJiraTickets(w, deps) {
|
|
907
|
+
const tracker = w.tracker;
|
|
908
|
+
if (tracker?.kind !== 'jira' || !tracker.projectKeys?.length)
|
|
909
|
+
return { baseline: false, triggers: [], results: [] };
|
|
910
|
+
const client = (deps.jira ?? jiraFor)(w);
|
|
911
|
+
if (client.mode !== 'live' || !client.search)
|
|
912
|
+
return { baseline: false, triggers: [], results: [] };
|
|
913
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
914
|
+
const key = jiraCursorKey(w);
|
|
915
|
+
const cur = getCursor(key);
|
|
916
|
+
if (!isFresh(cur?.polledAt ?? cur?.since, now)) {
|
|
917
|
+
if (cur?.since)
|
|
918
|
+
return catchUpJira(w.name, tracker.projectKeys, client, cur.since, cur.polledAt, now, key);
|
|
919
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
920
|
+
return { baseline: true, triggers: [], results: [] };
|
|
921
|
+
}
|
|
922
|
+
const sinceMs = Date.parse(cur.since) - OVERLAP_MS;
|
|
923
|
+
const seen = new Set(cur.seen ?? []);
|
|
924
|
+
const readMax = Math.min(JIRA_POLL_HARD_MAX, JIRA_POLL_MAX + seen.size);
|
|
925
|
+
const issues = await searchJira(client, tracker.projectKeys, sinceMs, now, 'ASC', readMax);
|
|
926
|
+
if (!issues)
|
|
927
|
+
return { baseline: false, triggers: [], results: [] };
|
|
928
|
+
const triggers = await jiraTriggers(client, issues, sinceMs, seen);
|
|
929
|
+
// A full read may have cut the window short. Keep the window start and the handled issues so the next
|
|
930
|
+
// poll reads deeper; the overlap and the dedupe keys make a repeat harmless.
|
|
931
|
+
const capped = issues.length >= readMax;
|
|
932
|
+
if (capped && readMax < JIRA_POLL_HARD_MAX) {
|
|
933
|
+
const handled = [...seen, ...issues.map(jiraSeenKey)];
|
|
934
|
+
setCursor(key, { since: cur.since, polledAt: now.toISOString(), seen: [...new Set(handled)] });
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
if (capped)
|
|
938
|
+
log.warn(`poll jira ${w.name}: more than ${JIRA_POLL_HARD_MAX} issues changed in one window; the rest are not reviewed`);
|
|
939
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
940
|
+
}
|
|
941
|
+
const results = triggers.map((ev) => dispatchTrigger(ev));
|
|
942
|
+
return { baseline: false, triggers, results };
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* The issues of the given Jira projects updated since `sinceMs`, through the connected client, or null when no project key is valid.
|
|
946
|
+
* JQL dates are in the Jira user's time zone and have minute precision, so the search uses a relative window that covers the
|
|
947
|
+
* cursor and the exact cut happens on the returned timestamps.
|
|
948
|
+
*/
|
|
949
|
+
async function searchJira(client, projectKeys, sinceMs, now, order, max) {
|
|
950
|
+
const minutes = Math.max(1, Math.ceil((now.getTime() - sinceMs) / 60_000) + 1);
|
|
951
|
+
const keys = projectKeys.map((k) => k.trim().toUpperCase()).filter((k) => JIRA_KEY.test(k));
|
|
952
|
+
if (!keys.length)
|
|
953
|
+
return null;
|
|
954
|
+
const jql = `project in (${keys.map((k) => `"${k}"`).join(', ')}) AND updated >= "-${minutes}m" ORDER BY updated ${order}`;
|
|
955
|
+
const fields = ['summary', 'description', 'issuetype', 'priority', 'labels', 'reporter', 'creator', 'project', 'updated', 'created', 'comment'];
|
|
956
|
+
if (env().JIRA_AC_FIELD)
|
|
957
|
+
fields.push(env().JIRA_AC_FIELD);
|
|
958
|
+
return client.search(jql, { fields, expand: 'changelog', max });
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The events of Jira issues read from `sinceMs` on, in the order given: `ticket_created` for an issue created since, and an update
|
|
962
|
+
* for a description or acceptance-criteria edit since. Edits by the bot account (JIRA_BOT_ACCOUNT_ID, else the account that owns the
|
|
963
|
+
* token) are dropped, and an issue whose newest comment is the bot's and came after the change is skipped: the bot has already
|
|
964
|
+
* answered it. `seen` holds `key|updated` of issues a window that is larger than one read has handled.
|
|
965
|
+
*/
|
|
966
|
+
async function jiraTriggers(client, issues, sinceMs, seen) {
|
|
967
|
+
const bot = env().JIRA_BOT_ACCOUNT_ID;
|
|
968
|
+
await client.myself().catch(() => null); // learn the dot's own account so its signed writes are recognized
|
|
969
|
+
const triggers = [];
|
|
970
|
+
for (const issue of issues) {
|
|
971
|
+
if (seen.has(jiraSeenKey(issue)))
|
|
972
|
+
continue;
|
|
973
|
+
const f = (issue.fields ?? {});
|
|
974
|
+
const created = Date.parse(String(f.created ?? ''));
|
|
975
|
+
let payload;
|
|
976
|
+
let changedAt = created;
|
|
977
|
+
if (Number.isFinite(created) && created >= sinceMs) {
|
|
978
|
+
payload = { webhookEvent: 'jira:issue_created', issue, user: f.creator ?? f.reporter };
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
const histories = (issue.changelog?.histories ?? [])
|
|
982
|
+
.filter((h) => Date.parse(h.created) >= sinceMs && (h.items ?? []).some(isJiraSpecItem))
|
|
983
|
+
.filter((h) => !bot || h.author?.accountId !== bot)
|
|
984
|
+
.sort((a, b) => Date.parse(a.created) - Date.parse(b.created));
|
|
985
|
+
const h = histories.at(-1);
|
|
986
|
+
if (h) {
|
|
987
|
+
changedAt = Date.parse(h.created);
|
|
988
|
+
// The edit time stands in for fields.updated, so a later comment does not make a new dedupe key.
|
|
989
|
+
payload = {
|
|
990
|
+
webhookEvent: 'jira:issue_updated',
|
|
991
|
+
issue: { ...issue, fields: { ...f, updated: h.created } },
|
|
992
|
+
user: h.author,
|
|
993
|
+
changelog: { items: h.items },
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (!payload)
|
|
998
|
+
continue;
|
|
999
|
+
const comments = (f.comment?.comments ?? []).slice().sort((a, b) => Date.parse(a.created) - Date.parse(b.created));
|
|
1000
|
+
const last = comments.at(-1);
|
|
1001
|
+
if (last && Date.parse(last.created) >= changedAt && ((bot && last.author?.accountId === bot) || isOwnWrite('jira', last.author?.accountId, last.body)))
|
|
1002
|
+
continue;
|
|
1003
|
+
const ev = normalizeJira(payload);
|
|
1004
|
+
if (ev)
|
|
1005
|
+
triggers.push(ev);
|
|
1006
|
+
}
|
|
1007
|
+
return triggers;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* The poll after a stale cursor (KAT-363): the server did not poll Jira since `polledAt`, so the tickets created or edited in the
|
|
1011
|
+
* gap were never seen. It searches the dot's projects from the old cursor, the 1000 most recently updated first, and dispatches
|
|
1012
|
+
* the events newest first, each dot taking at most CATCH_UP_MAX (dispatchCatchUp). Each event says so (`catchUp`).
|
|
1013
|
+
*/
|
|
1014
|
+
async function catchUpJira(dotName, projectKeys, client, since, polledAt, now, key) {
|
|
1015
|
+
const sinceMs = Date.parse(since) - OVERLAP_MS;
|
|
1016
|
+
const issues = await searchJira(client, projectKeys, sinceMs, now, 'DESC', JIRA_POLL_MAX);
|
|
1017
|
+
setCursor(key, { since: now.toISOString(), polledAt: now.toISOString() });
|
|
1018
|
+
if (!issues)
|
|
1019
|
+
return { baseline: true, triggers: [], results: [] };
|
|
1020
|
+
const events = await jiraTriggers(client, issues, sinceMs, new Set());
|
|
1021
|
+
const gapSince = polledAt ?? since;
|
|
1022
|
+
const gapMs = now.getTime() - Date.parse(gapSince);
|
|
1023
|
+
for (const ev of events)
|
|
1024
|
+
ev.catchUp = { since: gapSince, gapMs, source: 'Jira' };
|
|
1025
|
+
const results = await dispatchCatchUp(events, false);
|
|
1026
|
+
logCatchUp(`poll ${dotName} (Jira)`, 'ticket event(s)', gapMs, events.length, results);
|
|
1027
|
+
return { baseline: false, triggers: events, results };
|
|
1028
|
+
}
|
|
1029
|
+
// Ticket review backfill (KAT-381)
|
|
1030
|
+
/**
|
|
1031
|
+
* Before this, the first poll of a tracker only recorded a cursor, so the open tickets that were there before the dot started never got a
|
|
1032
|
+
* review and day one looked empty. Now each dot that polls a tracker and has not picked its backfill takes the open tickets in its scope,
|
|
1033
|
+
* the most recently updated ones up to the tracker's `backfillLimit` (default 25, 0 turns it off). The list goes in the poll cursor store
|
|
1034
|
+
* under `ticket-backfill:<dot id>:<scope>` (the scope is the repo, `jira` or `linear`), which is also the mark that the dot picked: a restart,
|
|
1035
|
+
* and a later poll, find the mark and pick nothing again.
|
|
1036
|
+
*
|
|
1037
|
+
* The reviews start one at a time, oldest update first: a dot starts the next one when the review before it is done (a ticket_review job
|
|
1038
|
+
* that is not queued or running) and its daily model cap is not reached. So 25 tickets never fill the queue ahead of a PR check, and a dot
|
|
1039
|
+
* at its cap holds no job and no "waiting" run for each of them. Each review is an ordinary ticket_created event marked `backfill`. Nobody
|
|
1040
|
+
* asked for it, so its comment and status label wait in the Inbox under the Ask and Act rules (a dot set to hand off hands the review off, and a dot set to act when asked skips it; KAT-381). A limit lowered later, or set to 0,
|
|
1041
|
+
* cuts the list that was picked; the tickets cut stay cut when the limit rises again. A review already queued when the limit drops to 0
|
|
1042
|
+
* ends at once when its turn comes. Its dedupe key is the key of a new ticket, so a ticket the dot reviewed
|
|
1043
|
+
* when it was opened is never reviewed twice.
|
|
1044
|
+
*
|
|
1045
|
+
* A dot that was already running long before its first poll (older than STALE_CURSOR_MS, as for PRs, KAT-352) is not new: an upgrade or a
|
|
1046
|
+
* re-enabled poller never replays its tickets. A tracker that names `backfillLimit` itself asks for the backfill, so it runs for any dot age.
|
|
1047
|
+
*/
|
|
1048
|
+
const BACKFILL_PREFIX = 'ticket-backfill:';
|
|
1049
|
+
/** How often a dot looks at whether its next backfilled review can start. */
|
|
1050
|
+
const BACKFILL_EVERY_MS = 10_000;
|
|
1051
|
+
const backfillKey = (watchId, scope) => `${BACKFILL_PREFIX}${watchId}:${scope.toLowerCase()}`;
|
|
1052
|
+
/** The dot has a backfilled review that is queued or running. */
|
|
1053
|
+
function activeBackfillJobs(watchId) {
|
|
1054
|
+
const jobs = list('jobs', { ...(watchId ? { watchId } : {}), kind: 'ticket_review', status: ['queued', 'running'] });
|
|
1055
|
+
return jobs.filter((j) => j.payload?.backfill === true);
|
|
1056
|
+
}
|
|
1057
|
+
/** True when this dot already has a review of the ticket the event names. */
|
|
1058
|
+
function alreadyReviewed(w, ev) {
|
|
1059
|
+
return !!findOne('jobs', { dedupeKey: `${ev.dedupeKey}:${w.id}:ticket_review` }) || reviewedSameBody(w.id, ev);
|
|
1060
|
+
}
|
|
1061
|
+
/** What a dot with no mark does now: picks a backfill, only marks (a dot that was running long ago), or stays out (limit 0). */
|
|
1062
|
+
function backfillMode(w, now) {
|
|
1063
|
+
const named = explicitBackfillLimit(w);
|
|
1064
|
+
if (named === 0)
|
|
1065
|
+
return 'off';
|
|
1066
|
+
if (named === undefined && now.getTime() - Date.parse(w.createdAt) > STALE_CURSOR_MS)
|
|
1067
|
+
return 'mark';
|
|
1068
|
+
return 'pick';
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* True when the dot has no backfill row in this scope, or only the mark of a dot that was running long before its first poll and its
|
|
1072
|
+
* tracker now names a limit above 0 (the upgrade step: add `backfillLimit` to the tracker of an older dot and save it).
|
|
1073
|
+
*/
|
|
1074
|
+
function needsBackfillPick(w, scope) {
|
|
1075
|
+
const row = getCursor(backfillKey(w.id, scope));
|
|
1076
|
+
if (!row)
|
|
1077
|
+
return true;
|
|
1078
|
+
return row.marked === true && (explicitBackfillLimit(w) ?? 0) > 0;
|
|
1079
|
+
}
|
|
1080
|
+
/** The scopes the dot's tracker polls now: its repos, `jira` or `linear`. A backfill list in any other scope is left over from an edit. */
|
|
1081
|
+
function liveBackfillScopes(w) {
|
|
1082
|
+
const t = w.tracker;
|
|
1083
|
+
if (t?.kind === 'github')
|
|
1084
|
+
return new Set(trackerRepos(w).map((r) => r.toLowerCase()));
|
|
1085
|
+
if (t?.kind === 'jira')
|
|
1086
|
+
return new Set(['jira']);
|
|
1087
|
+
if (t?.kind === 'linear')
|
|
1088
|
+
return new Set(['linear']);
|
|
1089
|
+
return new Set();
|
|
1090
|
+
}
|
|
1091
|
+
/** Every stored backfill row, with the cursor id, the dot id and the scope. Rows that cannot be read are left out. */
|
|
1092
|
+
function storedBackfills() {
|
|
1093
|
+
ensureCursorTable();
|
|
1094
|
+
const rows = db.prepare('SELECT id, value FROM poll_cursors WHERE substr(id, 1, ?) = ?').all(BACKFILL_PREFIX.length, BACKFILL_PREFIX);
|
|
1095
|
+
const out = [];
|
|
1096
|
+
for (const r of rows) {
|
|
1097
|
+
const rest = r.id.slice(BACKFILL_PREFIX.length);
|
|
1098
|
+
const cut = rest.indexOf(':');
|
|
1099
|
+
if (cut < 1)
|
|
1100
|
+
continue;
|
|
1101
|
+
try {
|
|
1102
|
+
out.push({ id: r.id, watchId: rest.slice(0, cut), scope: rest.slice(cut + 1), state: JSON.parse(r.value) });
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
/* an unreadable row counts as nothing left */
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return out;
|
|
1109
|
+
}
|
|
1110
|
+
/** True when the dot still polls the scope of the row. A row of a dot that is gone, or of a scope an edit took away, is not. */
|
|
1111
|
+
function inLiveScope(b, dots) {
|
|
1112
|
+
let scopes = dots.get(b.watchId);
|
|
1113
|
+
if (!scopes) {
|
|
1114
|
+
const w = findOne('watches', { id: b.watchId });
|
|
1115
|
+
scopes = w ? liveBackfillScopes(w) : new Set();
|
|
1116
|
+
dots.set(b.watchId, scopes);
|
|
1117
|
+
}
|
|
1118
|
+
return scopes.has(b.scope);
|
|
1119
|
+
}
|
|
1120
|
+
/** The dots' backfills that still have a scope to drain, as dot id to the stored state. */
|
|
1121
|
+
function backfillStates() {
|
|
1122
|
+
const dots = new Map();
|
|
1123
|
+
return storedBackfills().filter((b) => inLiveScope(b, dots));
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Deletes the backfill rows of scopes a dot no longer polls (a repo taken off its tracker, or a tracker of another kind). Without it the
|
|
1127
|
+
* list would never drain: Activity would show "Reviewing N open tickets" for good and the 10 s tick would keep running for nothing. A row
|
|
1128
|
+
* with no tickets left stays, because it is also the mark that the dot picked. Returns how many rows went.
|
|
1129
|
+
*/
|
|
1130
|
+
export function pruneOrphanBackfills() {
|
|
1131
|
+
const dots = new Map();
|
|
1132
|
+
const gone = storedBackfills().filter((b) => b.state.pending?.length && !inLiveScope(b, dots));
|
|
1133
|
+
for (const b of gone)
|
|
1134
|
+
db.prepare('DELETE FROM poll_cursors WHERE id = ?').run(b.id);
|
|
1135
|
+
return gone.length;
|
|
1136
|
+
}
|
|
1137
|
+
/** Tickets each dot has still to review for its backfill: the ones not started plus the review queued or running. Dots with none are left out. */
|
|
1138
|
+
export function backfillRemaining() {
|
|
1139
|
+
const out = new Map();
|
|
1140
|
+
for (const b of backfillStates())
|
|
1141
|
+
if (b.state.pending?.length)
|
|
1142
|
+
out.set(b.watchId, (out.get(b.watchId) ?? 0) + b.state.pending.length);
|
|
1143
|
+
const active = activeBackfillJobs();
|
|
1144
|
+
// A dot's review in flight counts only while the dot has a backfill: a plain ticket review has no `backfill` mark.
|
|
1145
|
+
for (const j of active)
|
|
1146
|
+
out.set(j.watchId, (out.get(j.watchId) ?? 0) + 1);
|
|
1147
|
+
return out;
|
|
1148
|
+
}
|
|
1149
|
+
/** True when the dot has backfilled tickets left, no review of them runs, and its daily model cap holds the next one back. */
|
|
1150
|
+
export function backfillHeld(w, now = new Date()) {
|
|
1151
|
+
return !!capState(w, now)?.reached && activeBackfillJobs(w.id).length === 0;
|
|
1152
|
+
}
|
|
1153
|
+
function hasPendingBackfill(watchId) {
|
|
1154
|
+
return backfillStates().some((b) => b.watchId === watchId && b.state.pending?.length > 0);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Picks the backfill of a dot for one tracker scope when it has no mark. `pick` returns the dot's open tickets in scope, most recently
|
|
1158
|
+
* updated first, that the dot has no review of, at most `limit` of them. Returns true when it stored a list with tickets in it.
|
|
1159
|
+
*/
|
|
1160
|
+
async function seedBackfill(w, scope, now, pick) {
|
|
1161
|
+
const key = backfillKey(w.id, scope);
|
|
1162
|
+
if (!needsBackfillPick(w, scope))
|
|
1163
|
+
return false;
|
|
1164
|
+
const mode = backfillMode(w, now);
|
|
1165
|
+
if (mode === 'off')
|
|
1166
|
+
return false;
|
|
1167
|
+
const at = now.toISOString();
|
|
1168
|
+
if (mode === 'mark') {
|
|
1169
|
+
setCursor(key, { at, total: 0, pending: [], marked: true });
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
// The limit is the dot's, not each scope's: what the dot's other scopes already picked counts against it.
|
|
1173
|
+
const pickedElsewhere = backfillStates()
|
|
1174
|
+
.filter((b) => b.watchId === w.id && b.scope !== scope.toLowerCase())
|
|
1175
|
+
.reduce((n, b) => n + (b.state.total ?? 0), 0);
|
|
1176
|
+
const limit = Math.max(0, backfillLimitOf(w) - pickedElsewhere);
|
|
1177
|
+
const events = limit > 0 ? (await pick(limit, (ev) => !alreadyReviewed(w, ev))).slice(0, limit) : [];
|
|
1178
|
+
// Oldest update first: the list is stored in the order the reviews start.
|
|
1179
|
+
const pending = events.map((ev) => ev.ticket.key).reverse();
|
|
1180
|
+
setCursor(key, { at, total: pending.length, pending });
|
|
1181
|
+
log.info(`${w.name}: ticket backfill of ${scope}: ${pending.length} open ticket(s) to review, one at a time`);
|
|
1182
|
+
return pending.length > 0;
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Holds the stored backfill lists of a dot to the limit its tracker names now. The pick checks the limit once, so a limit lowered later (or
|
|
1186
|
+
* set to 0, which turns the backfill off) would not stop a list that was already picked. The limit counts for the dot, across all its tracker
|
|
1187
|
+
* scopes: the tickets already started count against the new limit, and what is left of the lists is cut to the rest, scope by scope. A row
|
|
1188
|
+
* stays after it is cut, because it is also the mark that the dot picked. Returns how many tickets are left in all the lists.
|
|
1189
|
+
*/
|
|
1190
|
+
function trimBackfillToLimit(w) {
|
|
1191
|
+
const rows = backfillStates()
|
|
1192
|
+
.filter((b) => b.watchId === w.id)
|
|
1193
|
+
.sort((a, b) => a.scope.localeCompare(b.scope));
|
|
1194
|
+
const started = rows.reduce((n, b) => n + Math.max(0, b.state.total - (b.state.pending?.length ?? 0)), 0);
|
|
1195
|
+
let budget = Math.max(0, backfillLimitOf(w) - started);
|
|
1196
|
+
let left = 0;
|
|
1197
|
+
for (const b of rows) {
|
|
1198
|
+
const pending = b.state.pending ?? [];
|
|
1199
|
+
const keep = Math.min(pending.length, budget);
|
|
1200
|
+
budget -= keep;
|
|
1201
|
+
left += keep;
|
|
1202
|
+
if (keep < pending.length) {
|
|
1203
|
+
const begun = Math.max(0, b.state.total - pending.length);
|
|
1204
|
+
// The picked count follows the cut, so the tickets already started stay the same on the next read.
|
|
1205
|
+
setCursor(backfillKey(b.watchId, b.scope), { ...b.state, pending: pending.slice(0, keep), total: begun + keep });
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return left;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Starts the next review of a dot's backfill in one scope, when the review before it is done and the daily model cap is not reached. A
|
|
1212
|
+
* ticket that is gone, closed or out of scope when its turn comes is dropped and the next one is tried. Returns what it queued (at most one).
|
|
1213
|
+
*/
|
|
1214
|
+
async function drainBackfill(w, source, now) {
|
|
1215
|
+
const out = { triggers: [], results: [] };
|
|
1216
|
+
const key = backfillKey(w.id, source.scope);
|
|
1217
|
+
if (!getCursor(key)?.pending?.length)
|
|
1218
|
+
return out;
|
|
1219
|
+
if (trimBackfillToLimit(w) === 0)
|
|
1220
|
+
return out;
|
|
1221
|
+
if (!getCursor(key)?.pending?.length)
|
|
1222
|
+
return out;
|
|
1223
|
+
if (activeBackfillJobs(w.id).length || capState(w, now)?.reached)
|
|
1224
|
+
return out;
|
|
1225
|
+
for (;;) {
|
|
1226
|
+
const next = getCursor(key)?.pending?.[0];
|
|
1227
|
+
if (!next)
|
|
1228
|
+
break;
|
|
1229
|
+
// A failed read (network, rate limit) leaves the ticket in its place; the next try is in a few seconds.
|
|
1230
|
+
const ev = await source.event(next).catch((err) => {
|
|
1231
|
+
throw new Error(`could not read ${next}: ${pollErrorText(err)}`, { cause: err });
|
|
1232
|
+
});
|
|
1233
|
+
// The read took time: the scheduler may have cut the list to a lower limit meanwhile. Hold it to the limit again, and read the list
|
|
1234
|
+
// fresh, so a stale copy is never written back and a ticket the cut dropped is not started.
|
|
1235
|
+
trimBackfillToLimit(findOne('watches', { id: w.id }) ?? w);
|
|
1236
|
+
const state = getCursor(key);
|
|
1237
|
+
if (!state?.pending?.length || state.pending[0] !== next)
|
|
1238
|
+
break;
|
|
1239
|
+
let queued = false;
|
|
1240
|
+
if (ev) {
|
|
1241
|
+
const sent = { ...ev, backfill: true };
|
|
1242
|
+
const res = dispatchTrigger(sent, { onlyWatchIds: [w.id] });
|
|
1243
|
+
queued = res.jobs.length > 0;
|
|
1244
|
+
if (queued) {
|
|
1245
|
+
out.triggers.push(sent);
|
|
1246
|
+
out.results.push(res);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// Stored after the dispatch: a crash in between repeats the ticket, and its dedupe key keeps that from being a second review.
|
|
1250
|
+
state.pending.shift();
|
|
1251
|
+
setCursor(key, state);
|
|
1252
|
+
if (queued)
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
return out;
|
|
1256
|
+
}
|
|
1257
|
+
// GitHub backfill
|
|
1258
|
+
const repoPayload = (full) => {
|
|
1259
|
+
const [owner, name] = full.split('/');
|
|
1260
|
+
return { owner: { login: owner }, name, full_name: full };
|
|
1261
|
+
};
|
|
1262
|
+
/** The ticket_created event of an issue row for a dot's backfill, or null for a PR, the dot's own issue, or an issue out of the dot's scope. */
|
|
1263
|
+
function githubBackfillEvent(w, full, issue) {
|
|
1264
|
+
if (issue.pull_request || issue.state === 'closed')
|
|
1265
|
+
return null;
|
|
1266
|
+
const ev = normalizeGithubIssue('issues', { action: 'opened', issue, repository: repoPayload(full), sender: issue.user });
|
|
1267
|
+
return ev && issueInScope(w, ev) ? ev : null;
|
|
1268
|
+
}
|
|
1269
|
+
function githubBackfillSource(w, full, deps) {
|
|
1270
|
+
const f = deps.fetch ?? fetch;
|
|
1271
|
+
return {
|
|
1272
|
+
scope: full,
|
|
1273
|
+
async event(key) {
|
|
1274
|
+
const n = Number(key.split('#')[1]);
|
|
1275
|
+
const token = await (deps.githubToken ? deps.githubToken(full) : defaultGithubToken(full, f));
|
|
1276
|
+
let issue;
|
|
1277
|
+
try {
|
|
1278
|
+
issue = await ghGet(f, token, `/repos/${full}/issues/${n}`);
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
// A deleted or moved issue is not coming back.
|
|
1282
|
+
const status = err.status;
|
|
1283
|
+
if (status === 404 || status === 410)
|
|
1284
|
+
return null;
|
|
1285
|
+
throw err;
|
|
1286
|
+
}
|
|
1287
|
+
await rememberGithubIdentity([issue], f, token);
|
|
1288
|
+
const ev = githubBackfillEvent(w, full, issue);
|
|
1289
|
+
// Recorded now, so a later edit of the text the review read is seen as an edit.
|
|
1290
|
+
if (ev && !issueState(full, n))
|
|
1291
|
+
recordIssue(full, issue);
|
|
1292
|
+
return ev;
|
|
1293
|
+
},
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Called after each poll of the issues of `full`: every dot that polls this tracker repo and has no backfill mark picks its backfill (a new
|
|
1298
|
+
* dot, or a dot on a tracker that was already polled), reading the open issues most recently updated first, up to ISSUE_PAGES pages, and
|
|
1299
|
+
* starts the first review. The open issues are read once for all the dots that need them.
|
|
1300
|
+
*/
|
|
1301
|
+
async function seedGithubBackfills(full, deps) {
|
|
1302
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
1303
|
+
const f = deps.fetch ?? fetch;
|
|
1304
|
+
const scope = full.toLowerCase();
|
|
1305
|
+
const dots = list('watches').filter((w) => wantsGithubIssuesPoll(w) && trackerRepos(w).includes(scope) && needsBackfillPick(w, scope));
|
|
1306
|
+
const out = { triggers: [], results: [] };
|
|
1307
|
+
if (!dots.length)
|
|
1308
|
+
return out;
|
|
1309
|
+
const rows = [];
|
|
1310
|
+
let page = 0;
|
|
1311
|
+
let more = true;
|
|
1312
|
+
let token;
|
|
1313
|
+
const readMore = async () => {
|
|
1314
|
+
if (!more || page >= ISSUE_PAGES) {
|
|
1315
|
+
more = false;
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
token ??= await (deps.githubToken ? deps.githubToken(full) : defaultGithubToken(full, f));
|
|
1319
|
+
page++;
|
|
1320
|
+
const batch = await ghGet(f, token, `/repos/${full}/issues?state=open&sort=updated&direction=desc&per_page=100&page=${page}`);
|
|
1321
|
+
await rememberGithubIdentity(batch, f, token);
|
|
1322
|
+
rows.push(...batch);
|
|
1323
|
+
more = batch.length === 100;
|
|
1324
|
+
};
|
|
1325
|
+
for (const w of dots) {
|
|
1326
|
+
const seeded = await seedBackfill(w, scope, now, async (limit, fresh) => {
|
|
1327
|
+
for (;;) {
|
|
1328
|
+
const events = [];
|
|
1329
|
+
for (const issue of rows) {
|
|
1330
|
+
const ev = githubBackfillEvent(w, full, issue);
|
|
1331
|
+
if (ev && fresh(ev))
|
|
1332
|
+
events.push(ev);
|
|
1333
|
+
}
|
|
1334
|
+
if (events.length >= limit || !more)
|
|
1335
|
+
return events;
|
|
1336
|
+
await readMore();
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
if (seeded) {
|
|
1340
|
+
const started = await drainBackfill(w, githubBackfillSource(w, scope, deps), now);
|
|
1341
|
+
out.triggers.push(...started.triggers);
|
|
1342
|
+
out.results.push(...started.results);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return out;
|
|
1346
|
+
}
|
|
1347
|
+
// Jira backfill
|
|
1348
|
+
const jiraFields = () => {
|
|
1349
|
+
const fields = ['status', 'summary', 'description', 'issuetype', 'priority', 'labels', 'reporter', 'creator', 'project', 'updated', 'created', 'comment'];
|
|
1350
|
+
if (env().JIRA_AC_FIELD)
|
|
1351
|
+
fields.push(env().JIRA_AC_FIELD);
|
|
1352
|
+
return fields;
|
|
1353
|
+
};
|
|
1354
|
+
const jiraDone = (issue) => (issue.fields ?? {}).status?.statusCategory?.key === 'done';
|
|
1355
|
+
const jiraBackfillEvent = (issue) => jiraDone(issue) ? null : normalizeJira({ webhookEvent: 'jira:issue_created', issue, user: issue.fields?.creator ?? issue.fields?.reporter });
|
|
1356
|
+
function jiraBackfillSource(client) {
|
|
1357
|
+
return {
|
|
1358
|
+
scope: 'jira',
|
|
1359
|
+
async event(key) {
|
|
1360
|
+
if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(key))
|
|
1361
|
+
return null;
|
|
1362
|
+
let issues;
|
|
1363
|
+
try {
|
|
1364
|
+
issues = await client.search(`key = "${key}"`, { fields: jiraFields(), max: 1 });
|
|
1365
|
+
}
|
|
1366
|
+
catch (err) {
|
|
1367
|
+
// Jira answers a key that does not exist, or that the account cannot see, with HTTP 400 (a JQL error), not an empty result.
|
|
1368
|
+
// The key passed the check above, so it is not a typo: the ticket is gone for this dot and is not coming back.
|
|
1369
|
+
const status = err.status;
|
|
1370
|
+
if (status === 400 || status === 404)
|
|
1371
|
+
return null;
|
|
1372
|
+
throw err;
|
|
1373
|
+
}
|
|
1374
|
+
return issues[0] ? jiraBackfillEvent(issues[0]) : null;
|
|
1375
|
+
},
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/** The Jira dot picks its backfill: the open issues of its projects, most recently updated first. */
|
|
1379
|
+
async function seedJiraBackfill(w, client, now) {
|
|
1380
|
+
const out = { triggers: [], results: [] };
|
|
1381
|
+
const tracker = w.tracker;
|
|
1382
|
+
if (tracker?.kind !== 'jira' || !client.search)
|
|
1383
|
+
return out;
|
|
1384
|
+
const seeded = await seedBackfill(w, 'jira', now, async (limit, fresh) => {
|
|
1385
|
+
const keys = tracker.projectKeys.map((k) => k.trim().toUpperCase()).filter((k) => JIRA_KEY.test(k));
|
|
1386
|
+
if (!keys.length)
|
|
1387
|
+
return [];
|
|
1388
|
+
const jql = `project in (${keys.map((k) => `"${k}"`).join(', ')}) AND statusCategory != Done ORDER BY updated DESC`;
|
|
1389
|
+
// Twice the limit: the dot's own filings are dropped from the list.
|
|
1390
|
+
const issues = await client.search(jql, { fields: jiraFields(), max: Math.min(200, limit * 2) });
|
|
1391
|
+
return issues.map(jiraBackfillEvent).filter((ev) => !!ev && fresh(ev));
|
|
1392
|
+
});
|
|
1393
|
+
return seeded ? drainBackfill(w, jiraBackfillSource(client), now) : out;
|
|
1394
|
+
}
|
|
1395
|
+
// Linear backfill
|
|
1396
|
+
const OPEN_STATE = { type: { nin: ['completed', 'canceled'] } };
|
|
1397
|
+
/** The Linear teams, and the project, of a dot as an issue filter. */
|
|
1398
|
+
function linearScope(tracker) {
|
|
1399
|
+
const filter = { team: { key: { in: tracker.teamKeys } } };
|
|
1400
|
+
if (tracker.projectName)
|
|
1401
|
+
filter.project = { name: { eq: tracker.projectName } };
|
|
1402
|
+
return filter;
|
|
1403
|
+
}
|
|
1404
|
+
const linearBackfillEvent = (d) => normalizeLinear({ type: 'Issue', action: 'create', data: { ...d, creatorId: d.creator?.id } });
|
|
1405
|
+
function linearBackfillSource(tracker, f, apiKey, sleep) {
|
|
1406
|
+
return {
|
|
1407
|
+
scope: 'linear',
|
|
1408
|
+
async event(key) {
|
|
1409
|
+
const m = /^([A-Za-z0-9]+)-(\d+)$/.exec(key);
|
|
1410
|
+
if (!m)
|
|
1411
|
+
return null;
|
|
1412
|
+
const filter = {
|
|
1413
|
+
...linearScope(tracker),
|
|
1414
|
+
team: { key: { in: tracker.teamKeys.filter((k) => k.toLowerCase() === m[1].toLowerCase()) } },
|
|
1415
|
+
number: { eq: Number(m[2]) },
|
|
1416
|
+
};
|
|
1417
|
+
const data = await linearGql(f, apiKey, issuesQuery('updatedAt'), { filter }, sleep);
|
|
1418
|
+
const d = data.issues.nodes[0];
|
|
1419
|
+
return d && !['completed', 'canceled'].includes(String(d.state?.type)) ? linearBackfillEvent(d) : null;
|
|
1420
|
+
},
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
/** The Linear dot picks its backfill: the open issues of its teams (and project), most recently updated first. */
|
|
1424
|
+
async function seedLinearBackfill(w, f, apiKey, now, sleep) {
|
|
1425
|
+
const out = { triggers: [], results: [] };
|
|
1426
|
+
const tracker = w.tracker;
|
|
1427
|
+
if (tracker?.kind !== 'linear' || !tracker.teamKeys?.length)
|
|
1428
|
+
return out;
|
|
1429
|
+
const seeded = await seedBackfill(w, 'linear', now, async (limit, fresh) => {
|
|
1430
|
+
const events = [];
|
|
1431
|
+
let after;
|
|
1432
|
+
for (let page = 0; page < 4 && events.length < limit; page++) {
|
|
1433
|
+
const data = await linearGql(f, apiKey, issuesQuery('updatedAt'), { filter: { ...linearScope(tracker), state: OPEN_STATE }, after }, sleep);
|
|
1434
|
+
for (const d of data.issues.nodes) {
|
|
1435
|
+
const ev = linearBackfillEvent(d);
|
|
1436
|
+
if (ev && fresh(ev))
|
|
1437
|
+
events.push(ev);
|
|
1438
|
+
}
|
|
1439
|
+
if (!data.issues.pageInfo.hasNextPage || !data.issues.pageInfo.endCursor)
|
|
1440
|
+
break;
|
|
1441
|
+
after = data.issues.pageInfo.endCursor;
|
|
1442
|
+
}
|
|
1443
|
+
return events;
|
|
1444
|
+
});
|
|
1445
|
+
return seeded ? drainBackfill(w, linearBackfillSource(tracker, f, apiKey, sleep), now) : out;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* The scheduler's step for one dot: starts the next review of each of its backfills that has tickets left (drainBackfill). Reads nothing
|
|
1449
|
+
* when no backfill is waiting, so a dot without one costs a read of the cursor store.
|
|
1450
|
+
*/
|
|
1451
|
+
export async function drainBackfills(w, deps = {}) {
|
|
1452
|
+
const out = { baseline: false, triggers: [], results: [] };
|
|
1453
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
1454
|
+
const t = w.tracker;
|
|
1455
|
+
const take = (s) => {
|
|
1456
|
+
out.triggers.push(...s.triggers);
|
|
1457
|
+
out.results.push(...s.results);
|
|
1458
|
+
};
|
|
1459
|
+
if (!t || !hasPendingBackfill(w.id))
|
|
1460
|
+
return out;
|
|
1461
|
+
// A limit lowered to 0 or below what already started clears the list, even when the tracker cannot be read right now.
|
|
1462
|
+
trimBackfillToLimit(w);
|
|
1463
|
+
if (!hasPendingBackfill(w.id))
|
|
1464
|
+
return out;
|
|
1465
|
+
if (t.kind === 'github') {
|
|
1466
|
+
for (const full of trackerRepos(w))
|
|
1467
|
+
take(await drainBackfill(w, githubBackfillSource(w, full, deps), now));
|
|
1468
|
+
}
|
|
1469
|
+
else if (t.kind === 'jira') {
|
|
1470
|
+
const client = (deps.jira ?? jiraFor)(w);
|
|
1471
|
+
if (client.mode === 'live' && client.search)
|
|
1472
|
+
take(await drainBackfill(w, jiraBackfillSource(client), now));
|
|
1473
|
+
}
|
|
1474
|
+
else if (t.kind === 'linear') {
|
|
1475
|
+
const apiKey = env().LINEAR_API_KEY;
|
|
1476
|
+
if (apiKey)
|
|
1477
|
+
take(await drainBackfill(w, linearBackfillSource(t, deps.fetch ?? fetch, apiKey, deps.sleep), now));
|
|
1478
|
+
}
|
|
1479
|
+
return out;
|
|
1480
|
+
}
|
|
1481
|
+
// Scheduler
|
|
1482
|
+
/**
|
|
1483
|
+
* Starts GitHub, Linear, and Jira polling for every unpaused watch that opts in
|
|
1484
|
+
* (LOCALDOST_POLL=1 for all, or watch.pollGithub / watch.pollLinear / watch.pollJira).
|
|
1485
|
+
* GitHub polls need GITHUB_TOKEN or a GitHub App; Linear polls need LINEAR_API_KEY.
|
|
1486
|
+
* Returns a stop function.
|
|
1487
|
+
*/
|
|
1488
|
+
export function startPollers(deps = {}) {
|
|
1489
|
+
if (sandboxOn()) {
|
|
1490
|
+
sandboxSkips('the pollers');
|
|
1491
|
+
return () => undefined;
|
|
1492
|
+
}
|
|
1493
|
+
const lastRun = new Map();
|
|
1494
|
+
const inflight = new Set();
|
|
1495
|
+
let warnedGh = false;
|
|
1496
|
+
const run = (key, everyMs, fn, label) => {
|
|
1497
|
+
const now = Date.now();
|
|
1498
|
+
if (inflight.has(key) || now - (lastRun.get(key) ?? 0) < everyMs)
|
|
1499
|
+
return;
|
|
1500
|
+
lastRun.set(key, now);
|
|
1501
|
+
inflight.add(key);
|
|
1502
|
+
fn()
|
|
1503
|
+
.then((o) => {
|
|
1504
|
+
recordPollResult(key);
|
|
1505
|
+
if (o.baseline)
|
|
1506
|
+
log.info(`poll ${label}: cursor recorded`);
|
|
1507
|
+
else if (o.triggers.length)
|
|
1508
|
+
log.info(`poll ${label}: ${o.triggers.length} new event(s)`);
|
|
1509
|
+
})
|
|
1510
|
+
.catch((err) => {
|
|
1511
|
+
recordPollResult(key, err);
|
|
1512
|
+
log.warn(`poll ${label} failed: ${err.message}`);
|
|
1513
|
+
})
|
|
1514
|
+
.finally(() => inflight.delete(key));
|
|
1515
|
+
};
|
|
1516
|
+
const tick = () => {
|
|
1517
|
+
let watches;
|
|
1518
|
+
try {
|
|
1519
|
+
watches = list('watches');
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
log.warn(`pollers cannot list watches: ${err.message}`);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
const ghEvery = githubPollSec() * 1000;
|
|
1526
|
+
const linEvery = linearPollSec() * 1000;
|
|
1527
|
+
let targets;
|
|
1528
|
+
try {
|
|
1529
|
+
targets = githubPollTargets(watches);
|
|
1530
|
+
}
|
|
1531
|
+
catch (err) {
|
|
1532
|
+
log.warn(`pollers cannot read repos: ${err.message}`);
|
|
1533
|
+
targets = new Map();
|
|
1534
|
+
}
|
|
1535
|
+
if (targets.size) {
|
|
1536
|
+
if (!deps.githubToken && githubMode() === 'demo') {
|
|
1537
|
+
if (!warnedGh)
|
|
1538
|
+
log.warn('GitHub poll mode needs GITHUB_TOKEN or a GitHub App; skipping GitHub polls');
|
|
1539
|
+
warnedGh = true;
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
// Keyed by repo and branch so every dot that holds a repo shares one poll.
|
|
1543
|
+
for (const [key, t] of targets)
|
|
1544
|
+
run(key, ghEvery, () => pollGithubRepoOnce(t, deps), `${t.owner}/${t.name}`);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
try {
|
|
1548
|
+
// A backfill list in a scope the dot no longer polls (a repo taken off its tracker, a new tracker kind) would never drain.
|
|
1549
|
+
const dropped = pruneOrphanBackfills();
|
|
1550
|
+
if (dropped)
|
|
1551
|
+
log.info(`ticket backfill: dropped ${dropped} list(s) of a scope no dot polls any more`);
|
|
1552
|
+
}
|
|
1553
|
+
catch (err) {
|
|
1554
|
+
log.warn(`ticket backfill: cannot drop old lists: ${err.message}`);
|
|
1555
|
+
}
|
|
1556
|
+
for (const w of watches) {
|
|
1557
|
+
// A limit lowered to 0 (or below what already started) clears the list even for a dot whose tracker polling is off, because that dot
|
|
1558
|
+
// never reaches drainBackfills, and Activity would keep showing "Reviewing N open tickets" for a list nothing drains.
|
|
1559
|
+
try {
|
|
1560
|
+
if (hasPendingBackfill(w.id))
|
|
1561
|
+
trimBackfillToLimit(w);
|
|
1562
|
+
}
|
|
1563
|
+
catch (err) {
|
|
1564
|
+
log.warn(`ticket backfill ${w.name}: cannot hold the list to its limit: ${err.message}`);
|
|
1565
|
+
}
|
|
1566
|
+
// The next review of a ticket backfill (KAT-381), when the one before it is done. One key per dot, whatever its tracker.
|
|
1567
|
+
const githubReady = !!deps.githubToken || githubMode() !== 'demo';
|
|
1568
|
+
if (!w.paused && !w.archived && ((wantsGithubIssuesPoll(w) && githubReady) || wantsJiraPoll(w) || wantsLinearPoll(w)) && hasPendingBackfill(w.id))
|
|
1569
|
+
run(`backfill:${w.id}`, BACKFILL_EVERY_MS, () => drainBackfills(w, deps), `ticket backfill ${w.name}`);
|
|
1570
|
+
if (wantsGithubIssuesPoll(w)) {
|
|
1571
|
+
if (!deps.githubToken && githubMode() === 'demo') {
|
|
1572
|
+
if (!warnedGh)
|
|
1573
|
+
log.warn('GitHub poll mode needs GITHUB_TOKEN or a GitHub App; skipping GitHub polls');
|
|
1574
|
+
warnedGh = true;
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
// Keyed by repo, so a repo several dots review is polled once.
|
|
1578
|
+
for (const full of trackerRepos(w))
|
|
1579
|
+
run(githubIssuesCursorKey(full), ghEvery, () => pollGithubIssuesOnce(full, deps), `${full} issues`);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (wantsJiraPoll(w)) {
|
|
1583
|
+
const client = (deps.jira ?? jiraFor)(w);
|
|
1584
|
+
if (client.mode !== 'live')
|
|
1585
|
+
log.warnOnce('jira-poll-key', 'Jira poll mode needs Jira connected (JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN); skipping Jira polls');
|
|
1586
|
+
else
|
|
1587
|
+
run(jiraCursorKey(w), jiraPollSec() * 1000, () => pollJiraOnce(w, deps), `jira ${w.name}`);
|
|
1588
|
+
}
|
|
1589
|
+
if (wantsLinearPoll(w)) {
|
|
1590
|
+
if (!env().LINEAR_API_KEY)
|
|
1591
|
+
log.warnOnce('linear-poll-key', 'Linear poll mode needs LINEAR_API_KEY; skipping Linear polls');
|
|
1592
|
+
else
|
|
1593
|
+
run(linearCursorKey(w), linEvery, () => pollLinearOnce(w, deps), `linear ${w.name}`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
const timer = setInterval(tick, TICK_MS);
|
|
1598
|
+
timer.unref?.();
|
|
1599
|
+
const first = setTimeout(tick, 1000);
|
|
1600
|
+
first.unref?.();
|
|
1601
|
+
return () => {
|
|
1602
|
+
clearInterval(timer);
|
|
1603
|
+
clearTimeout(first);
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
//# sourceMappingURL=pollers.js.map
|