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,820 @@
|
|
|
1
|
+
// ── The PERIOD METRIC VECTOR (CORE, deterministic — no model, no new table) ──────────────────
|
|
2
|
+
//
|
|
3
|
+
// The numbers behind the Insights "Reports" sub-tab: twelve figures describing ONE completed
|
|
4
|
+
// period ("18 Aug – 1 Sep"), plus the coverage read that keeps a comparison between two of them
|
|
5
|
+
// honest. The storage, the narration and the routes are plugin-owned; everything here is free,
|
|
6
|
+
// deterministic and reproducible.
|
|
7
|
+
//
|
|
8
|
+
// ⚠ IT WRITES ITS OWN SQL AND REUSES NONE OF THE EXISTING GETTERS. Measured against the live
|
|
9
|
+
// dev DB before this file existed: `getWorkspaceMetrics`' stat tiles honour an arbitrary window
|
|
10
|
+
// but its weekly series are a FIXED 12 weeks ending NOW, `openPrs`/`ciFailingNow` are
|
|
11
|
+
// current-state snapshots and `ciFailureReasons` has no upper bound at all;
|
|
12
|
+
// `getWorkspaceMetricsDetail` ignores `window.toMs` outright. None of them can answer "what did
|
|
13
|
+
// July look like", and bending them into it would move every existing tile. So this file is a
|
|
14
|
+
// second, narrower reader over the same tables — deliberately, and not by oversight.
|
|
15
|
+
//
|
|
16
|
+
// ⚠ EVERY METRIC IS **WINDOW-PURE**: a function of events timestamped in `[fromMs, toMs)` and
|
|
17
|
+
// nothing else. A stored historical period has to stay reproducible, and a snapshot is not — so
|
|
18
|
+
// `openPrs`, `ciFailingNow`, open-PR age and current thread state are all ABSENT from the vector
|
|
19
|
+
// even though the DORA header carries them. (Trunk red share is absent for a different reason:
|
|
20
|
+
// `trunk_ci_status_events` has no backfill — a known gap — so it is not computable for a past
|
|
21
|
+
// period AT ALL, and a metric that silently reads 0% green for every period before the feature
|
|
22
|
+
// shipped is worse than an omitted one.)
|
|
23
|
+
//
|
|
24
|
+
// ⚠ EVERY PREDICATE IS **TWO-SIDED**: `>= from AND < to`. A one-sided `gte` is precisely what
|
|
25
|
+
// makes the three existing getters unusable here, and the half-open upper bound is what stops
|
|
26
|
+
// two adjacent periods double-counting the event that lands on their shared boundary.
|
|
27
|
+
// `period-metrics.test.ts` pins both edges on four different columns.
|
|
28
|
+
//
|
|
29
|
+
// ⚠ RETROACTIVE HISTORY IS BIASED UNLESS COVERAGE IS RESPECTED, and that is what
|
|
30
|
+
// `getPeriodCoverage` is for. Measured: merged-PR counts by 14-day period over the last six
|
|
31
|
+
// months read 570, 572, 557, 494, 491, 537, 464, 292, 354, 232, 230, 177, 39 — which looks like
|
|
32
|
+
// explosive growth. The number of repos CONTRIBUTING to those same periods is 18, 19, 18, 18,
|
|
33
|
+
// 19, 18, 18, 13, 12, 10, 9, 6, 4. The "trend" is repo onboarding, not team output. A forecast
|
|
34
|
+
// fitted on that would predict growth that is purely a data artifact.
|
|
35
|
+
//
|
|
36
|
+
// ⚑ ITS OWN FILE, NOT queries.ts: that file is 13k lines and CONTAINS LITERAL NUL BYTES around
|
|
37
|
+
// offset 132k, so every search tool silently under-reports matches inside it. Nothing imports
|
|
38
|
+
// this file back, so importing queries.ts here creates no cycle (the db/bot-volume.ts and
|
|
39
|
+
// db/bot-overlap.ts precedent).
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { and, count, desc, eq, gte, inArray, lt, lte, ne } from 'drizzle-orm';
|
|
42
|
+
import { db, schema } from './client.js';
|
|
43
|
+
import { resolveActorLanes } from './actor-lanes.js';
|
|
44
|
+
import { listWorkspaces } from './queries.js';
|
|
45
|
+
// MIRRORED from @pierre-review/shared `ACTOR_LANES`, for the same release-guard reason the metric
|
|
46
|
+
// keys are (see below). Render order: people first, then the automation that AUTHORS code (the
|
|
47
|
+
// lanes that distort throughput), then the automation that RESPONDS to it (the lanes that distort
|
|
48
|
+
// review counts).
|
|
49
|
+
const ACTOR_LANE_ORDER = [
|
|
50
|
+
'human',
|
|
51
|
+
'code_agent',
|
|
52
|
+
'dependency',
|
|
53
|
+
'ai_review',
|
|
54
|
+
'quality_gate',
|
|
55
|
+
'release',
|
|
56
|
+
'housekeeping',
|
|
57
|
+
];
|
|
58
|
+
const { prComments, pullRequests, repos, reviewComments, reviews, reviewThreads } = schema;
|
|
59
|
+
// MIRRORED from @pierre-review/shared (PERIOD_METRICS_SCHEMA_VERSION / PERIOD_METRIC_KEYS).
|
|
60
|
+
// Inlined rather than imported for the same reason `AI_CREDITS_PER_USD` is inlined in
|
|
61
|
+
// db/credits.ts: the shared package is TYPES ONLY and is not shipped, so `build-release.mjs`
|
|
62
|
+
// greps `release/dist` and FAILS the build on a real runtime import of it. Two spellings, kept
|
|
63
|
+
// in lockstep — and `period-metrics.test.ts` imports the shared originals (a test is not in
|
|
64
|
+
// release/dist) and asserts the two arrays are identical, so the drift is caught in CI rather
|
|
65
|
+
// than by a reader.
|
|
66
|
+
export const PERIOD_METRICS_SCHEMA_VERSION = 2;
|
|
67
|
+
/** CLOSED + ORDERED at schema version 2. This order IS the render order and is part of the
|
|
68
|
+
* contract. Each human-only twin sits immediately after the blended figure it corrects — read
|
|
69
|
+
* adjacently they state the automation gap without narration. */
|
|
70
|
+
export const PERIOD_METRIC_KEYS = [
|
|
71
|
+
'merged_prs',
|
|
72
|
+
'human_merged_prs',
|
|
73
|
+
'opened_prs',
|
|
74
|
+
'automation_merge_share_pct',
|
|
75
|
+
'median_lead_time_hours',
|
|
76
|
+
'median_time_to_first_human_review_hours',
|
|
77
|
+
'merge_ci_success_pct',
|
|
78
|
+
'median_pr_size_lines',
|
|
79
|
+
'median_human_pr_size_lines',
|
|
80
|
+
'review_threads_opened',
|
|
81
|
+
'threads_replied_within_36h_pct',
|
|
82
|
+
'bot_review_comments',
|
|
83
|
+
'human_review_comments',
|
|
84
|
+
'bot_comments_per_merged_pr',
|
|
85
|
+
'reviewer_concentration_pct',
|
|
86
|
+
];
|
|
87
|
+
export const PERIOD_METRIC_META = {
|
|
88
|
+
merged_prs: { direction: 'up_good', sampleFloor: 5, absoluteFloor: 3 },
|
|
89
|
+
human_merged_prs: { direction: 'up_good', sampleFloor: 5, absoluteFloor: 3 },
|
|
90
|
+
opened_prs: { direction: 'neutral', sampleFloor: 5, absoluteFloor: 3 },
|
|
91
|
+
// NEUTRAL, and that is a product decision rather than a hedge. More automation is not
|
|
92
|
+
// self-evidently good (a team drowning in bumps) or bad (a team shipping with agents) — the
|
|
93
|
+
// lane split below is what makes the number readable, and an arrow claiming a direction would
|
|
94
|
+
// assert a judgement the figure cannot support.
|
|
95
|
+
automation_merge_share_pct: { direction: 'neutral', sampleFloor: 5, absoluteFloor: 5 },
|
|
96
|
+
median_lead_time_hours: { direction: 'down_good', sampleFloor: 5, absoluteFloor: 2 },
|
|
97
|
+
median_time_to_first_human_review_hours: {
|
|
98
|
+
direction: 'down_good',
|
|
99
|
+
sampleFloor: 5,
|
|
100
|
+
absoluteFloor: 1,
|
|
101
|
+
},
|
|
102
|
+
merge_ci_success_pct: { direction: 'up_good', sampleFloor: 5, absoluteFloor: 5 },
|
|
103
|
+
median_pr_size_lines: { direction: 'down_good', sampleFloor: 5, absoluteFloor: 20 },
|
|
104
|
+
median_human_pr_size_lines: { direction: 'down_good', sampleFloor: 5, absoluteFloor: 20 },
|
|
105
|
+
review_threads_opened: { direction: 'neutral', sampleFloor: 10, absoluteFloor: 5 },
|
|
106
|
+
threads_replied_within_36h_pct: { direction: 'up_good', sampleFloor: 10, absoluteFloor: 5 },
|
|
107
|
+
bot_review_comments: { direction: 'neutral', sampleFloor: 10, absoluteFloor: 5 },
|
|
108
|
+
human_review_comments: { direction: 'up_good', sampleFloor: 10, absoluteFloor: 5 },
|
|
109
|
+
bot_comments_per_merged_pr: { direction: 'neutral', sampleFloor: 5, absoluteFloor: 0.5 },
|
|
110
|
+
reviewer_concentration_pct: { direction: 'down_good', sampleFloor: 10, absoluteFloor: 5 },
|
|
111
|
+
};
|
|
112
|
+
// The reply grace for `threads_replied_within_36h_pct`. MIRRORED from `OVERDUE_GRACE_MS` in
|
|
113
|
+
// queries.ts (file-private there, and that file is the NUL-byte one — importing across it for a
|
|
114
|
+
// constant is not worth the coupling). Same 36h the bot verdict already treats as "a working day
|
|
115
|
+
// and a bit", so the two surfaces agree about what "responded promptly" means.
|
|
116
|
+
const REPLY_GRACE_MS = 36 * 60 * 60 * 1000;
|
|
117
|
+
// ── Scan caps (the ROLLUP_SCAN_CAP honesty rule) ─────────────────────────────────────────────
|
|
118
|
+
// A period is a sprint, not a year, so these are far past anything real: the busiest repo in the
|
|
119
|
+
// dev corpus contributes ~1000 merged PRs per 180 days. They exist so a pathological account
|
|
120
|
+
// cannot pull an unbounded result set into memory. Every capped scan is ordered so that what it
|
|
121
|
+
// keeps is the MOST RECENT slice rather than an arbitrary one.
|
|
122
|
+
const PERIOD_PR_SCAN_CAP = 20_000;
|
|
123
|
+
const PERIOD_THREAD_SCAN_CAP = 50_000;
|
|
124
|
+
const PERIOD_COMMENT_SCAN_CAP = 200_000;
|
|
125
|
+
// ⚠ A DIFFERENT KIND OF CAP FROM THE THREE ABOVE, and much smaller for a reason that is not
|
|
126
|
+
// memory. The first-human-review fold has to look up every review on a candidate PR — including
|
|
127
|
+
// ones OUTSIDE the window, since a PR first reviewed by a person in January must not be counted
|
|
128
|
+
// as newly reviewed now — so the candidate ids travel as BIND PARAMETERS in an `IN (…)`. SQLite
|
|
129
|
+
// caps those at 32,766 and Postgres at 65,535, so reusing `PERIOD_PR_SCAN_CAP` (20,000) would sit
|
|
130
|
+
// one busy quarter away from a hard driver error rather than a truncated result. A period is a
|
|
131
|
+
// sprint: 5,000 human-reviewed PRs in one is far beyond anything real.
|
|
132
|
+
const PERIOD_FIRST_REVIEW_PR_CAP = 5_000;
|
|
133
|
+
export function median(xs) {
|
|
134
|
+
if (xs.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
137
|
+
const mid = Math.floor(s.length / 2);
|
|
138
|
+
return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
139
|
+
}
|
|
140
|
+
/** 2dp — display figures. An unrounded 16.888888888888889 in a report cell is worse than
|
|
141
|
+
* useless, and an unrounded float also churns the fingerprint on re-computation.
|
|
142
|
+
* (Both exported for db/person-period.ts — the person vector rounds and medians identically.) */
|
|
143
|
+
export function round2(n) {
|
|
144
|
+
return Math.round(n * 100) / 100;
|
|
145
|
+
}
|
|
146
|
+
/** A count is a real observation: 0 merged PRs in a period is `0`, not "no data". Only an EMPTY
|
|
147
|
+
* SCOPE and a statistic with nothing behind it (a median of nothing, a percentage of nothing)
|
|
148
|
+
* are null. `null` is NOT `0` and must never render as `0`. */
|
|
149
|
+
const countCell = (n) => ({ value: n, sampleSize: n });
|
|
150
|
+
// ── The ONE definition of "time until a person reviewed it" ──────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// Extracted because it is read by TWO surfaces — the vector's
|
|
153
|
+
// `median_time_to_first_human_review_hours` and the lane panel's
|
|
154
|
+
// `medianTimeToFirstHumanReviewHours` — that sit one above the other on the same screen, and they
|
|
155
|
+
// disagreed on the live database the first time this shipped: 18.16h in the table against 18.27h
|
|
156
|
+
// in the panel below it.
|
|
157
|
+
//
|
|
158
|
+
// The cause was not rounding. The lane fold read only reviews INSIDE the window and took each
|
|
159
|
+
// PR's earliest of those, so a PR a person had reviewed in a previous period counted as freshly
|
|
160
|
+
// reviewed. The vector fold looked at all of time and required the first human review to fall in
|
|
161
|
+
// the window, which is the correct question. Two folds, one screen, two answers — and the panel's
|
|
162
|
+
// caption asserted they were the same measurement.
|
|
163
|
+
//
|
|
164
|
+
// So there is now one fold. Anything that wants this number calls this.
|
|
165
|
+
//
|
|
166
|
+
// EXPORTED (P4.2): the 1:1 person vector's "median hours THEIR PRs waited for a first human
|
|
167
|
+
// review" is this same measurement narrowed to one author — `authorUserId` narrows the candidate
|
|
168
|
+
// PR population (query 1; query 2 runs over those candidates, so one predicate covers both) and
|
|
169
|
+
// changes nothing else. A second fold of this number is the named shipped bug this comment block
|
|
170
|
+
// opens with; the narrowing parameter exists precisely so no one writes one.
|
|
171
|
+
export async function loadFirstHumanReviewHours(accountId, scope, from, to, lanes, authorUserId,
|
|
172
|
+
// Evidence sink (the People report's person-period evidence): when given, receives
|
|
173
|
+
// {prId, atMs} for EXACTLY the PRs whose hours entered the median — the sample population off
|
|
174
|
+
// this one fold, so no caller ever writes a sibling predicate to name it. Optional + append-
|
|
175
|
+
// only: both period-vector call sites pass nothing and are byte-identical to before.
|
|
176
|
+
samplesOut,
|
|
177
|
+
// Truncation sink (see `ReviewFoldTruncation`): when given, set to true if ANY of this fold's
|
|
178
|
+
// three caps cut the population — the two PERIOD_COMMENT_SCAN_CAP limits below, or the
|
|
179
|
+
// PERIOD_FIRST_REVIEW_PR_CAP candidate break.
|
|
180
|
+
//
|
|
181
|
+
// Optional and APPEND-ONLY for the same reason `samplesOut` is: this is THE ONE FOLD for this
|
|
182
|
+
// number (the shipped 18.16h-vs-18.27h bug this block opens with), so it gets extended, never
|
|
183
|
+
// forked, and the three call sites that pass nothing are byte-identical to before.
|
|
184
|
+
truncatedOut) {
|
|
185
|
+
// A scan that came back exactly at its cap is ASSUMED to have been cut — the `noteCap`
|
|
186
|
+
// convention db/flow-findings.ts uses. A second COUNT(*) is not worth it, and the cost of a
|
|
187
|
+
// false positive is one honest caveat on screen.
|
|
188
|
+
const noteTruncated = () => {
|
|
189
|
+
if (truncatedOut)
|
|
190
|
+
truncatedOut.truncated = true;
|
|
191
|
+
};
|
|
192
|
+
const inScope = and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), ...(authorUserId != null ? [eq(pullRequests.authorId, authorUserId)] : []));
|
|
193
|
+
// (1) CANDIDATES: every PR with a non-pending review submitted in the window. A superset of
|
|
194
|
+
// what we want — a PR whose first HUMAN review is in-window necessarily has a review in-window
|
|
195
|
+
// — narrowed by step (2). Newest-first so the dedupe keeps the most recent on hitting the cap.
|
|
196
|
+
const reviewedPrRows = await db
|
|
197
|
+
.select({ prId: reviews.prId })
|
|
198
|
+
.from(reviews)
|
|
199
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
200
|
+
.where(and(inScope, gte(reviews.submittedAt, from), lt(reviews.submittedAt, to), ne(reviews.state, 'pending')))
|
|
201
|
+
.orderBy(desc(reviews.submittedAt), desc(reviews.id))
|
|
202
|
+
.limit(PERIOD_COMMENT_SCAN_CAP)
|
|
203
|
+
.execute();
|
|
204
|
+
if (reviewedPrRows.length >= PERIOD_COMMENT_SCAN_CAP)
|
|
205
|
+
noteTruncated();
|
|
206
|
+
// Deduped in TS rather than with a `groupBy`, which keeps the query free of an aggregate:
|
|
207
|
+
// `min(submitted_at)` comes back as an epoch integer on SQLite and a Date on Postgres
|
|
208
|
+
// (drizzle's `mode: 'timestamp'` mapping applies to selected COLUMNS, not to `sql` fragments),
|
|
209
|
+
// and that is a dialect divergence this file has no reason to acquire for a trivial fold.
|
|
210
|
+
const candidatePrIds = [];
|
|
211
|
+
const seenPr = new Set();
|
|
212
|
+
for (const r of reviewedPrRows) {
|
|
213
|
+
if (seenPr.has(r.prId))
|
|
214
|
+
continue;
|
|
215
|
+
seenPr.add(r.prId);
|
|
216
|
+
candidatePrIds.push(r.prId);
|
|
217
|
+
if (candidatePrIds.length >= PERIOD_FIRST_REVIEW_PR_CAP) {
|
|
218
|
+
// The one cap a caller can NEVER infer from the return value: the ids dropped here never
|
|
219
|
+
// reach query (2), so the hours array is short by an unknowable amount rather than clipped
|
|
220
|
+
// at a recognisable length.
|
|
221
|
+
noteTruncated();
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (candidatePrIds.length === 0)
|
|
226
|
+
return [];
|
|
227
|
+
// (2) EVERY non-pending review on a candidate PR, across ALL TIME — deliberately NOT restricted
|
|
228
|
+
// to the window.
|
|
229
|
+
//
|
|
230
|
+
// ⚠ THE UNBOUNDED TIME RANGE IS THE WHOLE POINT and must not be "optimised" back to
|
|
231
|
+
// `[from, to)`. That optimisation is precisely the bug described above. A PR a person reviewed
|
|
232
|
+
// in January and revisited today would answer "first reviewed today", reporting a months-old
|
|
233
|
+
// review as fresh latency. Seeing the earlier review is the only way to rule it out.
|
|
234
|
+
//
|
|
235
|
+
// ASCENDING so each PR's earliest review survives the cap; a newest-first cap would drop
|
|
236
|
+
// exactly the rows the fold is looking for.
|
|
237
|
+
const rows = await db
|
|
238
|
+
.select({
|
|
239
|
+
prId: reviews.prId,
|
|
240
|
+
authorId: reviews.authorId,
|
|
241
|
+
submittedAt: reviews.submittedAt,
|
|
242
|
+
openedAt: pullRequests.openedAt,
|
|
243
|
+
})
|
|
244
|
+
.from(reviews)
|
|
245
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
246
|
+
.where(and(inArray(reviews.prId, candidatePrIds), ne(reviews.state, 'pending')))
|
|
247
|
+
.orderBy(reviews.submittedAt, reviews.id)
|
|
248
|
+
.limit(PERIOD_COMMENT_SCAN_CAP)
|
|
249
|
+
.execute();
|
|
250
|
+
if (rows.length >= PERIOD_COMMENT_SCAN_CAP)
|
|
251
|
+
noteTruncated();
|
|
252
|
+
const firstHumanByPr = new Map();
|
|
253
|
+
for (const r of rows) {
|
|
254
|
+
if (firstHumanByPr.has(r.prId))
|
|
255
|
+
continue;
|
|
256
|
+
if (lanes.laneOf(r.authorId) !== 'human')
|
|
257
|
+
continue;
|
|
258
|
+
firstHumanByPr.set(r.prId, { at: r.submittedAt.getTime(), openedAt: r.openedAt.getTime() });
|
|
259
|
+
}
|
|
260
|
+
const fromMs = from.getTime();
|
|
261
|
+
const toMs = to.getTime();
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const [prId, { at, openedAt }] of firstHumanByPr) {
|
|
264
|
+
if (at < fromMs || at >= toMs)
|
|
265
|
+
continue; // first human review was in some OTHER period
|
|
266
|
+
const hours = (at - openedAt) / 3_600_000;
|
|
267
|
+
// A review timestamped before its own PR opened is clock skew, not a negative latency.
|
|
268
|
+
// Excluding it is cheap and it is exactly the direction of error this metric exists to fix.
|
|
269
|
+
if (hours >= 0) {
|
|
270
|
+
out.push(hours);
|
|
271
|
+
samplesOut?.push({ prId, atMs: at });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/** A function, not a shared constant — the struct carries a Map, and one mutable module-level
|
|
277
|
+
* instance handed to every empty-scope call is a trap waiting for the first consumer that
|
|
278
|
+
* writes to it (the bot-volume `emptyBase` precedent). */
|
|
279
|
+
function emptyBase() {
|
|
280
|
+
return {
|
|
281
|
+
mergedPrs: [],
|
|
282
|
+
openedPrs: 0,
|
|
283
|
+
humanFirstReviewHours: [],
|
|
284
|
+
threadsOpened: 0,
|
|
285
|
+
threadsRepliedWithin: 0,
|
|
286
|
+
botComments: 0,
|
|
287
|
+
humanComments: 0,
|
|
288
|
+
reviewsByHuman: new Map(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async function loadPeriodBase(accountId, scope, from, to) {
|
|
292
|
+
const repoIds = scope.repoIds;
|
|
293
|
+
// ⚠ THE AUTOMATION SET IS THE LANE RESOLVER'S UNION, NOT `automatedReviewerUserIds` ALONE.
|
|
294
|
+
//
|
|
295
|
+
// The workspace verdict still decides who counts as a bot, but on its own it MISSES the second
|
|
296
|
+
// row of a duplicated identity: `dependabot` and `dependabot[bot]` are separate `users` rows on
|
|
297
|
+
// real accounts and one of each pair sat at `automated = 0`, i.e. was counted as a person. That
|
|
298
|
+
// is what put bot text in `human_review_comments` and bot reviewers in the concentration
|
|
299
|
+
// figure. `resolveActorLanes` unions the verdict with `users.isBot` and the login vocabularies
|
|
300
|
+
// and subtracts anyone a human vouched for, which is the same set the lane panel renders — so
|
|
301
|
+
// the metric table and the lane breakdown under it can no longer disagree about who is a person.
|
|
302
|
+
//
|
|
303
|
+
// Role stays `'all'` inside that resolver by the ROI/flagging convention: a quality check posts
|
|
304
|
+
// exactly the kind of text `bot_review_comments` counts, and narrowing to reviewers would make
|
|
305
|
+
// the bot and human halves fail to sum to the total.
|
|
306
|
+
const lanes = await resolveActorLanes(accountId, scope);
|
|
307
|
+
const automatedIds = [...lanes.automatedIds];
|
|
308
|
+
const inScope = and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds));
|
|
309
|
+
const [mergedRows, openedRow, humanFirstReviewHours, threadRows, commentRows, reviewRows, botCommentRow] = await Promise.all([
|
|
310
|
+
// (1) PRs MERGED in the window — the population behind metrics 1, 3, 5, 6 and the
|
|
311
|
+
// denominator of 11. Newest-merged first so the cap keeps a recent sample.
|
|
312
|
+
db
|
|
313
|
+
.select({
|
|
314
|
+
authorId: pullRequests.authorId,
|
|
315
|
+
openedAt: pullRequests.openedAt,
|
|
316
|
+
mergedAt: pullRequests.mergedAt,
|
|
317
|
+
additions: pullRequests.additions,
|
|
318
|
+
deletions: pullRequests.deletions,
|
|
319
|
+
changedFiles: pullRequests.changedFiles,
|
|
320
|
+
ciStatus: pullRequests.ciStatus,
|
|
321
|
+
})
|
|
322
|
+
.from(pullRequests)
|
|
323
|
+
.where(and(inScope, gte(pullRequests.mergedAt, from), lt(pullRequests.mergedAt, to)))
|
|
324
|
+
.orderBy(desc(pullRequests.mergedAt), desc(pullRequests.id))
|
|
325
|
+
.limit(PERIOD_PR_SCAN_CAP)
|
|
326
|
+
.execute(),
|
|
327
|
+
// (2) PRs OPENED in the window. A bare count — no row is needed, and an exact aggregate
|
|
328
|
+
// cannot be truncated. Deliberately NOT restricted to merged PRs: a period's intake is
|
|
329
|
+
// intake whether or not it landed.
|
|
330
|
+
db
|
|
331
|
+
.select({ c: count() })
|
|
332
|
+
.from(pullRequests)
|
|
333
|
+
.where(and(inScope, gte(pullRequests.openedAt, from), lt(pullRequests.openedAt, to)))
|
|
334
|
+
.execute(),
|
|
335
|
+
// (4) Hours from open to the FIRST HUMAN REVIEW, for PRs whose first human review landed
|
|
336
|
+
// in this window. ONE shared fold — see `loadFirstHumanReviewHours` for why it is not
|
|
337
|
+
// inlined here and why it must look outside the window to answer correctly.
|
|
338
|
+
loadFirstHumanReviewHours(accountId, scope, from, to, lanes),
|
|
339
|
+
// (7) Review threads whose ROOT COMMENT lands in the window. `reviewThreads.createdAt` IS
|
|
340
|
+
// the root comment's timestamp (sync writes it from `comments.nodes[0].createdAt`), so no
|
|
341
|
+
// second lookup is needed to bucket a thread.
|
|
342
|
+
db
|
|
343
|
+
.select({ id: reviewThreads.id, createdAt: reviewThreads.createdAt })
|
|
344
|
+
.from(reviewThreads)
|
|
345
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
|
|
346
|
+
.where(and(inScope, gte(reviewThreads.createdAt, from), lt(reviewThreads.createdAt, to)))
|
|
347
|
+
.orderBy(desc(reviewThreads.createdAt), desc(reviewThreads.id))
|
|
348
|
+
.limit(PERIOD_THREAD_SCAN_CAP)
|
|
349
|
+
.execute(),
|
|
350
|
+
// (8) The comments on those threads, for the reply test.
|
|
351
|
+
//
|
|
352
|
+
// The comment window is `[from, to + 36h)`: a reply that qualifies must be within the
|
|
353
|
+
// grace of a root that itself started in-window, so nothing outside that span can change
|
|
354
|
+
// the answer — and bounding it keeps the scan proportional to the period rather than to
|
|
355
|
+
// the age of the longest-lived thread.
|
|
356
|
+
//
|
|
357
|
+
// ⚠ ORDERED **ASCENDING**, the opposite of every other capped scan here, and that
|
|
358
|
+
// inversion is load-bearing: this fold needs each thread's EARLIEST two comments. A
|
|
359
|
+
// newest-first cap would drop the ROOTS first and silently turn late replies into roots.
|
|
360
|
+
db
|
|
361
|
+
.select({ threadId: reviewComments.threadId, createdAt: reviewComments.createdAt })
|
|
362
|
+
.from(reviewComments)
|
|
363
|
+
.innerJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
|
|
364
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
|
|
365
|
+
.where(and(inScope, gte(reviewThreads.createdAt, from), lt(reviewThreads.createdAt, to), gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, new Date(to.getTime() + REPLY_GRACE_MS))))
|
|
366
|
+
.orderBy(reviewComments.createdAt, reviewComments.id)
|
|
367
|
+
.limit(PERIOD_COMMENT_SCAN_CAP)
|
|
368
|
+
.execute(),
|
|
369
|
+
// (12) Submitted reviews in the window, per author. `pending` reviews are drafts,
|
|
370
|
+
// invisible on GitHub — the same predicate every other review-activity reader uses.
|
|
371
|
+
// Exact aggregate, no cap.
|
|
372
|
+
db
|
|
373
|
+
.select({ authorId: reviews.authorId, c: count() })
|
|
374
|
+
.from(reviews)
|
|
375
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
376
|
+
.where(and(inScope, gte(reviews.submittedAt, from), lt(reviews.submittedAt, to), ne(reviews.state, 'pending')))
|
|
377
|
+
.groupBy(reviews.authorId)
|
|
378
|
+
.execute(),
|
|
379
|
+
// (9) Bot-authored review comments in the window. Counted as its own exact aggregate and
|
|
380
|
+
// subtracted from the total below, rather than as a `notInArray` twin: `notInArray` drops
|
|
381
|
+
// NULL authors in both dialects (SQL three-valued logic), which would silently lose every
|
|
382
|
+
// comment by a since-deleted GitHub account from the human half.
|
|
383
|
+
automatedIds.length === 0
|
|
384
|
+
? Promise.resolve([{ c: 0 }])
|
|
385
|
+
: db
|
|
386
|
+
.select({ c: count() })
|
|
387
|
+
.from(reviewComments)
|
|
388
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
389
|
+
.where(and(inScope, gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to), inArray(reviewComments.authorId, automatedIds)))
|
|
390
|
+
.execute(),
|
|
391
|
+
]);
|
|
392
|
+
// (9 + 10) The total, so `human = total − bot` and the two halves always sum. A comment whose
|
|
393
|
+
// author is NULL (deleted account) lands in the HUMAN half — by the definition's own words
|
|
394
|
+
// ("NOT authored by an automated reviewer") and because the alternative is a third bucket
|
|
395
|
+
// nothing renders.
|
|
396
|
+
const [totalCommentRow] = await db
|
|
397
|
+
.select({ c: count() })
|
|
398
|
+
.from(reviewComments)
|
|
399
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
400
|
+
.where(and(inScope, gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to)))
|
|
401
|
+
.execute();
|
|
402
|
+
const mergedPrs = [];
|
|
403
|
+
for (const r of mergedRows) {
|
|
404
|
+
// `mergedAt` cannot be null here (the two-sided predicate excludes nulls in both dialects),
|
|
405
|
+
// but the COLUMN is nullable, so the narrowing is explicit rather than a `!`.
|
|
406
|
+
if (!r.mergedAt)
|
|
407
|
+
continue;
|
|
408
|
+
// ⚠ UNKNOWN SIZE, NOT ZERO SIZE — the bot-volume.ts rule. Under lean storage these three
|
|
409
|
+
// columns default to 0, so a PR whose detail never hydrated looks exactly like an empty one
|
|
410
|
+
// (135 of three.js's 796 merged PRs are in that state in the dev corpus). Feeding a
|
|
411
|
+
// fabricated 0 into a MEDIAN drags it towards zero by however many rows never hydrated,
|
|
412
|
+
// which is the most convincing kind of wrong number.
|
|
413
|
+
const observed = r.changedFiles > 0 || r.additions > 0 || r.deletions > 0;
|
|
414
|
+
// ⚠ `ciStatus` IS A HEAD-COMMIT SNAPSHOT, and it is window-pure ONLY because the population
|
|
415
|
+
// is MERGED PRs: a merged PR's head never moves again, so the value is frozen at merge time
|
|
416
|
+
// and the metric stays reproducible. It would NOT be for open PRs — one more reason the
|
|
417
|
+
// population is what it is.
|
|
418
|
+
//
|
|
419
|
+
// Only the DETERMINATE outcomes are in the denominator. `null` means never observed (no CI
|
|
420
|
+
// configured, or a row that predates the column), and 'pending'/'expected'/'unknown' mean we
|
|
421
|
+
// genuinely do not know — counting any of them as a CI failure would report a repo with no
|
|
422
|
+
// CI at all as 0% green, which is a lie with a number attached.
|
|
423
|
+
const ci = r.ciStatus;
|
|
424
|
+
mergedPrs.push({
|
|
425
|
+
leadMs: r.mergedAt.getTime() - r.openedAt.getTime(),
|
|
426
|
+
loc: observed ? r.additions + r.deletions : null,
|
|
427
|
+
ciDeterminate: ci === 'success' || ci === 'failure' || ci === 'error',
|
|
428
|
+
ciSuccess: ci === 'success',
|
|
429
|
+
lane: lanes.laneOf(r.authorId),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
// The reply fold. Per thread, the two earliest comment timestamps: `t[0]` is the root and
|
|
433
|
+
// `t[1]` is the first reply, so "replied within 36h" is `t[1] - t[0] <= REPLY_GRACE_MS`.
|
|
434
|
+
//
|
|
435
|
+
// Reading the ROOT off the comment set rather than off `reviewThreads.createdAt` is deliberate
|
|
436
|
+
// and costs nothing: it is immune to a same-second sibling (sqlite stores timestamps as epoch
|
|
437
|
+
// SECONDS, so a bot posting twice in one second would defeat a strict `createdAt > root`
|
|
438
|
+
// comparison) and to the degenerate thread whose `createdAt` fell back to the PR's.
|
|
439
|
+
//
|
|
440
|
+
// ⚠ KNOWN SOFTNESS, and it is the definition's ("any reply"), not an oversight: the author of
|
|
441
|
+
// the follow-up is not checked, so a bot that appends to its OWN thread counts as a reply.
|
|
442
|
+
// Narrowing it to "a different author" would be a different metric and would need its own
|
|
443
|
+
// schema version.
|
|
444
|
+
const firstTwo = new Map();
|
|
445
|
+
for (const c of commentRows) {
|
|
446
|
+
const seen = firstTwo.get(c.threadId);
|
|
447
|
+
if (seen == null)
|
|
448
|
+
firstTwo.set(c.threadId, [c.createdAt.getTime()]);
|
|
449
|
+
else if (seen.length < 2)
|
|
450
|
+
seen.push(c.createdAt.getTime());
|
|
451
|
+
}
|
|
452
|
+
let threadsRepliedWithin = 0;
|
|
453
|
+
for (const t of threadRows) {
|
|
454
|
+
const seen = firstTwo.get(t.id);
|
|
455
|
+
// No comment rows at all ⇒ nothing to reply to and nothing that replied. Not an error: a
|
|
456
|
+
// thread outside the (capped) scan lands here too, which is why the cap is set where it is.
|
|
457
|
+
if (!seen || seen.length < 2)
|
|
458
|
+
continue;
|
|
459
|
+
if (seen[1] - seen[0] <= REPLY_GRACE_MS)
|
|
460
|
+
threadsRepliedWithin += 1;
|
|
461
|
+
}
|
|
462
|
+
// ⚠ REVIEWER CONCENTRATION IS OVER **HUMAN** REVIEWERS. Included, a bot that reviews every PR
|
|
463
|
+
// pins the metric near 100% forever and it stops moving — a dead row on the report, in exactly
|
|
464
|
+
// the bot-heavy workspaces this product is for. The question the metric answers ("is review
|
|
465
|
+
// load resting on one person?") is a bus-factor question, and an always-on bot is not a bus
|
|
466
|
+
// factor. Any caption for it must say "among human reviewers".
|
|
467
|
+
const automated = new Set(automatedIds);
|
|
468
|
+
const reviewsByHuman = new Map();
|
|
469
|
+
for (const r of reviewRows) {
|
|
470
|
+
// A null author is a deleted account: unattributable, so it cannot be anyone's share.
|
|
471
|
+
if (r.authorId == null || automated.has(r.authorId))
|
|
472
|
+
continue;
|
|
473
|
+
reviewsByHuman.set(r.authorId, (reviewsByHuman.get(r.authorId) ?? 0) + r.c);
|
|
474
|
+
}
|
|
475
|
+
const botComments = botCommentRow[0]?.c ?? 0;
|
|
476
|
+
const totalComments = totalCommentRow?.c ?? 0;
|
|
477
|
+
return {
|
|
478
|
+
mergedPrs,
|
|
479
|
+
openedPrs: openedRow[0]?.c ?? 0,
|
|
480
|
+
humanFirstReviewHours,
|
|
481
|
+
threadsOpened: threadRows.length,
|
|
482
|
+
threadsRepliedWithin,
|
|
483
|
+
botComments,
|
|
484
|
+
humanComments: Math.max(0, totalComments - botComments),
|
|
485
|
+
reviewsByHuman,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// ── The fingerprint ──────────────────────────────────────────────────────────────────────────
|
|
489
|
+
/**
|
|
490
|
+
* sha256 over the raw per-metric figures plus the exact scope and window that produced them.
|
|
491
|
+
*
|
|
492
|
+
* ⚠ NOTHING `Date.now()`-DERIVED MAY ENTER IT. This is the payload-hash trap this codebase has
|
|
493
|
+
* hit repeatedly (the digest cost trap, the annotation hunk): a hash that folds in a moving
|
|
494
|
+
* value differs on every recomputation, so a stored report would read `stale` forever and the
|
|
495
|
+
* UI would invite a re-generation that changes nothing and costs money. Everything below is a
|
|
496
|
+
* function of stored rows and the caller's fixed window.
|
|
497
|
+
*
|
|
498
|
+
* The scope and the window are IN the hash on purpose. §6.4's membership drift is a real reason
|
|
499
|
+
* for a stored report to go stale: adding a repo to the workspace changes what "this period"
|
|
500
|
+
* means even when not one existing row moved.
|
|
501
|
+
*/
|
|
502
|
+
function fingerprintOf(scope, window, cells) {
|
|
503
|
+
const parts = [
|
|
504
|
+
`v${PERIOD_METRICS_SCHEMA_VERSION}`,
|
|
505
|
+
`ws:${scope.workspaceId}`,
|
|
506
|
+
`repos:${[...scope.repoIds].sort((a, b) => a - b).join(',')}`,
|
|
507
|
+
`win:${window.fromMs}-${window.toMs}`,
|
|
508
|
+
];
|
|
509
|
+
for (const key of PERIOD_METRIC_KEYS) {
|
|
510
|
+
const cell = cells[key];
|
|
511
|
+
parts.push(`${key}=${cell.value ?? '~'}/${cell.sampleSize}`);
|
|
512
|
+
}
|
|
513
|
+
return createHash('sha256').update(parts.join('|'), 'utf8').digest('hex');
|
|
514
|
+
}
|
|
515
|
+
// ── (1) The vector ───────────────────────────────────────────────────────────────────────────
|
|
516
|
+
export async function getPeriodMetrics(accountId,
|
|
517
|
+
// `workspaceId` decides who counts as a bot; `repoIds` narrows what is measured. A BotScope is
|
|
518
|
+
// only ever built by `resolveWorkspaceScope`, so `repoIds ⊆ the workspace's membership`.
|
|
519
|
+
scope, window) {
|
|
520
|
+
const from = new Date(window.fromMs);
|
|
521
|
+
const to = new Date(window.toMs);
|
|
522
|
+
// `repoIds: []` is a real empty workspace — every metric is `null` with sampleSize 0, and it
|
|
523
|
+
// is NOT an error and NOT a widening to the account. (`from >= to` is degenerate but legal:
|
|
524
|
+
// the two-sided predicates simply match nothing, so it falls out as an all-empty base rather
|
|
525
|
+
// than needing a guard that would have to decide what to throw.)
|
|
526
|
+
const empty = scope.repoIds.length === 0;
|
|
527
|
+
const base = empty ? emptyBase() : await loadPeriodBase(accountId, scope, from, to);
|
|
528
|
+
const merged = base.mergedPrs.length;
|
|
529
|
+
const sized = base.mergedPrs.filter((p) => p.loc != null);
|
|
530
|
+
const ciKnown = base.mergedPrs.filter((p) => p.ciDeterminate);
|
|
531
|
+
const ciGreen = ciKnown.filter((p) => p.ciSuccess).length;
|
|
532
|
+
const humanReviews = [...base.reviewsByHuman.values()];
|
|
533
|
+
const totalHumanReviews = humanReviews.reduce((n, c) => n + c, 0);
|
|
534
|
+
const busiestReviewer = humanReviews.reduce((m, c) => (c > m ? c : m), 0);
|
|
535
|
+
const leadHours = base.mergedPrs.map((p) => p.leadMs / 3_600_000);
|
|
536
|
+
const reviewHours = base.humanFirstReviewHours;
|
|
537
|
+
const sizes = sized.map((p) => p.loc);
|
|
538
|
+
// The human-only twins. `human` here is the AUTHOR's lane, so a PR opened by a person and
|
|
539
|
+
// merged by a merge queue is still human work — "who merged it" is never a proxy for "who did
|
|
540
|
+
// the work", which is exactly why `release` is its own lane rather than folded into throughput.
|
|
541
|
+
const humanMerged = base.mergedPrs.filter((p) => p.lane === 'human');
|
|
542
|
+
const humanSizes = humanMerged.filter((p) => p.loc != null).map((p) => p.loc);
|
|
543
|
+
const automationMerged = merged - humanMerged.length;
|
|
544
|
+
const nullCell = { value: null, sampleSize: 0 };
|
|
545
|
+
const pct = (numerator, denominator) => denominator === 0
|
|
546
|
+
? nullCell
|
|
547
|
+
: { value: round2((numerator / denominator) * 100), sampleSize: denominator };
|
|
548
|
+
const med = (xs) => {
|
|
549
|
+
const m = median(xs);
|
|
550
|
+
return m == null ? nullCell : { value: round2(m), sampleSize: xs.length };
|
|
551
|
+
};
|
|
552
|
+
// `Record<PeriodMetricKey, …>` — a metric missing from EITHER branch is a COMPILE error, which
|
|
553
|
+
// is the only thing guaranteeing the response always carries all twelve and is why the empty
|
|
554
|
+
// branch is spelled out rather than built by a loop (a loop would need a cast, and the cast is
|
|
555
|
+
// exactly what would let a future 13th metric go missing here in silence).
|
|
556
|
+
//
|
|
557
|
+
// ⚠ THE EMPTY BRANCH NULLS THE **COUNTS** TOO. It is not the same answer as a populated scope
|
|
558
|
+
// that happened to see nothing: "this workspace has no repos" is not "this workspace merged
|
|
559
|
+
// nothing", and a 0 in that slot renders as a real observation.
|
|
560
|
+
const cells = empty
|
|
561
|
+
? {
|
|
562
|
+
merged_prs: nullCell,
|
|
563
|
+
human_merged_prs: nullCell,
|
|
564
|
+
opened_prs: nullCell,
|
|
565
|
+
automation_merge_share_pct: nullCell,
|
|
566
|
+
median_lead_time_hours: nullCell,
|
|
567
|
+
median_time_to_first_human_review_hours: nullCell,
|
|
568
|
+
merge_ci_success_pct: nullCell,
|
|
569
|
+
median_pr_size_lines: nullCell,
|
|
570
|
+
median_human_pr_size_lines: nullCell,
|
|
571
|
+
review_threads_opened: nullCell,
|
|
572
|
+
threads_replied_within_36h_pct: nullCell,
|
|
573
|
+
bot_review_comments: nullCell,
|
|
574
|
+
human_review_comments: nullCell,
|
|
575
|
+
bot_comments_per_merged_pr: nullCell,
|
|
576
|
+
reviewer_concentration_pct: nullCell,
|
|
577
|
+
}
|
|
578
|
+
: {
|
|
579
|
+
merged_prs: countCell(merged),
|
|
580
|
+
human_merged_prs: countCell(humanMerged.length),
|
|
581
|
+
opened_prs: countCell(base.openedPrs),
|
|
582
|
+
// A SHARE OF NOTHING IS NOT ZERO. With no merges the question has no denominator, and a
|
|
583
|
+
// 0% here would read as "all of it was people" — the opposite failure to the one the
|
|
584
|
+
// metric exists to prevent.
|
|
585
|
+
automation_merge_share_pct: pct(automationMerged, merged),
|
|
586
|
+
median_lead_time_hours: med(leadHours),
|
|
587
|
+
median_time_to_first_human_review_hours: med(reviewHours),
|
|
588
|
+
merge_ci_success_pct: pct(ciGreen, ciKnown.length),
|
|
589
|
+
median_pr_size_lines: med(sizes),
|
|
590
|
+
median_human_pr_size_lines: med(humanSizes),
|
|
591
|
+
review_threads_opened: countCell(base.threadsOpened),
|
|
592
|
+
threads_replied_within_36h_pct: pct(base.threadsRepliedWithin, base.threadsOpened),
|
|
593
|
+
bot_review_comments: countCell(base.botComments),
|
|
594
|
+
human_review_comments: countCell(base.humanComments),
|
|
595
|
+
// A ratio, not a count: no merged PRs means the question has no denominator, so it is
|
|
596
|
+
// null rather than the 0 that "the bots said nothing" would also produce.
|
|
597
|
+
bot_comments_per_merged_pr: merged === 0
|
|
598
|
+
? nullCell
|
|
599
|
+
: { value: round2(base.botComments / merged), sampleSize: merged },
|
|
600
|
+
reviewer_concentration_pct: pct(busiestReviewer, totalHumanReviews),
|
|
601
|
+
};
|
|
602
|
+
return {
|
|
603
|
+
// `lowSample` is stamped HERE, beside the floors, and travels on the wire. The alternative —
|
|
604
|
+
// shipping `sampleSize` and letting the SPA compare it against its own copy of the floors —
|
|
605
|
+
// puts the same constants in two repos, and the first time one of them is tuned the marker
|
|
606
|
+
// silently disagrees with the significance rule computed from the other.
|
|
607
|
+
//
|
|
608
|
+
// It answers a DIFFERENT question from `PeriodMetricDelta.significant`: this says the FIGURE
|
|
609
|
+
// is thin, that says the CHANGE is not worth asserting. A metric can easily be one and not the
|
|
610
|
+
// other, and "0h from 2 reviews" needs saying even when nothing moved.
|
|
611
|
+
metrics: PERIOD_METRIC_KEYS.map((key) => ({
|
|
612
|
+
key,
|
|
613
|
+
...cells[key],
|
|
614
|
+
lowSample: cells[key].sampleSize < PERIOD_METRIC_META[key].sampleFloor,
|
|
615
|
+
})),
|
|
616
|
+
fingerprint: fingerprintOf(scope, window, cells),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
// ── (2) Coverage ─────────────────────────────────────────────────────────────────────────────
|
|
620
|
+
/**
|
|
621
|
+
* Which of `repoIds` were already being TRACKED at `atMs` — i.e. `repos.createdAt <= atMs`.
|
|
622
|
+
*
|
|
623
|
+
* ⚠ THIS IS THE RULE THAT KEEPS THE FORECAST HONEST, and it is not optional. See the header:
|
|
624
|
+
* a naive period-over-period sweep over this dataset shows a merged-PR count climbing from 39 to
|
|
625
|
+
* 570 over six months, which is entirely repo onboarding. A comparison is drawn over the
|
|
626
|
+
* INTERSECTION of two periods' tracked sets so it is like-for-like, and a forecast series uses
|
|
627
|
+
* only periods whose coverage is complete for that subset.
|
|
628
|
+
*
|
|
629
|
+
* `repos.createdAt` is when the repo was added to THIS account, not when it was created on
|
|
630
|
+
* GitHub — the same column My Turn's "New PRs" cutoff rides, and it is load-bearing for the same
|
|
631
|
+
* reason: it is the earliest instant we could possibly know anything about the repo.
|
|
632
|
+
*
|
|
633
|
+
* The bound is INCLUSIVE (`<=`), unlike the metric window's half-open upper edge: a repo added
|
|
634
|
+
* at the exact instant a period starts contributed to the whole of it.
|
|
635
|
+
*/
|
|
636
|
+
export async function getPeriodCoverage(accountId, repoIds, atMs) {
|
|
637
|
+
if (repoIds.length === 0)
|
|
638
|
+
return { trackedRepoIds: [] };
|
|
639
|
+
const rows = await db
|
|
640
|
+
.select({ id: repos.id })
|
|
641
|
+
.from(repos)
|
|
642
|
+
.where(and(eq(repos.accountId, accountId), inArray(repos.id, repoIds),
|
|
643
|
+
// ⚠ A REAL `lte`, never `lt(…, atMs + 1)`. SQLite stores `mode: 'timestamp'` as epoch
|
|
644
|
+
// SECONDS, so a one-millisecond nudge lands in the SAME stored value and would silently
|
|
645
|
+
// flip the boundary from inclusive to exclusive — in one dialect only.
|
|
646
|
+
lte(repos.createdAt, new Date(atMs))))
|
|
647
|
+
.execute();
|
|
648
|
+
// Ascending so a stored `repo_ids_json` and a recomputed one compare byte-for-byte.
|
|
649
|
+
return { trackedRepoIds: rows.map((r) => r.id).sort((a, b) => a - b) };
|
|
650
|
+
}
|
|
651
|
+
export async function getPeriodLanes(accountId, scope, window) {
|
|
652
|
+
const from = new Date(window.fromMs);
|
|
653
|
+
const to = new Date(window.toMs);
|
|
654
|
+
const empty = () => ({
|
|
655
|
+
lanes: ACTOR_LANE_ORDER.map((lane) => ({
|
|
656
|
+
lane,
|
|
657
|
+
mergedPrs: 0,
|
|
658
|
+
openedPrs: 0,
|
|
659
|
+
medianPrSizeLines: null,
|
|
660
|
+
medianLeadTimeHours: null,
|
|
661
|
+
comments: 0,
|
|
662
|
+
approvals: 0,
|
|
663
|
+
})),
|
|
664
|
+
automationMergeSharePct: null,
|
|
665
|
+
medianTimeToFirstHumanReviewHours: null,
|
|
666
|
+
silentAutomation: [],
|
|
667
|
+
});
|
|
668
|
+
if (scope.repoIds.length === 0)
|
|
669
|
+
return empty();
|
|
670
|
+
const lanes = await resolveActorLanes(accountId, scope);
|
|
671
|
+
const inScope = and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds));
|
|
672
|
+
// ⚠ THE FIRST-HUMAN-REVIEW FIGURE COMES FROM THE SHARED FOLD, not from a per-review query here.
|
|
673
|
+
//
|
|
674
|
+
// It used to have its own, over reviews INSIDE the window only — which meant a PR a person had
|
|
675
|
+
// reviewed in a previous period counted as freshly reviewed. On the live database that reported
|
|
676
|
+
// 18.27h in this panel against the vector's 18.16h, one directly above the other, with a caption
|
|
677
|
+
// claiming they were the same measurement. They are the same measurement now because they are
|
|
678
|
+
// the same code.
|
|
679
|
+
const [merged, opened, inlineRows, issueRows, reviewRows, humanFirstReviewHours] = await Promise.all([
|
|
680
|
+
db
|
|
681
|
+
.select({
|
|
682
|
+
authorId: pullRequests.authorId,
|
|
683
|
+
openedAt: pullRequests.openedAt,
|
|
684
|
+
mergedAt: pullRequests.mergedAt,
|
|
685
|
+
additions: pullRequests.additions,
|
|
686
|
+
deletions: pullRequests.deletions,
|
|
687
|
+
changedFiles: pullRequests.changedFiles,
|
|
688
|
+
})
|
|
689
|
+
.from(pullRequests)
|
|
690
|
+
.where(and(inScope, gte(pullRequests.mergedAt, from), lt(pullRequests.mergedAt, to)))
|
|
691
|
+
.orderBy(desc(pullRequests.mergedAt), desc(pullRequests.id))
|
|
692
|
+
.limit(PERIOD_PR_SCAN_CAP)
|
|
693
|
+
.execute(),
|
|
694
|
+
db
|
|
695
|
+
.select({ authorId: pullRequests.authorId, c: count() })
|
|
696
|
+
.from(pullRequests)
|
|
697
|
+
.where(and(inScope, gte(pullRequests.openedAt, from), lt(pullRequests.openedAt, to)))
|
|
698
|
+
.groupBy(pullRequests.authorId)
|
|
699
|
+
.execute(),
|
|
700
|
+
db
|
|
701
|
+
.select({ authorId: reviewComments.authorId, c: count() })
|
|
702
|
+
.from(reviewComments)
|
|
703
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
|
|
704
|
+
.where(and(inScope, gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to)))
|
|
705
|
+
.groupBy(reviewComments.authorId)
|
|
706
|
+
.execute(),
|
|
707
|
+
db
|
|
708
|
+
.select({ authorId: prComments.authorId, c: count() })
|
|
709
|
+
.from(prComments)
|
|
710
|
+
.innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
|
|
711
|
+
.where(and(inScope, gte(prComments.createdAt, from), lt(prComments.createdAt, to)))
|
|
712
|
+
.groupBy(prComments.authorId)
|
|
713
|
+
.execute(),
|
|
714
|
+
// Bodies and approvals in ONE pass, split by state on the fold. `pending` is a draft nobody
|
|
715
|
+
// can see — the same exclusion every other review reader applies.
|
|
716
|
+
db
|
|
717
|
+
.select({ authorId: reviews.authorId, state: reviews.state, body: reviews.body, c: count() })
|
|
718
|
+
.from(reviews)
|
|
719
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
720
|
+
.where(and(inScope, gte(reviews.submittedAt, from), lt(reviews.submittedAt, to), ne(reviews.state, 'pending')))
|
|
721
|
+
.groupBy(reviews.authorId, reviews.state, reviews.body)
|
|
722
|
+
.execute(),
|
|
723
|
+
loadFirstHumanReviewHours(accountId, scope, from, to, lanes),
|
|
724
|
+
]);
|
|
725
|
+
const blank = () => ({ mergedPrs: 0, openedPrs: 0, sizes: [], leads: [], comments: 0, approvals: 0 });
|
|
726
|
+
const acc = new Map(ACTOR_LANE_ORDER.map((l) => [l, blank()]));
|
|
727
|
+
const bump = (lane) => acc.get(lane) ?? acc.set(lane, blank()).get(lane);
|
|
728
|
+
const active = new Set();
|
|
729
|
+
for (const r of merged) {
|
|
730
|
+
const lane = lanes.laneOf(r.authorId);
|
|
731
|
+
const a = bump(lane);
|
|
732
|
+
a.mergedPrs += 1;
|
|
733
|
+
if (r.authorId != null)
|
|
734
|
+
active.add(r.authorId);
|
|
735
|
+
// The SIZED rule, identical to the vector's: an unhydrated PR is indistinguishable from an
|
|
736
|
+
// empty one, so it contributes a lead time but not a size.
|
|
737
|
+
if ((r.changedFiles ?? 0) > 0 || (r.additions ?? 0) > 0 || (r.deletions ?? 0) > 0) {
|
|
738
|
+
a.sizes.push((r.additions ?? 0) + (r.deletions ?? 0));
|
|
739
|
+
}
|
|
740
|
+
if (r.mergedAt != null && r.openedAt != null) {
|
|
741
|
+
a.leads.push((r.mergedAt.getTime() - r.openedAt.getTime()) / 3_600_000);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const r of opened) {
|
|
745
|
+
bump(lanes.laneOf(r.authorId)).openedPrs += r.c;
|
|
746
|
+
if (r.authorId != null && r.c > 0)
|
|
747
|
+
active.add(r.authorId);
|
|
748
|
+
}
|
|
749
|
+
for (const rows of [inlineRows, issueRows]) {
|
|
750
|
+
for (const r of rows) {
|
|
751
|
+
bump(lanes.laneOf(r.authorId)).comments += r.c;
|
|
752
|
+
if (r.authorId != null && r.c > 0)
|
|
753
|
+
active.add(r.authorId);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
for (const r of reviewRows) {
|
|
757
|
+
const a = bump(lanes.laneOf(r.authorId));
|
|
758
|
+
if (r.state === 'approved')
|
|
759
|
+
a.approvals += r.c;
|
|
760
|
+
// A review BODY is a comment; an empty-bodied approval is not.
|
|
761
|
+
if (r.body != null && r.body.trim().length > 0)
|
|
762
|
+
a.comments += r.c;
|
|
763
|
+
if (r.authorId != null && r.c > 0)
|
|
764
|
+
active.add(r.authorId);
|
|
765
|
+
}
|
|
766
|
+
const out = ACTOR_LANE_ORDER.map((lane) => {
|
|
767
|
+
const a = acc.get(lane) ?? blank();
|
|
768
|
+
return {
|
|
769
|
+
lane,
|
|
770
|
+
mergedPrs: a.mergedPrs,
|
|
771
|
+
openedPrs: a.openedPrs,
|
|
772
|
+
medianPrSizeLines: median(a.sizes),
|
|
773
|
+
medianLeadTimeHours: median(a.leads),
|
|
774
|
+
comments: a.comments,
|
|
775
|
+
approvals: a.approvals,
|
|
776
|
+
};
|
|
777
|
+
});
|
|
778
|
+
const totalMerged = out.reduce((n, l) => n + l.mergedPrs, 0);
|
|
779
|
+
const automatedMerged = out
|
|
780
|
+
.filter((l) => l.lane !== 'human')
|
|
781
|
+
.reduce((n, l) => n + l.mergedPrs, 0);
|
|
782
|
+
return {
|
|
783
|
+
lanes: out,
|
|
784
|
+
automationMergeSharePct: totalMerged === 0 ? null : round2((automatedMerged / totalMerged) * 100),
|
|
785
|
+
medianTimeToFirstHumanReviewHours: median(humanFirstReviewHours),
|
|
786
|
+
silentAutomation: [...lanes.lane.entries()]
|
|
787
|
+
.filter(([userId]) => !active.has(userId))
|
|
788
|
+
.map(([userId, lane]) => ({ userId, lane })),
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* COST: N × `getPeriodMetrics`, one per workspace — the same multiplication that put the
|
|
793
|
+
* comparison route on the `search` rate tier. The consuming route's tier must account for it.
|
|
794
|
+
* Rows come back in `listWorkspaces` order (Default first, then by name) so the axis columns
|
|
795
|
+
* are stable across reloads.
|
|
796
|
+
*/
|
|
797
|
+
export async function getPeriodMetricsForWorkspaces(accountId, window) {
|
|
798
|
+
const all = await listWorkspaces(accountId);
|
|
799
|
+
if (all.length === 0)
|
|
800
|
+
return [];
|
|
801
|
+
return Promise.all(all.map(async (w) => {
|
|
802
|
+
const [{ metrics }, { trackedRepoIds }] = await Promise.all([
|
|
803
|
+
getPeriodMetrics(accountId, { workspaceId: w.id, repoIds: w.repoIds }, window),
|
|
804
|
+
// Coverage at the WINDOW's start — the same inclusive bound the headline coverage uses.
|
|
805
|
+
getPeriodCoverage(accountId, w.repoIds, window.fromMs),
|
|
806
|
+
]);
|
|
807
|
+
return {
|
|
808
|
+
workspaceId: w.id,
|
|
809
|
+
name: w.name,
|
|
810
|
+
isDefault: w.isDefault,
|
|
811
|
+
coverage: {
|
|
812
|
+
trackedRepos: trackedRepoIds.length,
|
|
813
|
+
totalRepos: w.repoIds.length,
|
|
814
|
+
complete: trackedRepoIds.length === w.repoIds.length,
|
|
815
|
+
},
|
|
816
|
+
metrics,
|
|
817
|
+
};
|
|
818
|
+
}));
|
|
819
|
+
}
|
|
820
|
+
//# sourceMappingURL=period-metrics.js.map
|