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,1289 @@
|
|
|
1
|
+
import { getBotFlaggingComments, getBotVendorComments } from '../../db/ml-labels.js';
|
|
2
|
+
import { getBotOverlapClusters } from '../../db/bot-overlap.js';
|
|
3
|
+
import { getBotVolume, getBotVolumeScatter, getPrBotVolume } from '../../db/bot-volume.js';
|
|
4
|
+
import { getBotAnalytics, getBotOnlyPrs, getBotVendorPrs, getBotDedupClusters, getResolvableBotThreadPrs, getResolvableBotThreadsForScope, listDetectedReviewers, resetWorkspaceReviewerIdentity, resetWorkspaceReviewerJudgement, resolveWorkspaceScope, setReviewerCost, setWorkspaceReviewer, SCOPE_RESOLVE_THREAD_CAP, } from '../../db/queries.js';
|
|
5
|
+
import { resolveThreadsOnGitHub } from '../../bot-triage/resolve.js';
|
|
6
|
+
import { getAutomationOutput } from '../../db/automation-output.js';
|
|
7
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
8
|
+
import { entitledProCapabilities } from '../../pro/contract.js';
|
|
9
|
+
// Parse a comma-separated id list into a positive-int array, or null when empty/absent (so
|
|
10
|
+
// `resolveWorkspaceScope` treats it as "no narrowing — every repo in the workspace"). Mirrors the
|
|
11
|
+
// parser in activity.ts.
|
|
12
|
+
//
|
|
13
|
+
// ⚠ ITS RESULT IS NEVER A SCOPE. It is the `narrow` argument to `resolveWorkspaceScope`, which
|
|
14
|
+
// INTERSECTS it with the workspace's membership. A handler that passed this list straight to a
|
|
15
|
+
// getter would let `?workspace=5&repoIds=<a repo of workspace 9>` measure one workspace's data
|
|
16
|
+
// through another's verdicts, and make the listing's lazy classifier write workspace-5 rows for
|
|
17
|
+
// actors with zero footprint there.
|
|
18
|
+
function parseIntList(raw) {
|
|
19
|
+
if (raw == null || raw.trim() === '')
|
|
20
|
+
return null;
|
|
21
|
+
const ids = raw
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
24
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
25
|
+
return ids.length > 0 ? ids : null;
|
|
26
|
+
}
|
|
27
|
+
/* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
ENTITLEMENT — WHO MAY READ THE ROI POPULATION
|
|
29
|
+
─────────────────────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
The Bot-ROI panel went PAID (`botDepth`) — the whole panel, not just its cost columns: the
|
|
32
|
+
per-bot signal-to-noise table, the verdicts, the ML flagging strip, the volume column and every
|
|
33
|
+
drill-down hanging off them. Which means the analytics ROUTES that feed it need the gate too; a
|
|
34
|
+
client-side `if (!botDepth) return <lock/>` is a rendering decision, not a monetisation gate, and
|
|
35
|
+
leaves the whole table one `curl` away (which is exactly the state /volume/scatter was in — its
|
|
36
|
+
only consumer was capability-gated, its route was not).
|
|
37
|
+
|
|
38
|
+
TWO PREDICATES, because two different questions get asked of this file:
|
|
39
|
+
|
|
40
|
+
• `botDepthEntitled` — "may this account see the ROI panel's own data?" Used by the routes
|
|
41
|
+
whose ENTIRE population is that panel: the volume family, the flagging drill-down, the
|
|
42
|
+
per-vendor PR list, and the seat PRICES on the reviewer listing.
|
|
43
|
+
|
|
44
|
+
• `botAnalyticsEntitled` — the UNION `botDepth || periodReports`, for the THREE routes the ROI
|
|
45
|
+
panel SHARES with the People report's bot sections (`GET /api/bot-analytics`,
|
|
46
|
+
`…/vendor/:key/comments` and `GET /api/bot-authoring`, all three read by PeopleReportDetail).
|
|
47
|
+
Gating those on `botDepth` alone would dark a surface someone had already bought under a
|
|
48
|
+
different capability. They are genuinely two products over one fold, so the predicate says
|
|
49
|
+
so rather than pretending one flag covers both.
|
|
50
|
+
|
|
51
|
+
⚠ ALL THREE UNION ROUTES MUST USE **THE SAME PREDICATE**, not "the union where it was convenient".
|
|
52
|
+
`/api/bot-authoring` shipped ungated in the first cut of this change precisely because it does
|
|
53
|
+
not live under `/api/pro/` and reads as a plain core route — and it is the People report's bot
|
|
54
|
+
AUTHORING vector plus its evidence cards, i.e. exactly the paid population the locked pane
|
|
55
|
+
promises. A free cloud account could collect bot user ids from the (deliberately free) reviewer
|
|
56
|
+
listing and read the whole vector by URL. If a fourth surface ever shares this fold, add it here
|
|
57
|
+
rather than inventing a third predicate.
|
|
58
|
+
|
|
59
|
+
⚠ WHAT IS **NOT** GATED, and must not be swept in by a later "finish the job" pass: the reviewer
|
|
60
|
+
LISTING itself (`GET /api/bot-reviewers` is the app-wide bot identity/colour backbone and the
|
|
61
|
+
free classification screen — its price COLUMNS are stripped, the rows are not withheld),
|
|
62
|
+
`…/bot-only-prs` (the list behind the FREE governance caution — its caption and its list must
|
|
63
|
+
agree, and the caption is outside the panel), the resolvable-thread read/resolve pair, and
|
|
64
|
+
per-PR dedup. Those are `botTriage`-free surfaces.
|
|
65
|
+
|
|
66
|
+
⚠ `!req.account` READS AS UNENTITLED, matching the cost route's long-standing shape. In the real
|
|
67
|
+
app the account is always populated (local synthesizes one; cloud 401s first), so this only ever
|
|
68
|
+
fires in a bare test harness — where "no account" genuinely is "no entitlement".
|
|
69
|
+
|
|
70
|
+
⚠ A TEST HARNESS NEEDS **TWO** THINGS TO REACH A GATED ROUTE, and registering the auth plugin is
|
|
71
|
+
only the first. `entitledProCapabilities` intersects the account with the LOADED PLUGIN's live
|
|
72
|
+
capability singleton, which is `EMPTY_CAPABILITIES` in any process that never bound a plugin — so
|
|
73
|
+
even a synthesized `isLocal` account is legitimately unentitled. A test that wants a 200 must
|
|
74
|
+
also `setProCapabilities({...})` (pro/contract.ts) in its `beforeAll`. */
|
|
75
|
+
function botDepthEntitled(req) {
|
|
76
|
+
return Boolean(req.account && entitledProCapabilities(req.account).botDepth);
|
|
77
|
+
}
|
|
78
|
+
function botAnalyticsEntitled(req) {
|
|
79
|
+
if (!req.account)
|
|
80
|
+
return false;
|
|
81
|
+
const caps = entitledProCapabilities(req.account);
|
|
82
|
+
return caps.botDepth || caps.periodReports;
|
|
83
|
+
}
|
|
84
|
+
// The 402 body every gate here answers with — the same shape the `/api/pro/*` blanket gate sends
|
|
85
|
+
// (api/plugins/auth.ts), so a client cannot tell a core paid route from a plugin one.
|
|
86
|
+
const PRO_REQUIRED = { error: 'pro required' };
|
|
87
|
+
/* THE PRICE STRIP — the ONE spelling, applied by EVERY handler that returns a reviewer row.
|
|
88
|
+
*
|
|
89
|
+
* `PUT …/cost` is the only route that WRITES a price, and it is 402'd without `botDepth`. But four
|
|
90
|
+
* routes READ one back: the listing, the PATCH, and both resets. Stripping only the listing left
|
|
91
|
+
* three doors open — a downgraded account could still read every seat price it had entered by
|
|
92
|
+
* calling `DELETE /api/bot-reviewers/:id/identity` (a free route, no body, echoes the whole row).
|
|
93
|
+
*
|
|
94
|
+
* ⚠ THE STRIPPED SHAPE IS EXACTLY A NEVER-PRICED ROW, `costModel: 'flat'` included
|
|
95
|
+
* (`WorkspaceReviewer` documents "a row with no price is always 'flat'"), so nothing downstream has
|
|
96
|
+
* to learn a fourth cost state. The consequence — a `null` price now means EITHER "no price set"
|
|
97
|
+
* OR "you may not see it" — is documented on the field in packages/shared: decide which from
|
|
98
|
+
* `/api/me`'s capability, never from the value.
|
|
99
|
+
*
|
|
100
|
+
* ⚠ THE GDPR EXPORT IS A DELIBERATE EXEMPTION, not a hole this missed. `GET /api/me/export`
|
|
101
|
+
* (db/export-account.ts) still carries `workspace_reviewers.monthly_cents`: it is data the subject
|
|
102
|
+
* typed in, and Art. 15/20 access/portability are about the subject's own data, not about a
|
|
103
|
+
* feature tier. That is a considered split — see the note in export-account.ts's column list. */
|
|
104
|
+
function stripCost(r) {
|
|
105
|
+
return { ...r, costMonthlyUsd: null, costModel: 'flat', effectiveMonthlyUsd: null };
|
|
106
|
+
}
|
|
107
|
+
// The explicit-bounds span cap — mirrors the synthesis person grain's PERSON_WINDOW_MAX_MS
|
|
108
|
+
// (packages/pro/src/synthesis/scope.ts; a plugin constant core cannot import): far past any real
|
|
109
|
+
// reporting period, tight enough that one request can never name a decade of rows.
|
|
110
|
+
const WINDOW_BOUNDS_MAX_SPAN_MS = 200 * 86_400_000;
|
|
111
|
+
// Resolve the optional `?fromMs=&toMs=` pair into `getBotAnalytics`' widened window form
|
|
112
|
+
// (`{kind, fromMs, toMs}` — apiVersion 18; the enum alone when no bounds were sent). The pair
|
|
113
|
+
// names a POPULATION, so garbage 400s rather than degrading (`{error}` here → 400 in the
|
|
114
|
+
// handler): both-or-neither, `fromMs < toMs`, span ≤ the 200-day cap. The digits-only shape is
|
|
115
|
+
// already ajv-enforced by the route schemas; bounds only narrow WHAT IS MEASURED inside the
|
|
116
|
+
// account's own resolved scope — they carry no authority, exactly like the enum they refine.
|
|
117
|
+
function parseWindowBounds(window, fromMs, toMs) {
|
|
118
|
+
if (fromMs == null && toMs == null)
|
|
119
|
+
return { window };
|
|
120
|
+
if (fromMs == null || toMs == null) {
|
|
121
|
+
return { error: '`fromMs` and `toMs` are only valid together' };
|
|
122
|
+
}
|
|
123
|
+
const from = Number(fromMs);
|
|
124
|
+
const to = Number(toMs);
|
|
125
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) {
|
|
126
|
+
return { error: '`fromMs`/`toMs` must be epoch-ms integers' };
|
|
127
|
+
}
|
|
128
|
+
if (from >= to)
|
|
129
|
+
return { error: '`fromMs` must be earlier than `toMs`' };
|
|
130
|
+
if (to - from > WINDOW_BOUNDS_MAX_SPAN_MS) {
|
|
131
|
+
return { error: '`fromMs`/`toMs` span too large (max 200 days)' };
|
|
132
|
+
}
|
|
133
|
+
return { window: { kind: window, fromMs: from, toMs: to } };
|
|
134
|
+
}
|
|
135
|
+
// ── THE SCOPE PARAMETER ─────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Every read route here takes `?workspace=<integer>` (absent / unknown / unparseable / another
|
|
137
|
+
// tenant's id ⇒ the account's Default workspace — NOT a 404: every id yields the same response
|
|
138
|
+
// shape, so it is not an existence oracle, the resolved id is always one the caller owns, and a
|
|
139
|
+
// stale bookmark degrades to something renderable instead of a blank screen). `?repoIds=<csv>`
|
|
140
|
+
// survives alongside it and still narrows the DATA — it no longer competes with the scope for the
|
|
141
|
+
// VERDICT, because the workspace owns that outright.
|
|
142
|
+
//
|
|
143
|
+
// The wire type is `string`, not `integer`, on purpose: an ajv `integer` would 400 on a garbage
|
|
144
|
+
// `?workspace=`, and the contract above is that garbage degrades to Default rather than erroring.
|
|
145
|
+
//
|
|
146
|
+
// ── THE WRITE SURFACE: TWO ROUTES, SPLIT BY MUTABILITY (NOT BY GRAIN) ───────────────────────
|
|
147
|
+
// There is ONE grain now — a `workspace_reviewers` row, keyed (account, workspace, actor) — so the
|
|
148
|
+
// grain mismatch the old three-route split defended against cannot occur. What survives is a
|
|
149
|
+
// MUTABILITY difference:
|
|
150
|
+
//
|
|
151
|
+
// PATCH /api/bot-reviewers/:userId WorkspaceReviewerPatchBody automated/role/kind/label
|
|
152
|
+
// PUT /api/bot-reviewers/:userId/cost ReviewerCostBody monthly_cents
|
|
153
|
+
//
|
|
154
|
+
// `automated`, `role`, `kind` and `label` are all RE-DERIVABLE: a wrong write is repaired by the
|
|
155
|
+
// next classification pass or by a reset, so they belong in one body keyed by two INDEPENDENT
|
|
156
|
+
// provenance flags. `monthly_cents` is derivable by nothing and is money. Keeping it on its own
|
|
157
|
+
// PUT means NO COMBINED BODY CAN ADDRESS THE COLUMN AT ALL, and the PATCH handler's `set:` object
|
|
158
|
+
// contains no cost key. That is the same structural guarantee the old two-table split provided,
|
|
159
|
+
// preserved with one fewer table — and `additionalProperties:false` is NOT what provides it
|
|
160
|
+
// (Fastify's ajv runs `removeAdditional:true`, so unknown keys are STRIPPED, not rejected). What
|
|
161
|
+
// provides it is that each handler calls exactly ONE query-layer function with a narrow `set:`.
|
|
162
|
+
//
|
|
163
|
+
// …plus TWO RESETS, one per PROVENANCE FLAG:
|
|
164
|
+
//
|
|
165
|
+
// DELETE /api/bot-reviewers/:userId/judgement?workspaceId= automated/role back to auto
|
|
166
|
+
// DELETE /api/bot-reviewers/:userId/identity?workspaceId= kind/label back to auto, PRICE KEPT
|
|
167
|
+
//
|
|
168
|
+
// ⚠ THE RESETS ARE NOT OPTIONAL POLISH. A manual write pins its half against re-derivation, and
|
|
169
|
+
// flipping the value back by hand leaves it just as pinned — so without a way back to auto, every
|
|
170
|
+
// edit here is permanent. That is also what makes the role-only patch's `source:'manual'` stamp
|
|
171
|
+
// (which pins `automated` for that workspace as a side effect) the right trade rather than a trap:
|
|
172
|
+
// the pin is visible AND undoable in the same place. `WorkspaceReviewer.isManualOverride` and
|
|
173
|
+
// `.identitySource` exist to drive exactly these two affordances.
|
|
174
|
+
//
|
|
175
|
+
// ⚠ BOTH RESETS ARE AN UPDATE + IMMEDIATE RE-DERIVE, NOT A ROW DELETE, and both answer 200 with
|
|
176
|
+
// the re-derived row. The old per-repo judgement reset DELETED its row (the row held nothing else,
|
|
177
|
+
// and the listing re-derived a missing one), so there was nothing to echo and it answered 204.
|
|
178
|
+
// This row also carries the identity AND the price, so a delete is lossy.
|
|
179
|
+
//
|
|
180
|
+
// ⚠ EVERY WRITE ON THIS SURFACE IS WORKSPACE-WIDE. The old per-repo PATCH could honestly promise
|
|
181
|
+
// "this leaves your other repos alone"; nothing here can. A control rendered in a repo-shaped
|
|
182
|
+
// context must say so in its copy.
|
|
183
|
+
//
|
|
184
|
+
// ⚠ READS DEGRADE, WRITES 404. A write names the row it edits, so an unowned or unknown
|
|
185
|
+
// `workspaceId` must not silently land in Default — the query layer's ownership gate returns null
|
|
186
|
+
// and the handler 404s. Only the read routes fall back, and only because their answer is shaped
|
|
187
|
+
// identically whatever the id was.
|
|
188
|
+
// PATCH /api/bot-reviewers/:userId — the four re-derivable fields of ONE workspace_reviewers row.
|
|
189
|
+
//
|
|
190
|
+
// `workspaceId` is REQUIRED and rides in the BODY: the row IS the object, and the workspace is
|
|
191
|
+
// part of its key, not a filter over it. The query layer verifies the workspace belongs to the
|
|
192
|
+
// caller and 404s otherwise (the composite FK `(workspace_id, account_id) → workspaces(id,
|
|
193
|
+
// account_id)` would also reject a cross-tenant write, but a constraint violation is a 500 and
|
|
194
|
+
// this must be a 404).
|
|
195
|
+
//
|
|
196
|
+
// `role` IS a closed 2-value set, so it is enumerated (an unknown role would land in a NOT NULL
|
|
197
|
+
// column the metric split branches on). `kind` is left an open nullable string: the
|
|
198
|
+
// `AutomatedReviewerKind` union lives in the types-only shared package the backend cannot import
|
|
199
|
+
// at runtime, and the query layer coerces an unknown kind, so pinning the vendor list here would
|
|
200
|
+
// only add drift. All four value fields are OPTIONAL — absent means "leave the stored value
|
|
201
|
+
// alone", so an old client that only knows `automated` cannot silently un-mark a quality check —
|
|
202
|
+
// but the handler rejects a body carrying NONE of them.
|
|
203
|
+
//
|
|
204
|
+
// ⚠ THERE IS NO `monthlyUsd` HERE, and that is the structural half of the cost guarantee: the
|
|
205
|
+
// handler calls `setWorkspaceReviewer`, whose `set:` object has no `monthlyCents` key.
|
|
206
|
+
const patchSchema = {
|
|
207
|
+
params: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
required: ['userId'],
|
|
210
|
+
properties: { userId: { type: 'integer' } },
|
|
211
|
+
},
|
|
212
|
+
body: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
required: ['workspaceId'],
|
|
216
|
+
properties: {
|
|
217
|
+
workspaceId: { type: 'integer', minimum: 1 },
|
|
218
|
+
automated: { type: 'boolean' },
|
|
219
|
+
// Must stay in lockstep with `ReviewerRole` in shared — the schema is the only validation
|
|
220
|
+
// this field gets, and an unlisted role is rejected with a 400 rather than stored.
|
|
221
|
+
role: {
|
|
222
|
+
type: 'string',
|
|
223
|
+
enum: ['review', 'quality_check', 'dependency', 'code_agent', 'release', 'housekeeping'],
|
|
224
|
+
},
|
|
225
|
+
kind: { type: ['string', 'null'] },
|
|
226
|
+
label: { type: ['string', 'null'] },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
// DELETE /api/bot-reviewers/:userId/judgement?workspaceId= — hand automated/role/confidence/
|
|
231
|
+
// reasons back to detection for ONE workspace, and re-derive in the same request.
|
|
232
|
+
//
|
|
233
|
+
// `workspaceId` rides in the QUERY STRING rather than a body: a DELETE body is stripped by enough
|
|
234
|
+
// intermediaries (and by some fetch implementations) that it is not a place to put a required
|
|
235
|
+
// field. It is required for the same reason it is required on the PATCH — the row is the object.
|
|
236
|
+
const judgementResetSchema = {
|
|
237
|
+
params: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
required: ['userId'],
|
|
240
|
+
properties: { userId: { type: 'integer' } },
|
|
241
|
+
},
|
|
242
|
+
querystring: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
required: ['workspaceId'],
|
|
246
|
+
properties: { workspaceId: { type: 'integer', minimum: 1 } },
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
// DELETE /api/bot-reviewers/:userId/identity?workspaceId= — clear kind/label, `identitySource`
|
|
250
|
+
// back to 'auto', re-derive immediately. Same shape and same reasoning as the judgement reset
|
|
251
|
+
// (spelled out rather than aliased so the two can diverge without a shared-object surprise).
|
|
252
|
+
const identityResetSchema = {
|
|
253
|
+
params: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
required: ['userId'],
|
|
256
|
+
properties: { userId: { type: 'integer' } },
|
|
257
|
+
},
|
|
258
|
+
querystring: {
|
|
259
|
+
type: 'object',
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
required: ['workspaceId'],
|
|
262
|
+
properties: { workspaceId: { type: 'integer', minimum: 1 } },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
// PUT /api/bot-reviewers/:userId/cost — what this bot costs IN THIS WORKSPACE.
|
|
266
|
+
//
|
|
267
|
+
// `monthlyUsd` is REQUIRED and NULLABLE precisely so `undefined` is not a third meaning: a number
|
|
268
|
+
// sets the price (0 is real — "we pay nothing"), null clears it. Clearing is a COLUMN write, never
|
|
269
|
+
// a row delete: the row also carries the judgement and the identity.
|
|
270
|
+
//
|
|
271
|
+
// ⚠ THE BOUNDS ARE NOT COSMETIC. Storage is int4 CENTS in both dialects, and 21474836.47 is where
|
|
272
|
+
// the dialects STOP AGREEING: Postgres RAISES `integer out of range` (a 500) above it while
|
|
273
|
+
// SQLite's 64-bit integers accept the value happily — so an unbounded field means the same
|
|
274
|
+
// request succeeds locally and 500s in cloud, leaving a number cloud can never represent.
|
|
275
|
+
// (Measured: monthlyUsd 99999999999 stored 2147483647 on pg and 9999999999900 on sqlite.) ajv
|
|
276
|
+
// 400s out-of-range here; the query layer ALSO clamps, as the backstop for every other caller.
|
|
277
|
+
//
|
|
278
|
+
// `multipleOf: 0.01` rejects a fractional-cent price like $1.005, which nothing downstream can
|
|
279
|
+
// print and which the two rounding paths were measured disagreeing about. (Non-finite values
|
|
280
|
+
// cannot survive JSON.parse, and `type: number` rejects them anyway.)
|
|
281
|
+
//
|
|
282
|
+
// `costModel` is OPTIONAL (omitted ⇒ 'flat') and rides THIS body because it changes what the
|
|
283
|
+
// number MEANS: 'per_seat' makes `monthlyUsd` a per-seat unit price, multiplied on read by the
|
|
284
|
+
// workspace's derived seat count. It is money the same way the number is, so it must never appear
|
|
285
|
+
// in the PATCH body — the no-combined-body guarantee covers both columns or neither. A CLEAR
|
|
286
|
+
// (`monthlyUsd: null`) resets the stored model to 'flat' server-side regardless of what rides
|
|
287
|
+
// along.
|
|
288
|
+
const costSchema = {
|
|
289
|
+
params: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
required: ['userId'],
|
|
292
|
+
properties: { userId: { type: 'integer' } },
|
|
293
|
+
},
|
|
294
|
+
body: {
|
|
295
|
+
type: 'object',
|
|
296
|
+
additionalProperties: false,
|
|
297
|
+
required: ['workspaceId', 'monthlyUsd'],
|
|
298
|
+
properties: {
|
|
299
|
+
workspaceId: { type: 'integer', minimum: 1 },
|
|
300
|
+
monthlyUsd: {
|
|
301
|
+
type: ['number', 'null'],
|
|
302
|
+
minimum: 0,
|
|
303
|
+
maximum: 21474836.47,
|
|
304
|
+
multipleOf: 0.01,
|
|
305
|
+
},
|
|
306
|
+
costModel: { type: 'string', enum: ['flat', 'per_seat'] },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
// GET /api/bot-reviewers?workspace=&repoIds= — the bot listing for ONE workspace. `workspace`
|
|
311
|
+
// decides the VERDICT; `repoIds` narrows which repos' footprints are displayed. They can no longer
|
|
312
|
+
// disagree, because they answer different questions — and the narrowing is bounded by the
|
|
313
|
+
// workspace's membership inside `resolveWorkspaceScope`, so it can never reach outside the scope.
|
|
314
|
+
const listReviewersSchema = {
|
|
315
|
+
querystring: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
additionalProperties: false,
|
|
318
|
+
properties: {
|
|
319
|
+
workspace: { type: 'string' },
|
|
320
|
+
repoIds: { type: 'string' },
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
// GET /api/bot-analytics?window=&workspace=&repoIds=[&fromMs=&toMs=] — the window is a closed
|
|
325
|
+
// 4-value set, safe to enum + default; the optional bounds pair refines it to a REAL period (the
|
|
326
|
+
// People report's bot sections) and is validated by `parseWindowBounds` (only-together, ordered,
|
|
327
|
+
// span-capped ⇒ 400 — a bad bound is a client bug, not a stale bookmark).
|
|
328
|
+
// GET /api/bot-authoring — the AUTHORING half of "what did this automation do" (plan follow-up).
|
|
329
|
+
// Bounds are REQUIRED here, unlike the two routes above: this endpoint exists to serve the People
|
|
330
|
+
// report's bot sections, which always know their period, and a vector whose window silently
|
|
331
|
+
// defaulted to a rolling enum would be captioned with the report's dates while measuring
|
|
332
|
+
// something else. Same digits-only ajv shape and the same `parseWindowBounds` rules otherwise.
|
|
333
|
+
const authoringSchema = {
|
|
334
|
+
querystring: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
additionalProperties: false,
|
|
337
|
+
required: ['userId', 'fromMs', 'toMs'],
|
|
338
|
+
properties: {
|
|
339
|
+
userId: { type: 'string', pattern: '^[0-9]+$' },
|
|
340
|
+
workspace: { type: 'string' },
|
|
341
|
+
repoIds: { type: 'string' },
|
|
342
|
+
fromMs: { type: 'string', pattern: '^[0-9]+$' },
|
|
343
|
+
toMs: { type: 'string', pattern: '^[0-9]+$' },
|
|
344
|
+
// `1` adds the receipt rows under the vector, mirroring the person route's `?evidence=1`.
|
|
345
|
+
evidence: { type: 'string' },
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
const analyticsSchema = {
|
|
350
|
+
querystring: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
additionalProperties: false,
|
|
353
|
+
properties: {
|
|
354
|
+
window: {
|
|
355
|
+
type: 'string',
|
|
356
|
+
enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
|
|
357
|
+
default: 'rolling_14',
|
|
358
|
+
},
|
|
359
|
+
// The workspace whose verdicts these metrics are computed under. Absent/unknown ⇒ Default.
|
|
360
|
+
workspace: { type: 'string' },
|
|
361
|
+
// Optional DATA narrowing (comma-separated repo ids) — the per-repo Bots tab. It is
|
|
362
|
+
// intersected with the workspace's membership; it does NOT change who counts as a bot.
|
|
363
|
+
repoIds: { type: 'string' },
|
|
364
|
+
// Optional explicit bounds (epoch-ms integer strings, half-open [fromMs, toMs)) — they
|
|
365
|
+
// only narrow WHAT IS MEASURED inside the resolved scope, never who counts as a bot.
|
|
366
|
+
fromMs: { type: 'string', pattern: '^[0-9]+$' },
|
|
367
|
+
toMs: { type: 'string', pattern: '^[0-9]+$' },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
// GET /api/bot-analytics/vendor/:key/prs?window= — per-REVIEWER PR drill-down. `key` is the
|
|
372
|
+
// analytics-row identity: `u<userId>` (a single reviewer) or the 'pierre' sentinel; the handler
|
|
373
|
+
// parses it (anything else → 400). The window/workspace/repoIds resolution is identical to
|
|
374
|
+
// /api/bot-analytics, because this list must reproduce ONE of that panel's rows.
|
|
375
|
+
const vendorPrsSchema = {
|
|
376
|
+
params: {
|
|
377
|
+
type: 'object',
|
|
378
|
+
required: ['key'],
|
|
379
|
+
properties: { key: { type: 'string' } },
|
|
380
|
+
},
|
|
381
|
+
querystring: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
additionalProperties: false,
|
|
384
|
+
properties: {
|
|
385
|
+
window: {
|
|
386
|
+
type: 'string',
|
|
387
|
+
enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
|
|
388
|
+
default: 'rolling_14',
|
|
389
|
+
},
|
|
390
|
+
workspace: { type: 'string' },
|
|
391
|
+
repoIds: { type: 'string' },
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
// GET /api/bot-analytics/vendor/:key/comments?window=[&fromMs=&toMs=] — the per-REVIEWER
|
|
396
|
+
// COMMENTS drill-down. Same shape as vendorPrsSchema, spelled out rather than aliased so the two
|
|
397
|
+
// can diverge without a shared-object surprise (the reset-schema precedent above) — and it HAS
|
|
398
|
+
// diverged: this one also takes the optional explicit-bounds pair (the People report's per-bot
|
|
399
|
+
// evidence cards cover the real period), validated exactly like /api/bot-analytics's.
|
|
400
|
+
const vendorCommentsSchema = {
|
|
401
|
+
params: {
|
|
402
|
+
type: 'object',
|
|
403
|
+
required: ['key'],
|
|
404
|
+
properties: { key: { type: 'string' } },
|
|
405
|
+
},
|
|
406
|
+
querystring: {
|
|
407
|
+
type: 'object',
|
|
408
|
+
additionalProperties: false,
|
|
409
|
+
properties: {
|
|
410
|
+
window: {
|
|
411
|
+
type: 'string',
|
|
412
|
+
enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
|
|
413
|
+
default: 'rolling_14',
|
|
414
|
+
},
|
|
415
|
+
workspace: { type: 'string' },
|
|
416
|
+
repoIds: { type: 'string' },
|
|
417
|
+
fromMs: { type: 'string', pattern: '^[0-9]+$' },
|
|
418
|
+
toMs: { type: 'string', pattern: '^[0-9]+$' },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
// The two ML enums, spelled out LOCALLY rather than imported from the shared package.
|
|
423
|
+
//
|
|
424
|
+
// ⚠ `@pierre-review/shared` is TYPES-ONLY and is not a published runtime dependency — the
|
|
425
|
+
// release build greps `release/dist` and FAILS on a surviving value import — so this file cannot
|
|
426
|
+
// write `enum: [...ML_CATEGORIES]`. `db/ml-labels.ts` carries exactly the same accommodation (its
|
|
427
|
+
// own private `ML_CATEGORY_VALUES` / `SEVERITY_KEYS`). These lists must stay in step with the
|
|
428
|
+
// `MlCategory` / `MlSeverity` unions; drift shows up as a 400 on a class the model really emits,
|
|
429
|
+
// which is why the `MlCategory[]` / `MlSeverity[]` annotations are load-bearing — a value the
|
|
430
|
+
// union dropped stops compiling here.
|
|
431
|
+
const FLAGGING_CATEGORY_VALUES = [
|
|
432
|
+
'correctness_bug',
|
|
433
|
+
'security',
|
|
434
|
+
'performance',
|
|
435
|
+
'style_readability',
|
|
436
|
+
'maintainability_refactor',
|
|
437
|
+
'testing',
|
|
438
|
+
'documentation',
|
|
439
|
+
'nitpick',
|
|
440
|
+
'praise',
|
|
441
|
+
];
|
|
442
|
+
const FLAGGING_SEVERITY_VALUES = ['nit', 'minor', 'major', 'critical'];
|
|
443
|
+
// GET /api/bot-analytics/flagging?select=&…&window=&workspace=&repoIds=&limit=&cursor= — the
|
|
444
|
+
// drill-down behind EVERY tile and chip on the Bots rail's ML strip. One route, one discriminated
|
|
445
|
+
// `select`, because every arm names a population of the SAME windowed label scan the strip is
|
|
446
|
+
// folded from; six routes could not have guaranteed that.
|
|
447
|
+
//
|
|
448
|
+
// ⚠ `workspace` and `repoIds` are STRING, never integer — the degrade-to-Default contract at the
|
|
449
|
+
// top of this file: an ajv `integer` would 400 on a garbage `?workspace=`, and garbage must
|
|
450
|
+
// render the Default workspace rather than error. `limit` IS an integer, and that asymmetry is
|
|
451
|
+
// the point: a bad page size is a client bug, not a stale bookmark.
|
|
452
|
+
//
|
|
453
|
+
// ⚠ `additionalProperties:false` here STRIPS unknown keys rather than rejecting them (Fastify's
|
|
454
|
+
// ajv runs `removeAdditional:true`), so it is tidiness, not a guarantee. What bounds this route is
|
|
455
|
+
// that the handler builds its selector from the enumerated keys and hands the scope to exactly one
|
|
456
|
+
// getter.
|
|
457
|
+
const flaggingSchema = {
|
|
458
|
+
querystring: {
|
|
459
|
+
type: 'object',
|
|
460
|
+
additionalProperties: false,
|
|
461
|
+
required: ['select'],
|
|
462
|
+
properties: {
|
|
463
|
+
select: {
|
|
464
|
+
type: 'string',
|
|
465
|
+
enum: ['findings', 'summaries', 'severity', 'category', 'overlap'],
|
|
466
|
+
},
|
|
467
|
+
// CSV for `select=severity` (the High-severity tile sends `major,critical`). ajv cannot
|
|
468
|
+
// validate a CSV against an enum, so the handler parses it and 400s on anything unknown.
|
|
469
|
+
severities: { type: 'string' },
|
|
470
|
+
category: { type: 'string', enum: [...FLAGGING_CATEGORY_VALUES] },
|
|
471
|
+
// The confusion-matrix cell filter — a PAIR. 'none' is the vendor axis for "the bot
|
|
472
|
+
// declared no severity at all", which is a real column, not a missing value.
|
|
473
|
+
cellVendor: { type: 'string', enum: ['nit', 'minor', 'major', 'critical', 'none'] },
|
|
474
|
+
cellOurs: { type: 'string', enum: ['nit', 'minor', 'major', 'critical'] },
|
|
475
|
+
disagree: { type: 'string', enum: ['any', 'over', 'under'] },
|
|
476
|
+
// The per-bot narrowing, opened by the Behaviour tab's inflation index: a CSV of `users.id`s
|
|
477
|
+
// (the numbers behind `u<userId>` keys), NOT vendor key strings.
|
|
478
|
+
//
|
|
479
|
+
// ⚠ A LIST, because the card-level "View all N →" sums over the PANEL's bots (role
|
|
480
|
+
// `'review'`) while this route resolves role `'all'` — both deliberate — so only the exact
|
|
481
|
+
// id set can make the button's number and the list it opens agree by construction.
|
|
482
|
+
//
|
|
483
|
+
// ⚠ STRING + `parseIntList`, the `repoIds` precedent, and NOT an ajv array: a CSV is what
|
|
484
|
+
// every other id list on this surface is spelled as, and ajv cannot validate its members
|
|
485
|
+
// anyway. Bounds and validation therefore live in `parseIntList` (positive integers only,
|
|
486
|
+
// anything else dropped) exactly as they do for `repoIds`.
|
|
487
|
+
//
|
|
488
|
+
// ⚠ And it MUST be declared here at all: `additionalProperties: false` under Fastify's
|
|
489
|
+
// `removeAdditional: true` STRIPS an undeclared key and answers 200, so a client sending
|
|
490
|
+
// `authorUserIds` against a schema that never heard of it would list the WHOLE workspace
|
|
491
|
+
// under a caption promising a subset.
|
|
492
|
+
authorUserIds: { type: 'string' },
|
|
493
|
+
window: {
|
|
494
|
+
type: 'string',
|
|
495
|
+
enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
|
|
496
|
+
default: 'rolling_14',
|
|
497
|
+
},
|
|
498
|
+
workspace: { type: 'string' },
|
|
499
|
+
repoIds: { type: 'string' },
|
|
500
|
+
limit: { type: 'integer', default: 20, minimum: 1, maximum: 50 },
|
|
501
|
+
// OPAQUE server-minted state. Bounded so a hostile client cannot make the handler parse an
|
|
502
|
+
// arbitrarily long string; a malformed one degrades to the first page rather than 400ing.
|
|
503
|
+
cursor: { type: 'string', minLength: 1, maxLength: 64 },
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
// ── Bot comment VOLUME (PAID `botDepth`, deterministic — db/bot-volume.ts) ──────────────────
|
|
508
|
+
// Three routes over ONE base scan: the per-bot column, its PR drill-down, and the LOC chart. The
|
|
509
|
+
// FOLD stays core and capability-blind; the three handlers below carry the gate.
|
|
510
|
+
//
|
|
511
|
+
// ⚠ `workspace`/`repoIds` are STRING here for the same reason they are everywhere else on this
|
|
512
|
+
// surface — an ajv `integer` would 400 on a stale bookmark, and a stale bookmark must degrade to
|
|
513
|
+
// the Default workspace. `limit` IS an integer (a bad page size is a client bug, not a bookmark).
|
|
514
|
+
//
|
|
515
|
+
// ⚠ EVERY PARAMETER THESE HANDLERS READ MUST BE DECLARED HERE. Fastify runs ajv with
|
|
516
|
+
// `removeAdditional: true`, so an undeclared key is STRIPPED and the request answers 200 with the
|
|
517
|
+
// filter silently not applied — a `sort=ratio` the schema never heard of would return the
|
|
518
|
+
// comments-sorted list under a caption promising the other one.
|
|
519
|
+
const volumeScopeSchema = {
|
|
520
|
+
querystring: {
|
|
521
|
+
type: 'object',
|
|
522
|
+
additionalProperties: false,
|
|
523
|
+
properties: {
|
|
524
|
+
window: {
|
|
525
|
+
type: 'string',
|
|
526
|
+
enum: ['rolling_7', 'rolling_14', 'rolling_30', 'rolling_90', 'sprint'],
|
|
527
|
+
default: 'rolling_30',
|
|
528
|
+
},
|
|
529
|
+
workspace: { type: 'string' },
|
|
530
|
+
repoIds: { type: 'string' },
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
const volumePrsSchema = {
|
|
535
|
+
querystring: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
additionalProperties: false,
|
|
538
|
+
properties: {
|
|
539
|
+
window: {
|
|
540
|
+
type: 'string',
|
|
541
|
+
enum: ['rolling_7', 'rolling_14', 'rolling_30', 'rolling_90', 'sprint'],
|
|
542
|
+
default: 'rolling_30',
|
|
543
|
+
},
|
|
544
|
+
workspace: { type: 'string' },
|
|
545
|
+
repoIds: { type: 'string' },
|
|
546
|
+
// The per-bot narrowing. A CSV of `users.id`s, the `BotFlaggingRefine.authorUserIds`
|
|
547
|
+
// spelling verbatim — ONE convention on this surface, not two. Present-but-empty means NO
|
|
548
|
+
// bots (see the handler); only absence widens.
|
|
549
|
+
authorUserIds: { type: 'string' },
|
|
550
|
+
// ⚠ AN ENUM, so an unknown sort 400s rather than silently falling back to the default and
|
|
551
|
+
// presenting a comments-ranked list as a ratio-ranked one. The default is `'comments'` by
|
|
552
|
+
// product decision even though raw count mostly ranks by SIZE — see BotVolumePrSort.
|
|
553
|
+
sort: { type: 'string', enum: ['comments', 'ratio'], default: 'comments' },
|
|
554
|
+
limit: { type: 'integer', default: 20, minimum: 1, maximum: 50 },
|
|
555
|
+
// OPAQUE server-minted state, bounded so a hostile client cannot make the handler parse an
|
|
556
|
+
// arbitrarily long string. A malformed cursor degrades to the first page rather than 400ing.
|
|
557
|
+
cursor: { type: 'string', minLength: 1, maxLength: 64 },
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
const prIdParamSchema = {
|
|
562
|
+
params: {
|
|
563
|
+
type: 'object',
|
|
564
|
+
required: ['id'],
|
|
565
|
+
properties: { id: { type: 'integer' } },
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
// GET /api/bot-threads/resolvable?workspace=&repoIds= — the workspace-wide review list. Same
|
|
569
|
+
// scope resolution as the analytics routes.
|
|
570
|
+
const resolvableSchema = {
|
|
571
|
+
querystring: {
|
|
572
|
+
type: 'object',
|
|
573
|
+
additionalProperties: false,
|
|
574
|
+
properties: {
|
|
575
|
+
workspace: { type: 'string' },
|
|
576
|
+
repoIds: { type: 'string' },
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
// POST /api/bot-threads/resolve — the confirm-gated workspace-wide resolve. `threadIds` is the
|
|
581
|
+
// explicit reviewed list (required; [] is a legal no-op), capped at SCOPE_RESOLVE_THREAD_CAP per
|
|
582
|
+
// request (the client chunks a larger selection); `workspaceId` is the scope the server re-derives
|
|
583
|
+
// eligibility against.
|
|
584
|
+
//
|
|
585
|
+
// ⚠ `workspaceId` IS REQUIRED, and it is what makes "the listing and the resolve agree" structural
|
|
586
|
+
// rather than a convention. Its predecessor carried an optional `repoIds` while the listing was
|
|
587
|
+
// resolved from a TEAM scope, so a reviewer marked automated only under a per-team override had
|
|
588
|
+
// its threads offered by the listing and then found ineligible here — the route resolved 0 with no
|
|
589
|
+
// error anywhere. One workspace id on both sides cannot disagree with itself.
|
|
590
|
+
const scopeResolveSchema = {
|
|
591
|
+
body: {
|
|
592
|
+
type: 'object',
|
|
593
|
+
required: ['threadIds', 'workspaceId'],
|
|
594
|
+
additionalProperties: false,
|
|
595
|
+
properties: {
|
|
596
|
+
threadIds: {
|
|
597
|
+
type: 'array',
|
|
598
|
+
items: { type: 'integer' },
|
|
599
|
+
maxItems: SCOPE_RESOLVE_THREAD_CAP,
|
|
600
|
+
},
|
|
601
|
+
workspaceId: { type: 'integer', minimum: 1 },
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
// Bot-triage platform routes (CORE, always registered — never "core" in the TIER sense). Two tiers
|
|
606
|
+
// live in this one file and the split is deliberate:
|
|
607
|
+
//
|
|
608
|
+
// FREE (`botTriage`) detection/override + the reviewer listing, the bot-only governance list,
|
|
609
|
+
// per-PR cross-bot dedup, and the confirm-gated workspace-wide thread resolve
|
|
610
|
+
// — everything an `npx` install needs to classify and triage its bots.
|
|
611
|
+
// PAID (`botDepth`) the ROI ANALYTICS: the per-bot table, the ML flagging strip, the volume
|
|
612
|
+
// family, every drill-down off them, and the seat PRICES. See the two
|
|
613
|
+
// entitlement predicates at the top of this file for the exact split and for
|
|
614
|
+
// the two routes the People report shares.
|
|
615
|
+
//
|
|
616
|
+
// Every handler is account-scoped via accountIdOf(req); id-addressed reads/writes verify ownership
|
|
617
|
+
// → 404. No AI anywhere in here — paid does not mean model-backed, it means the analysis is the
|
|
618
|
+
// product.
|
|
619
|
+
export async function botTriageRoutes(app) {
|
|
620
|
+
// One row per (workspace, actor), each carrying its judgement, identity, price and the evidence
|
|
621
|
+
// behind them — including `repoFootprints[]`, the real blast radius of an edit that is
|
|
622
|
+
// workspace-wide by design. Powers the Bots "Settings" tab.
|
|
623
|
+
//
|
|
624
|
+
// ⚠ THE ROUTE IS FREE AND MUST STAY FREE — but its PRICE COLUMNS ARE NOT. This is the identity
|
|
625
|
+
// and colour backbone for the whole SPA (the feed's vendor tags, ThreadList, BotTriageCard, the
|
|
626
|
+
// People picker, every drill-down header), so a 402 here would black out bot identity app-wide,
|
|
627
|
+
// and it also serves the free classification screen an OSS user needs. What it may NOT do is
|
|
628
|
+
// ship seat pricing to an account that cannot see pricing: today the client merely HIDES those
|
|
629
|
+
// cells, which is a rendering decision, not a gate. So the rows come back with the money
|
|
630
|
+
// stripped — exactly the shape a never-priced row already has, `costModel: 'flat'` included
|
|
631
|
+
// (`WorkspaceReviewer` documents "a row with no price is always 'flat'"), so nothing downstream
|
|
632
|
+
// has to learn a fourth cost state.
|
|
633
|
+
//
|
|
634
|
+
// `workspaceSeatCount` SURVIVES the strip: it is a derived HEADCOUNT (distinct human PR authors),
|
|
635
|
+
// not a price, and it is already visible in other free surfaces.
|
|
636
|
+
app.get('/api/bot-reviewers', { schema: listReviewersSchema }, async (req) => {
|
|
637
|
+
const { workspace, repoIds } = req.query;
|
|
638
|
+
const accountId = accountIdOf(req);
|
|
639
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
640
|
+
const listing = await listDetectedReviewers(accountId, scope);
|
|
641
|
+
if (botDepthEntitled(req))
|
|
642
|
+
return listing;
|
|
643
|
+
const resp = {
|
|
644
|
+
...listing,
|
|
645
|
+
reviewers: listing.reviewers.map(stripCost),
|
|
646
|
+
};
|
|
647
|
+
return resp;
|
|
648
|
+
});
|
|
649
|
+
// ── THE ONE WRITE ─────────────────────────────────────────────────────────────────────────
|
|
650
|
+
// Force a reviewer automated (or back to human), set its role, name its vendor, relabel it —
|
|
651
|
+
// for ONE workspace. The two halves stamp their provenance flags INDEPENDENTLY: automated/role
|
|
652
|
+
// stamp `source:'manual'`; kind/label stamp `identitySource:'manual'`.
|
|
653
|
+
//
|
|
654
|
+
// ⚠ THAT INDEPENDENCE IS THE ONLY THING LEFT DOING THE JOB THE TWO-TABLE SPLIT USED TO DO. The
|
|
655
|
+
// predecessor wrote identity per repo and read it per actor, so "Not a bot" on ONE repo nulled
|
|
656
|
+
// the kind and CodeRabbit lost its brand colour and vendor name on every repo the user never
|
|
657
|
+
// touched. With both facts on one row there is no table boundary left to catch a handler that
|
|
658
|
+
// stamps one flag while the user edited the other — only these two flags.
|
|
659
|
+
//
|
|
660
|
+
// ⚠ IT CANNOT REACH THE PRICE. No `monthlyUsd` in the body, and `setWorkspaceReviewer`'s `set:`
|
|
661
|
+
// object carries no cost key.
|
|
662
|
+
//
|
|
663
|
+
// 404 covers an unknown user id, an unknown/foreign workspace id, AND an actor with no stored
|
|
664
|
+
// row and no footprint in that workspace — deliberately the same status for all three:
|
|
665
|
+
// distinguishing them would turn this route into an existence oracle over another tenant's
|
|
666
|
+
// workspace ids and over the GLOBAL users table.
|
|
667
|
+
app.patch('/api/bot-reviewers/:userId', { schema: patchSchema }, async (req, reply) => {
|
|
668
|
+
const { userId } = req.params;
|
|
669
|
+
const body = req.body;
|
|
670
|
+
// An opinion-free patch is a client bug, not a no-op worth a 200: with none of the four fields
|
|
671
|
+
// there is nothing to write, and stamping a provenance flag anyway would freeze detection for
|
|
672
|
+
// that workspace on the strength of an empty request.
|
|
673
|
+
if (body.automated === undefined &&
|
|
674
|
+
body.role === undefined &&
|
|
675
|
+
body.kind === undefined &&
|
|
676
|
+
body.label === undefined) {
|
|
677
|
+
reply.status(400);
|
|
678
|
+
return {
|
|
679
|
+
error: 'BadRequest',
|
|
680
|
+
message: 'Patch must carry at least one of `automated`, `role`, `kind`, `label`',
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const reviewer = await setWorkspaceReviewer(accountIdOf(req), userId, body);
|
|
684
|
+
if (!reviewer) {
|
|
685
|
+
reply.status(404);
|
|
686
|
+
return {
|
|
687
|
+
error: 'NotFound',
|
|
688
|
+
message: `Reviewer ${userId} not found in workspace ${body.workspaceId}`,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
// The classification WRITE is free; the price it echoes back is not — see `stripCost`.
|
|
692
|
+
const resp = botDepthEntitled(req) ? reviewer : stripCost(reviewer);
|
|
693
|
+
return resp;
|
|
694
|
+
});
|
|
695
|
+
// ── JUDGEMENT RESET ───────────────────────────────────────────────────────────────────────
|
|
696
|
+
// Hand `automated`/`role`/`confidence`/`reasons` back to detection for one workspace and
|
|
697
|
+
// re-derive in the same request, so the auto verdict lands in this response.
|
|
698
|
+
//
|
|
699
|
+
// ⚠ IT IS AN UPDATE, NOT A ROW DELETE, which is why it answers 200 and not the old 204. The row
|
|
700
|
+
// also holds the vendor identity and the price; deleting it would take both with it. A
|
|
701
|
+
// clear-WITHOUT-derive would be worse still: the human's values would sit under an auto label
|
|
702
|
+
// until something else overwrote them — a stale opinion wearing the wrong provenance.
|
|
703
|
+
//
|
|
704
|
+
// ⚠ IT TOUCHES NEITHER THE IDENTITY NOR THE PRICE. Different provenance flag, different route.
|
|
705
|
+
//
|
|
706
|
+
// ⚠ BLAST RADIUS IS THE WHOLE WORKSPACE. The UI offers it only where `isManualOverride` is true
|
|
707
|
+
// — a control that resets an already-auto row would appear to do nothing.
|
|
708
|
+
//
|
|
709
|
+
// Same 404 rule as the PATCH, and the same reason: one status for unknown user / unknown or
|
|
710
|
+
// foreign workspace / no stored row, so it is never an existence oracle.
|
|
711
|
+
app.delete('/api/bot-reviewers/:userId/judgement', { schema: judgementResetSchema }, async (req, reply) => {
|
|
712
|
+
const { userId } = req.params;
|
|
713
|
+
const { workspaceId } = req.query;
|
|
714
|
+
const reviewer = await resetWorkspaceReviewerJudgement(accountIdOf(req), userId, workspaceId);
|
|
715
|
+
if (!reviewer) {
|
|
716
|
+
reply.status(404);
|
|
717
|
+
return {
|
|
718
|
+
error: 'NotFound',
|
|
719
|
+
message: `No stored judgement for reviewer ${userId} in workspace ${workspaceId}`,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
// Echoed, unlike its 204-answering predecessor: the row still exists (it still holds the
|
|
723
|
+
// identity and the price), and this is the re-derived verdict the caller asked to fall back
|
|
724
|
+
// to — which saves the client a refetch to learn what auto detection actually came back as.
|
|
725
|
+
// The price it carries is stripped for an unentitled reader, exactly as on the listing.
|
|
726
|
+
const resp = botDepthEntitled(req) ? reviewer : stripCost(reviewer);
|
|
727
|
+
return resp;
|
|
728
|
+
});
|
|
729
|
+
// ── IDENTITY RESET ────────────────────────────────────────────────────────────────────────
|
|
730
|
+
// Hand `kind` + `label` back to detection: clear them, set `identitySource:'auto'`, and
|
|
731
|
+
// re-derive immediately so the auto answer lands in this response.
|
|
732
|
+
//
|
|
733
|
+
// ⚠ IT KEEPS THE PRICE. The row also carries `monthly_cents`, and a price is not a
|
|
734
|
+
// classification opinion — clearing it because someone un-named a vendor is exactly the coupling
|
|
735
|
+
// this contract keeps separated. The UI says so in words, because "reset" reads as "delete
|
|
736
|
+
// everything" otherwise.
|
|
737
|
+
//
|
|
738
|
+
// ⚠ IT TOUCHES NO JUDGEMENT FIELD. Naming a vendor is not a statement about how it behaves, and
|
|
739
|
+
// stamping `source` from here would freeze auto-classification for the whole workspace.
|
|
740
|
+
//
|
|
741
|
+
// Same 404 rule as its judgement twin.
|
|
742
|
+
app.delete('/api/bot-reviewers/:userId/identity', { schema: identityResetSchema }, async (req, reply) => {
|
|
743
|
+
const { userId } = req.params;
|
|
744
|
+
const { workspaceId } = req.query;
|
|
745
|
+
const reviewer = await resetWorkspaceReviewerIdentity(accountIdOf(req), userId, workspaceId);
|
|
746
|
+
if (!reviewer) {
|
|
747
|
+
reply.status(404);
|
|
748
|
+
return {
|
|
749
|
+
error: 'NotFound',
|
|
750
|
+
message: `Reviewer ${userId} not found in workspace ${workspaceId}`,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
// ⚠ IT KEEPS THE PRICE (above) but does not necessarily SHOW it: same strip as the listing,
|
|
754
|
+
// or this free, body-less DELETE becomes the read path for every price on the workspace.
|
|
755
|
+
const resp = botDepthEntitled(req) ? reviewer : stripCost(reviewer);
|
|
756
|
+
return resp;
|
|
757
|
+
});
|
|
758
|
+
// ── COST ──────────────────────────────────────────────────────────────────────────────────
|
|
759
|
+
// What this bot costs per month IN THIS WORKSPACE. A number sets it (0 is real: "we pay
|
|
760
|
+
// nothing"); null CLEARS it — a column write, never a row delete, because the row also carries
|
|
761
|
+
// the judgement and the identity.
|
|
762
|
+
//
|
|
763
|
+
// ⚠ IT IS A STANDALONE ROUTE ON PURPOSE, and that is the whole reason it did not fold into the
|
|
764
|
+
// PATCH above when the two grains merged. Cost is derivable by nothing and is money: giving it
|
|
765
|
+
// its own body means no combined body can address `monthly_cents` at all. That structural
|
|
766
|
+
// guarantee survived the two-TABLE split; it must survive the two-GRAIN merge.
|
|
767
|
+
//
|
|
768
|
+
// ⚠ THE PRICE IS PER WORKSPACE, like every other attribute on the row. The UPDATE predicate is
|
|
769
|
+
// (account_id, workspace_id, author_user_id) — exactly one row. The same actor's rows in other
|
|
770
|
+
// workspaces are untouched and may legitimately hold different numbers; nothing reconciles them,
|
|
771
|
+
// there is no fan-out writer and no INSERT seed. Within one workspace there is one row per
|
|
772
|
+
// actor, so a total there is a plain sum; across workspaces it is not a sum at all, and no
|
|
773
|
+
// surface may add them up.
|
|
774
|
+
//
|
|
775
|
+
// Same 404 rule as the resets: no row for that (workspace, actor) ⇒ no price to attach.
|
|
776
|
+
app.put('/api/bot-reviewers/:userId/cost', { schema: costSchema }, async (req, reply) => {
|
|
777
|
+
// ── PAID (`botDepth`, plan P0.3) ── The cost overlay re-gated from "plugin loaded"
|
|
778
|
+
// (`botTriage`) to the paid depth tier. Same entitlement view /api/me serves and the
|
|
779
|
+
// /api/pro/* 402 gate mirrors: local accounts are fully entitled whenever the plugin is
|
|
780
|
+
// bound (so a flags-on local run keeps working), a free cloud account gets the same 402
|
|
781
|
+
// shape as the plugin routes. Everything else that WRITES on this resource (classification,
|
|
782
|
+
// identity, role) stays free — this is still the only bot-reviewers WRITE behind the check.
|
|
783
|
+
//
|
|
784
|
+
// ⚠ THE READ SIDE IS FOUR ROUTES, NOT ONE. The listing, the PATCH and both resets all echo a
|
|
785
|
+
// `WorkspaceReviewer`, so all four run `stripCost` (see its note) — otherwise the free,
|
|
786
|
+
// body-less `DELETE …/identity` is a perfectly good read path for every price this route
|
|
787
|
+
// refuses to write. The ONE deliberate exemption is the GDPR export, which is the subject's
|
|
788
|
+
// own data rather than a product surface.
|
|
789
|
+
if (!botDepthEntitled(req)) {
|
|
790
|
+
reply.status(402);
|
|
791
|
+
return PRO_REQUIRED;
|
|
792
|
+
}
|
|
793
|
+
const { userId } = req.params;
|
|
794
|
+
const { workspaceId, monthlyUsd, costModel } = req.body;
|
|
795
|
+
const reviewer = await setReviewerCost(accountIdOf(req), userId, workspaceId, monthlyUsd, costModel);
|
|
796
|
+
if (!reviewer) {
|
|
797
|
+
reply.status(404);
|
|
798
|
+
return {
|
|
799
|
+
error: 'NotFound',
|
|
800
|
+
message: `Reviewer ${userId} not found in workspace ${workspaceId}`,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const resp = reviewer;
|
|
804
|
+
return resp;
|
|
805
|
+
});
|
|
806
|
+
// Bot ROI / utilisation over a window (default rolling_14): per-vendor threads/acted-on/
|
|
807
|
+
// untouched + verdict + trend + deterministic tuning suggestions. Cost is SERVER-resolved from
|
|
808
|
+
// the workspace row — a null here is FINAL.
|
|
809
|
+
//
|
|
810
|
+
// ⚠ Never sum the price across workspaces. Within this one workspace's rows it is a plain sum.
|
|
811
|
+
// The authoring-automation vector: deterministic, no AI. `output: null` is the ONE degrade shape
|
|
812
|
+
// (unknown/foreign id, a HUMAN, or an automation that has never authored a PR in this workspace)
|
|
813
|
+
// so the route is not an existence oracle — the person route's posture.
|
|
814
|
+
//
|
|
815
|
+
// PAID ON THE UNION (`botDepth || periodReports`), like its two siblings. Its ONE consumer is the
|
|
816
|
+
// People report's bot sections (PeopleReportDetail's `useReportBotAuthoring` — the only call site
|
|
817
|
+
// in the SPA), which is now a locked pane without `periodReports`; and `?evidence=1` adds the
|
|
818
|
+
// receipt PR cards on top of the vector. `botDepth` alone would blank the bot half of a report a
|
|
819
|
+
// `periodReports` customer already paid for.
|
|
820
|
+
//
|
|
821
|
+
// The gate goes FIRST, ahead of `parseWindowBounds`, so the 400/402 split cannot be read as an
|
|
822
|
+
// oracle over which bounds the fold would have accepted.
|
|
823
|
+
app.get('/api/bot-authoring', { schema: authoringSchema }, async (req, reply) => {
|
|
824
|
+
if (!botAnalyticsEntitled(req)) {
|
|
825
|
+
reply.status(402);
|
|
826
|
+
return PRO_REQUIRED;
|
|
827
|
+
}
|
|
828
|
+
const { userId, workspace, repoIds, fromMs, toMs, evidence } = req.query;
|
|
829
|
+
// Reuse the shared validator so the bounds rules (ordered, span-capped) have ONE spelling
|
|
830
|
+
// across all three routes. The enum it refines is irrelevant here — the fold takes the pair.
|
|
831
|
+
const bounds = parseWindowBounds('rolling_14', fromMs, toMs);
|
|
832
|
+
if ('error' in bounds) {
|
|
833
|
+
reply.status(400);
|
|
834
|
+
return { error: 'BadRequest', message: bounds.error };
|
|
835
|
+
}
|
|
836
|
+
const accountId = accountIdOf(req);
|
|
837
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
838
|
+
const output = await getAutomationOutput(accountId, scope, Number(userId), { fromMs: Number(fromMs), toMs: Number(toMs) }, { evidence: evidence === '1' });
|
|
839
|
+
return {
|
|
840
|
+
workspaceId: scope.workspaceId,
|
|
841
|
+
window: { fromMs: Number(fromMs), toMs: Number(toMs) },
|
|
842
|
+
output,
|
|
843
|
+
};
|
|
844
|
+
});
|
|
845
|
+
app.get('/api/bot-analytics', { schema: analyticsSchema }, async (req, reply) => {
|
|
846
|
+
const { window, workspace, repoIds, fromMs, toMs } = req.query;
|
|
847
|
+
// Optional REAL bounds (the People report) refine the enum via the fold's widened
|
|
848
|
+
// apiVersion-18 window form; a malformed pair 400s (it names a population, never degrades).
|
|
849
|
+
const bounds = parseWindowBounds(window, fromMs, toMs);
|
|
850
|
+
if ('error' in bounds) {
|
|
851
|
+
reply.status(400);
|
|
852
|
+
return { error: 'BadRequest', message: bounds.error };
|
|
853
|
+
}
|
|
854
|
+
const accountId = accountIdOf(req);
|
|
855
|
+
// ONE object drives both halves: `workspaceId` decides who counts as a bot, `repoIds` narrows
|
|
856
|
+
// which data is measured — and the narrowing is already bounded by the workspace's membership,
|
|
857
|
+
// so the two cannot describe different sets of repos.
|
|
858
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
859
|
+
const entitled = botAnalyticsEntitled(req);
|
|
860
|
+
// The Inflation column's weekly history — folded only when the account can see the column it
|
|
861
|
+
// draws in, which since the whole table went paid means "whenever the rest of this response is
|
|
862
|
+
// populated". Kept as its own flag because it is an extra SCAN WIDTH inside the getter (the
|
|
863
|
+
// trend span), not just a field to drop.
|
|
864
|
+
const analytics = await getBotAnalytics(accountId, bounds.window, scope, {
|
|
865
|
+
inflationHistory: entitled,
|
|
866
|
+
});
|
|
867
|
+
if (entitled)
|
|
868
|
+
return analytics;
|
|
869
|
+
// ── THE NARROWED (unentitled) SHAPE ───────────────────────────────────────────────────────
|
|
870
|
+
// ⚠ THIS ROUTE IS NOT 402-ABLE, and that is the single most important thing to know before
|
|
871
|
+
// editing it. One response feeds FOUR consumers on two tiers: the paid ROI panel and its
|
|
872
|
+
// drill-downs, the paid People report's bot sections — and the FREE amber "Only a bot reviewed
|
|
873
|
+
// N open PRs" governance caution, which lives in BotsView OUTSIDE the panel and reads nothing
|
|
874
|
+
// but `totals.botOnlyPrs`. A blanket 402 would make that caution silently vanish (the client
|
|
875
|
+
// reads `?? 0`, so there is no error anywhere) and would re-fire every five minutes on the
|
|
876
|
+
// hook's sync-cadence poll. So the paid POPULATION is withheld and the free verdicts stay.
|
|
877
|
+
//
|
|
878
|
+
// The fold above still RUNS for an unentitled account — it is where `botOnlyPrs` and
|
|
879
|
+
// `suggestions` come from, so there is nothing cheaper to call and this costs exactly what the
|
|
880
|
+
// route cost before the gate. Only `inflationHistory` (an extra scan WIDTH, not a field) is
|
|
881
|
+
// actually skipped.
|
|
882
|
+
//
|
|
883
|
+
// WHAT SURVIVES, and why each one:
|
|
884
|
+
// • `totals.botOnlyPrs` + `overdueGraceMs` — the free governance caution and its wording.
|
|
885
|
+
// • `suggestions` — the free amber tuning-suggestions box, which is hoisted OUT of the panel
|
|
886
|
+
// into BotsView's free area precisely so this narrowing can keep feeding it.
|
|
887
|
+
// WHAT IS WITHHELD: `vendors` (the ROI table: verdicts, noise ratios, PRICES), `qualityChecks`,
|
|
888
|
+
// the whole `ml` block (the flagging strip), and the ROI half of `totals`.
|
|
889
|
+
//
|
|
890
|
+
// ⚠ THE ZEROS IN `totals` ARE A WIRE ARTEFACT, NOT DATA. `vendors`, `suggestions` and `totals`
|
|
891
|
+
// are REQUIRED on `BotAnalyticsResponse` (`ml`/`qualityChecks` are optional, which is why those
|
|
892
|
+
// two are simply absent — the honest spelling). Nothing free renders these numbers, and the SPA
|
|
893
|
+
// decides what to draw from its own `/api/me` capability rather than by sniffing the payload,
|
|
894
|
+
// so no screen can read a zero as a measurement. `actedOnPct` is already nullable, so it takes
|
|
895
|
+
// the honest `null` rather than a 0%.
|
|
896
|
+
//
|
|
897
|
+
// ⚠ MAKING THE THREE OPTIONAL IN `packages/shared` WAS CONSIDERED AND DEFERRED, deliberately.
|
|
898
|
+
// It would let "not entitled" and "nothing happened" differ on the wire, which is the tidier
|
|
899
|
+
// contract — but `BotAnalyticsResponse.totals`/`.vendors`/`.suggestions` are read by the PRIVATE
|
|
900
|
+
// plugin submodule too (`packages/pro/src/insights/chat.ts` grounds on all three), so widening
|
|
901
|
+
// them is a two-repository, gitlink-coordinated edit for a distinction NOTHING currently reads:
|
|
902
|
+
// every consumer is contractually forbidden from sniffing this payload for entitlement. Revisit
|
|
903
|
+
// it with the next apiVersion bump, when the plugin half is already being touched. The ambiguity
|
|
904
|
+
// is pinned in bot-triage-entitlement.test.ts so it stays a known shape rather than a surprise.
|
|
905
|
+
const resp = {
|
|
906
|
+
enabled: analytics.enabled,
|
|
907
|
+
generatedAt: analytics.generatedAt,
|
|
908
|
+
window: analytics.window,
|
|
909
|
+
vendors: [],
|
|
910
|
+
totals: {
|
|
911
|
+
threads: 0,
|
|
912
|
+
comments: 0,
|
|
913
|
+
actedOn: 0,
|
|
914
|
+
actedOnPct: null,
|
|
915
|
+
untouched: 0,
|
|
916
|
+
botOnlyPrs: analytics.totals.botOnlyPrs,
|
|
917
|
+
overdueGraceMs: analytics.totals.overdueGraceMs,
|
|
918
|
+
overlapClusters: 0,
|
|
919
|
+
},
|
|
920
|
+
suggestions: analytics.suggestions,
|
|
921
|
+
};
|
|
922
|
+
return resp;
|
|
923
|
+
});
|
|
924
|
+
// The bot BEHAVIOUR analytics route MOVED to the plugin (`GET /api/pro/bot-behaviour`,
|
|
925
|
+
// packages/pro/src/bots/behaviour-routes.ts) behind the `botDepth` entitlement — the
|
|
926
|
+
// periodReports precedent: the compute stays CORE (db/queries.ts getBotBehaviourAnalytics,
|
|
927
|
+
// reached via the ProHostQueries.getBotBehaviour seam), but there is no free surface for it.
|
|
928
|
+
// The exact PR list behind the analytics totals.botOnlyPrs count — "only a bot reviewed these".
|
|
929
|
+
// Same window/scope resolution as /api/bot-analytics so the amber caption's number and this
|
|
930
|
+
// expandable list are computed identically and can't drift. Unbounded but small (real bot-only
|
|
931
|
+
// PRs); no pagination.
|
|
932
|
+
app.get('/api/bot-analytics/bot-only-prs', { schema: analyticsSchema }, async (req) => {
|
|
933
|
+
const { window, workspace, repoIds } = req.query;
|
|
934
|
+
const accountId = accountIdOf(req);
|
|
935
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
936
|
+
const { window: win, prs } = await getBotOnlyPrs(accountId, window, scope);
|
|
937
|
+
const resp = { window: win, prs, generatedAt: new Date().toISOString() };
|
|
938
|
+
return resp;
|
|
939
|
+
});
|
|
940
|
+
// The per-REVIEWER PR drill-down behind one Bot-ROI row: the PRs that one automated reviewer
|
|
941
|
+
// touched in the window (threads/comments/acted-on/untouched/bot-only), newest-activity first.
|
|
942
|
+
// `key` is the analytics row identity — `u<userId>` (a single reviewer) or the 'pierre' sentinel;
|
|
943
|
+
// anything else is a client bug → 400.
|
|
944
|
+
//
|
|
945
|
+
// PAID (`botDepth`). It reproduces ONE ROW of the paid table and has no other opener, so unlike
|
|
946
|
+
// /api/bot-analytics there is no free half to preserve: a 402 is the honest answer rather than an
|
|
947
|
+
// empty list, which would read as "this bot touched nothing".
|
|
948
|
+
app.get('/api/bot-analytics/vendor/:key/prs', { schema: vendorPrsSchema }, async (req, reply) => {
|
|
949
|
+
if (!botDepthEntitled(req)) {
|
|
950
|
+
reply.status(402);
|
|
951
|
+
return PRO_REQUIRED;
|
|
952
|
+
}
|
|
953
|
+
const { key } = req.params;
|
|
954
|
+
const { window, workspace, repoIds } = req.query;
|
|
955
|
+
const m = /^u(\d+)$/.exec(key);
|
|
956
|
+
const target = m
|
|
957
|
+
? { userId: Number(m[1]) }
|
|
958
|
+
: key === 'pierre'
|
|
959
|
+
? { kind: 'pierre' }
|
|
960
|
+
: null;
|
|
961
|
+
if (!target) {
|
|
962
|
+
reply.status(400);
|
|
963
|
+
return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
|
|
964
|
+
}
|
|
965
|
+
const accountId = accountIdOf(req);
|
|
966
|
+
// The SAME scope the ROI row was computed at — this list reproduces one of that panel's rows,
|
|
967
|
+
// so the header label and the per-PR `botOnly` badge must take the identical workspace and
|
|
968
|
+
// repo set. One screen cannot show two contradictory bot-only answers.
|
|
969
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
970
|
+
const resp = await getBotVendorPrs(accountId, target, window, scope);
|
|
971
|
+
return resp;
|
|
972
|
+
});
|
|
973
|
+
// The per-REVIEWER COMMENTS drill-down behind the same Bot-ROI row: everything the reviewer
|
|
974
|
+
// said in the window (inline review comments, PR comments, review bodies), each row with its
|
|
975
|
+
// stored ML label INLINE — one response, never the per-PR label index per row. Same key parse
|
|
976
|
+
// and the same scope resolution as the /prs sibling, so the list reproduces the same ROI row;
|
|
977
|
+
// the 'pierre' sentinel answers empty (verbatim reviews are human-posted — no per-comment
|
|
978
|
+
// rows to attribute). ⚠ Rate tier `search`, pinned in rate-limit.test.ts — this ships up to
|
|
979
|
+
// 3000 comment BODIES per source plus a three-way label join per request.
|
|
980
|
+
//
|
|
981
|
+
// PAID ON THE UNION (`botDepth || periodReports`) — one of the two routes with two paid owners:
|
|
982
|
+
// the ROI table's comments drill-down AND every bot section of the People report's per-bot
|
|
983
|
+
// evidence cards. Gating it on `botDepth` alone would blank evidence a `periodReports` customer
|
|
984
|
+
// had already bought. 402 rather than an empty list, for the /prs sibling's reason: `comments:
|
|
985
|
+
// []` is indistinguishable from "this bot said nothing in the window", which is a claim.
|
|
986
|
+
app.get('/api/bot-analytics/vendor/:key/comments', { schema: vendorCommentsSchema }, async (req, reply) => {
|
|
987
|
+
if (!botAnalyticsEntitled(req)) {
|
|
988
|
+
reply.status(402);
|
|
989
|
+
return PRO_REQUIRED;
|
|
990
|
+
}
|
|
991
|
+
const { key } = req.params;
|
|
992
|
+
const { window, workspace, repoIds, fromMs, toMs } = req.query;
|
|
993
|
+
const m = /^u(\d+)$/.exec(key);
|
|
994
|
+
const target = m
|
|
995
|
+
? { userId: Number(m[1]) }
|
|
996
|
+
: key === 'pierre'
|
|
997
|
+
? { kind: 'pierre' }
|
|
998
|
+
: null;
|
|
999
|
+
if (!target) {
|
|
1000
|
+
reply.status(400);
|
|
1001
|
+
return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
|
|
1002
|
+
}
|
|
1003
|
+
// Same optional real-bounds refinement as /api/bot-analytics (the People report's per-bot
|
|
1004
|
+
// evidence cards) — a malformed pair 400s.
|
|
1005
|
+
const bounds = parseWindowBounds(window, fromMs, toMs);
|
|
1006
|
+
if ('error' in bounds) {
|
|
1007
|
+
reply.status(400);
|
|
1008
|
+
return { error: 'BadRequest', message: bounds.error };
|
|
1009
|
+
}
|
|
1010
|
+
const accountId = accountIdOf(req);
|
|
1011
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
1012
|
+
const resp = await getBotVendorComments(accountId, target, bounds.window, scope);
|
|
1013
|
+
return resp;
|
|
1014
|
+
});
|
|
1015
|
+
// ── "WHAT THE BOTS ARE FLAGGING" ──────────────────────────────────────────────────────────
|
|
1016
|
+
// The drill-down behind every tile and chip on the Bots rail's ML strip. `select` picks the
|
|
1017
|
+
// population; the response is discriminated on `kind` — 'comments' for the four label
|
|
1018
|
+
// selectors, 'clusters' for same-line overlap (deterministic line clusters, not ML rows).
|
|
1019
|
+
//
|
|
1020
|
+
// ⚠ `total` IS THE TILE'S NUMBER, BY CONSTRUCTION — not an independently-derived count that
|
|
1021
|
+
// happens to agree. The strip's buckets are a JS fold (`foldMlLabelRow`) over a JSON
|
|
1022
|
+
// `categories` column that no portable SQL predicate can express, so a `COUNT(*) WHERE
|
|
1023
|
+
// severity IN (…)` could never match it: it would count summaries and praise, could not
|
|
1024
|
+
// express praise at all, and would ignore both `coerceSeverity` failures and the scan cap's
|
|
1025
|
+
// newest-first truncation. The getters therefore re-run the strip's identical scan and
|
|
1026
|
+
// identical fold and then slice. Nothing on either side of this route re-derives a count —
|
|
1027
|
+
// note in particular that the client's `pillOf` tests praise BEFORE isSummary, the opposite
|
|
1028
|
+
// of the backend, so a client-side recount would disagree with the tile it drilled into.
|
|
1029
|
+
//
|
|
1030
|
+
// ⚠ Rate tier `search`, pinned in rate-limit.test.ts, and that predicate is anchored on this
|
|
1031
|
+
// EXACT path — spelling this route with a sub-path or a path parameter would silently drop it
|
|
1032
|
+
// back onto the 600/min blanket bucket with no error anywhere. Per request it re-runs the
|
|
1033
|
+
// strip's whole 50k-row label scan (the page offset is a JS slice over the fold) or the
|
|
1034
|
+
// window's whole thread scan plus ±3-line clustering, then hydrates a page of comment BODIES —
|
|
1035
|
+
// and an IntersectionObserver fires it again on every scroll.
|
|
1036
|
+
//
|
|
1037
|
+
// PAID (`botDepth`). Its only openers are the ROI panel's ML strip tiles and the Inflation
|
|
1038
|
+
// cells, both inside the locked panel — and the gate goes FIRST, above the selector parsing, so
|
|
1039
|
+
// an unentitled caller cannot use the 400/200 split as an oracle over which selectors exist.
|
|
1040
|
+
app.get('/api/bot-analytics/flagging', { schema: flaggingSchema }, async (req, reply) => {
|
|
1041
|
+
if (!botDepthEntitled(req)) {
|
|
1042
|
+
reply.status(402);
|
|
1043
|
+
return PRO_REQUIRED;
|
|
1044
|
+
}
|
|
1045
|
+
const q = req.query;
|
|
1046
|
+
// The selector and the refinement are assembled BEFORE any DB work: every failure below is a
|
|
1047
|
+
// client bug, and there is nothing to resolve a scope for in a request that cannot name a
|
|
1048
|
+
// population.
|
|
1049
|
+
let selector;
|
|
1050
|
+
if (q.select === 'severity') {
|
|
1051
|
+
// ⚠ AN UNKNOWN OR EMPTY SEVERITY LIST 400s, unlike `?workspace=`. The difference is who
|
|
1052
|
+
// minted the value: a workspace id can be a stale bookmark and must degrade to something
|
|
1053
|
+
// renderable, whereas this list is written by the tile that opened the tab. Silently
|
|
1054
|
+
// dropping an unrecognised class would name a DIFFERENT population than the tile did and
|
|
1055
|
+
// the list would quietly disagree with the number the user clicked.
|
|
1056
|
+
const raw = (q.severities ?? '')
|
|
1057
|
+
.split(',')
|
|
1058
|
+
.map((s) => s.trim())
|
|
1059
|
+
.filter((s) => s !== '');
|
|
1060
|
+
const severities = raw.filter((s) => FLAGGING_SEVERITY_VALUES.includes(s));
|
|
1061
|
+
if (severities.length === 0 || severities.length !== raw.length) {
|
|
1062
|
+
reply.status(400);
|
|
1063
|
+
return {
|
|
1064
|
+
error: 'BadRequest',
|
|
1065
|
+
message: `\`severities\` must be a comma-separated subset of ${FLAGGING_SEVERITY_VALUES.join(', ')}`,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
selector = { kind: 'severity', severities };
|
|
1069
|
+
}
|
|
1070
|
+
else if (q.select === 'category') {
|
|
1071
|
+
// ajv has already rejected an unknown category VALUE, so only absence can reach here.
|
|
1072
|
+
if (!q.category) {
|
|
1073
|
+
reply.status(400);
|
|
1074
|
+
return { error: 'BadRequest', message: '`select=category` requires `category`' };
|
|
1075
|
+
}
|
|
1076
|
+
selector = { kind: 'category', category: q.category };
|
|
1077
|
+
}
|
|
1078
|
+
else if (q.select === 'summaries') {
|
|
1079
|
+
selector = { kind: 'summaries' };
|
|
1080
|
+
}
|
|
1081
|
+
else if (q.select === 'overlap') {
|
|
1082
|
+
selector = { kind: 'overlap' };
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
selector = { kind: 'findings' };
|
|
1086
|
+
}
|
|
1087
|
+
// A confusion-matrix cell is a PAIR; half of one names no cell. Rejected rather than
|
|
1088
|
+
// silently ignored, because a filter that appears to be applied and is not is the worse
|
|
1089
|
+
// failure mode — the user reads an unfiltered list as the filtered one.
|
|
1090
|
+
let cell = null;
|
|
1091
|
+
if (q.cellVendor && q.cellOurs) {
|
|
1092
|
+
cell = { vendor: q.cellVendor, ours: q.cellOurs };
|
|
1093
|
+
}
|
|
1094
|
+
else if (q.cellVendor || q.cellOurs) {
|
|
1095
|
+
reply.status(400);
|
|
1096
|
+
return {
|
|
1097
|
+
error: 'BadRequest',
|
|
1098
|
+
message: '`cellVendor` and `cellOurs` must be sent together or not at all',
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
// ⚠ NO OWNERSHIP CHECK ON `authorUserIds`, and that is not an oversight: `users` is a GLOBAL
|
|
1102
|
+
// table, so "does this account own that user row" is not a question it can answer. The
|
|
1103
|
+
// narrowing is applied as a predicate over an already accountId- and scope-filtered label
|
|
1104
|
+
// scan, so a foreign or nonexistent id matches nothing — an empty list, identical in both
|
|
1105
|
+
// cases, which is what stops it being an existence oracle.
|
|
1106
|
+
//
|
|
1107
|
+
// ⚠ PRESENT-BUT-EMPTY IS "NO BOTS", NEVER "EVERY BOT" — the `repoIds` rule (`if (ids)`, never
|
|
1108
|
+
// `ids.length > 0`). `parseIntList` collapses absence AND an unusable list to null, which is
|
|
1109
|
+
// right for a repo NARROW (null = don't narrow) and wrong here: a caller that computed an
|
|
1110
|
+
// empty bot set and sent it would get the whole workspace back under a caption promising a
|
|
1111
|
+
// subset — the very failure this parameter was widened to prevent. So the KEY's presence
|
|
1112
|
+
// decides whether there is a narrowing at all, and `parseIntList` only parses its members.
|
|
1113
|
+
const authorUserIds = q.authorUserIds == null ? null : (parseIntList(q.authorUserIds) ?? []);
|
|
1114
|
+
const refine = {
|
|
1115
|
+
cell,
|
|
1116
|
+
disagree: q.disagree ?? null,
|
|
1117
|
+
authorUserIds,
|
|
1118
|
+
};
|
|
1119
|
+
// ⚠ THE CURSOR DEGRADES, IT NEVER 400s — the opposite rule to `severities` above, and for the
|
|
1120
|
+
// opposite reason: it is server-minted state a client only ever echoes back, and it is OPAQUE
|
|
1121
|
+
// on the wire precisely so its encoding can change (a later keyset switch) without breaking
|
|
1122
|
+
// in-flight clients. Today it carries an offset into the folded population. Anything that is
|
|
1123
|
+
// not exactly that — a stale shape, a value past the end, an unsafe integer — starts again at
|
|
1124
|
+
// the first page, which is always a renderable answer.
|
|
1125
|
+
const m = /^o:(\d+)$/.exec(q.cursor ?? '');
|
|
1126
|
+
const parsedOffset = m ? Number(m[1]) : 0;
|
|
1127
|
+
const offset = Number.isSafeInteger(parsedOffset) ? parsedOffset : 0;
|
|
1128
|
+
const accountId = accountIdOf(req);
|
|
1129
|
+
// The SAME scope the strip was measured at — this list reproduces one of that strip's tiles,
|
|
1130
|
+
// so it must take the identical workspace (who counts as a bot) and repo narrowing (which
|
|
1131
|
+
// data is measured), bounded by the workspace's membership inside the resolver. A `BotScope`
|
|
1132
|
+
// is only ever built here; `parseIntList`'s result is the `narrow` argument, never a scope.
|
|
1133
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
|
|
1134
|
+
const page = { offset, limit: q.limit };
|
|
1135
|
+
const resp = selector.kind === 'overlap'
|
|
1136
|
+
? await getBotOverlapClusters(accountId, refine, q.window, scope, page)
|
|
1137
|
+
: await getBotFlaggingComments(accountId, selector, refine, q.window, scope, page);
|
|
1138
|
+
return resp;
|
|
1139
|
+
});
|
|
1140
|
+
// ── Bot comment VOLUME — the three routes over db/bot-volume.ts's one base scan ───────────
|
|
1141
|
+
//
|
|
1142
|
+
// GET /api/bot-analytics/volume — the ROI tab's "avg bot comments per PR" column + workspace
|
|
1143
|
+
// totals. The two averages differ ONLY by denominator (`avgCommentsPerCommentedPr` divides by
|
|
1144
|
+
// the PRs that bot touched, `avgCommentsPerScopePr` by every merged PR in scope) and on a quiet
|
|
1145
|
+
// repo they differ by ~6× — the field names carry that, and so must the column header.
|
|
1146
|
+
//
|
|
1147
|
+
// ⚠ Rate tier `search`, pinned in rate-limit.test.ts. Per request it walks every merged PR in
|
|
1148
|
+
// the window (up to 5000) plus three grouped comment counts over them, then folds in JS. No
|
|
1149
|
+
// GitHub, no model — this process's event loop, the flagging-drill-down shape of cost — so it
|
|
1150
|
+
// borrows the same 60/min bucket rather than the 600/min blanket one.
|
|
1151
|
+
//
|
|
1152
|
+
// ── ALL THREE VOLUME ROUTES ARE PAID (`botDepth`) ─────────────────────────────────────────
|
|
1153
|
+
// Their only consumers are the ROI table's "Comments/PR" column, that column's drill-down tab,
|
|
1154
|
+
// and the `botDepth`-gated WorkspaceBotCharts scatter — no free surface reads any of them, so
|
|
1155
|
+
// there is nothing here to narrow and a 402 is the whole gate. The scatter in particular was the
|
|
1156
|
+
// file's one CLIENT-ONLY gate (component gated, route open); it is closed in the same pass,
|
|
1157
|
+
// because "the component that draws it is hidden" has never been a gate.
|
|
1158
|
+
app.get('/api/bot-analytics/volume', { schema: volumeScopeSchema }, async (req, reply) => {
|
|
1159
|
+
if (!botDepthEntitled(req)) {
|
|
1160
|
+
reply.status(402);
|
|
1161
|
+
return PRO_REQUIRED;
|
|
1162
|
+
}
|
|
1163
|
+
const q = req.query;
|
|
1164
|
+
const accountId = accountIdOf(req);
|
|
1165
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
|
|
1166
|
+
const resp = await getBotVolume(accountId, q.window, scope);
|
|
1167
|
+
return resp;
|
|
1168
|
+
});
|
|
1169
|
+
// GET /api/bot-analytics/volume/prs — the paginated drill-down behind that column. Default sort
|
|
1170
|
+
// is raw `botComments` DESC; `sort=ratio` ranks by the bucket-relative expectation, which is
|
|
1171
|
+
// the only ordering that surfaces a small PR a bot tore apart (raw count mostly ranks by size).
|
|
1172
|
+
app.get('/api/bot-analytics/volume/prs', { schema: volumePrsSchema }, async (req, reply) => {
|
|
1173
|
+
if (!botDepthEntitled(req)) {
|
|
1174
|
+
reply.status(402);
|
|
1175
|
+
return PRO_REQUIRED;
|
|
1176
|
+
}
|
|
1177
|
+
const q = req.query;
|
|
1178
|
+
const accountId = accountIdOf(req);
|
|
1179
|
+
// ⚠ PRESENT-BUT-EMPTY IS "NO BOTS", NEVER "EVERY BOT" — the `repoIds` rule (`if (ids)`, never
|
|
1180
|
+
// `ids.length > 0`), identical to the flagging route above. `parseIntList` collapses absence
|
|
1181
|
+
// AND an unusable list to null, which is right for a repo NARROW and wrong here: a caller
|
|
1182
|
+
// that computed an empty bot set and sent it would get the whole workspace back under a
|
|
1183
|
+
// caption promising a subset. So the KEY's presence decides whether there is a narrowing at
|
|
1184
|
+
// all, and `parseIntList` only parses its members.
|
|
1185
|
+
//
|
|
1186
|
+
// ⚠ NO OWNERSHIP CHECK, and that is not an oversight: `users` is a GLOBAL table, so "does this
|
|
1187
|
+
// account own that user row" is not a question it can answer. The narrowing is applied over an
|
|
1188
|
+
// already accountId- and scope-filtered fold, so a foreign or nonexistent id matches nothing —
|
|
1189
|
+
// an empty list, identical in both cases, which is what stops it being an existence oracle.
|
|
1190
|
+
const authorUserIds = q.authorUserIds == null ? null : (parseIntList(q.authorUserIds) ?? []);
|
|
1191
|
+
const refine = { authorUserIds };
|
|
1192
|
+
// ⚠ THE CURSOR DEGRADES, IT NEVER 400s — server-minted state a client only ever echoes back,
|
|
1193
|
+
// opaque precisely so its encoding can change without breaking in-flight clients. Anything
|
|
1194
|
+
// that is not exactly `o:<n>` starts again at the first page, which is always renderable.
|
|
1195
|
+
const m = /^o:(\d+)$/.exec(q.cursor ?? '');
|
|
1196
|
+
const parsedOffset = m ? Number(m[1]) : 0;
|
|
1197
|
+
const offset = Number.isSafeInteger(parsedOffset) ? parsedOffset : 0;
|
|
1198
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
|
|
1199
|
+
const resp = await getPrBotVolume(accountId, q.window, scope, refine, {
|
|
1200
|
+
offset,
|
|
1201
|
+
limit: q.limit,
|
|
1202
|
+
sort: q.sort,
|
|
1203
|
+
});
|
|
1204
|
+
return resp;
|
|
1205
|
+
});
|
|
1206
|
+
// GET /api/bot-analytics/volume/scatter — the Behaviour tab's LOC-vs-volume chart: one point per
|
|
1207
|
+
// SIZED merged PR (capped at 2000, newest first) plus the five bucket means over the WHOLE
|
|
1208
|
+
// scope. The buckets are what make the chart readable — size is sublinear in comments, so the
|
|
1209
|
+
// expectation curve bends and the naive "comments per 100 LOC" reading is the one to avoid.
|
|
1210
|
+
app.get('/api/bot-analytics/volume/scatter', { schema: volumeScopeSchema }, async (req, reply) => {
|
|
1211
|
+
if (!botDepthEntitled(req)) {
|
|
1212
|
+
reply.status(402);
|
|
1213
|
+
return PRO_REQUIRED;
|
|
1214
|
+
}
|
|
1215
|
+
const q = req.query;
|
|
1216
|
+
const accountId = accountIdOf(req);
|
|
1217
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
|
|
1218
|
+
const resp = await getBotVolumeScatter(accountId, q.window, scope);
|
|
1219
|
+
return resp;
|
|
1220
|
+
});
|
|
1221
|
+
// Cross-bot dedup clusters for one PR (≥2 automated reviewers on the same path/line window).
|
|
1222
|
+
// Ownership-scoped → 404 for a PR this account doesn't own. No `?workspace=`: the getter derives
|
|
1223
|
+
// the judgement scope from the PR's own repo, which is the only workspace that can be right for
|
|
1224
|
+
// it — a caller-supplied one could measure this PR's threads under another workspace's verdicts.
|
|
1225
|
+
app.get('/api/prs/:id/bot-dedup', { schema: prIdParamSchema }, async (req, reply) => {
|
|
1226
|
+
const { id } = req.params;
|
|
1227
|
+
const resp = await getBotDedupClusters(id, accountIdOf(req));
|
|
1228
|
+
if (!resp) {
|
|
1229
|
+
reply.status(404);
|
|
1230
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
1231
|
+
}
|
|
1232
|
+
return resp;
|
|
1233
|
+
});
|
|
1234
|
+
// The workspace-wide review list: every PR with ≥1 `likely_addressed` automated-reviewer thread
|
|
1235
|
+
// in the scope, UNCAPPED, newest-thread-first, each row carrying all its resolvable thread ids +
|
|
1236
|
+
// a bot thread-state mix + `totalThreads` (the whole backlog). The client sorts / paginates /
|
|
1237
|
+
// "Select all"s across pages and chunks the resolve. Read-only.
|
|
1238
|
+
app.get('/api/bot-threads/resolvable', { schema: resolvableSchema }, async (req) => {
|
|
1239
|
+
const { workspace, repoIds } = req.query;
|
|
1240
|
+
const accountId = accountIdOf(req);
|
|
1241
|
+
const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
|
|
1242
|
+
const { prs, totalThreads } = await getResolvableBotThreadPrs(accountId, scope);
|
|
1243
|
+
const resp = {
|
|
1244
|
+
prs,
|
|
1245
|
+
totalThreads,
|
|
1246
|
+
generatedAt: new Date().toISOString(),
|
|
1247
|
+
};
|
|
1248
|
+
return resp;
|
|
1249
|
+
});
|
|
1250
|
+
// The confirm-gated workspace-wide resolve. NEVER blind: the server RE-DERIVES the eligible set
|
|
1251
|
+
// (owned + automated-reviewer-originated + `likely_addressed` + unresolved) ∩ the client's
|
|
1252
|
+
// explicit reviewed ids, under the workspace named in the body, then resolves each via the SAME
|
|
1253
|
+
// shared helper the per-PR route uses. An empty list is a no-op (not an error); per-thread
|
|
1254
|
+
// failures are reported, not fatal. The re-derive path passes `threadIds` so the page cap is
|
|
1255
|
+
// bypassed — no requested-and-eligible id is silently dropped.
|
|
1256
|
+
app.post('/api/bot-threads/resolve', { schema: scopeResolveSchema }, async (req) => {
|
|
1257
|
+
const { threadIds, workspaceId } = req.body;
|
|
1258
|
+
const accountId = accountIdOf(req);
|
|
1259
|
+
if (threadIds.length === 0) {
|
|
1260
|
+
const noop = { resolved: 0, failed: 0, results: [] };
|
|
1261
|
+
return noop;
|
|
1262
|
+
}
|
|
1263
|
+
// The listing and the resolve now derive the judgement from THE SAME workspace id, so they
|
|
1264
|
+
// cannot evaluate different rules. Under the team grain the listing was team-resolved while
|
|
1265
|
+
// this re-derive ran at the account default, and a reviewer marked automated only under a
|
|
1266
|
+
// per-team override had its threads offered and then found ineligible — the route resolved 0.
|
|
1267
|
+
// No `repoIds` narrowing here on purpose: the resolve acts on ids the user explicitly ticked,
|
|
1268
|
+
// and narrowing them a second time could only silently drop some of them.
|
|
1269
|
+
//
|
|
1270
|
+
// `workspaceId` goes through the SCOPE resolver, not an ownership 404, because here it names a
|
|
1271
|
+
// scope rather than a row to write: an unknown or foreign id degrades to the caller's Default
|
|
1272
|
+
// like every other read scope, and the worst it can do is re-derive fewer eligible threads.
|
|
1273
|
+
// The resolver only ever returns a workspace the caller owns, so it can never reach another
|
|
1274
|
+
// tenant.
|
|
1275
|
+
const scope = await resolveWorkspaceScope(accountId, workspaceId);
|
|
1276
|
+
const { threads: eligible } = await getResolvableBotThreadsForScope(accountId, scope, threadIds);
|
|
1277
|
+
const result = await resolveThreadsOnGitHub(accountId, eligible.map((t) => ({ id: t.threadId, threadNodeId: t.threadNodeId, prId: t.prId })));
|
|
1278
|
+
req.log.info({
|
|
1279
|
+
accountId,
|
|
1280
|
+
workspaceId: scope.workspaceId,
|
|
1281
|
+
requested: threadIds.length,
|
|
1282
|
+
eligible: eligible.length,
|
|
1283
|
+
resolved: result.resolved,
|
|
1284
|
+
failed: result.failed,
|
|
1285
|
+
}, 'workspace-wide bot-thread resolve');
|
|
1286
|
+
return result;
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
//# sourceMappingURL=bot-triage.js.map
|