limn-review 0.1.123
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/LICENSE +110 -0
- package/README.md +133 -0
- package/dist/ai/runtime.js +535 -0
- package/dist/api/plugins/auth.js +169 -0
- package/dist/api/plugins/error-handler.js +114 -0
- package/dist/api/plugins/rate-limit.js +855 -0
- package/dist/api/plugins/security.js +321 -0
- package/dist/api/routes/activity.js +103 -0
- package/dist/api/routes/ai-runtime.js +67 -0
- package/dist/api/routes/auth.js +194 -0
- package/dist/api/routes/billing.js +209 -0
- package/dist/api/routes/bot-triage.js +1289 -0
- package/dist/api/routes/conflicts.js +730 -0
- package/dist/api/routes/contact.js +318 -0
- package/dist/api/routes/daily-brief.js +58 -0
- package/dist/api/routes/flow.js +71 -0
- package/dist/api/routes/health.js +7 -0
- package/dist/api/routes/insights.js +200 -0
- package/dist/api/routes/me.js +458 -0
- package/dist/api/routes/mentions.js +22 -0
- package/dist/api/routes/mergers.js +8 -0
- package/dist/api/routes/ml-labels.js +109 -0
- package/dist/api/routes/open-prs.js +35 -0
- package/dist/api/routes/prs.js +1633 -0
- package/dist/api/routes/reactions.js +217 -0
- package/dist/api/routes/repos.js +563 -0
- package/dist/api/routes/search.js +41 -0
- package/dist/api/routes/threads.js +118 -0
- package/dist/api/routes/timeline.js +150 -0
- package/dist/api/routes/users.js +67 -0
- package/dist/api/routes/webhooks.js +181 -0
- package/dist/api/routes/workspaces.js +322 -0
- package/dist/app.js +274 -0
- package/dist/ascii.js +19 -0
- package/dist/auth/account.js +482 -0
- package/dist/auth/crypto.js +46 -0
- package/dist/bot-triage/resolve.js +59 -0
- package/dist/cli.js +388 -0
- package/dist/coding/activity.js +80 -0
- package/dist/coding/agent.js +288 -0
- package/dist/coding/ai-fix/auto-fix.js +212 -0
- package/dist/coding/ai-fix/manager.js +370 -0
- package/dist/coding/ai-fix/persist.js +213 -0
- package/dist/coding/ai-fix/pr-context.js +58 -0
- package/dist/coding/ai-fix/prompts.js +138 -0
- package/dist/coding/ai-fix/review-seed.js +357 -0
- package/dist/coding/ai-fix/routes.js +425 -0
- package/dist/coding/git-ops.js +321 -0
- package/dist/coding/git.js +184 -0
- package/dist/coding/merge.js +262 -0
- package/dist/coding/safe-path.js +75 -0
- package/dist/coding/schema.js +43 -0
- package/dist/config.js +648 -0
- package/dist/conflict/diff.js +304 -0
- package/dist/conflict/git.js +118 -0
- package/dist/conflict/hash.js +73 -0
- package/dist/conflict/janitor.js +218 -0
- package/dist/conflict/land.js +675 -0
- package/dist/conflict/model-types.js +2 -0
- package/dist/conflict/model.js +1081 -0
- package/dist/conflict/parse.js +142 -0
- package/dist/conflict/seam.js +136 -0
- package/dist/conflict/session.js +532 -0
- package/dist/conflict/suggestion.js +398 -0
- package/dist/conflict/tokens.js +72 -0
- package/dist/data-dir.js +112 -0
- package/dist/db/actor-lanes.js +213 -0
- package/dist/db/automation-ids.js +97 -0
- package/dist/db/automation-output.js +349 -0
- package/dist/db/blast-radius-query.js +36 -0
- package/dist/db/blast-radius.js +226 -0
- package/dist/db/bot-dormancy.js +115 -0
- package/dist/db/bot-overlap.js +579 -0
- package/dist/db/bot-volume.js +722 -0
- package/dist/db/bot-window.js +35 -0
- package/dist/db/branch-queries.js +310 -0
- package/dist/db/change-shape.js +243 -0
- package/dist/db/changepoint.js +75 -0
- package/dist/db/cleanup.js +36 -0
- package/dist/db/client.js +102 -0
- package/dist/db/code-loc.js +388 -0
- package/dist/db/credits.js +79 -0
- package/dist/db/daily-brief.js +406 -0
- package/dist/db/dependency-cards.js +145 -0
- package/dist/db/erase-account.js +255 -0
- package/dist/db/export-account.js +189 -0
- package/dist/db/failing-checks.js +124 -0
- package/dist/db/file-coupling.js +197 -0
- package/dist/db/flow-detail.js +661 -0
- package/dist/db/flow-pointers.js +197 -0
- package/dist/db/flow-settings.js +67 -0
- package/dist/db/forecast.js +114 -0
- package/dist/db/line-overlap.js +81 -0
- package/dist/db/merge-queue.js +189 -0
- package/dist/db/migrate.js +17 -0
- package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
- package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
- package/dist/db/migrations/0002_famous_sersi.sql +9 -0
- package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
- package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
- package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
- package/dist/db/migrations/0006_cute_violations.sql +45 -0
- package/dist/db/migrations/0007_dark_anthem.sql +2 -0
- package/dist/db/migrations/0008_multitenant_accounts.sql +44 -0
- package/dist/db/migrations/0009_lean_bodies.sql +13 -0
- package/dist/db/migrations/0010_lean_bodies_nullable.sql +56 -0
- package/dist/db/migrations/0011_pr_diff_size.sql +10 -0
- package/dist/db/migrations/0012_findings_included_default.sql +7 -0
- package/dist/db/migrations/0013_review_routing.sql +9 -0
- package/dist/db/migrations/0014_account_last_active.sql +6 -0
- package/dist/db/migrations/0015_repos_inbox_watch.sql +9 -0
- package/dist/db/migrations/0016_repo_viewer_permission.sql +7 -0
- package/dist/db/migrations/0017_account_display_name.sql +7 -0
- package/dist/db/migrations/0018_finding_comment_kind.sql +7 -0
- package/dist/db/migrations/0019_finding_file_in_diff.sql +7 -0
- package/dist/db/migrations/0020_claude_review_cost_telemetry.sql +11 -0
- package/dist/db/migrations/0021_account_feed_last_seen.sql +7 -0
- package/dist/db/migrations/0022_ci_status_events.sql +21 -0
- package/dist/db/migrations/0023_ai_usage.sql +21 -0
- package/dist/db/migrations/0024_pr_head_ref_name.sql +6 -0
- package/dist/db/migrations/0025_account_billing.sql +7 -0
- package/dist/db/migrations/0026_ai_credit_allowance.sql +5 -0
- package/dist/db/migrations/0027_user_github_type.sql +6 -0
- package/dist/db/migrations/0028_bot_review_classification.sql +21 -0
- package/dist/db/migrations/0029_bot_mute_rules.sql +19 -0
- package/dist/db/migrations/0030_teams.sql +32 -0
- package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
- package/dist/db/migrations/0032_benchmark_contributions.sql +29 -0
- package/dist/db/migrations/0033_thread_addressed_confidence.sql +9 -0
- package/dist/db/migrations/0034_thread_resolved_at.sql +6 -0
- package/dist/db/migrations/0035_pr_first_review_requested_at.sql +5 -0
- package/dist/db/migrations/0036_search_index.sql +25 -0
- package/dist/db/migrations/0037_author_indexes.sql +15 -0
- package/dist/db/migrations/0038_auto_merge_requests.sql +37 -0
- package/dist/db/migrations/0039_branch_status.sql +44 -0
- package/dist/db/migrations/0040_pr_review_decision.sql +10 -0
- package/dist/db/migrations/0041_branch_commit_detail.sql +38 -0
- package/dist/db/migrations/0042_repo_reviewers.sql +176 -0
- package/dist/db/migrations/0043_account_reviewers.sql +131 -0
- package/dist/db/migrations/0044_workspaces.sql +138 -0
- package/dist/db/migrations/0045_workspace_reviewers.sql +180 -0
- package/dist/db/migrations/0046_drop_inbox_watch.sql +25 -0
- package/dist/db/migrations/0047_ml_comment_labels.sql +40 -0
- package/dist/db/migrations/0048_ml_vendor_severity.sql +31 -0
- package/dist/db/migrations/0049_seat_pricing_repo_description.sql +21 -0
- package/dist/db/migrations/0050_users_app_slug.sql +12 -0
- package/dist/db/migrations/0051_auto_merge_queue.sql +17 -0
- package/dist/db/migrations/0052_trunk_ci_status_events.sql +43 -0
- package/dist/db/migrations/0053_automation_roles.sql +79 -0
- package/dist/db/migrations/0054_automation_vendor_kinds.sql +514 -0
- package/dist/db/migrations/0055_auto_merge_phase.sql +12 -0
- package/dist/db/migrations/0056_pr_mentions.sql +22 -0
- package/dist/db/migrations/0057_large_pr_threshold.sql +14 -0
- package/dist/db/migrations/0058_pending_mute.sql +53 -0
- package/dist/db/migrations/0059_merge_queue_state.sql +33 -0
- package/dist/db/migrations/0060_drop_my_turn_dismissals.sql +33 -0
- package/dist/db/migrations/0061_auto_merge_durable_anchors.sql +27 -0
- package/dist/db/migrations/0062_blast_radius_config.sql +21 -0
- package/dist/db/migrations/0063_repo_file_coupling.sql +33 -0
- package/dist/db/migrations/0064_pr_content_kind.sql +21 -0
- package/dist/db/migrations/0065_workspace_flow_settings.sql +13 -0
- package/dist/db/migrations/0066_review_request_events.sql +31 -0
- package/dist/db/migrations/0067_pr_security_signals.sql +86 -0
- package/dist/db/migrations/0068_my_turn_settings.sql +18 -0
- package/dist/db/migrations/0069_my_turn_dismissals.sql +41 -0
- package/dist/db/migrations/0070_claude_review_follow_up_ticket.sql +22 -0
- package/dist/db/migrations/0071_claude_review_trigger.sql +11 -0
- package/dist/db/migrations/0072_commit_message_headline.sql +13 -0
- package/dist/db/migrations/0073_claude_review_chat.sql +31 -0
- package/dist/db/migrations/0074_adopt_agentic_tables.sql +98 -0
- package/dist/db/migrations/0075_review_lens_drop_learnings.sql +15 -0
- package/dist/db/migrations/0076_claude_review_threads.sql +17 -0
- package/dist/db/migrations/0077_claude_review_ci_failures.sql +10 -0
- package/dist/db/migrations/0078_ai_fix_change_report.sql +17 -0
- package/dist/db/migrations/0079_claude_finding_story.sql +16 -0
- package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
- package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
- package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
- package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
- package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
- package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
- package/dist/db/migrations/meta/0006_snapshot.json +1745 -0
- package/dist/db/migrations/meta/0007_snapshot.json +1759 -0
- package/dist/db/migrations/meta/_journal.json +566 -0
- package/dist/db/migrations-pg/0000_tired_alex_power.sql +268 -0
- package/dist/db/migrations-pg/0001_dizzy_mockingbird.sql +3 -0
- package/dist/db/migrations-pg/0002_glorious_ikaris.sql +4 -0
- package/dist/db/migrations-pg/0003_melted_secret_warriors.sql +2 -0
- package/dist/db/migrations-pg/0004_numerous_triton.sql +1 -0
- package/dist/db/migrations-pg/0005_thick_blockbuster.sql +2 -0
- package/dist/db/migrations-pg/0006_majestic_dracula.sql +1 -0
- package/dist/db/migrations-pg/0007_same_human_torch.sql +2 -0
- package/dist/db/migrations-pg/0008_slimy_toxin.sql +1 -0
- package/dist/db/migrations-pg/0009_fuzzy_jubilee.sql +4 -0
- package/dist/db/migrations-pg/0010_late_hammerhead.sql +1 -0
- package/dist/db/migrations-pg/0011_happy_leech.sql +16 -0
- package/dist/db/migrations-pg/0012_woozy_captain_midlands.sql +16 -0
- package/dist/db/migrations-pg/0013_yielding_nicolaos.sql +1 -0
- package/dist/db/migrations-pg/0014_wakeful_bill_hollister.sql +2 -0
- package/dist/db/migrations-pg/0015_aspiring_black_tom.sql +1 -0
- package/dist/db/migrations-pg/0016_cute_ghost_rider.sql +30 -0
- package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
- package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
- package/dist/db/migrations-pg/0019_benchmark_contributions.sql +22 -0
- package/dist/db/migrations-pg/0020_majestic_edwin_jarvis.sql +3 -0
- package/dist/db/migrations-pg/0021_green_salo.sql +1 -0
- package/dist/db/migrations-pg/0022_wise_baron_strucker.sql +1 -0
- package/dist/db/migrations-pg/0023_search_index.sql +19 -0
- package/dist/db/migrations-pg/0024_author_indexes.sql +9 -0
- package/dist/db/migrations-pg/0025_auto_merge_requests.sql +26 -0
- package/dist/db/migrations-pg/0026_branch_status.sql +32 -0
- package/dist/db/migrations-pg/0027_pr_review_decision.sql +5 -0
- package/dist/db/migrations-pg/0028_branch_commit_detail.sql +9 -0
- package/dist/db/migrations-pg/0029_repo_reviewers.sql +127 -0
- package/dist/db/migrations-pg/0030_account_reviewers.sql +77 -0
- package/dist/db/migrations-pg/0031_workspaces.sql +131 -0
- package/dist/db/migrations-pg/0032_workspace_reviewers.sql +165 -0
- package/dist/db/migrations-pg/0033_drop_inbox_watch.sql +14 -0
- package/dist/db/migrations-pg/0034_ml_comment_labels.sql +33 -0
- package/dist/db/migrations-pg/0035_ml_vendor_severity.sql +16 -0
- package/dist/db/migrations-pg/0036_seat_pricing_repo_description.sql +12 -0
- package/dist/db/migrations-pg/0037_users_app_slug.sql +9 -0
- package/dist/db/migrations-pg/0038_auto_merge_queue.sql +10 -0
- package/dist/db/migrations-pg/0039_trunk_ci_status_events.sql +27 -0
- package/dist/db/migrations-pg/0040_automation_roles.sql +63 -0
- package/dist/db/migrations-pg/0041_automation_vendor_kinds.sql +488 -0
- package/dist/db/migrations-pg/0042_auto_merge_phase.sql +8 -0
- package/dist/db/migrations-pg/0043_pr_mentions.sql +19 -0
- package/dist/db/migrations-pg/0044_large_pr_threshold.sql +6 -0
- package/dist/db/migrations-pg/0045_pending_mute.sql +26 -0
- package/dist/db/migrations-pg/0046_merge_queue_state.sql +18 -0
- package/dist/db/migrations-pg/0047_drop_my_turn_dismissals.sql +21 -0
- package/dist/db/migrations-pg/0048_auto_merge_durable_anchors.sql +27 -0
- package/dist/db/migrations-pg/0049_blast_radius_config.sql +8 -0
- package/dist/db/migrations-pg/0050_repo_file_coupling.sql +13 -0
- package/dist/db/migrations-pg/0051_pr_content_kind.sql +6 -0
- package/dist/db/migrations-pg/0052_workspace_flow_settings.sql +4 -0
- package/dist/db/migrations-pg/0053_review_request_events.sql +18 -0
- package/dist/db/migrations-pg/0054_pr_security_signals.sql +59 -0
- package/dist/db/migrations-pg/0055_my_turn_settings.sql +6 -0
- package/dist/db/migrations-pg/0056_my_turn_dismissals.sql +21 -0
- package/dist/db/migrations-pg/0057_claude_review_follow_up_ticket.sql +8 -0
- package/dist/db/migrations-pg/0058_claude_review_trigger.sql +4 -0
- package/dist/db/migrations-pg/0059_commit_message_headline.sql +3 -0
- package/dist/db/migrations-pg/0060_claude_review_chat.sql +24 -0
- package/dist/db/migrations-pg/0061_adopt_agentic_tables.sql +79 -0
- package/dist/db/migrations-pg/0062_review_lens_drop_learnings.sql +5 -0
- package/dist/db/migrations-pg/0063_claude_review_threads.sql +7 -0
- package/dist/db/migrations-pg/0064_claude_review_ci_failures.sql +4 -0
- package/dist/db/migrations-pg/0065_ai_fix_change_report.sql +9 -0
- package/dist/db/migrations-pg/0066_claude_finding_story.sql +7 -0
- package/dist/db/migrations-pg/meta/0000_snapshot.json +2137 -0
- package/dist/db/migrations-pg/meta/0001_snapshot.json +2143 -0
- package/dist/db/migrations-pg/meta/0002_snapshot.json +2170 -0
- package/dist/db/migrations-pg/meta/0003_snapshot.json +2182 -0
- package/dist/db/migrations-pg/meta/0004_snapshot.json +2188 -0
- package/dist/db/migrations-pg/meta/0005_snapshot.json +2201 -0
- package/dist/db/migrations-pg/meta/0006_snapshot.json +2207 -0
- package/dist/db/migrations-pg/meta/0007_snapshot.json +2219 -0
- package/dist/db/migrations-pg/meta/0008_snapshot.json +2226 -0
- package/dist/db/migrations-pg/meta/0009_snapshot.json +2250 -0
- package/dist/db/migrations-pg/meta/0010_snapshot.json +2256 -0
- package/dist/db/migrations-pg/meta/0011_snapshot.json +2412 -0
- package/dist/db/migrations-pg/meta/0012_snapshot.json +2528 -0
- package/dist/db/migrations-pg/meta/0013_snapshot.json +2534 -0
- package/dist/db/migrations-pg/meta/0014_snapshot.json +2547 -0
- package/dist/db/migrations-pg/meta/0015_snapshot.json +2553 -0
- package/dist/db/migrations-pg/meta/0016_snapshot.json +2774 -0
- package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
- package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
- package/dist/db/migrations-pg/meta/0020_snapshot.json +3228 -0
- package/dist/db/migrations-pg/meta/0021_snapshot.json +3234 -0
- package/dist/db/migrations-pg/meta/0022_snapshot.json +3240 -0
- package/dist/db/migrations-pg/meta/_journal.json +475 -0
- package/dist/db/ml-labels.js +1666 -0
- package/dist/db/my-turn-dismissals.js +343 -0
- package/dist/db/my-turn-settings.js +34 -0
- package/dist/db/pending-classify.js +46 -0
- package/dist/db/pending-mute.js +171 -0
- package/dist/db/pending-tabs.js +238 -0
- package/dist/db/period-metrics.js +820 -0
- package/dist/db/person-period.js +763 -0
- package/dist/db/pr-intervals.js +930 -0
- package/dist/db/pr-liveness.js +240 -0
- package/dist/db/pr-mentions.js +279 -0
- package/dist/db/pr-merge-queue-stamp.js +91 -0
- package/dist/db/queries.js +14631 -0
- package/dist/db/reaction-targets.js +94 -0
- package/dist/db/repo-activity.js +171 -0
- package/dist/db/retention.js +138 -0
- package/dist/db/review-threads-for-review.js +240 -0
- package/dist/db/run-migrations.js +24 -0
- package/dist/db/schema.pg.js +1220 -0
- package/dist/db/schema.sqlite.js +1801 -0
- package/dist/db/search.js +317 -0
- package/dist/db/security-alerts.js +218 -0
- package/dist/db/synthesis-input.js +565 -0
- package/dist/db/triage.js +320 -0
- package/dist/db/usage.js +63 -0
- package/dist/db/work-plan.js +1086 -0
- package/dist/db/working-hours.js +161 -0
- package/dist/feed/my-turn.js +122 -0
- package/dist/github/actions-logs.js +397 -0
- package/dist/github/auth.js +86 -0
- package/dist/github/branch-queries.js +229 -0
- package/dist/github/client.js +454 -0
- package/dist/github/codeowners.js +221 -0
- package/dist/github/commit-checks.js +105 -0
- package/dist/github/compare.js +110 -0
- package/dist/github/diff-anchor.js +227 -0
- package/dist/github/mutations.js +706 -0
- package/dist/github/pr-file-content.js +254 -0
- package/dist/github/pr-liveness.js +161 -0
- package/dist/github/queries.js +875 -0
- package/dist/github/rate-budget.js +129 -0
- package/dist/github/reactions.js +161 -0
- package/dist/github/reviewer-suggest.js +104 -0
- package/dist/github/team-reviewers.js +96 -0
- package/dist/index.js +184 -0
- package/dist/merge/auto-merge-runner.js +960 -0
- package/dist/ml/severity-client.js +218 -0
- package/dist/pr/detail-enricher.js +26 -0
- package/dist/pro/bind.js +302 -0
- package/dist/pro/contract.js +37 -0
- package/dist/pro/migrate.js +64 -0
- package/dist/review/agent-context.js +127 -0
- package/dist/review/agent.js +385 -0
- package/dist/review/agentic.js +59 -0
- package/dist/review/auth.js +94 -0
- package/dist/review/chat-agent.js +179 -0
- package/dist/review/claude-review/ai-ready.js +16 -0
- package/dist/review/claude-review/auto-settings.js +139 -0
- package/dist/review/claude-review/auto.js +244 -0
- package/dist/review/claude-review/chat.js +489 -0
- package/dist/review/claude-review/ci-failures.js +350 -0
- package/dist/review/claude-review/follow-up.js +310 -0
- package/dist/review/claude-review/manager.js +686 -0
- package/dist/review/claude-review/persist.js +996 -0
- package/dist/review/claude-review/prompts.js +357 -0
- package/dist/review/claude-review/routes.js +518 -0
- package/dist/review/claude-review/routing.js +61 -0
- package/dist/review/claude-review/specialists.js +190 -0
- package/dist/review/claude-review/threads.js +265 -0
- package/dist/review/claude-review/ticket.js +282 -0
- package/dist/review/clone-hygiene.js +380 -0
- package/dist/review/clone-manager.js +367 -0
- package/dist/review/llm.js +113 -0
- package/dist/review/model-options.js +48 -0
- package/dist/review/plugin-providers.js +12 -0
- package/dist/review/post-review.js +216 -0
- package/dist/review/post-seam.js +161 -0
- package/dist/review/prepare.js +168 -0
- package/dist/review/pricing.js +17 -0
- package/dist/review/schema.js +156 -0
- package/dist/review/submit-map.js +41 -0
- package/dist/review/usage.js +90 -0
- package/dist/shared/claude-review.js +335 -0
- package/dist/shared/conflict-fold.js +151 -0
- package/dist/shared/conflicts.js +30 -0
- package/dist/shared/flow-settings.js +191 -0
- package/dist/shared/index.js +8 -0
- package/dist/shared/my-turn-settings.js +238 -0
- package/dist/shared/pending-rules.js +281 -0
- package/dist/shared/product.js +54 -0
- package/dist/shared/types.js +896 -0
- package/dist/status.js +386 -0
- package/dist/sync/adaptive.js +185 -0
- package/dist/sync/app-attribution.js +66 -0
- package/dist/sync/auth-notices.js +36 -0
- package/dist/sync/backfill-ci-history.js +315 -0
- package/dist/sync/backfill-commit-headlines.js +136 -0
- package/dist/sync/backfill-pr-security.js +175 -0
- package/dist/sync/backfill-review-requests.js +97 -0
- package/dist/sync/benchmark-rollup.js +50 -0
- package/dist/sync/bot-detection.js +309 -0
- package/dist/sync/bot-resolution-markers.js +30 -0
- package/dist/sync/branch-status.js +634 -0
- package/dist/sync/classify-change-shape.js +150 -0
- package/dist/sync/commit-files.js +74 -0
- package/dist/sync/derive-thread-state.js +99 -0
- package/dist/sync/drain-review-threads.js +212 -0
- package/dist/sync/hydrate-detail.js +482 -0
- package/dist/sync/mention-scan.js +89 -0
- package/dist/sync/ml-enrichment.js +478 -0
- package/dist/sync/pr-change-signal.js +96 -0
- package/dist/sync/pr-liveness-sweep.js +139 -0
- package/dist/sync/pr-settle.js +381 -0
- package/dist/sync/refresh-pr.js +163 -0
- package/dist/sync/reparse-vendor-badges.js +495 -0
- package/dist/sync/resync-after-write.js +280 -0
- package/dist/sync/review-fingerprint.js +117 -0
- package/dist/sync/reviewer-behavior.js +103 -0
- package/dist/sync/reviewer-classify.js +503 -0
- package/dist/sync/routing-files.js +97 -0
- package/dist/sync/scheduled-jobs.js +12 -0
- package/dist/sync/scheduler.js +159 -0
- package/dist/sync/security-detect.js +521 -0
- package/dist/sync/sync-manager.js +750 -0
- package/dist/sync/sync-one-pr.js +205 -0
- package/dist/sync/sync-repo.js +400 -0
- package/dist/sync/unsettled-prs.js +506 -0
- package/dist/sync/upsert.js +1190 -0
- package/package.json +56 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/AiFixTab-CQP6PT5u.js +2 -0
- package/public/assets/ClaudeReviewTab-DQJ-58ZA.js +8 -0
- package/public/assets/PrBotBehaviourTab-CCnFrWbC.js +1 -0
- package/public/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
- package/public/assets/highlight-BEHUn5zE.css +10 -0
- package/public/assets/highlight-CIIZLa6Z.js +12 -0
- package/public/assets/index-BDnYs6zg.js +73 -0
- package/public/assets/index-mCuI--RC.css +1 -0
- package/public/assets/markdown-I_JLLVAZ.js +32 -0
- package/public/assets/perfProbe-Da13EhTE.js +1 -0
- package/public/assets/tanstack-5qsxxaLe.js +17 -0
- package/public/assets/vis-C3BCkP5U.js +1280 -0
- package/public/assets/vis-ziCVehMy.css +1 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon-48.png +0 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +41 -0
- package/public-landing/apple-touch-icon.png +0 -0
- package/public-landing/assets/archivo-latin-500-BWmvx2Mg.woff2 +0 -0
- package/public-landing/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
- package/public-landing/assets/index-CA3eEGXO.js +40 -0
- package/public-landing/assets/index-naQrQ6Fe.css +1 -0
- package/public-landing/assets/jetbrains-mono-latin-400-RruCX7RW.woff2 +0 -0
- package/public-landing/assets/newsreader-latin-400-DExrRRrN.woff2 +0 -0
- package/public-landing/assets/newsreader-latin-400-italic-b0I-AwbV.woff2 +0 -0
- package/public-landing/bots/index.html +156 -0
- package/public-landing/contact/index.html +156 -0
- package/public-landing/cookies/index.html +156 -0
- package/public-landing/demo/limn-walkthrough-poster.jpg +0 -0
- package/public-landing/demo/limn-walkthrough.cues.json +44 -0
- package/public-landing/demo/limn-walkthrough.mp4 +0 -0
- package/public-landing/favicon-16.png +0 -0
- package/public-landing/favicon-32.png +0 -0
- package/public-landing/favicon-48.png +0 -0
- package/public-landing/favicon.svg +5 -0
- package/public-landing/features/index.html +156 -0
- package/public-landing/for-developers/index.html +156 -0
- package/public-landing/for-managers/index.html +156 -0
- package/public-landing/how-it-works/index.html +156 -0
- package/public-landing/how-we-measure/index.html +156 -0
- package/public-landing/icon-192.png +0 -0
- package/public-landing/icon-512.png +0 -0
- package/public-landing/index.html +156 -0
- package/public-landing/insights/index.html +156 -0
- package/public-landing/og-image.png +0 -0
- package/public-landing/pricing/index.html +156 -0
- package/public-landing/privacy/index.html +156 -0
- package/public-landing/pro/index.html +156 -0
- package/public-landing/reviews/index.html +156 -0
- package/public-landing/robots.txt +8 -0
- package/public-landing/shots/benchmark.png +0 -0
- package/public-landing/shots/bot-roi.png +0 -0
- package/public-landing/shots/bot-settings.png +0 -0
- package/public-landing/shots/chronology.png +0 -0
- package/public-landing/shots/feed.png +0 -0
- package/public-landing/shots/flow-metrics.png +0 -0
- package/public-landing/shots/free-reports.png +0 -0
- package/public-landing/shots/pending-board.png +0 -0
- package/public-landing/shots/pending-card.png +0 -0
- package/public-landing/shots/period-report.png +0 -0
- package/public-landing/shots/pr-detail.png +0 -0
- package/public-landing/shots/pr-threads.png +0 -0
- package/public-landing/shots/reach.png +0 -0
- package/public-landing/shots/repo-rows.png +0 -0
- package/public-landing/shots/severity-findings.png +0 -0
- package/public-landing/shots/severity-strip.png +0 -0
- package/public-landing/site.webmanifest +14 -0
- package/public-landing/sitemap.xml +51 -0
- package/public-landing/terms/index.html +156 -0
|
@@ -0,0 +1,1666 @@
|
|
|
1
|
+
// Query layer for ML severity/category labels on BOT-authored text (CORE, free tier).
|
|
2
|
+
//
|
|
3
|
+
// Three jobs, and nothing else:
|
|
4
|
+
// 1. CANDIDATES — which bot-authored targets in a workspace have no label yet
|
|
5
|
+
// (the background worker's worklist; see sync/ml-enrichment.ts)
|
|
6
|
+
// 2. WRITE — upsert a batch of labels
|
|
7
|
+
// 3. READS — the per-PR badge index, and the Bots severity rollup
|
|
8
|
+
//
|
|
9
|
+
// WHO COUNTS AS A BOT is not decided here: it is `automatedReviewerUserIds(accountId,
|
|
10
|
+
// workspaceId, 'all')` from queries.ts, the one workspace-grain answer. `'all'` rather than
|
|
11
|
+
// `'review'` on purpose — a quality check (SonarQube, Codecov, Hound) posts exactly the kind
|
|
12
|
+
// of finding a severity label is FOR, and the role split exists to stop a linter's volume
|
|
13
|
+
// distorting a REVIEWER's ROI numbers, which is a different question from "how bad is this
|
|
14
|
+
// comment". The role is still reported per row, so a consumer can narrow.
|
|
15
|
+
//
|
|
16
|
+
// TIMING: `workspace_reviewers` rows are written LAZILY (listDetectedReviewers, on a read of
|
|
17
|
+
// the Bots tab), never by sync. Known vendor logins are automated with zero stored rows, so
|
|
18
|
+
// CodeRabbit et al. are candidates from the first sync; a purely in-house bot becomes a
|
|
19
|
+
// candidate only once it has been classified. That is fine BECAUSE this is a pull-based
|
|
20
|
+
// worker: it re-derives the bot set every tick, so a newly-classified or newly-marked bot's
|
|
21
|
+
// whole backlog is picked up on the next pass with no backfill trigger of its own.
|
|
22
|
+
import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, sql } from 'drizzle-orm';
|
|
23
|
+
import { db, schema } from './client.js';
|
|
24
|
+
import { botWindowMs } from './bot-window.js';
|
|
25
|
+
import { labelFor as labelForKind } from '../sync/reviewer-classify.js';
|
|
26
|
+
import { automatedReviewerUserIds, classificationKindForUser, classificationLabelMap, listWorkspaces, } from './queries.js';
|
|
27
|
+
const { mlCommentLabels, pullRequests, repos, reviewComments, reviewThreads, prComments, reviews, users, } = schema;
|
|
28
|
+
const ML_CATEGORY_VALUES = new Set([
|
|
29
|
+
'correctness_bug',
|
|
30
|
+
'security',
|
|
31
|
+
'performance',
|
|
32
|
+
'style_readability',
|
|
33
|
+
'maintainability_refactor',
|
|
34
|
+
'testing',
|
|
35
|
+
'documentation',
|
|
36
|
+
'nitpick',
|
|
37
|
+
'praise', // v2 non-finding class — excluded from severity-weighted rollups below
|
|
38
|
+
]);
|
|
39
|
+
const SEVERITY_KEYS = ['nit', 'minor', 'major', 'critical'];
|
|
40
|
+
/**
|
|
41
|
+
* "This row has text worth classifying."
|
|
42
|
+
*
|
|
43
|
+
* ⚠ `IS NOT NULL` IS NOT ENOUGH, and getting this wrong is silent. An approval with no comment
|
|
44
|
+
* is a `reviews` row with an EMPTY-STRING body — there are 5.4k of them in this repo's own dev
|
|
45
|
+
* database. They are not candidates (there is nothing to classify), but a NOT NULL test counts
|
|
46
|
+
* them, so the candidate query and the pending COUNT would disagree forever: the worker would
|
|
47
|
+
* skip them every tick while the Bots panel reported them as "still being processed" and
|
|
48
|
+
* coverage never reached 100%.
|
|
49
|
+
*
|
|
50
|
+
* `trim(x) <> ''` is portable — both SQLite and Postgres have `trim` — so the two callers below
|
|
51
|
+
* share ONE predicate rather than two that could drift.
|
|
52
|
+
*/
|
|
53
|
+
const hasText = (col) => sql `trim(${col}) <> ''`;
|
|
54
|
+
function emptySeverityCounts() {
|
|
55
|
+
return { nit: 0, minor: 0, major: 0, critical: 0 };
|
|
56
|
+
}
|
|
57
|
+
/** Anything the service invents beyond its nine documented values is dropped, not stored. */
|
|
58
|
+
function coerceCategories(raw) {
|
|
59
|
+
if (!Array.isArray(raw))
|
|
60
|
+
return [];
|
|
61
|
+
return raw.filter((c) => typeof c === 'string' && ML_CATEGORY_VALUES.has(c));
|
|
62
|
+
}
|
|
63
|
+
function coerceSeverity(raw) {
|
|
64
|
+
return typeof raw === 'string' && SEVERITY_KEYS.includes(raw)
|
|
65
|
+
? raw
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
const VENDOR_CONFIDENCE_KEYS = ['high', 'medium', 'low'];
|
|
69
|
+
function coerceVendorConfidence(raw) {
|
|
70
|
+
return typeof raw === 'string' && VENDOR_CONFIDENCE_KEYS.includes(raw)
|
|
71
|
+
? raw
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Deep re-sync support: drop every ML label in one repo so the enrichment worker re-scores
|
|
76
|
+
* it against the CURRENTLY served model. Labels carry the model_version that produced them;
|
|
77
|
+
* a deep sync is the user's explicit "re-fetch and re-derive everything" gesture, so stale
|
|
78
|
+
* versions are purged rather than left to mix with fresh ones in the same charts.
|
|
79
|
+
*/
|
|
80
|
+
export async function deleteMlLabelsForRepo(accountId, repoId) {
|
|
81
|
+
await db
|
|
82
|
+
.delete(mlCommentLabels)
|
|
83
|
+
.where(and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.repoId, repoId)))
|
|
84
|
+
.execute();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Bot-authored targets in this workspace that carry no label yet, NEWEST FIRST.
|
|
88
|
+
*
|
|
89
|
+
* Newest-first is a product decision, not an implementation detail: the enrichment is a
|
|
90
|
+
* background sweep that can take an hour over a large history, and the labels a user is about
|
|
91
|
+
* to look at are the ones on today's PRs. History fills in behind them.
|
|
92
|
+
*
|
|
93
|
+
* `limit` is a POOL size, not a batch size — the caller re-sorts the pool by body length before
|
|
94
|
+
* batching, because the model pads a batch to its longest member (see config.mlBatchMaxChars).
|
|
95
|
+
*/
|
|
96
|
+
export async function listMlCandidates(accountId, scope, limit) {
|
|
97
|
+
if (scope.repoIds.length === 0)
|
|
98
|
+
return [];
|
|
99
|
+
const automatedIds = await automatedReviewerUserIds(accountId, scope.workspaceId, 'all');
|
|
100
|
+
if (automatedIds.length === 0)
|
|
101
|
+
return [];
|
|
102
|
+
// The join predicate is identical for all three kinds bar the discriminator and which id
|
|
103
|
+
// space `target_id` is compared against — hence the AnyColumn parameter rather than three
|
|
104
|
+
// copies that could drift.
|
|
105
|
+
const unlabelled = (kind, targetId) => and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, kind), eq(mlCommentLabels.targetId, targetId));
|
|
106
|
+
const [rc, pc, rv] = await Promise.all([
|
|
107
|
+
db
|
|
108
|
+
.select({
|
|
109
|
+
targetId: reviewComments.id,
|
|
110
|
+
prId: reviewComments.prId,
|
|
111
|
+
repoId: pullRequests.repoId,
|
|
112
|
+
authorUserId: reviewComments.authorId,
|
|
113
|
+
body: reviewComments.body,
|
|
114
|
+
targetCreatedAt: reviewComments.createdAt,
|
|
115
|
+
diffHunk: reviewComments.diffHunk,
|
|
116
|
+
path: reviewThreads.path,
|
|
117
|
+
})
|
|
118
|
+
.from(reviewComments)
|
|
119
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
120
|
+
// LEFT, not inner: `path` is only a HINT to the model, so a review comment whose thread
|
|
121
|
+
// row is missing (an orphan cannot arise under this schema's FKs, but nothing here should
|
|
122
|
+
// depend on that) must still be selectable — `countUnlabelledBotText` does not join
|
|
123
|
+
// threads at all, and an inner join here would count such a row as pending forever while
|
|
124
|
+
// never offering it to the worker (the exact phantom-pending drift `hasText` exists to
|
|
125
|
+
// prevent on the body predicate).
|
|
126
|
+
.leftJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
|
|
127
|
+
.leftJoin(mlCommentLabels, unlabelled('review_comment', reviewComments.id))
|
|
128
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviewComments.authorId), inArray(reviewComments.authorId, automatedIds), isNotNull(reviewComments.body), hasText(reviewComments.body), isNull(mlCommentLabels.id)))
|
|
129
|
+
.orderBy(desc(reviewComments.createdAt))
|
|
130
|
+
.limit(limit)
|
|
131
|
+
.execute(),
|
|
132
|
+
db
|
|
133
|
+
.select({
|
|
134
|
+
targetId: prComments.id,
|
|
135
|
+
prId: prComments.prId,
|
|
136
|
+
repoId: pullRequests.repoId,
|
|
137
|
+
authorUserId: prComments.authorId,
|
|
138
|
+
body: prComments.body,
|
|
139
|
+
targetCreatedAt: prComments.createdAt,
|
|
140
|
+
})
|
|
141
|
+
.from(prComments)
|
|
142
|
+
.innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
|
|
143
|
+
.leftJoin(mlCommentLabels, unlabelled('pr_comment', prComments.id))
|
|
144
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(prComments.authorId), inArray(prComments.authorId, automatedIds), isNotNull(prComments.body), hasText(prComments.body), isNull(mlCommentLabels.id)))
|
|
145
|
+
.orderBy(desc(prComments.createdAt))
|
|
146
|
+
.limit(limit)
|
|
147
|
+
.execute(),
|
|
148
|
+
db
|
|
149
|
+
.select({
|
|
150
|
+
targetId: reviews.id,
|
|
151
|
+
prId: reviews.prId,
|
|
152
|
+
repoId: pullRequests.repoId,
|
|
153
|
+
authorUserId: reviews.authorId,
|
|
154
|
+
body: reviews.body,
|
|
155
|
+
targetCreatedAt: reviews.submittedAt,
|
|
156
|
+
})
|
|
157
|
+
.from(reviews)
|
|
158
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
159
|
+
.leftJoin(mlCommentLabels, unlabelled('review', reviews.id))
|
|
160
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviews.authorId), inArray(reviews.authorId, automatedIds), isNotNull(reviews.body), hasText(reviews.body), isNull(mlCommentLabels.id)))
|
|
161
|
+
.orderBy(desc(reviews.submittedAt))
|
|
162
|
+
.limit(limit)
|
|
163
|
+
.execute(),
|
|
164
|
+
]);
|
|
165
|
+
const out = [];
|
|
166
|
+
const push = (kind, rows) => {
|
|
167
|
+
for (const r of rows) {
|
|
168
|
+
const raw = r.body ?? '';
|
|
169
|
+
// ⚠ THE SQL PREDICATES ARE THE SOLE AUTHORITY ON CANDIDACY — nothing is dropped here.
|
|
170
|
+
// This loop used to skip `raw.trim() === ''`, which sounds harmless and is not: SQL's
|
|
171
|
+
// `trim()` strips SPACES ONLY in both dialects, JavaScript's strips all whitespace, so a
|
|
172
|
+
// body of " \n " passed the SQL filter and was then discarded in JS. The row is then
|
|
173
|
+
// pending forever — re-selected on every tick, never labelled, and counted by
|
|
174
|
+
// `countUnlabelledBotText` as work outstanding. Whatever SQL offers, we classify; the
|
|
175
|
+
// trim below only normalises the text we SEND (falling back to the raw string, so the
|
|
176
|
+
// service is never handed an empty body).
|
|
177
|
+
if (r.authorUserId == null)
|
|
178
|
+
continue; // unreachable — `isNotNull(authorId)` above; narrows the type
|
|
179
|
+
out.push({
|
|
180
|
+
targetKind: kind,
|
|
181
|
+
targetId: r.targetId,
|
|
182
|
+
prId: r.prId,
|
|
183
|
+
repoId: r.repoId,
|
|
184
|
+
authorUserId: r.authorUserId,
|
|
185
|
+
body: raw.trim() || raw,
|
|
186
|
+
targetCreatedAt: r.targetCreatedAt,
|
|
187
|
+
diffHunk: r.diffHunk ?? null,
|
|
188
|
+
path: r.path ?? null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
push('review_comment', rc);
|
|
193
|
+
push('pr_comment', pc);
|
|
194
|
+
push('review', rv);
|
|
195
|
+
out.sort((a, b) => b.targetCreatedAt.getTime() - a.targetCreatedAt.getTime());
|
|
196
|
+
return out.slice(0, limit);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Upsert labels.
|
|
200
|
+
*
|
|
201
|
+
* ⚠ THE CONFLICT TARGET IS THE TABLE'S DECLARED UNIQUE — `(account_id, target_kind,
|
|
202
|
+
* target_id)`, index `mcl_account_target`. A stale target type-checks perfectly and only fails
|
|
203
|
+
* at RUNTIME, in both dialects, when a row is actually written.
|
|
204
|
+
*
|
|
205
|
+
* Rows are written one statement per row rather than one multi-VALUES insert: a batch mixes
|
|
206
|
+
* three target kinds and drizzle's `onConflictDoUpdate` set-clause would have to reference
|
|
207
|
+
* `excluded.*` uniformly, which is the kind of thing that reads fine and silently writes the
|
|
208
|
+
* wrong row when the shapes diverge. Batches are ≤128 and this is a background worker.
|
|
209
|
+
*/
|
|
210
|
+
export async function upsertMlLabels(rows) {
|
|
211
|
+
let written = 0;
|
|
212
|
+
for (const r of rows) {
|
|
213
|
+
const values = {
|
|
214
|
+
accountId: r.accountId,
|
|
215
|
+
repoId: r.repoId,
|
|
216
|
+
prId: r.prId,
|
|
217
|
+
targetKind: r.targetKind,
|
|
218
|
+
targetId: r.targetId,
|
|
219
|
+
authorUserId: r.authorUserId,
|
|
220
|
+
severity: r.severity,
|
|
221
|
+
severityOrd: r.severityOrd,
|
|
222
|
+
severityProb: r.severityProb,
|
|
223
|
+
// `?? null` rather than a straight read: drizzle DROPS an `undefined` key from both the
|
|
224
|
+
// INSERT column list and the SET list, so an accidental undefined would insert fine and
|
|
225
|
+
// then silently refuse to clear a stale vendor claim on the update path. An explicit null
|
|
226
|
+
// means the same thing on both.
|
|
227
|
+
vendorSeverity: r.vendorSeverity ?? null,
|
|
228
|
+
vendorSeverityConfidence: r.vendorSeverityConfidence ?? null,
|
|
229
|
+
categories: r.categories,
|
|
230
|
+
categoryProbs: r.categoryProbs,
|
|
231
|
+
isSummary: r.isSummary,
|
|
232
|
+
backend: r.backend,
|
|
233
|
+
modelVersion: r.modelVersion,
|
|
234
|
+
bodyHash: r.bodyHash,
|
|
235
|
+
targetCreatedAt: r.targetCreatedAt,
|
|
236
|
+
updatedAt: new Date(),
|
|
237
|
+
};
|
|
238
|
+
const res = await db
|
|
239
|
+
.insert(mlCommentLabels)
|
|
240
|
+
.values(values)
|
|
241
|
+
.onConflictDoUpdate({
|
|
242
|
+
target: [
|
|
243
|
+
mlCommentLabels.accountId,
|
|
244
|
+
mlCommentLabels.targetKind,
|
|
245
|
+
mlCommentLabels.targetId,
|
|
246
|
+
],
|
|
247
|
+
set: {
|
|
248
|
+
severity: values.severity,
|
|
249
|
+
severityOrd: values.severityOrd,
|
|
250
|
+
severityProb: values.severityProb,
|
|
251
|
+
// ⚠ IN BOTH HALVES. A re-score must be able to CLEAR a vendor claim as well as set
|
|
252
|
+
// one (a vendor drops its badge, or the service that read it is rolled back), so
|
|
253
|
+
// these are written on the update path too — an omitted key here would freeze the
|
|
254
|
+
// first value ever stored while the insert path looked perfectly correct.
|
|
255
|
+
vendorSeverity: values.vendorSeverity,
|
|
256
|
+
vendorSeverityConfidence: values.vendorSeverityConfidence,
|
|
257
|
+
categories: values.categories,
|
|
258
|
+
categoryProbs: values.categoryProbs,
|
|
259
|
+
isSummary: values.isSummary,
|
|
260
|
+
backend: values.backend,
|
|
261
|
+
modelVersion: values.modelVersion,
|
|
262
|
+
bodyHash: values.bodyHash,
|
|
263
|
+
targetCreatedAt: values.targetCreatedAt,
|
|
264
|
+
updatedAt: values.updatedAt,
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
.returning({ id: mlCommentLabels.id })
|
|
268
|
+
.execute();
|
|
269
|
+
written += res.length;
|
|
270
|
+
}
|
|
271
|
+
return written;
|
|
272
|
+
}
|
|
273
|
+
// ── 3. Reads ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
// EXPORTED for the person-period evidence fold (db/person-period.ts), which attaches stored
|
|
275
|
+
// labels to a handful of capped comment rows — same coercions, one spelling.
|
|
276
|
+
export function toWireLabel(row) {
|
|
277
|
+
const severity = coerceSeverity(row.severity);
|
|
278
|
+
if (!severity)
|
|
279
|
+
return null;
|
|
280
|
+
return {
|
|
281
|
+
targetKind: row.targetKind,
|
|
282
|
+
targetId: row.targetId,
|
|
283
|
+
severity,
|
|
284
|
+
severityOrd: row.severityOrd,
|
|
285
|
+
severityProb: row.severityProb,
|
|
286
|
+
// Coerced on the way OUT as well as in. The column is plain text in both dialects (the
|
|
287
|
+
// drizzle `enum` is a compile-time nicety, not a CHECK constraint), and unlike `severity`
|
|
288
|
+
// above a bad value here must NOT drop the whole label — the row's real severity is the
|
|
289
|
+
// useful one, so an unreadable vendor claim degrades to "no vendor claim".
|
|
290
|
+
vendorSeverity: coerceSeverity(row.vendorSeverity),
|
|
291
|
+
vendorSeverityConfidence: coerceVendorConfidence(row.vendorSeverityConfidence),
|
|
292
|
+
categories: coerceCategories(row.categories),
|
|
293
|
+
isSummary: row.isSummary,
|
|
294
|
+
backend: row.backend,
|
|
295
|
+
modelVersion: row.modelVersion,
|
|
296
|
+
createdAt: row.createdAt.toISOString(),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Every label on one PR — the ONE query each badge reads, so N cards on a PR cost one request.
|
|
301
|
+
*
|
|
302
|
+
* Returns null for a PR this account does not own (the handler 404s on that), which is the
|
|
303
|
+
* standard id-addressed ownership rule: ownership is checked HERE, not in the handler.
|
|
304
|
+
*/
|
|
305
|
+
export async function getPrMlLabels(prId, accountId) {
|
|
306
|
+
const owned = await db
|
|
307
|
+
.select({ id: pullRequests.id })
|
|
308
|
+
.from(pullRequests)
|
|
309
|
+
.where(and(eq(pullRequests.id, prId), eq(pullRequests.accountId, accountId)))
|
|
310
|
+
.limit(1)
|
|
311
|
+
.execute();
|
|
312
|
+
if (owned.length === 0)
|
|
313
|
+
return null;
|
|
314
|
+
const rows = await db
|
|
315
|
+
.select()
|
|
316
|
+
.from(mlCommentLabels)
|
|
317
|
+
.where(and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.prId, prId)))
|
|
318
|
+
.execute();
|
|
319
|
+
return rows.map(toWireLabel).filter((l) => l !== null);
|
|
320
|
+
}
|
|
321
|
+
// A workspace's whole labelled corpus is read into memory for the rollup. It is bounded by
|
|
322
|
+
// retention (180 days by default) and each row is a handful of small columns, but a cap keeps a
|
|
323
|
+
// pathological account from turning one request into a huge allocation.
|
|
324
|
+
//
|
|
325
|
+
// TWO THINGS THE CAP NEEDS TO BE HONEST. It must have an ORDER BY — without one the rows a
|
|
326
|
+
// truncated scan returns are whatever the storage engine hands back first, which differs between
|
|
327
|
+
// SQLite and Postgres and CHANGES on Postgres after an UPDATE, so the same workspace would
|
|
328
|
+
// report different totals run to run. And the response must SAY it truncated (`truncated`),
|
|
329
|
+
// because a capped scan silently presented as a total is exactly the failure `pending` exists
|
|
330
|
+
// to avoid at the other end.
|
|
331
|
+
const ROLLUP_SCAN_CAP = 50_000;
|
|
332
|
+
/**
|
|
333
|
+
* The Bots-interface severity rollup: how the bots in this workspace are distributed across
|
|
334
|
+
* severities and categories, plus how much of their corpus has been labelled so far.
|
|
335
|
+
*
|
|
336
|
+
* Deliberately UNWINDOWED in this first cut — it covers every label in scope. The Bots panels
|
|
337
|
+
* around it are windowed, so the block states its own coverage rather than borrowing theirs.
|
|
338
|
+
*/
|
|
339
|
+
export async function getBotSeverityRollup(accountId, scope, enabled) {
|
|
340
|
+
const generatedAt = new Date().toISOString();
|
|
341
|
+
const empty = {
|
|
342
|
+
workspaceId: scope.workspaceId,
|
|
343
|
+
repoIds: scope.repoIds,
|
|
344
|
+
enabled,
|
|
345
|
+
labelled: 0,
|
|
346
|
+
pending: 0,
|
|
347
|
+
unscorable: 0,
|
|
348
|
+
totals: {
|
|
349
|
+
bySeverity: emptySeverityCounts(),
|
|
350
|
+
byCategory: [],
|
|
351
|
+
summaries: 0,
|
|
352
|
+
praise: 0,
|
|
353
|
+
findings: 0,
|
|
354
|
+
},
|
|
355
|
+
rows: [],
|
|
356
|
+
backends: [],
|
|
357
|
+
truncated: false,
|
|
358
|
+
generatedAt,
|
|
359
|
+
};
|
|
360
|
+
if (scope.repoIds.length === 0)
|
|
361
|
+
return empty;
|
|
362
|
+
const automatedIds = await automatedReviewerUserIds(accountId, scope.workspaceId, 'all');
|
|
363
|
+
if (automatedIds.length === 0)
|
|
364
|
+
return empty;
|
|
365
|
+
const [labelRows, kindMap, labelMap, userRows, pending, unscorable] = await Promise.all([
|
|
366
|
+
db
|
|
367
|
+
.select({
|
|
368
|
+
authorUserId: mlCommentLabels.authorUserId,
|
|
369
|
+
severity: mlCommentLabels.severity,
|
|
370
|
+
isSummary: mlCommentLabels.isSummary,
|
|
371
|
+
categories: mlCommentLabels.categories,
|
|
372
|
+
backend: mlCommentLabels.backend,
|
|
373
|
+
})
|
|
374
|
+
.from(mlCommentLabels)
|
|
375
|
+
.where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds)))
|
|
376
|
+
.orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
|
|
377
|
+
.limit(ROLLUP_SCAN_CAP)
|
|
378
|
+
.execute(),
|
|
379
|
+
classificationKindForUser(accountId, scope.workspaceId),
|
|
380
|
+
classificationLabelMap(accountId, scope.workspaceId),
|
|
381
|
+
db
|
|
382
|
+
.select({ id: users.id, login: users.githubLogin, name: users.displayName })
|
|
383
|
+
.from(users)
|
|
384
|
+
.where(inArray(users.id, automatedIds))
|
|
385
|
+
.execute(),
|
|
386
|
+
countUnlabelledBotText(accountId, scope, automatedIds),
|
|
387
|
+
countUnscorableBotText(accountId, scope, automatedIds),
|
|
388
|
+
]);
|
|
389
|
+
const loginById = new Map();
|
|
390
|
+
for (const u of userRows)
|
|
391
|
+
loginById.set(u.id, u.login || u.name?.trim() || `#${u.id}`);
|
|
392
|
+
const totalsBySeverity = emptySeverityCounts();
|
|
393
|
+
const totalsByCategory = new Map();
|
|
394
|
+
const backends = new Set();
|
|
395
|
+
let summaries = 0;
|
|
396
|
+
let praise = 0;
|
|
397
|
+
let findings = 0;
|
|
398
|
+
const byBot = new Map();
|
|
399
|
+
for (const row of labelRows) {
|
|
400
|
+
const severity = coerceSeverity(row.severity);
|
|
401
|
+
if (!severity)
|
|
402
|
+
continue;
|
|
403
|
+
if (row.backend)
|
|
404
|
+
backends.add(row.backend);
|
|
405
|
+
const acc = byBot.get(row.authorUserId) ?? {
|
|
406
|
+
labelled: 0,
|
|
407
|
+
bySeverity: emptySeverityCounts(),
|
|
408
|
+
summaries: 0,
|
|
409
|
+
praise: 0,
|
|
410
|
+
findings: 0,
|
|
411
|
+
high: 0,
|
|
412
|
+
categories: new Map(),
|
|
413
|
+
};
|
|
414
|
+
acc.labelled += 1;
|
|
415
|
+
const categories = coerceCategories(row.categories);
|
|
416
|
+
const isPraise = categories.includes('praise');
|
|
417
|
+
if (row.isSummary) {
|
|
418
|
+
acc.summaries += 1;
|
|
419
|
+
summaries += 1;
|
|
420
|
+
}
|
|
421
|
+
else if (isPraise) {
|
|
422
|
+
// v2 non-finding class: the bot acknowledging a fix / withdrawing a concern. Counted
|
|
423
|
+
// like summaries — visible as labelled work, excluded from every severity-weighted
|
|
424
|
+
// number for the same reason walkthroughs are (it is not a finding).
|
|
425
|
+
acc.praise += 1;
|
|
426
|
+
praise += 1;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
acc.findings += 1;
|
|
430
|
+
findings += 1;
|
|
431
|
+
// ⚠ SEVERITY COUNTS ARE FINDINGS-ONLY, and it has to be inside this branch. Counting a
|
|
432
|
+
// walkthrough's severity here while every RATE below divides by `findings` would let
|
|
433
|
+
// "nits as a share of findings" exceed 100% — a vendor that posts one summary per PR would
|
|
434
|
+
// reliably push it over. Summaries have their own counter; nothing divides by `labelled`.
|
|
435
|
+
acc.bySeverity[severity] += 1;
|
|
436
|
+
totalsBySeverity[severity] += 1;
|
|
437
|
+
if (severity === 'major' || severity === 'critical')
|
|
438
|
+
acc.high += 1;
|
|
439
|
+
// Categories describe a FINDING. A walkthrough comment's category is an artefact of the
|
|
440
|
+
// marker parser reading a summary table, so counting it would make "what do the bots
|
|
441
|
+
// talk about" a chart of each vendor's summary template.
|
|
442
|
+
for (const c of categories) {
|
|
443
|
+
acc.categories.set(c, (acc.categories.get(c) ?? 0) + 1);
|
|
444
|
+
totalsByCategory.set(c, (totalsByCategory.get(c) ?? 0) + 1);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
byBot.set(row.authorUserId, acc);
|
|
448
|
+
}
|
|
449
|
+
const rows = [...byBot.entries()]
|
|
450
|
+
.map(([userId, acc]) => {
|
|
451
|
+
const login = loginById.get(userId) ?? `#${userId}`;
|
|
452
|
+
const kind = (kindMap.get(userId) ?? null);
|
|
453
|
+
return {
|
|
454
|
+
reviewerKey: `u${userId}`,
|
|
455
|
+
userId,
|
|
456
|
+
login,
|
|
457
|
+
// Custom label → vendor brand → login: the same precedence the ROI panel uses, so one
|
|
458
|
+
// bot reads with one name across the whole Bots interface.
|
|
459
|
+
label: labelMap.get(userId) ?? (kind ? vendorBrand(kind, login) : login),
|
|
460
|
+
kind,
|
|
461
|
+
labelled: acc.labelled,
|
|
462
|
+
bySeverity: acc.bySeverity,
|
|
463
|
+
highShare: acc.findings > 0 ? acc.high / acc.findings : 0,
|
|
464
|
+
summaries: acc.summaries,
|
|
465
|
+
praise: acc.praise,
|
|
466
|
+
topCategories: [...acc.categories.entries()]
|
|
467
|
+
.map(([category, count]) => ({ category, count }))
|
|
468
|
+
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category))
|
|
469
|
+
.slice(0, 5),
|
|
470
|
+
};
|
|
471
|
+
})
|
|
472
|
+
.sort((a, b) => b.labelled - a.labelled || a.label.localeCompare(b.label));
|
|
473
|
+
return {
|
|
474
|
+
workspaceId: scope.workspaceId,
|
|
475
|
+
repoIds: scope.repoIds,
|
|
476
|
+
enabled,
|
|
477
|
+
labelled: labelRows.length,
|
|
478
|
+
pending,
|
|
479
|
+
unscorable,
|
|
480
|
+
totals: {
|
|
481
|
+
bySeverity: totalsBySeverity,
|
|
482
|
+
byCategory: [...totalsByCategory.entries()]
|
|
483
|
+
.map(([category, count]) => ({ category, count }))
|
|
484
|
+
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)),
|
|
485
|
+
summaries,
|
|
486
|
+
praise,
|
|
487
|
+
findings,
|
|
488
|
+
},
|
|
489
|
+
rows,
|
|
490
|
+
backends: [...backends].sort(),
|
|
491
|
+
truncated: labelRows.length >= ROLLUP_SCAN_CAP,
|
|
492
|
+
generatedAt,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
// Vendor brand names for the row label. Kept local and minimal on purpose: the frontend owns
|
|
496
|
+
// BOT_VENDOR_META (colours + display names), and duplicating that whole map server-side would
|
|
497
|
+
// give two answers to "what is this bot called".
|
|
498
|
+
function vendorBrand(kind, login) {
|
|
499
|
+
if (kind === 'in_house' || kind === 'vendor' || kind === 'pierre')
|
|
500
|
+
return login;
|
|
501
|
+
return kind
|
|
502
|
+
.split('_')
|
|
503
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
504
|
+
.join(' ');
|
|
505
|
+
}
|
|
506
|
+
/** null = this row's severity could not be coerced, so it belongs to none of the three buckets. */
|
|
507
|
+
export function foldMlLabelRow(row) {
|
|
508
|
+
const severity = coerceSeverity(row.severity);
|
|
509
|
+
if (!severity)
|
|
510
|
+
return null;
|
|
511
|
+
const categories = coerceCategories(row.categories);
|
|
512
|
+
if (row.isSummary)
|
|
513
|
+
return { bucket: 'summary', severity, categories };
|
|
514
|
+
// v2 non-finding class: the bot acknowledging a fix / withdrawing a concern. Bucketed like
|
|
515
|
+
// summaries — visible as labelled work, excluded from every severity-weighted number, because
|
|
516
|
+
// it is not a finding.
|
|
517
|
+
if (categories.includes('praise'))
|
|
518
|
+
return { bucket: 'praise', severity, categories };
|
|
519
|
+
return { bucket: 'finding', severity, categories };
|
|
520
|
+
}
|
|
521
|
+
export async function getMlWindowAggregates(accountId, scope, automatedIds, from,
|
|
522
|
+
// The window's upper bound — a Date ONLY when the caller holds EXPLICIT bounds (the People
|
|
523
|
+
// report's completed period), applied half-open (`lt`, the route's stated [fromMs, toMs)
|
|
524
|
+
// contract). `null` = the enum form, whose window ends at now: the scan then carries NO upper
|
|
525
|
+
// predicate, which keeps its WHERE byte-identical to `foldBotFlaggingPopulation`'s — the
|
|
526
|
+
// identity behind "an Inflation count equals the drill-down's filteredTotal" (the drill-down
|
|
527
|
+
// only ever serves enum windows). A bot still labelling after a completed period must not leak
|
|
528
|
+
// those rows into the period's counts, weekly buckets, or cap budget.
|
|
529
|
+
to,
|
|
530
|
+
// Pro (`botDepth`) only: widen the scan back to `trendFrom` and fold the per-bot WEEKLY
|
|
531
|
+
// inflation series over it (`inflationWeekly`, 12 buckets of 7 days from `trendFrom` — the
|
|
532
|
+
// same anchoring as getBotAnalytics' own trend buckets, so the sparkline and the ROI trend
|
|
533
|
+
// share an x-axis). Omitted ⇒ the scan stays window-only and `inflationWeekly` is null.
|
|
534
|
+
//
|
|
535
|
+
// ⚠ WINDOW COUNTS ARE UNCHANGED BY THE WIDENING. The scan orders newest-first with the same
|
|
536
|
+
// cap, so the in-window rows are a PREFIX of the widened scan — identical rows in identical
|
|
537
|
+
// order to the window-only scan (and to the flagging drill-down's) until the cap bites INSIDE
|
|
538
|
+
// the window, which truncated both scans identically before. Every non-weekly aggregate below
|
|
539
|
+
// gates on `inWindow`.
|
|
540
|
+
history) {
|
|
541
|
+
const emptyTotals = {
|
|
542
|
+
labelled: 0,
|
|
543
|
+
findings: 0,
|
|
544
|
+
summaries: 0,
|
|
545
|
+
praise: 0,
|
|
546
|
+
pending: 0,
|
|
547
|
+
unscorable: 0,
|
|
548
|
+
bySeverity: emptySeverityCounts(),
|
|
549
|
+
byCategory: [],
|
|
550
|
+
backends: [],
|
|
551
|
+
truncated: false,
|
|
552
|
+
};
|
|
553
|
+
if (scope.repoIds.length === 0 || automatedIds.length === 0) {
|
|
554
|
+
return { byBot: new Map(), totals: emptyTotals };
|
|
555
|
+
}
|
|
556
|
+
// The widened lower bound covers the window by construction in the caller (trendFrom =
|
|
557
|
+
// min(from, now − 84d)); min() here is a backstop so a mis-anchored history can never NARROW
|
|
558
|
+
// the window scan.
|
|
559
|
+
const scanFrom = history != null && history.trendFrom.getTime() < from.getTime() ? history.trendFrom : from;
|
|
560
|
+
const fromMs = from.getTime();
|
|
561
|
+
const [labelRows, pending, unscorable] = await Promise.all([
|
|
562
|
+
db
|
|
563
|
+
.select({
|
|
564
|
+
authorUserId: mlCommentLabels.authorUserId,
|
|
565
|
+
severity: mlCommentLabels.severity,
|
|
566
|
+
isSummary: mlCommentLabels.isSummary,
|
|
567
|
+
categories: mlCommentLabels.categories,
|
|
568
|
+
backend: mlCommentLabels.backend,
|
|
569
|
+
// The two extra columns the Inflation fold needs. `vendorSeverity` is compared, never
|
|
570
|
+
// believed (MlLabel.vendorSeverity); `targetCreatedAt` decides `inWindow` and the weekly
|
|
571
|
+
// bucket.
|
|
572
|
+
vendorSeverity: mlCommentLabels.vendorSeverity,
|
|
573
|
+
targetCreatedAt: mlCommentLabels.targetCreatedAt,
|
|
574
|
+
})
|
|
575
|
+
.from(mlCommentLabels)
|
|
576
|
+
.where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds), gte(mlCommentLabels.targetCreatedAt, scanFrom),
|
|
577
|
+
// Explicit bounds only (see the `to` parameter note) — newest-first still holds, so
|
|
578
|
+
// the in-window rows remain a prefix of any widened scan.
|
|
579
|
+
...(to != null ? [lt(mlCommentLabels.targetCreatedAt, to)] : [])))
|
|
580
|
+
.orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
|
|
581
|
+
.limit(ROLLUP_SCAN_CAP)
|
|
582
|
+
.execute(),
|
|
583
|
+
countUnlabelledBotText(accountId, scope, automatedIds, from, to),
|
|
584
|
+
// NOT window-bounded on purpose: the NULL-body population is legacy by nature (the
|
|
585
|
+
// 2026-06 lean-storage window) and mostly sits OUTSIDE every rolling window — a windowed
|
|
586
|
+
// count would read 0 while badges are visibly missing from the very lists this strip
|
|
587
|
+
// sits above. This is the honesty channel getBotSeverityRollup carries; when the merged
|
|
588
|
+
// table retired that response's panel, the count has to live HERE or the Bots screen
|
|
589
|
+
// claims "N of N scored" over comments it can never score.
|
|
590
|
+
countUnscorableBotText(accountId, scope, automatedIds),
|
|
591
|
+
]);
|
|
592
|
+
// The weekly buckets for the Pro sparkline: 12 × 7 days anchored on `trendFrom`, index clamped
|
|
593
|
+
// — byte-for-byte the bucketing getBotAnalytics uses for its own trend arrays, so the two
|
|
594
|
+
// series share an x-axis.
|
|
595
|
+
const WEEK_MS = 7 * 86_400_000;
|
|
596
|
+
const SPAN_WEEKS = 12;
|
|
597
|
+
const trendFromMs = history?.trendFrom.getTime() ?? 0;
|
|
598
|
+
const weekIndexOf = (ms) => Math.min(SPAN_WEEKS - 1, Math.max(0, Math.floor((ms - trendFromMs) / WEEK_MS)));
|
|
599
|
+
const emptyWeekly = () => Array.from({ length: SPAN_WEEKS }, (_, i) => ({
|
|
600
|
+
weekStartMs: trendFromMs + i * WEEK_MS,
|
|
601
|
+
overCall: 0,
|
|
602
|
+
underCall: 0,
|
|
603
|
+
}));
|
|
604
|
+
const byBot = new Map();
|
|
605
|
+
const totalsBySeverity = emptySeverityCounts();
|
|
606
|
+
const totalsByCategory = new Map();
|
|
607
|
+
const backends = new Set();
|
|
608
|
+
let findings = 0;
|
|
609
|
+
let summaries = 0;
|
|
610
|
+
let praise = 0;
|
|
611
|
+
// The totals' `labelled` twin under a widened scan: rows whose targetCreatedAt is in-window,
|
|
612
|
+
// fold success or not — exactly what `labelRows.length` counted when the scan was window-only.
|
|
613
|
+
let windowLabelled = 0;
|
|
614
|
+
// Whether the cap bit INSIDE the window. Rows arrive newest-first, so if the scan hit the cap
|
|
615
|
+
// but its oldest row already predates `from`, every in-window row was captured and the WINDOW
|
|
616
|
+
// totals are complete — only the (Pro, advisory) weekly history is a newest-first sample.
|
|
617
|
+
const capHit = labelRows.length >= ROLLUP_SCAN_CAP;
|
|
618
|
+
const oldestScannedMs = capHit
|
|
619
|
+
? (labelRows[labelRows.length - 1]?.targetCreatedAt.getTime() ?? fromMs)
|
|
620
|
+
: null;
|
|
621
|
+
const windowTruncated = capHit && oldestScannedMs != null && oldestScannedMs >= fromMs;
|
|
622
|
+
for (const row of labelRows) {
|
|
623
|
+
const rowMs = row.targetCreatedAt.getTime();
|
|
624
|
+
const inWindow = rowMs >= fromMs;
|
|
625
|
+
if (inWindow)
|
|
626
|
+
windowLabelled += 1;
|
|
627
|
+
// ⚠ Through `foldMlLabelRow`, NEVER a local re-spelling of the three branches: the flagging
|
|
628
|
+
// drill-down slices this same scan with this same fold, and a second copy of the order here
|
|
629
|
+
// is exactly how the tile and the list it opens would drift apart.
|
|
630
|
+
const folded = foldMlLabelRow(row);
|
|
631
|
+
if (!folded)
|
|
632
|
+
continue;
|
|
633
|
+
if (inWindow && row.backend)
|
|
634
|
+
backends.add(row.backend);
|
|
635
|
+
let acc = byBot.get(row.authorUserId);
|
|
636
|
+
if (!acc) {
|
|
637
|
+
acc = {
|
|
638
|
+
labelled: 0,
|
|
639
|
+
findings: 0,
|
|
640
|
+
summaries: 0,
|
|
641
|
+
praise: 0,
|
|
642
|
+
high: 0,
|
|
643
|
+
bySeverity: emptySeverityCounts(),
|
|
644
|
+
vendorBadged: 0,
|
|
645
|
+
vendorOverCall: 0,
|
|
646
|
+
vendorUnderCall: 0,
|
|
647
|
+
inflationWeekly: history != null ? emptyWeekly() : null,
|
|
648
|
+
};
|
|
649
|
+
byBot.set(row.authorUserId, acc);
|
|
650
|
+
}
|
|
651
|
+
if (inWindow)
|
|
652
|
+
acc.labelled += 1;
|
|
653
|
+
if (folded.bucket === 'summary') {
|
|
654
|
+
if (inWindow) {
|
|
655
|
+
acc.summaries += 1;
|
|
656
|
+
summaries += 1;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else if (folded.bucket === 'praise') {
|
|
660
|
+
if (inWindow) {
|
|
661
|
+
acc.praise += 1;
|
|
662
|
+
praise += 1;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
// ⚠ FINDINGS-ONLY, inside this branch — the same phantom-gap rule as the rollup: counting
|
|
667
|
+
// a walkthrough's severity while every rate divides by `findings` lets a share top 100%.
|
|
668
|
+
if (inWindow) {
|
|
669
|
+
acc.findings += 1;
|
|
670
|
+
findings += 1;
|
|
671
|
+
acc.bySeverity[folded.severity] += 1;
|
|
672
|
+
totalsBySeverity[folded.severity] += 1;
|
|
673
|
+
if (folded.severity === 'major' || folded.severity === 'critical')
|
|
674
|
+
acc.high += 1;
|
|
675
|
+
for (const c of folded.categories) {
|
|
676
|
+
totalsByCategory.set(c, (totalsByCategory.get(c) ?? 0) + 1);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// ── Inflation — findings-only, badged-only, via the ONE direction rule ───────────────
|
|
680
|
+
// `vendorAgreementOf` is the same classifier the confusion matrix and the flagging
|
|
681
|
+
// drill-down's `disagree` refinement apply, over the same fold of the same scan — which is
|
|
682
|
+
// exactly why the ROI column's count equals that drill-down's `filteredTotal` for the same
|
|
683
|
+
// bot + direction. A null vendor claim is SILENCE, in neither counter.
|
|
684
|
+
const dir = vendorAgreementOf(coerceSeverity(row.vendorSeverity), folded.severity);
|
|
685
|
+
if (dir != null) {
|
|
686
|
+
if (inWindow) {
|
|
687
|
+
acc.vendorBadged += 1;
|
|
688
|
+
if (dir === 'over')
|
|
689
|
+
acc.vendorOverCall += 1;
|
|
690
|
+
else if (dir === 'under')
|
|
691
|
+
acc.vendorUnderCall += 1;
|
|
692
|
+
}
|
|
693
|
+
if (acc.inflationWeekly != null && dir !== 'agree') {
|
|
694
|
+
const wk = acc.inflationWeekly[weekIndexOf(rowMs)];
|
|
695
|
+
if (dir === 'over')
|
|
696
|
+
wk.overCall += 1;
|
|
697
|
+
else
|
|
698
|
+
wk.underCall += 1;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
byBot,
|
|
705
|
+
totals: {
|
|
706
|
+
labelled: windowLabelled,
|
|
707
|
+
findings,
|
|
708
|
+
summaries,
|
|
709
|
+
praise,
|
|
710
|
+
pending,
|
|
711
|
+
unscorable,
|
|
712
|
+
bySeverity: totalsBySeverity,
|
|
713
|
+
byCategory: [...totalsByCategory.entries()]
|
|
714
|
+
.map(([category, count]) => ({ category, count }))
|
|
715
|
+
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)),
|
|
716
|
+
backends: [...backends].sort(),
|
|
717
|
+
// Says the WINDOW sample is capped — under a widened (Pro-history) scan a cap that bit
|
|
718
|
+
// only in the pre-window tail leaves every window total complete, and claiming otherwise
|
|
719
|
+
// would be the same dishonesty the flag exists to prevent, inverted.
|
|
720
|
+
truncated: windowTruncated,
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
export async function listMlLabelsForBehaviour(accountId, scope, automatedIds, from, to) {
|
|
725
|
+
if (scope.repoIds.length === 0 || automatedIds.length === 0) {
|
|
726
|
+
return { rows: [], truncated: false };
|
|
727
|
+
}
|
|
728
|
+
// Newest-first + a cap, then `truncated` — the ROLLUP_SCAN_CAP contract: a capped scan needs an
|
|
729
|
+
// ORDER BY or the sample differs run to run (heap order flips on Postgres after an UPDATE), and
|
|
730
|
+
// it has to SAY it was capped rather than present a sample as a total.
|
|
731
|
+
const raw = await db
|
|
732
|
+
.select({
|
|
733
|
+
authorUserId: mlCommentLabels.authorUserId,
|
|
734
|
+
severity: mlCommentLabels.severity,
|
|
735
|
+
vendorSeverity: mlCommentLabels.vendorSeverity,
|
|
736
|
+
categories: mlCommentLabels.categories,
|
|
737
|
+
isSummary: mlCommentLabels.isSummary,
|
|
738
|
+
targetCreatedAt: mlCommentLabels.targetCreatedAt,
|
|
739
|
+
})
|
|
740
|
+
.from(mlCommentLabels)
|
|
741
|
+
.where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds), gte(mlCommentLabels.targetCreatedAt, from), lte(mlCommentLabels.targetCreatedAt, to)))
|
|
742
|
+
.orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
|
|
743
|
+
.limit(ROLLUP_SCAN_CAP)
|
|
744
|
+
.execute();
|
|
745
|
+
const rows = [];
|
|
746
|
+
for (const r of raw) {
|
|
747
|
+
const severity = coerceSeverity(r.severity);
|
|
748
|
+
if (!severity)
|
|
749
|
+
continue;
|
|
750
|
+
rows.push({
|
|
751
|
+
authorUserId: r.authorUserId,
|
|
752
|
+
severity,
|
|
753
|
+
vendorSeverity: coerceSeverity(r.vendorSeverity),
|
|
754
|
+
categories: coerceCategories(r.categories),
|
|
755
|
+
isSummary: r.isSummary,
|
|
756
|
+
targetCreatedAtMs: r.targetCreatedAt.getTime(),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return { rows, truncated: raw.length >= ROLLUP_SCAN_CAP };
|
|
760
|
+
}
|
|
761
|
+
// ── The per-REVIEWER comments drill-down (GET /api/bot-analytics/vendor/:key/comments) ──────
|
|
762
|
+
// Everything one automated reviewer SAID in the window — inline review comments (path + thread
|
|
763
|
+
// state), issue-level PR comments, and non-empty review bodies — each row carrying its stored
|
|
764
|
+
// ML label INLINE via a LEFT JOIN on (account_id, target_kind, target_id). One response serves
|
|
765
|
+
// the whole list: a cross-PR list must never mount the per-PR label index per row.
|
|
766
|
+
//
|
|
767
|
+
// Deliberately a NEW query rather than a re-export of getBotReviewComments: that query is
|
|
768
|
+
// role:'review' by design (it feeds the synthesis seam's 'workspace-bots' fold), while this
|
|
769
|
+
// drill-down must also serve quality_check-roled rows —
|
|
770
|
+
// mirroring getBotVendorPrs, whose quality-check section offers the same drill-down.
|
|
771
|
+
//
|
|
772
|
+
// The 'pierre' sentinel answers EMPTY: its verbatim reviews are posted with the human's token
|
|
773
|
+
// (per-review provenance), so there are no attributable per-comment rows to list — the same
|
|
774
|
+
// reasoning as its getBotVendorPrs special case, minus the review-join it uses for PR rows.
|
|
775
|
+
const BOT_VENDOR_COMMENT_CAP = 3000; // most-recent rows per source (the themes-funnel precedent)
|
|
776
|
+
// The left-joined label columns → a wire MlLabel, or null when the target has no label row.
|
|
777
|
+
// Coercions mirror toWireLabel: an unreadable severity drops the LABEL (not the comment row),
|
|
778
|
+
// an unreadable vendor claim degrades to "no vendor claim".
|
|
779
|
+
function inlineLabel(targetKind, targetId, r) {
|
|
780
|
+
const severity = coerceSeverity(r.mlSeverity);
|
|
781
|
+
if (!severity || r.mlSeverityOrd == null || r.mlSeverityProb == null)
|
|
782
|
+
return null;
|
|
783
|
+
return {
|
|
784
|
+
targetKind,
|
|
785
|
+
targetId,
|
|
786
|
+
severity,
|
|
787
|
+
severityOrd: r.mlSeverityOrd,
|
|
788
|
+
severityProb: r.mlSeverityProb,
|
|
789
|
+
vendorSeverity: coerceSeverity(r.mlVendorSeverity),
|
|
790
|
+
vendorSeverityConfidence: coerceVendorConfidence(r.mlVendorSeverityConfidence),
|
|
791
|
+
categories: coerceCategories(r.mlCategories),
|
|
792
|
+
isSummary: r.mlIsSummary ?? false,
|
|
793
|
+
backend: r.mlBackend ?? '',
|
|
794
|
+
modelVersion: r.mlModelVersion ?? '',
|
|
795
|
+
createdAt: (r.mlCreatedAt ?? new Date(0)).toISOString(),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
export async function getBotVendorComments(accountId, target,
|
|
799
|
+
// Either a KIND (resolved here against the one shared duration mapping) or a kind carried
|
|
800
|
+
// ALONGSIDE explicit bounds — the getBotAnalytics window form, widened here for the same
|
|
801
|
+
// reason: the People report's bot sections must list a completed period's comments, and an
|
|
802
|
+
// arbitrary-bounds period has no enum spelling. Bounds only narrow WHAT IS MEASURED inside
|
|
803
|
+
// the caller's resolved scope (route-validated where they arrive over the wire).
|
|
804
|
+
window,
|
|
805
|
+
// The SAME BotScope the ROI row was computed at — this list reproduces one of that panel's
|
|
806
|
+
// rows, so it takes the identical workspace + repo narrowing.
|
|
807
|
+
scope) {
|
|
808
|
+
const nowMs = Date.now();
|
|
809
|
+
const windowKind = typeof window === 'string' ? window : window.kind;
|
|
810
|
+
const to = new Date(typeof window === 'string' ? nowMs : window.toMs);
|
|
811
|
+
// The one shared window→duration mapping (db/bot-window.ts) — same window as the ROI row —
|
|
812
|
+
// unless the caller supplied real bounds.
|
|
813
|
+
const from = new Date(typeof window === 'string' ? nowMs - botWindowMs(window) : window.fromMs);
|
|
814
|
+
const win = { kind: windowKind, from: from.toISOString(), to: to.toISOString() };
|
|
815
|
+
// The upper predicate, applied ONLY under EXPLICIT bounds — `getMlWindowAggregates`' `to`
|
|
816
|
+
// rule, and the ROI row this list reproduces uses the same `toBound`. Under the enum form the
|
|
817
|
+
// window ends at NOW, where an upper bound can only exclude rows written in the CURRENT SECOND
|
|
818
|
+
// (second-granular timestamps on sqlite) and would break byte-identity with the other
|
|
819
|
+
// enum-form scans over the same rows.
|
|
820
|
+
const toBound = typeof window === 'string' ? null : to;
|
|
821
|
+
const generatedAt = new Date(nowMs).toISOString();
|
|
822
|
+
if ('kind' in target) {
|
|
823
|
+
return {
|
|
824
|
+
enabled: true,
|
|
825
|
+
key: 'pierre',
|
|
826
|
+
kind: 'pierre',
|
|
827
|
+
label: labelForKind('pierre'),
|
|
828
|
+
login: null,
|
|
829
|
+
window: win,
|
|
830
|
+
comments: [],
|
|
831
|
+
truncated: false,
|
|
832
|
+
generatedAt,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
const userId = target.userId;
|
|
836
|
+
const key = `u${userId}`;
|
|
837
|
+
// NOT role-filtered — mirrors getBotVendorPrs: the quality-check section of the ROI panel
|
|
838
|
+
// offers the same drill-down.
|
|
839
|
+
const kindMap = await classificationKindForUser(accountId, scope.workspaceId);
|
|
840
|
+
if (!kindMap.has(userId)) {
|
|
841
|
+
// An id this workspace has NOT classified as automated identifies NOTHING. The users
|
|
842
|
+
// table is GLOBAL — resolving login/displayName for an arbitrary numeric id here
|
|
843
|
+
// would hand any tenant a cross-account login-enumeration oracle, exactly what the
|
|
844
|
+
// /api/users/:id/stats precedent exists to prevent (counts only, no profile fields).
|
|
845
|
+
// The key is the caller's own input; the label degrades to it; login stays null.
|
|
846
|
+
return {
|
|
847
|
+
enabled: true, key, kind: 'in_house', label: key, login: null, window: win,
|
|
848
|
+
comments: [], truncated: false, generatedAt,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const kindTyped = kindMap.get(userId) ?? 'in_house';
|
|
852
|
+
// Identity mirrors getBotVendorPrs / getBotAnalytics.reviewerLabel exactly: the workspace's
|
|
853
|
+
// custom label → the vendor's pretty name (known vendors) → login/display name. Safe to
|
|
854
|
+
// read the global row HERE: the classification row above proves the account association.
|
|
855
|
+
const classLabel = await classificationLabelMap(accountId, scope.workspaceId);
|
|
856
|
+
const [userRow] = await db
|
|
857
|
+
.select({ login: users.githubLogin, name: users.displayName })
|
|
858
|
+
.from(users)
|
|
859
|
+
.where(eq(users.id, userId))
|
|
860
|
+
.limit(1)
|
|
861
|
+
.execute();
|
|
862
|
+
const login = userRow?.login ?? null;
|
|
863
|
+
const custom = classLabel.get(userId);
|
|
864
|
+
let label;
|
|
865
|
+
if (custom)
|
|
866
|
+
label = custom;
|
|
867
|
+
else if (kindTyped !== 'in_house' && kindTyped !== 'pierre' && kindTyped !== 'vendor')
|
|
868
|
+
label = labelForKind(kindTyped);
|
|
869
|
+
else
|
|
870
|
+
label = userRow?.name?.trim() || login || key;
|
|
871
|
+
const empty = {
|
|
872
|
+
enabled: true, key, kind: kindTyped, label, login, window: win,
|
|
873
|
+
comments: [], truncated: false, generatedAt,
|
|
874
|
+
};
|
|
875
|
+
if (scope.repoIds.length === 0)
|
|
876
|
+
return empty;
|
|
877
|
+
const mlCols = {
|
|
878
|
+
mlSeverity: mlCommentLabels.severity,
|
|
879
|
+
mlSeverityOrd: mlCommentLabels.severityOrd,
|
|
880
|
+
mlSeverityProb: mlCommentLabels.severityProb,
|
|
881
|
+
mlVendorSeverity: mlCommentLabels.vendorSeverity,
|
|
882
|
+
mlVendorSeverityConfidence: mlCommentLabels.vendorSeverityConfidence,
|
|
883
|
+
mlCategories: mlCommentLabels.categories,
|
|
884
|
+
mlIsSummary: mlCommentLabels.isSummary,
|
|
885
|
+
mlBackend: mlCommentLabels.backend,
|
|
886
|
+
mlModelVersion: mlCommentLabels.modelVersion,
|
|
887
|
+
mlCreatedAt: mlCommentLabels.createdAt,
|
|
888
|
+
};
|
|
889
|
+
// Inline review-thread comments (carry the thread's path + derivedState).
|
|
890
|
+
const rcRows = await db
|
|
891
|
+
.select({
|
|
892
|
+
targetId: reviewComments.id,
|
|
893
|
+
prId: reviewComments.prId,
|
|
894
|
+
prNumber: pullRequests.number,
|
|
895
|
+
prTitle: pullRequests.title,
|
|
896
|
+
prAuthorId: pullRequests.authorId,
|
|
897
|
+
repoId: pullRequests.repoId,
|
|
898
|
+
owner: repos.owner,
|
|
899
|
+
name: repos.name,
|
|
900
|
+
body: reviewComments.body,
|
|
901
|
+
createdAt: reviewComments.createdAt,
|
|
902
|
+
path: reviewThreads.path,
|
|
903
|
+
derivedState: reviewThreads.derivedState,
|
|
904
|
+
threadId: reviewComments.threadId,
|
|
905
|
+
...mlCols,
|
|
906
|
+
})
|
|
907
|
+
.from(reviewComments)
|
|
908
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
909
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
910
|
+
.leftJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
|
|
911
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review_comment'), eq(mlCommentLabels.targetId, reviewComments.id)))
|
|
912
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(reviewComments.authorId, userId), gte(reviewComments.createdAt, from),
|
|
913
|
+
// Half-open [from, to) — the route's stated bounds contract, and the person-period
|
|
914
|
+
// evidence fold's spelling, so a boundary-ms comment lands in exactly one period.
|
|
915
|
+
...(toBound != null ? [lt(reviewComments.createdAt, toBound)] : [])))
|
|
916
|
+
.orderBy(desc(reviewComments.createdAt))
|
|
917
|
+
.limit(BOT_VENDOR_COMMENT_CAP)
|
|
918
|
+
.execute();
|
|
919
|
+
// Issue-level PR comments (no path / thread state).
|
|
920
|
+
const pcRows = await db
|
|
921
|
+
.select({
|
|
922
|
+
targetId: prComments.id,
|
|
923
|
+
prId: prComments.prId,
|
|
924
|
+
prNumber: pullRequests.number,
|
|
925
|
+
prTitle: pullRequests.title,
|
|
926
|
+
prAuthorId: pullRequests.authorId,
|
|
927
|
+
repoId: pullRequests.repoId,
|
|
928
|
+
owner: repos.owner,
|
|
929
|
+
name: repos.name,
|
|
930
|
+
body: prComments.body,
|
|
931
|
+
createdAt: prComments.createdAt,
|
|
932
|
+
...mlCols,
|
|
933
|
+
})
|
|
934
|
+
.from(prComments)
|
|
935
|
+
.innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
|
|
936
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
937
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'pr_comment'), eq(mlCommentLabels.targetId, prComments.id)))
|
|
938
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(prComments.authorId, userId), gte(prComments.createdAt, from), ...(toBound != null ? [lt(prComments.createdAt, toBound)] : [])))
|
|
939
|
+
.orderBy(desc(prComments.createdAt))
|
|
940
|
+
.limit(BOT_VENDOR_COMMENT_CAP)
|
|
941
|
+
.execute();
|
|
942
|
+
// Review BODIES with real text only (`hasText` — an approval with no comment is an
|
|
943
|
+
// empty-string body, and 5.4k empty rows are not things the bot "said"). Matches the label
|
|
944
|
+
// corpus: the candidate query applies the same predicate, so every row here is labellable.
|
|
945
|
+
const rvRows = await db
|
|
946
|
+
.select({
|
|
947
|
+
targetId: reviews.id,
|
|
948
|
+
prId: reviews.prId,
|
|
949
|
+
prNumber: pullRequests.number,
|
|
950
|
+
prTitle: pullRequests.title,
|
|
951
|
+
prAuthorId: pullRequests.authorId,
|
|
952
|
+
repoId: pullRequests.repoId,
|
|
953
|
+
owner: repos.owner,
|
|
954
|
+
name: repos.name,
|
|
955
|
+
body: reviews.body,
|
|
956
|
+
createdAt: reviews.submittedAt,
|
|
957
|
+
...mlCols,
|
|
958
|
+
})
|
|
959
|
+
.from(reviews)
|
|
960
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
961
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
962
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review'), eq(mlCommentLabels.targetId, reviews.id)))
|
|
963
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(reviews.authorId, userId), isNotNull(reviews.body), hasText(reviews.body), gte(reviews.submittedAt, from), ...(toBound != null ? [lt(reviews.submittedAt, toBound)] : [])))
|
|
964
|
+
.orderBy(desc(reviews.submittedAt))
|
|
965
|
+
.limit(BOT_VENDOR_COMMENT_CAP)
|
|
966
|
+
.execute();
|
|
967
|
+
const toIso = (d) => {
|
|
968
|
+
if (d == null)
|
|
969
|
+
return new Date(0).toISOString();
|
|
970
|
+
if (d instanceof Date)
|
|
971
|
+
return d.toISOString();
|
|
972
|
+
const ms = Number(d) > 1e12 ? Number(d) : Number(d) * 1000;
|
|
973
|
+
return new Date(ms).toISOString();
|
|
974
|
+
};
|
|
975
|
+
const out = [];
|
|
976
|
+
for (const r of rcRows) {
|
|
977
|
+
out.push({
|
|
978
|
+
targetKind: 'review_comment',
|
|
979
|
+
targetId: r.targetId,
|
|
980
|
+
prId: r.prId,
|
|
981
|
+
prNumber: r.prNumber,
|
|
982
|
+
prTitle: r.prTitle,
|
|
983
|
+
prAuthorId: r.prAuthorId ?? null,
|
|
984
|
+
repoId: r.repoId,
|
|
985
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
986
|
+
path: r.path ?? null,
|
|
987
|
+
threadId: r.threadId ?? null,
|
|
988
|
+
derivedState: r.derivedState ?? null,
|
|
989
|
+
body: r.body,
|
|
990
|
+
createdAt: toIso(r.createdAt),
|
|
991
|
+
mlLabel: inlineLabel('review_comment', r.targetId, r),
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
for (const r of pcRows) {
|
|
995
|
+
out.push({
|
|
996
|
+
targetKind: 'pr_comment',
|
|
997
|
+
targetId: r.targetId,
|
|
998
|
+
prId: r.prId,
|
|
999
|
+
prNumber: r.prNumber,
|
|
1000
|
+
prTitle: r.prTitle,
|
|
1001
|
+
prAuthorId: r.prAuthorId ?? null,
|
|
1002
|
+
repoId: r.repoId,
|
|
1003
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
1004
|
+
path: null,
|
|
1005
|
+
threadId: null,
|
|
1006
|
+
derivedState: null,
|
|
1007
|
+
body: r.body,
|
|
1008
|
+
createdAt: toIso(r.createdAt),
|
|
1009
|
+
mlLabel: inlineLabel('pr_comment', r.targetId, r),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
for (const r of rvRows) {
|
|
1013
|
+
out.push({
|
|
1014
|
+
targetKind: 'review',
|
|
1015
|
+
targetId: r.targetId,
|
|
1016
|
+
prId: r.prId,
|
|
1017
|
+
prNumber: r.prNumber,
|
|
1018
|
+
prTitle: r.prTitle,
|
|
1019
|
+
prAuthorId: r.prAuthorId ?? null,
|
|
1020
|
+
repoId: r.repoId,
|
|
1021
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
1022
|
+
path: null,
|
|
1023
|
+
threadId: null,
|
|
1024
|
+
derivedState: null,
|
|
1025
|
+
body: r.body,
|
|
1026
|
+
createdAt: toIso(r.createdAt),
|
|
1027
|
+
mlLabel: inlineLabel('review', r.targetId, r),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
// Newest-first, capped combined — `truncated` when either a source hit its own cap or the
|
|
1031
|
+
// combined stream overflowed (the getBotReviewComments rule).
|
|
1032
|
+
out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
1033
|
+
const truncated = rcRows.length >= BOT_VENDOR_COMMENT_CAP ||
|
|
1034
|
+
pcRows.length >= BOT_VENDOR_COMMENT_CAP ||
|
|
1035
|
+
rvRows.length >= BOT_VENDOR_COMMENT_CAP ||
|
|
1036
|
+
out.length > BOT_VENDOR_COMMENT_CAP;
|
|
1037
|
+
return {
|
|
1038
|
+
enabled: true, key, kind: kindTyped, label, login, window: win,
|
|
1039
|
+
comments: out.slice(0, BOT_VENDOR_COMMENT_CAP), truncated, generatedAt,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
async function countBotText(accountId, scope, automatedIds, mode,
|
|
1043
|
+
// Optional window on the SOURCE row's own timestamp (createdAt / submittedAt) — the windowed
|
|
1044
|
+
// ROI fold's coverage statement must be about the window it sits over. Absent `from` = the
|
|
1045
|
+
// whole corpus, exactly as before; `to` follows getMlWindowAggregates' contract (a Date only
|
|
1046
|
+
// under explicit bounds, applied half-open; null = the now-ending enum window, no upper
|
|
1047
|
+
// predicate).
|
|
1048
|
+
from, to) {
|
|
1049
|
+
if (scope.repoIds.length === 0 || automatedIds.length === 0)
|
|
1050
|
+
return 0;
|
|
1051
|
+
const n = sql `count(*)`;
|
|
1052
|
+
const bodyPredicate = (col) => mode === 'unlabelled' ? and(isNotNull(col), hasText(col)) : isNull(col);
|
|
1053
|
+
const windowed = (col) => [
|
|
1054
|
+
...(from ? [gte(col, from)] : []),
|
|
1055
|
+
...(to != null ? [lt(col, to)] : []),
|
|
1056
|
+
];
|
|
1057
|
+
const [rc, pc, rv] = await Promise.all([
|
|
1058
|
+
db
|
|
1059
|
+
.select({ n })
|
|
1060
|
+
.from(reviewComments)
|
|
1061
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
1062
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review_comment'), eq(mlCommentLabels.targetId, reviewComments.id)))
|
|
1063
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviewComments.authorId), inArray(reviewComments.authorId, automatedIds), bodyPredicate(reviewComments.body), isNull(mlCommentLabels.id), ...windowed(reviewComments.createdAt)))
|
|
1064
|
+
.execute(),
|
|
1065
|
+
db
|
|
1066
|
+
.select({ n })
|
|
1067
|
+
.from(prComments)
|
|
1068
|
+
.innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
|
|
1069
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'pr_comment'), eq(mlCommentLabels.targetId, prComments.id)))
|
|
1070
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(prComments.authorId), inArray(prComments.authorId, automatedIds), bodyPredicate(prComments.body), isNull(mlCommentLabels.id), ...windowed(prComments.createdAt)))
|
|
1071
|
+
.execute(),
|
|
1072
|
+
db
|
|
1073
|
+
.select({ n })
|
|
1074
|
+
.from(reviews)
|
|
1075
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
1076
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review'), eq(mlCommentLabels.targetId, reviews.id)))
|
|
1077
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviews.authorId), inArray(reviews.authorId, automatedIds), bodyPredicate(reviews.body), isNull(mlCommentLabels.id), ...windowed(reviews.submittedAt)))
|
|
1078
|
+
.execute(),
|
|
1079
|
+
]);
|
|
1080
|
+
return Number(rc[0]?.n ?? 0) + Number(pc[0]?.n ?? 0) + Number(rv[0]?.n ?? 0);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* How much bot text in scope is still waiting for a label. Three counts with the same
|
|
1084
|
+
* left-join-is-null predicate the candidate query uses, so "pending" and "what the worker will
|
|
1085
|
+
* pick up next" cannot drift apart. Optional `from` bounds the count to a window (the ROI fold).
|
|
1086
|
+
*/
|
|
1087
|
+
async function countUnlabelledBotText(accountId, scope, automatedIds, from, to) {
|
|
1088
|
+
return countBotText(accountId, scope, automatedIds, 'unlabelled', from, to);
|
|
1089
|
+
}
|
|
1090
|
+
/** How much bot text in scope can NEVER be labelled as stored — see `UnlabelledBodyMode`. */
|
|
1091
|
+
async function countUnscorableBotText(accountId, scope, automatedIds) {
|
|
1092
|
+
return countBotText(accountId, scope, automatedIds, 'unscorable');
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* The PRs in scope still carrying bot-authored NULL-body targets — the worklist for
|
|
1096
|
+
* `pnpm ml:backfill-bodies` (scripts/backfill-null-bodies.ts). Same bot set and the same
|
|
1097
|
+
* NULL-body predicate as `countUnscorableBotText`, so the script repairs exactly the
|
|
1098
|
+
* population that count reports.
|
|
1099
|
+
*/
|
|
1100
|
+
export async function listNullBodyBotPrIds(accountId, scope, automatedIds) {
|
|
1101
|
+
if (scope.repoIds.length === 0 || automatedIds.length === 0)
|
|
1102
|
+
return [];
|
|
1103
|
+
const prIdsFor = (kind, table) => db
|
|
1104
|
+
.selectDistinct({ prId: table.prId })
|
|
1105
|
+
.from(table)
|
|
1106
|
+
.innerJoin(pullRequests, eq(pullRequests.id, table.prId))
|
|
1107
|
+
.leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, kind), eq(mlCommentLabels.targetId, table.id)))
|
|
1108
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(table.authorId), inArray(table.authorId, automatedIds), isNull(table.body), isNull(mlCommentLabels.id)))
|
|
1109
|
+
.execute();
|
|
1110
|
+
const [rc, pc, rv] = await Promise.all([
|
|
1111
|
+
prIdsFor('review_comment', reviewComments),
|
|
1112
|
+
prIdsFor('pr_comment', prComments),
|
|
1113
|
+
prIdsFor('review', reviews),
|
|
1114
|
+
]);
|
|
1115
|
+
return [...new Set([...rc, ...pc, ...rv].map((r) => r.prId))];
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The account's whole enrichment backlog — what `GET /api/ml-status` reports so a sync surface
|
|
1119
|
+
* can keep showing activity until the labels exist, instead of announcing "complete" the moment
|
|
1120
|
+
* the GitHub walk ends and leaving the model calls unrepresented.
|
|
1121
|
+
*
|
|
1122
|
+
* ACCOUNT-WIDE, NOT WORKSPACE-SCOPED, because that is the worker's own grain: a tick walks every
|
|
1123
|
+
* workspace of every active account. Scoping this to the selected workspace would under-report
|
|
1124
|
+
* exactly the work that is running and let the indicator go quiet while the CPU is busy.
|
|
1125
|
+
*
|
|
1126
|
+
* Cost: one bot-set resolve plus three indexed counts PER WORKSPACE, and they share the same
|
|
1127
|
+
* left-join-is-null predicate as `listMlCandidates`, so "pending" and "what the worker will pick
|
|
1128
|
+
* up next" cannot drift. That is not free, which is why the route in front of it caches — see
|
|
1129
|
+
* the TTL note there. Workspaces are walked serially rather than with `Promise.all` so a
|
|
1130
|
+
* many-workspace account cannot open 4×N connections at once on a poll.
|
|
1131
|
+
*/
|
|
1132
|
+
export async function getMlBacklogForAccount(accountId) {
|
|
1133
|
+
const workspaces = await listWorkspaces(accountId);
|
|
1134
|
+
let pending = 0;
|
|
1135
|
+
let unscorable = 0;
|
|
1136
|
+
for (const ws of workspaces) {
|
|
1137
|
+
if (ws.repoIds.length === 0)
|
|
1138
|
+
continue;
|
|
1139
|
+
const scope = { workspaceId: ws.id, repoIds: ws.repoIds };
|
|
1140
|
+
const automatedIds = await automatedReviewerUserIds(accountId, ws.id, 'all');
|
|
1141
|
+
if (automatedIds.length === 0)
|
|
1142
|
+
continue;
|
|
1143
|
+
pending += await countUnlabelledBotText(accountId, scope, automatedIds);
|
|
1144
|
+
unscorable += await countUnscorableBotText(accountId, scope, automatedIds);
|
|
1145
|
+
}
|
|
1146
|
+
const labelledRows = await db
|
|
1147
|
+
.select({ n: sql `count(*)` })
|
|
1148
|
+
.from(mlCommentLabels)
|
|
1149
|
+
.where(eq(mlCommentLabels.accountId, accountId))
|
|
1150
|
+
.execute();
|
|
1151
|
+
return { pending, unscorable, labelled: Number(labelledRows[0]?.n ?? 0) };
|
|
1152
|
+
}
|
|
1153
|
+
// ── "What the bots are flagging" — the drill-down behind the ML totals strip ─────────────────
|
|
1154
|
+
//
|
|
1155
|
+
// Every tile and chip on the Bots rail's ML strip opens this ONE getter with a different
|
|
1156
|
+
// selector, and the contract is that the list's `total` IS the tile's number — not a second,
|
|
1157
|
+
// independently-derived count that happens to agree today.
|
|
1158
|
+
//
|
|
1159
|
+
// That rules out the obvious implementation. A `count(*) … WHERE severity = 'nit'` can never
|
|
1160
|
+
// equal the "Nits" tile: it counts summaries and praise, it cannot express praise at all (a JSON
|
|
1161
|
+
// column in both dialects), it ignores `coerceSeverity` failures, and it ignores ROLLUP_SCAN_CAP's
|
|
1162
|
+
// newest-first truncation. So this getter re-runs the STRIP'S OWN scan — byte-identical WHERE /
|
|
1163
|
+
// ORDER BY / LIMIT, only the select list widens — re-folds it through the shared
|
|
1164
|
+
// `foldMlLabelRow`, and slices the result. Pagination is therefore an OFFSET INTO THE FOLDED
|
|
1165
|
+
// IN-MEMORY POPULATION, behind an opaque `o:<n>` cursor so a later keyset switch is not a wire
|
|
1166
|
+
// break; keyset pagination is the wrong tool here because it cannot reproduce a count only JS
|
|
1167
|
+
// can compute.
|
|
1168
|
+
//
|
|
1169
|
+
// Cost is bounded the same way the strip's is: one capped label scan, then hydration of the
|
|
1170
|
+
// PAGE ONLY (≤50 rows) — never of the population.
|
|
1171
|
+
const FLAGGING_PAGE_MAX = 50;
|
|
1172
|
+
// The matrix axes, worst-first, mirroring the shared `ML_SEVERITIES` order with `'none'` (the bot
|
|
1173
|
+
// declared nothing) appended to the vendor axis. Local copies because `@pierre-review/shared` is
|
|
1174
|
+
// a TYPES-ONLY dependency of the backend — the release build greps `release/dist` and fails on a
|
|
1175
|
+
// real import — the same local-copy rule `REASON_PRIORITY` follows in queries.ts.
|
|
1176
|
+
// ⚠ Two severity orders live in this codebase (`ML_SEVERITIES` worst-first, the Bots panel's
|
|
1177
|
+
// `SEVERITY_COLUMNS` ascending). Flipping one axis silently transposes every cell, so the order
|
|
1178
|
+
// is pinned here and the client looks cells up by (vendor, ours) rather than by position.
|
|
1179
|
+
const MATRIX_VENDOR_AXIS = ['critical', 'major', 'minor', 'nit', 'none'];
|
|
1180
|
+
const MATRIX_OURS_AXIS = ['critical', 'major', 'minor', 'nit'];
|
|
1181
|
+
/**
|
|
1182
|
+
* OUR severity as a 0..3 ordinal — `SEVERITY_KEYS` is stored ascending, so its INDEX is the
|
|
1183
|
+
* ordinal, the same 0..3 the shared `ML_SEVERITY_ORD` map spells out for the frontend. Re-derived
|
|
1184
|
+
* here rather than imported for the types-only reason above.
|
|
1185
|
+
*
|
|
1186
|
+
* ⚠ Used ONLY to say which way a vendor's claim differs from ours. It never orders, seeds,
|
|
1187
|
+
* corrects or falls back OUR severity — see MlLabel.vendorSeverity.
|
|
1188
|
+
*/
|
|
1189
|
+
function severityOrdinal(s) {
|
|
1190
|
+
return SEVERITY_KEYS.indexOf(s);
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* THE ours-vs-vendor direction rule, in ONE place.
|
|
1194
|
+
*
|
|
1195
|
+
* `null` = the bot declared nothing, which is SILENCE, not conflict — it lands in the matrix's
|
|
1196
|
+
* 'none' column and in neither `agree` nor either disagreement counter. That is what keeps
|
|
1197
|
+
* `agree + overCall + underCall === declared` true, and it is the property every caption on
|
|
1198
|
+
* these screens leans on.
|
|
1199
|
+
*
|
|
1200
|
+
* Exported because three surfaces now ask the same question — the confusion matrix, the
|
|
1201
|
+
* flagging drill-down's `disagree` refinement, and the Behaviour tab's per-bot inflation index
|
|
1202
|
+
* (`getBotBehaviourAnalytics`) — and a fourth hand-spelled `>` is exactly how two numbers that
|
|
1203
|
+
* must agree come to differ by one row with nothing failing.
|
|
1204
|
+
*
|
|
1205
|
+
* ⚠ Used ONLY to say which way the two differ. The vendor's badge never orders, seeds, corrects
|
|
1206
|
+
* or falls back OUR severity — see MlLabel.vendorSeverity (0.474 vs 0.700 exact).
|
|
1207
|
+
*/
|
|
1208
|
+
export function vendorAgreementOf(vendor, ours) {
|
|
1209
|
+
if (vendor == null)
|
|
1210
|
+
return null;
|
|
1211
|
+
if (vendor === ours)
|
|
1212
|
+
return 'agree';
|
|
1213
|
+
return severityOrdinal(vendor) > severityOrdinal(ours) ? 'over' : 'under';
|
|
1214
|
+
}
|
|
1215
|
+
function emptyAgreementMatrix() {
|
|
1216
|
+
return {
|
|
1217
|
+
// Dense 5×4: a zero cell is PRESENT, not omitted — the grid renders every combination, and
|
|
1218
|
+
// an absent cell would read as "no data here" rather than "they never disagreed this way".
|
|
1219
|
+
cells: MATRIX_VENDOR_AXIS.flatMap((vendor) => MATRIX_OURS_AXIS.map((ours) => ({ vendor, ours, count: 0 }))),
|
|
1220
|
+
declared: 0,
|
|
1221
|
+
undeclared: 0,
|
|
1222
|
+
agree: 0,
|
|
1223
|
+
overCall: 0,
|
|
1224
|
+
underCall: 0,
|
|
1225
|
+
total: 0,
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* The ours-vs-vendor confusion matrix over a population.
|
|
1230
|
+
*
|
|
1231
|
+
* ⚠ A DISPLAY OF TWO CLAIMS, NEVER A RECONCILIATION. The vendor badge scores 0.474 exact against
|
|
1232
|
+
* our 0.700 on the adjudicated gold-300, so it is here to show WHERE the two differ and nothing
|
|
1233
|
+
* more: it does not enter a selector predicate, `total`, or the fold.
|
|
1234
|
+
*/
|
|
1235
|
+
function buildAgreementMatrix(rows) {
|
|
1236
|
+
const counts = new Map();
|
|
1237
|
+
let declared = 0;
|
|
1238
|
+
let agree = 0;
|
|
1239
|
+
let overCall = 0;
|
|
1240
|
+
let underCall = 0;
|
|
1241
|
+
for (const r of rows) {
|
|
1242
|
+
const axis = r.vendor ?? 'none';
|
|
1243
|
+
const key = `${axis}|${r.ours}`;
|
|
1244
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1245
|
+
// A null vendor claim is UNDECLARED — it lands in the 'none' column and in NEITHER agreement
|
|
1246
|
+
// nor disagreement. Silence is not a conflict, and `agree + over + under === declared` is the
|
|
1247
|
+
// property that keeps the caption honest. (`vendorAgreementOf` is the one direction rule.)
|
|
1248
|
+
const dir = vendorAgreementOf(r.vendor, r.ours);
|
|
1249
|
+
if (dir == null)
|
|
1250
|
+
continue;
|
|
1251
|
+
declared += 1;
|
|
1252
|
+
if (dir === 'agree')
|
|
1253
|
+
agree += 1;
|
|
1254
|
+
else if (dir === 'over')
|
|
1255
|
+
overCall += 1;
|
|
1256
|
+
else
|
|
1257
|
+
underCall += 1;
|
|
1258
|
+
}
|
|
1259
|
+
return {
|
|
1260
|
+
cells: MATRIX_VENDOR_AXIS.flatMap((vendor) => MATRIX_OURS_AXIS.map((ours) => ({
|
|
1261
|
+
vendor,
|
|
1262
|
+
ours,
|
|
1263
|
+
count: counts.get(`${vendor}|${ours}`) ?? 0,
|
|
1264
|
+
}))),
|
|
1265
|
+
declared,
|
|
1266
|
+
undeclared: rows.length - declared,
|
|
1267
|
+
agree,
|
|
1268
|
+
overCall,
|
|
1269
|
+
underCall,
|
|
1270
|
+
total: rows.length,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Does this folded row belong to the tile that was clicked?
|
|
1275
|
+
*
|
|
1276
|
+
* ⚠ `severity` and `category` gate on `bucket === 'finding'` because the strip increments
|
|
1277
|
+
* `bySeverity`/`byCategory` ONLY inside its finding branch (a walkthrough's severity is never
|
|
1278
|
+
* counted, and a walkthrough's categories are an artefact of the marker parser reading a summary
|
|
1279
|
+
* table). Dropping either gate makes the list overshoot the tile it opened from.
|
|
1280
|
+
*
|
|
1281
|
+
* ⚠ NOTHING HERE READS `vendorSeverity`. The bot's own badge is display-only; a selector that
|
|
1282
|
+
* consulted it would let the less accurate of the two labels decide which rows are "high
|
|
1283
|
+
* severity", which is precisely what MlLabel.vendorSeverity forbids.
|
|
1284
|
+
*/
|
|
1285
|
+
function matchesFlaggingSelector(selector, fold) {
|
|
1286
|
+
switch (selector.kind) {
|
|
1287
|
+
case 'findings':
|
|
1288
|
+
return fold.bucket === 'finding';
|
|
1289
|
+
case 'summaries':
|
|
1290
|
+
return fold.bucket === 'summary';
|
|
1291
|
+
case 'severity':
|
|
1292
|
+
return fold.bucket === 'finding' && selector.severities.includes(fold.severity);
|
|
1293
|
+
case 'category':
|
|
1294
|
+
// ⚠ PRAISE IS ITS OWN BUCKET, NOT A FINDING CATEGORY — the one arm where the obvious
|
|
1295
|
+
// spelling is unsatisfiable. `foldMlLabelRow` tests `isSummary` then praise then finding,
|
|
1296
|
+
// so a praise row NEVER carries `bucket === 'finding'`; asking for `category:'praise'`
|
|
1297
|
+
// under the finding gate matches nothing, for every input, forever.
|
|
1298
|
+
//
|
|
1299
|
+
// It stayed invisible while the only source of category selectors was the strip's chips,
|
|
1300
|
+
// which come from `byCategory` — incremented solely inside the FINDING branch, so praise
|
|
1301
|
+
// can never appear there. The drill-down's severity picker offers Praise directly (it sits
|
|
1302
|
+
// beside the four severities, the `SEVERITY_PILLS` precedent), which made the dead arm
|
|
1303
|
+
// reachable: the option reads "Praise · 55" from the analytics fold while the list it opens
|
|
1304
|
+
// is empty — a control that looks broken, which is the exact failure this codebase has
|
|
1305
|
+
// already paid for once with the CI-failure lens.
|
|
1306
|
+
return selector.category === 'praise'
|
|
1307
|
+
? fold.bucket === 'praise'
|
|
1308
|
+
: fold.bucket === 'finding' && fold.categories.includes(selector.category);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* The refinement the matrix / direction toggle / per-bot bar applies AFTER the selector has
|
|
1313
|
+
* fixed `total`.
|
|
1314
|
+
*
|
|
1315
|
+
* ⚠ `authorUserIds` narrows the LIST, never the matrix — the matrix is built over the selector
|
|
1316
|
+
* population pre-refine (phase 3 below) so a cell cannot zero out the cell that was clicked, and
|
|
1317
|
+
* a bot narrowing is no different: drilling into one bot must not silently redraw the grid the
|
|
1318
|
+
* click came from. `filteredTotal` is what describes the narrowed list.
|
|
1319
|
+
*
|
|
1320
|
+
* ⚠ `[]` MEANS "NO BOTS", NOT "EVERY BOT" — the `repoIds` rule. The gate is on the list being
|
|
1321
|
+
* PRESENT (`!= null`), never on its length, so a caller that computed an empty bot set gets an
|
|
1322
|
+
* empty page rather than the whole workspace under a caption promising a subset.
|
|
1323
|
+
*/
|
|
1324
|
+
function matchesFlaggingRefine(refine, vendor, ours, authorUserId) {
|
|
1325
|
+
// `includes` over an array of at most one entry per automated reviewer in the workspace — a
|
|
1326
|
+
// per-call Set would allocate once per scanned row for a membership test of ~10 numbers.
|
|
1327
|
+
if (refine.authorUserIds != null && !refine.authorUserIds.includes(authorUserId))
|
|
1328
|
+
return false;
|
|
1329
|
+
if (refine.cell) {
|
|
1330
|
+
if ((vendor ?? 'none') !== refine.cell.vendor)
|
|
1331
|
+
return false;
|
|
1332
|
+
if (ours !== refine.cell.ours)
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
if (refine.disagree) {
|
|
1336
|
+
// Undeclared is not a disagreement (see buildAgreementMatrix); `'any'` takes either
|
|
1337
|
+
// direction. One shared classifier, so this predicate and the matrix cannot drift.
|
|
1338
|
+
const dir = vendorAgreementOf(vendor, ours);
|
|
1339
|
+
if (dir == null || dir === 'agree')
|
|
1340
|
+
return false;
|
|
1341
|
+
if (refine.disagree !== 'any' && dir !== refine.disagree)
|
|
1342
|
+
return false;
|
|
1343
|
+
}
|
|
1344
|
+
return true;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Hydrate ONE PAGE (≤50 rows) into wire cards: four small parent selects, one `users` select and
|
|
1348
|
+
* the two identity maps. Never called with the population.
|
|
1349
|
+
*
|
|
1350
|
+
* ⚠ Every parent WHERE carries `eq(pullRequests.accountId, accountId)` and
|
|
1351
|
+
* `inArray(pullRequests.repoId, scope.repoIds)` even though these ids came from an
|
|
1352
|
+
* already-scoped scan. The tenancy predicate is not optional because the input was trusted —
|
|
1353
|
+
* that is how an id list stops being an existence oracle in every code path, not just the ones
|
|
1354
|
+
* whose caller remembered.
|
|
1355
|
+
*/
|
|
1356
|
+
export async function hydrateFlaggingPage(accountId, scope, pageRows) {
|
|
1357
|
+
if (pageRows.length === 0)
|
|
1358
|
+
return [];
|
|
1359
|
+
const idsFor = (kind) => pageRows.filter((p) => p.row.targetKind === kind).map((p) => p.row.targetId);
|
|
1360
|
+
const rcIds = idsFor('review_comment');
|
|
1361
|
+
const pcIds = idsFor('pr_comment');
|
|
1362
|
+
const rvIds = idsFor('review');
|
|
1363
|
+
const authorIds = [...new Set(pageRows.map((p) => p.row.authorUserId))];
|
|
1364
|
+
const [rcRows, pcRows, rvRows, userRows, kindMap, classLabel] = await Promise.all([
|
|
1365
|
+
rcIds.length > 0
|
|
1366
|
+
? db
|
|
1367
|
+
.select({
|
|
1368
|
+
targetId: reviewComments.id,
|
|
1369
|
+
prId: reviewComments.prId,
|
|
1370
|
+
prNumber: pullRequests.number,
|
|
1371
|
+
prTitle: pullRequests.title,
|
|
1372
|
+
prAuthorId: pullRequests.authorId,
|
|
1373
|
+
repoId: pullRequests.repoId,
|
|
1374
|
+
owner: repos.owner,
|
|
1375
|
+
name: repos.name,
|
|
1376
|
+
body: reviewComments.body,
|
|
1377
|
+
createdAt: reviewComments.createdAt,
|
|
1378
|
+
threadId: reviewComments.threadId,
|
|
1379
|
+
path: reviewThreads.path,
|
|
1380
|
+
line: reviewThreads.line,
|
|
1381
|
+
derivedState: reviewThreads.derivedState,
|
|
1382
|
+
})
|
|
1383
|
+
.from(reviewComments)
|
|
1384
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
1385
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
1386
|
+
// LEFT, as everywhere else: a comment whose thread row is missing still renders, just
|
|
1387
|
+
// without a path/line/state.
|
|
1388
|
+
.leftJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
|
|
1389
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), inArray(reviewComments.id, rcIds)))
|
|
1390
|
+
.execute()
|
|
1391
|
+
: [],
|
|
1392
|
+
pcIds.length > 0
|
|
1393
|
+
? db
|
|
1394
|
+
.select({
|
|
1395
|
+
targetId: prComments.id,
|
|
1396
|
+
prId: prComments.prId,
|
|
1397
|
+
prNumber: pullRequests.number,
|
|
1398
|
+
prTitle: pullRequests.title,
|
|
1399
|
+
prAuthorId: pullRequests.authorId,
|
|
1400
|
+
repoId: pullRequests.repoId,
|
|
1401
|
+
owner: repos.owner,
|
|
1402
|
+
name: repos.name,
|
|
1403
|
+
body: prComments.body,
|
|
1404
|
+
createdAt: prComments.createdAt,
|
|
1405
|
+
})
|
|
1406
|
+
.from(prComments)
|
|
1407
|
+
.innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
|
|
1408
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
1409
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), inArray(prComments.id, pcIds)))
|
|
1410
|
+
.execute()
|
|
1411
|
+
: [],
|
|
1412
|
+
rvIds.length > 0
|
|
1413
|
+
? db
|
|
1414
|
+
.select({
|
|
1415
|
+
targetId: reviews.id,
|
|
1416
|
+
prId: reviews.prId,
|
|
1417
|
+
prNumber: pullRequests.number,
|
|
1418
|
+
prTitle: pullRequests.title,
|
|
1419
|
+
prAuthorId: pullRequests.authorId,
|
|
1420
|
+
repoId: pullRequests.repoId,
|
|
1421
|
+
owner: repos.owner,
|
|
1422
|
+
name: repos.name,
|
|
1423
|
+
body: reviews.body,
|
|
1424
|
+
createdAt: reviews.submittedAt,
|
|
1425
|
+
})
|
|
1426
|
+
.from(reviews)
|
|
1427
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
1428
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
1429
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), inArray(reviews.id, rvIds)))
|
|
1430
|
+
.execute()
|
|
1431
|
+
: [],
|
|
1432
|
+
authorIds.length > 0
|
|
1433
|
+
? db
|
|
1434
|
+
.select({ id: users.id, login: users.githubLogin, name: users.displayName })
|
|
1435
|
+
.from(users)
|
|
1436
|
+
.where(inArray(users.id, authorIds))
|
|
1437
|
+
.execute()
|
|
1438
|
+
: [],
|
|
1439
|
+
classificationKindForUser(accountId, scope.workspaceId),
|
|
1440
|
+
classificationLabelMap(accountId, scope.workspaceId),
|
|
1441
|
+
]);
|
|
1442
|
+
const parents = new Map();
|
|
1443
|
+
for (const r of rcRows) {
|
|
1444
|
+
parents.set(`review_comment:${r.targetId}`, {
|
|
1445
|
+
prId: r.prId,
|
|
1446
|
+
prNumber: r.prNumber,
|
|
1447
|
+
prTitle: r.prTitle,
|
|
1448
|
+
prAuthorId: r.prAuthorId ?? null,
|
|
1449
|
+
repoId: r.repoId,
|
|
1450
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
1451
|
+
path: r.path ?? null,
|
|
1452
|
+
line: r.line ?? null,
|
|
1453
|
+
threadId: r.threadId ?? null,
|
|
1454
|
+
derivedState: r.derivedState ?? null,
|
|
1455
|
+
body: r.body,
|
|
1456
|
+
createdAt: r.createdAt,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
const pushFlat = (kind, rows) => {
|
|
1460
|
+
for (const r of rows) {
|
|
1461
|
+
parents.set(`${kind}:${r.targetId}`, {
|
|
1462
|
+
prId: r.prId,
|
|
1463
|
+
prNumber: r.prNumber,
|
|
1464
|
+
prTitle: r.prTitle,
|
|
1465
|
+
prAuthorId: r.prAuthorId ?? null,
|
|
1466
|
+
repoId: r.repoId,
|
|
1467
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
1468
|
+
path: null,
|
|
1469
|
+
line: null,
|
|
1470
|
+
threadId: null,
|
|
1471
|
+
derivedState: null,
|
|
1472
|
+
body: r.body,
|
|
1473
|
+
createdAt: r.createdAt,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
pushFlat('pr_comment', pcRows);
|
|
1478
|
+
pushFlat('review', rvRows);
|
|
1479
|
+
// Identity: the workspace's custom label → the vendor's pretty name → display name/login —
|
|
1480
|
+
// getBotAnalytics' `reviewerLabel` precedence verbatim, so one bot reads with one name across
|
|
1481
|
+
// the whole Bots interface.
|
|
1482
|
+
const displayById = new Map();
|
|
1483
|
+
const loginById = new Map();
|
|
1484
|
+
for (const u of userRows) {
|
|
1485
|
+
displayById.set(u.id, u.name?.trim() || u.login || `#${u.id}`);
|
|
1486
|
+
loginById.set(u.id, u.login || null);
|
|
1487
|
+
}
|
|
1488
|
+
const reviewerLabel = (userId, kind) => {
|
|
1489
|
+
const custom = classLabel.get(userId);
|
|
1490
|
+
if (custom)
|
|
1491
|
+
return custom;
|
|
1492
|
+
if (kind !== 'in_house' && kind !== 'pierre' && kind !== 'vendor')
|
|
1493
|
+
return labelForKind(kind);
|
|
1494
|
+
return displayById.get(userId) ?? labelForKind(kind);
|
|
1495
|
+
};
|
|
1496
|
+
const items = [];
|
|
1497
|
+
for (const { row, fold } of pageRows) {
|
|
1498
|
+
const parent = parents.get(`${row.targetKind}:${row.targetId}`);
|
|
1499
|
+
// A label whose parent row is gone is DROPPED from the page but stays counted in
|
|
1500
|
+
// `total`/`filteredTotal` — the same tolerance getBotVendorComments has, and the honest one:
|
|
1501
|
+
// re-deriving the count from what hydrated would put the list back out of step with the tile
|
|
1502
|
+
// it was opened from, which is the whole thing this getter exists to prevent.
|
|
1503
|
+
if (!parent)
|
|
1504
|
+
continue;
|
|
1505
|
+
const kind = kindMap.get(row.authorUserId) ?? 'in_house';
|
|
1506
|
+
items.push({
|
|
1507
|
+
targetKind: row.targetKind,
|
|
1508
|
+
targetId: row.targetId,
|
|
1509
|
+
prId: parent.prId,
|
|
1510
|
+
prNumber: parent.prNumber,
|
|
1511
|
+
prTitle: parent.prTitle,
|
|
1512
|
+
prAuthorId: parent.prAuthorId,
|
|
1513
|
+
repoId: parent.repoId,
|
|
1514
|
+
repoFullName: parent.repoFullName,
|
|
1515
|
+
path: parent.path,
|
|
1516
|
+
threadId: parent.threadId,
|
|
1517
|
+
derivedState: parent.derivedState,
|
|
1518
|
+
// Whitespace-only collapses to null so the card can tell "GitHub no longer returns this
|
|
1519
|
+
// text" apart from a body it simply hasn't been handed. Reachable because bodies are
|
|
1520
|
+
// re-upserted every walk while labels are never re-scored — see the hydration note above.
|
|
1521
|
+
body: parent.body && parent.body.trim() !== '' ? parent.body : null,
|
|
1522
|
+
createdAt: parent.createdAt.toISOString(),
|
|
1523
|
+
// Inline, never a per-card fetch: this list spans many PRs, so the per-PR
|
|
1524
|
+
// ['ml-labels', prId] index could not serve it (the ThreadAssessment
|
|
1525
|
+
// 60-requests-for-60-empty-boxes rule). Severity and categories come from the FOLD, so the
|
|
1526
|
+
// badge and the bucket that admitted the row can never disagree.
|
|
1527
|
+
mlLabel: {
|
|
1528
|
+
targetKind: row.targetKind,
|
|
1529
|
+
targetId: row.targetId,
|
|
1530
|
+
severity: fold.severity,
|
|
1531
|
+
severityOrd: row.severityOrd,
|
|
1532
|
+
severityProb: row.severityProb,
|
|
1533
|
+
vendorSeverity: coerceSeverity(row.vendorSeverity),
|
|
1534
|
+
vendorSeverityConfidence: coerceVendorConfidence(row.vendorSeverityConfidence),
|
|
1535
|
+
categories: fold.categories,
|
|
1536
|
+
isSummary: row.isSummary,
|
|
1537
|
+
backend: row.backend,
|
|
1538
|
+
modelVersion: row.modelVersion,
|
|
1539
|
+
createdAt: row.createdAt.toISOString(),
|
|
1540
|
+
},
|
|
1541
|
+
authorUserId: row.authorUserId,
|
|
1542
|
+
authorLogin: loginById.get(row.authorUserId) ?? null,
|
|
1543
|
+
authorLabel: reviewerLabel(row.authorUserId, kind),
|
|
1544
|
+
authorKind: kind,
|
|
1545
|
+
line: parent.line,
|
|
1546
|
+
// The PR, deliberately not a per-comment permalink: `review_threads` has no `url` column
|
|
1547
|
+
// and the numeric REST comment id is not stored, so a fabricated anchor would 404.
|
|
1548
|
+
prUrl: `https://github.com/${parent.repoFullName}/pull/${parent.prNumber}`,
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
return items;
|
|
1552
|
+
}
|
|
1553
|
+
export async function foldBotFlaggingPopulation(accountId, selector, refine, window,
|
|
1554
|
+
// ⚠ The SAME BotScope the strip was computed at — this population reproduces one of that
|
|
1555
|
+
// strip's tiles, so it takes the identical workspace + repo narrowing. `repoIds: []` is a real
|
|
1556
|
+
// empty workspace, never "widen to the account".
|
|
1557
|
+
scope) {
|
|
1558
|
+
const nowMs = Date.now();
|
|
1559
|
+
const to = new Date(nowMs);
|
|
1560
|
+
// The one shared window→duration mapping (db/bot-window.ts) — the same `from` getBotAnalytics
|
|
1561
|
+
// hands getMlWindowAggregates, which is what makes the two scans the same scan.
|
|
1562
|
+
const from = new Date(nowMs - botWindowMs(window));
|
|
1563
|
+
const win = { kind: window, from: from.toISOString(), to: to.toISOString() };
|
|
1564
|
+
const generatedAt = to.toISOString();
|
|
1565
|
+
const empty = {
|
|
1566
|
+
win,
|
|
1567
|
+
generatedAt,
|
|
1568
|
+
selected: [],
|
|
1569
|
+
narrowed: [],
|
|
1570
|
+
truncated: false,
|
|
1571
|
+
};
|
|
1572
|
+
if (scope.repoIds.length === 0)
|
|
1573
|
+
return empty;
|
|
1574
|
+
// `'all'`, not `'review'` — the strip counts quality checks too, and a drill-down that narrowed
|
|
1575
|
+
// the bot set would list fewer rows than the tile it opened from.
|
|
1576
|
+
const automatedIds = await automatedReviewerUserIds(accountId, scope.workspaceId, 'all');
|
|
1577
|
+
if (automatedIds.length === 0)
|
|
1578
|
+
return empty;
|
|
1579
|
+
// ── Phase 1: the population scan ─────────────────────────────────────────────────────────
|
|
1580
|
+
// ⚠ WHERE / ORDER BY / LIMIT are byte-identical to getMlWindowAggregates' scan; ONLY the select
|
|
1581
|
+
// list widens. That identity — same rows, same order, same cap — plus the shared fold below is
|
|
1582
|
+
// the entire reason `total` equals the tile rather than approximating it.
|
|
1583
|
+
const labelRows = await db
|
|
1584
|
+
.select({
|
|
1585
|
+
// What the fold consumes …
|
|
1586
|
+
authorUserId: mlCommentLabels.authorUserId,
|
|
1587
|
+
severity: mlCommentLabels.severity,
|
|
1588
|
+
isSummary: mlCommentLabels.isSummary,
|
|
1589
|
+
categories: mlCommentLabels.categories,
|
|
1590
|
+
backend: mlCommentLabels.backend,
|
|
1591
|
+
// … and what a CARD needs on top of it.
|
|
1592
|
+
id: mlCommentLabels.id,
|
|
1593
|
+
targetKind: mlCommentLabels.targetKind,
|
|
1594
|
+
targetId: mlCommentLabels.targetId,
|
|
1595
|
+
severityOrd: mlCommentLabels.severityOrd,
|
|
1596
|
+
severityProb: mlCommentLabels.severityProb,
|
|
1597
|
+
vendorSeverity: mlCommentLabels.vendorSeverity,
|
|
1598
|
+
vendorSeverityConfidence: mlCommentLabels.vendorSeverityConfidence,
|
|
1599
|
+
modelVersion: mlCommentLabels.modelVersion,
|
|
1600
|
+
createdAt: mlCommentLabels.createdAt,
|
|
1601
|
+
})
|
|
1602
|
+
.from(mlCommentLabels)
|
|
1603
|
+
.where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds), gte(mlCommentLabels.targetCreatedAt, from)))
|
|
1604
|
+
.orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
|
|
1605
|
+
.limit(ROLLUP_SCAN_CAP)
|
|
1606
|
+
.execute();
|
|
1607
|
+
// ── Phase 2: the shared fold, then the selector predicate ────────────────────────────────
|
|
1608
|
+
const selected = [];
|
|
1609
|
+
for (const row of labelRows) {
|
|
1610
|
+
const fold = foldMlLabelRow(row);
|
|
1611
|
+
if (!fold)
|
|
1612
|
+
continue;
|
|
1613
|
+
if (!matchesFlaggingSelector(selector, fold))
|
|
1614
|
+
continue;
|
|
1615
|
+
selected.push({ row, fold });
|
|
1616
|
+
}
|
|
1617
|
+
// ── Phase 4: refine (phase 3, the matrix, is response-only — the caller builds it) ───────
|
|
1618
|
+
const narrowed = selected.filter(({ row, fold }) => matchesFlaggingRefine(refine, coerceSeverity(row.vendorSeverity), fold.severity, row.authorUserId));
|
|
1619
|
+
return {
|
|
1620
|
+
win,
|
|
1621
|
+
generatedAt,
|
|
1622
|
+
selected,
|
|
1623
|
+
narrowed,
|
|
1624
|
+
// The same honesty rule as ROLLUP_SCAN_CAP everywhere else: a capped scan says so rather than
|
|
1625
|
+
// presenting a most-recent sample as a total.
|
|
1626
|
+
truncated: labelRows.length >= ROLLUP_SCAN_CAP,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
export async function getBotFlaggingComments(accountId, selector, refine, window,
|
|
1630
|
+
// ⚠ The SAME BotScope the strip was computed at — see foldBotFlaggingPopulation.
|
|
1631
|
+
scope, page) {
|
|
1632
|
+
const offset = Math.max(0, Math.trunc(page.offset));
|
|
1633
|
+
const limit = Math.max(1, Math.min(Math.trunc(page.limit), FLAGGING_PAGE_MAX));
|
|
1634
|
+
// ── Phases 1–2 + 4: the ONE population fold (shared with the synthesis input) ────────────
|
|
1635
|
+
const pop = await foldBotFlaggingPopulation(accountId, selector, refine, window, scope);
|
|
1636
|
+
const { selected, narrowed } = pop;
|
|
1637
|
+
// ── Phase 3: the matrix, over the SELECTOR population and PRE-refine ─────────────────────
|
|
1638
|
+
// Computed before the refinement so clicking a cell cannot zero out the cell that was clicked
|
|
1639
|
+
// — the commentFacetCounts / ConsolidatedFeedResponse.counts rule.
|
|
1640
|
+
const matrix = selected.length === 0
|
|
1641
|
+
? emptyAgreementMatrix()
|
|
1642
|
+
: buildAgreementMatrix(selected.map(({ row, fold }) => ({
|
|
1643
|
+
vendor: coerceSeverity(row.vendorSeverity),
|
|
1644
|
+
ours: fold.severity,
|
|
1645
|
+
})));
|
|
1646
|
+
const pageRows = narrowed.slice(offset, offset + limit);
|
|
1647
|
+
const consumed = offset + pageRows.length;
|
|
1648
|
+
// ── Phase 5: hydrate the page only ───────────────────────────────────────────────────────
|
|
1649
|
+
const items = await hydrateFlaggingPage(accountId, scope, pageRows);
|
|
1650
|
+
return {
|
|
1651
|
+
kind: 'comments',
|
|
1652
|
+
workspaceId: scope.workspaceId,
|
|
1653
|
+
window: pop.win,
|
|
1654
|
+
selector,
|
|
1655
|
+
refine,
|
|
1656
|
+
total: selected.length,
|
|
1657
|
+
filteredTotal: narrowed.length,
|
|
1658
|
+
matrix,
|
|
1659
|
+
items,
|
|
1660
|
+
// Opaque on the wire; `o:<n>` is today's encoding of "offset into the folded population".
|
|
1661
|
+
nextCursor: consumed < narrowed.length ? `o:${consumed}` : null,
|
|
1662
|
+
truncated: pop.truncated,
|
|
1663
|
+
generatedAt: pop.generatedAt,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
//# sourceMappingURL=ml-labels.js.map
|