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,930 @@
|
|
|
1
|
+
// ── WHERE IT'S STUCK: the court ledger (CORE, deterministic, NO MODEL) ───────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Every hour a pull request is open, somebody is holding the ball. Charge each interval to its
|
|
4
|
+
// holder and the hours account for themselves — no stage boundaries to argue about, and no
|
|
5
|
+
// assumption that a pull request moves through a pipeline exactly once (real ones oscillate
|
|
6
|
+
// author -> reviewer -> author).
|
|
7
|
+
//
|
|
8
|
+
// ⚠ WHAT THIS REPLACED, AND WHY. `db/flow-findings.ts` emitted findings at a PATH-BUCKET grain and
|
|
9
|
+
// produced rows like "src/** is a bottleneck". A directory is four proxies from anything an EM can
|
|
10
|
+
// change, and on a conventional single-package repo `src/**` IS the repository — so the row stated
|
|
11
|
+
// a fact about the repo with a directory's authority. Two of its four kinds are deleted outright
|
|
12
|
+
// (`single_reviewer_path`, `round_trips`); `size_latency` is deleted as a finding because 845,316
|
|
13
|
+
// pull requests say size does not predict time-to-merge (r_s = 0.26, the field's strongest
|
|
14
|
+
// negative result); `approval_parked` survives, absorbed here as the LANDING court, where it
|
|
15
|
+
// finally has a denominator.
|
|
16
|
+
//
|
|
17
|
+
// ⚠ A BOT ACTION NEVER MOVES THE BALL. One predicate, and it is the entire moat: a tool keying on
|
|
18
|
+
// `user.type === 'Bot'` cannot separate "this pull request was reviewed" from "a person looked at
|
|
19
|
+
// this". Human-ness comes from `resolveActorLanes`' UNION, never `users.isBot` alone — the second
|
|
20
|
+
// row of a duplicated identity (`dependabot` vs `dependabot[bot]`) carries `is_bot = 0` and would
|
|
21
|
+
// otherwise be counted as a human reviewer.
|
|
22
|
+
//
|
|
23
|
+
// ⚠ EVERY SENTENCE IN THIS FILE IS TEMPLATED. No model, no plugin, no AI import. An EM makes
|
|
24
|
+
// staffing decisions off this screen, so a generated sentence would launder an unverified figure
|
|
25
|
+
// into it. Same discipline `packages/pro/test/llm-isolation.test.ts` pins for the bot advisor.
|
|
26
|
+
//
|
|
27
|
+
// ── THE STATE MACHINE, and the three decisions inside it ─────────────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// Start at REVIEWER from `openedAt` (a fresh pull request is waiting to be looked at). Then per
|
|
30
|
+
// human action, charge the elapsed interval to the current court and move the ball:
|
|
31
|
+
//
|
|
32
|
+
// reviewer acts (review, or a comment by anyone who is not the author) -> AUTHOR
|
|
33
|
+
// author acts (a commit, or a comment by the author) -> LANDING if already
|
|
34
|
+
// approved, else REVIEWER
|
|
35
|
+
// an APPROVING review -> LANDING
|
|
36
|
+
//
|
|
37
|
+
// 1. A reviewer comment AFTER approval moves the ball back to the AUTHOR, not to the reviewer.
|
|
38
|
+
// Somebody said something and the author owes a reply; that is the author's court by the
|
|
39
|
+
// definition above, and it stays true after an approval.
|
|
40
|
+
// 2. An author push AFTER approval stays in LANDING. Whether it invalidates the approval depends
|
|
41
|
+
// on a branch-protection setting we do not sync, so the conservative reading is "approved,
|
|
42
|
+
// with new code, waiting to land" — and it does not silently inflate the author court.
|
|
43
|
+
// 3. ⚠ A pull request no human ever acted on is EXCLUDED, not scored. Its ledger would be 100%
|
|
44
|
+
// REVIEWER by construction and would swamp every share on the screen. On real data that is
|
|
45
|
+
// 46% of merges, so this is not an edge case — they are reported separately, as governance.
|
|
46
|
+
//
|
|
47
|
+
// ── WINDOW PURITY ────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// The window is on `mergedAt`, TWO-SIDED and half-open `[from, to)`, matching db/period-metrics.ts:
|
|
49
|
+
// a cycle-time figure belongs to the period the work COMPLETED in, or a long-running pull request
|
|
50
|
+
// would move between windows as it aged.
|
|
51
|
+
import { and, eq, gte, inArray, lt } from 'drizzle-orm';
|
|
52
|
+
import { FLOW_RULES, } from '../shared/index.js';
|
|
53
|
+
import { db, schema } from './client.js';
|
|
54
|
+
import { resolveActorLanes } from './actor-lanes.js';
|
|
55
|
+
import { surfacesForPath } from './blast-radius.js';
|
|
56
|
+
import { getResolvedFlowSettings } from './flow-settings.js';
|
|
57
|
+
import { buildWorkingCalendar } from './working-hours.js';
|
|
58
|
+
import { buildFlowDetail, ticketKeyOf, } from './flow-detail.js';
|
|
59
|
+
const { ciStatusEvents, commits, prComments, pullRequests, repos, reviewComments, reviewRequestEvents, reviews, users, } = schema;
|
|
60
|
+
// ── Window ───────────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
export const FLOW_DEFAULT_WINDOW_DAYS = 30;
|
|
62
|
+
// Clamped, not merely defaulted: below a week the medians rest on a handful of pull requests, and
|
|
63
|
+
// above 90 days the RETROACTIVE COVERAGE BIAS rule bites — a workspace that onboarded repos across
|
|
64
|
+
// the span shows a "trend" that is entirely onboarding.
|
|
65
|
+
export const FLOW_MIN_WINDOW_DAYS = 7;
|
|
66
|
+
export const FLOW_MAX_WINDOW_DAYS = 90;
|
|
67
|
+
// ── Scan caps ────────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Every scan is bounded and hitting any of them sets `coverage.truncated`. A silent truncation
|
|
69
|
+
// reads as "we covered everything", which is a stronger claim than any figure here can support.
|
|
70
|
+
const FLOW_PR_CAP = 5_000;
|
|
71
|
+
const FLOW_ACTION_CAP = 200_000;
|
|
72
|
+
/** `inArray` bind parameters per statement — far inside SQLite's 32,766 and Postgres' 65,535. */
|
|
73
|
+
const ID_CHUNK = 900;
|
|
74
|
+
// ── Floors and thresholds ────────────────────────────────────────────────────────────────────
|
|
75
|
+
// ⚠ Assigned from `FLOW_RULES` (packages/shared/src/flow-settings.ts), the ONE spelling the page's
|
|
76
|
+
// explanations quote. The NAMES stay: `pr-intervals.test.ts` reads the dominant-court expression
|
|
77
|
+
// by them, and pins the three calibrated values exactly.
|
|
78
|
+
/** Merged, human-touched pull requests a repo needs before its profile is a number rather than an
|
|
79
|
+
* anecdote. */
|
|
80
|
+
const FLOW_MIN_REPO_PRS = FLOW_RULES.minRepoPrs;
|
|
81
|
+
/** A court must hold at least this share before it is worth naming as the thing to fix. */
|
|
82
|
+
const FLOW_DOMINANT_SHARE = FLOW_RULES.dominantShare;
|
|
83
|
+
/**
|
|
84
|
+
* ⚠ AND THE REPO MUST ACTUALLY BE SLOW. This is the single most important constant in the file.
|
|
85
|
+
* A real repository in the development corpus is 73% author-court with a p75 lead time of
|
|
86
|
+
* EIGHTEEN MINUTES; reporting its share alone would invent a crisis in a healthy repo — the exact
|
|
87
|
+
* failure that made the path-bucket findings worthless. Lopsided AND slow, or say nothing.
|
|
88
|
+
*/
|
|
89
|
+
const FLOW_SLOW_P75_HOURS = FLOW_RULES.slowP75ClockHours;
|
|
90
|
+
/** Openable pull requests offered per claim. */
|
|
91
|
+
const FLOW_EVIDENCE_CAP = 5;
|
|
92
|
+
/** Repos listed. A board nobody can read is not a worklist. */
|
|
93
|
+
const FLOW_REPO_CAP = 12;
|
|
94
|
+
/** Below this share of a repo's merges, an unreviewed-merge rate is not worth a line. */
|
|
95
|
+
const FLOW_UNREVIEWED_MIN_SHARE = FLOW_RULES.unreviewedMinShare;
|
|
96
|
+
const FLOW_UNREVIEWED_MIN_COUNT = FLOW_RULES.unreviewedMinCount;
|
|
97
|
+
const HOUR_MS = 3_600_000;
|
|
98
|
+
// ── Small pure helpers ───────────────────────────────────────────────────────────────────────
|
|
99
|
+
function chunk(xs, n) {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (let i = 0; i < xs.length; i += n)
|
|
102
|
+
out.push(xs.slice(i, i + n));
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** Nearest-rank percentile over an unsorted array. Returns 0 for an empty one — every caller
|
|
106
|
+
* guards on the sample floor before it reads the result. */
|
|
107
|
+
export function percentile(xs, p) {
|
|
108
|
+
if (xs.length === 0)
|
|
109
|
+
return 0;
|
|
110
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
111
|
+
const i = Math.min(s.length - 1, Math.max(0, Math.ceil(p * s.length) - 1));
|
|
112
|
+
return s[i] ?? 0;
|
|
113
|
+
}
|
|
114
|
+
export function median(xs) {
|
|
115
|
+
return percentile(xs, 0.5);
|
|
116
|
+
}
|
|
117
|
+
/** One decimal at most, never a bare `.0` — "4.0h" implies a precision this file cannot support. */
|
|
118
|
+
function oneDp(n) {
|
|
119
|
+
return n.toFixed(1).replace(/\.0$/, '');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A duration in prose. ⚠ Used ONLY for the templated sentences; the wire carries raw hours and the
|
|
123
|
+
* SPA formats them, so a figure never appears on screen in two spellings.
|
|
124
|
+
*/
|
|
125
|
+
export function fmtHours(h) {
|
|
126
|
+
if (h < 1)
|
|
127
|
+
return `${Math.max(1, Math.round(h * 60))} minutes`;
|
|
128
|
+
if (h < 10)
|
|
129
|
+
return `${oneDp(h)} hours`;
|
|
130
|
+
if (h < 48)
|
|
131
|
+
return `${Math.round(h)} hours`;
|
|
132
|
+
return `${oneDp(h / 24)} days`;
|
|
133
|
+
}
|
|
134
|
+
function pct(share) {
|
|
135
|
+
return `${Math.round(share * 100)}%`;
|
|
136
|
+
}
|
|
137
|
+
function plural(n, one, many) {
|
|
138
|
+
return n === 1 ? one : many;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* THE LEDGER, as a pure function so it can be tested without a database. ⚠ THE ONLY STATE
|
|
142
|
+
* MACHINE: `walkCourts` below is a sum over these spells, so the clock-hour split and the
|
|
143
|
+
* working-hour split cannot disagree about who held the ball.
|
|
144
|
+
*
|
|
145
|
+
* `actions` need not be sorted; anything outside `[openedMs, mergedMs]` is ignored rather than
|
|
146
|
+
* clamped, because an action stamped outside the pull request's own life is a data error and
|
|
147
|
+
* silently stretching an interval to cover it would put invented hours on the screen.
|
|
148
|
+
*/
|
|
149
|
+
export function walkCourtIntervals(openedMs, mergedMs, actions) {
|
|
150
|
+
const walk = {
|
|
151
|
+
spells: [],
|
|
152
|
+
rounds: 0,
|
|
153
|
+
firstLookMs: null,
|
|
154
|
+
firstReviewerId: null,
|
|
155
|
+
approvedAtMs: null,
|
|
156
|
+
};
|
|
157
|
+
if (!(mergedMs > openedMs))
|
|
158
|
+
return walk;
|
|
159
|
+
const inLife = actions
|
|
160
|
+
.filter((a) => a.atMs >= openedMs && a.atMs <= mergedMs)
|
|
161
|
+
.sort((a, b) => a.atMs - b.atMs);
|
|
162
|
+
let court = 'reviewer';
|
|
163
|
+
let approved = false;
|
|
164
|
+
let last = openedMs;
|
|
165
|
+
const charge = (untilMs) => {
|
|
166
|
+
if (untilMs > last) {
|
|
167
|
+
const prev = walk.spells[walk.spells.length - 1];
|
|
168
|
+
if (prev && prev.court === court && prev.toMs === last)
|
|
169
|
+
prev.toMs = untilMs;
|
|
170
|
+
else
|
|
171
|
+
walk.spells.push({ court, fromMs: last, toMs: untilMs });
|
|
172
|
+
}
|
|
173
|
+
last = untilMs;
|
|
174
|
+
};
|
|
175
|
+
for (const a of inLife) {
|
|
176
|
+
charge(a.atMs);
|
|
177
|
+
const before = court;
|
|
178
|
+
if (a.by === 'reviewer' && walk.firstLookMs == null) {
|
|
179
|
+
walk.firstLookMs = a.atMs;
|
|
180
|
+
walk.firstReviewerId = a.userId ?? null;
|
|
181
|
+
}
|
|
182
|
+
if (a.approves) {
|
|
183
|
+
approved = true;
|
|
184
|
+
if (walk.approvedAtMs == null)
|
|
185
|
+
walk.approvedAtMs = a.atMs;
|
|
186
|
+
court = 'landing';
|
|
187
|
+
}
|
|
188
|
+
else if (a.by === 'reviewer') {
|
|
189
|
+
// Decision 1: somebody said something, so the author owes a reply — even after an approval.
|
|
190
|
+
court = 'author';
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// Decision 2: an author push after approval stays in LANDING. Whether it invalidates the
|
|
194
|
+
// approval is a branch-protection setting we do not sync.
|
|
195
|
+
court = approved ? 'landing' : 'reviewer';
|
|
196
|
+
}
|
|
197
|
+
if (court === 'author' && before !== 'author')
|
|
198
|
+
walk.rounds += 1;
|
|
199
|
+
}
|
|
200
|
+
charge(mergedMs);
|
|
201
|
+
return walk;
|
|
202
|
+
}
|
|
203
|
+
/** Clock hours per court — the sum of `walkCourtIntervals`' spells. */
|
|
204
|
+
export function walkCourts(openedMs, mergedMs, actions) {
|
|
205
|
+
const hours = { reviewer: 0, author: 0, landing: 0 };
|
|
206
|
+
for (const s of walkCourtIntervals(openedMs, mergedMs, actions).spells) {
|
|
207
|
+
hours[s.court] += (s.toMs - s.fromMs) / HOUR_MS;
|
|
208
|
+
}
|
|
209
|
+
return hours;
|
|
210
|
+
}
|
|
211
|
+
// ── The templated narrative ──────────────────────────────────────────────────────────────────
|
|
212
|
+
//
|
|
213
|
+
// ⚠ ONE SENTENCE PER COURT, AND EACH NAMES A DIFFERENT ACTION. That is the whole point of the
|
|
214
|
+
// split: "your PRs are slow" is not a finding, "nothing is blocked on the author or on CI, so this
|
|
215
|
+
// is a routing problem" is. Every figure interpolated below is code-derived.
|
|
216
|
+
//
|
|
217
|
+
// The actions are the ones with published effect sizes behind them, which is why they are worth
|
|
218
|
+
// hard-coding rather than leaving to the reader:
|
|
219
|
+
// reviewer -> a reminder on an overdue pull request, and naming an individual rather than a team
|
|
220
|
+
// author -> nothing an EM can push on directly; the honest advice is about round-trip cost
|
|
221
|
+
// landing -> automatic merge, which this product already ships
|
|
222
|
+
const COURT_WAIT = {
|
|
223
|
+
reviewer: 'waiting for somebody to look at it',
|
|
224
|
+
author: 'waiting for its author to answer review',
|
|
225
|
+
landing: 'approved and waiting to merge',
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* The REPO row's own sentence: figures specific to this repository and NOTHING ELSE.
|
|
229
|
+
*
|
|
230
|
+
* ⚠ No advice here — see `RepoCourtProfile.narrative`. Six repos in one court produced six
|
|
231
|
+
* identical recommendations on the first cut, which is exactly the restatement problem that made
|
|
232
|
+
* the path-bucket findings worthless.
|
|
233
|
+
*/
|
|
234
|
+
function narrate(court, share, prs, p75LeadHours, medianLeadHours) {
|
|
235
|
+
return (`${pct(share)} of open time is spent ${COURT_WAIT[court]}. ` +
|
|
236
|
+
`Across ${prs} merged pull ${plural(prs, 'request', 'requests')}, half cleared in ` +
|
|
237
|
+
`${fmtHours(medianLeadHours)} and the slowest quarter took ${fmtHours(p75LeadHours)} or more.`);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The advice, once per court. See `CourtDirective` in shared for why it lives at this grain.
|
|
241
|
+
*
|
|
242
|
+
* ⚠ `namedBeatsTeam` IS THIS WORKSPACE'S OWN MEASUREMENT, and it can overrule the default advice.
|
|
243
|
+
* "Request a named reviewer instead of a team" is the published intervention, but on a real
|
|
244
|
+
* workspace a team request was answered FASTER (1.8 working hours to 3.1). Advice the reader's
|
|
245
|
+
* own history contradicts is worse than none, so when the stored request history shows a team
|
|
246
|
+
* answered at least as quickly, the reviewer paragraph drops that clause. `null` (no history, or
|
|
247
|
+
* too few of either) keeps the default.
|
|
248
|
+
*/
|
|
249
|
+
function directiveFor(court, repos, namedBeatsTeam = null) {
|
|
250
|
+
const where = `${repos} ${plural(repos, 'repository', 'repositories')} here ${plural(repos, 'is', 'are')}`;
|
|
251
|
+
switch (court) {
|
|
252
|
+
case 'reviewer':
|
|
253
|
+
if (namedBeatsTeam === false) {
|
|
254
|
+
return (`${where} spending most of a pull request's life waiting for a person to look — not on ` +
|
|
255
|
+
`the author, and not on checks. That makes it a routing problem rather than a capacity ` +
|
|
256
|
+
`one: chase the reviews that are already overdue. Asking a team has been ` +
|
|
257
|
+
`answered as quickly as asking a named person here, so what matters is that somebody ` +
|
|
258
|
+
`follows up, not who was asked.`);
|
|
259
|
+
}
|
|
260
|
+
return (`${where} spending most of a pull request's life waiting for a person to look — not on ` +
|
|
261
|
+
`the author, and not on checks. That makes it a routing problem rather than a capacity ` +
|
|
262
|
+
`one: request a named reviewer instead of a team, and chase the reviews that are already ` +
|
|
263
|
+
`overdue. Both are cheap, and both are the interventions with the largest ` +
|
|
264
|
+
`measured effect on how long a pull request stays open.`);
|
|
265
|
+
case 'author':
|
|
266
|
+
return (`${where} spending most of a pull request's life waiting for its author to answer ` +
|
|
267
|
+
`review. Reviewers are keeping up — the cost is in the round trip. Fewer, clearer review ` +
|
|
268
|
+
`passes will do more here than asking anyone to go faster, and nothing on this screen is ` +
|
|
269
|
+
`an argument about how quickly an individual replies.`);
|
|
270
|
+
case 'landing':
|
|
271
|
+
return (`${where} spending most of a pull request's life approved and waiting to merge. The ` +
|
|
272
|
+
`review is already done, so this is the merge step and not a people problem. Arming ` +
|
|
273
|
+
`"merge when ready" lands these without anyone watching them.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The same advice in ONE line, for the page; `directiveFor`'s paragraph sits behind the "i".
|
|
278
|
+
*
|
|
279
|
+
* ⚠ It states the rule that put the repositories here (the dominant share, read from the constant
|
|
280
|
+
* so it cannot drift from the fold) and the action, and nothing else — the per-repository figures
|
|
281
|
+
* are printed on the rows beneath it. `namedBeatsTeam` has the same meaning as in `directiveFor`:
|
|
282
|
+
* only a measured `false` drops the named-reviewer clause.
|
|
283
|
+
*/
|
|
284
|
+
function directiveSummaryFor(court, namedBeatsTeam = null) {
|
|
285
|
+
const lead = `At least ${pct(FLOW_DOMINANT_SHARE)} of the time here is spent `;
|
|
286
|
+
switch (court) {
|
|
287
|
+
case 'reviewer':
|
|
288
|
+
return namedBeatsTeam === false
|
|
289
|
+
? `${lead}waiting for a reviewer. Chase overdue reviews.`
|
|
290
|
+
: `${lead}waiting for a reviewer. Ask a named person rather than a team, and chase overdue reviews.`;
|
|
291
|
+
case 'author':
|
|
292
|
+
return `${lead}waiting for the author. Fewer, clearer review rounds will help more than speed.`;
|
|
293
|
+
case 'landing':
|
|
294
|
+
return `${lead}approved and waiting to merge. Arm “merge when ready” to land these.`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// ── The refusals ─────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
//
|
|
299
|
+
// ⚠ ONE PLAIN FACT EACH. The rule behind a refusal (the lopsided-and-slow call-out, the
|
|
300
|
+
// unreviewed-merge floor) is explained behind that section's "i" on the page, quoting FLOW_RULES;
|
|
301
|
+
// repeating it inside the refusal put the same explanation on screen twice.
|
|
302
|
+
function noHumanReviewReason(windowDays) {
|
|
303
|
+
return `No pull request merged in the last ${windowDays} days had a human review or comment on it.`;
|
|
304
|
+
}
|
|
305
|
+
function noneStandsOutReason(measured, windowDays) {
|
|
306
|
+
return `Measured ${measured} ${plural(measured, 'repository', 'repositories')} in the last ${windowDays} days. None stands out.`;
|
|
307
|
+
}
|
|
308
|
+
function unreviewedUnderFloorReason() {
|
|
309
|
+
return 'No repository merged enough pull requests without a human review to name.';
|
|
310
|
+
}
|
|
311
|
+
function headlineFor(courts, medianLead, p75Lead, prs) {
|
|
312
|
+
const by = new Map(courts.map((c) => [c.court, c.share]));
|
|
313
|
+
const top = [...courts].sort((a, b) => b.share - a.share)[0];
|
|
314
|
+
const lead = `Half of pull requests cleared in ${fmtHours(medianLead)}; the slowest quarter took ` +
|
|
315
|
+
`${fmtHours(p75Lead)} or more.`;
|
|
316
|
+
return (`Across ${prs} merged pull requests a person actually worked on, time split ` +
|
|
317
|
+
`${pct(by.get('reviewer') ?? 0)} waiting for a reviewer, ` +
|
|
318
|
+
`${pct(by.get('author') ?? 0)} waiting for the author, and ` +
|
|
319
|
+
`${pct(by.get('landing') ?? 0)} approved and waiting to land. ` +
|
|
320
|
+
`${lead}${top && top.share >= FLOW_DOMINANT_SHARE ? '' : ' No single court dominates here.'}`);
|
|
321
|
+
}
|
|
322
|
+
function sharesOf(h) {
|
|
323
|
+
const total = h.reviewer + h.author + h.landing;
|
|
324
|
+
const s = (v) => (total > 0 ? v / total : 0);
|
|
325
|
+
return [
|
|
326
|
+
{ court: 'reviewer', hours: h.reviewer, share: s(h.reviewer) },
|
|
327
|
+
{ court: 'author', hours: h.author, share: s(h.author) },
|
|
328
|
+
{ court: 'landing', hours: h.landing, share: s(h.landing) },
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Lines added plus removed, or null when the size was never observed. The same ambiguity
|
|
333
|
+
* `codeLocFor` documents: the columns are NOT NULL with a 0 default, so all-zero columns with a
|
|
334
|
+
* file list that sums to zero is "we never saw its size", not "it changed nothing".
|
|
335
|
+
*/
|
|
336
|
+
function linesOf(pr) {
|
|
337
|
+
const cols = pr.additions + pr.deletions;
|
|
338
|
+
if (cols > 0)
|
|
339
|
+
return cols;
|
|
340
|
+
const fromFiles = (pr.files ?? []).reduce((n, f) => n + ((f?.additions ?? 0) + (f?.deletions ?? 0)), 0);
|
|
341
|
+
return fromFiles > 0 ? fromFiles : null;
|
|
342
|
+
}
|
|
343
|
+
export async function getFlowCourts(accountId, scope, windowDaysRaw) {
|
|
344
|
+
const windowDays = Math.min(FLOW_MAX_WINDOW_DAYS, Math.max(FLOW_MIN_WINDOW_DAYS, Math.round(windowDaysRaw)));
|
|
345
|
+
const toMs = Date.now();
|
|
346
|
+
const to = new Date(toMs);
|
|
347
|
+
const from = new Date(toMs - windowDays * 24 * HOUR_MS);
|
|
348
|
+
const refusals = [];
|
|
349
|
+
const refuse = (kind, reason, basis = 'insufficient_data') => {
|
|
350
|
+
refusals.push({ kind, reason, basis });
|
|
351
|
+
};
|
|
352
|
+
const settings = await getResolvedFlowSettings(accountId, scope.workspaceId);
|
|
353
|
+
const empty = (coverage) => ({
|
|
354
|
+
workspaceId: scope.workspaceId,
|
|
355
|
+
windowDays,
|
|
356
|
+
measuredPrs: 0,
|
|
357
|
+
courts: sharesOf({ reviewer: 0, author: 0, landing: 0 }),
|
|
358
|
+
medianLeadHours: 0,
|
|
359
|
+
p75LeadHours: 0,
|
|
360
|
+
headline: null,
|
|
361
|
+
repos: [],
|
|
362
|
+
directives: [],
|
|
363
|
+
unreviewed: [],
|
|
364
|
+
refusals,
|
|
365
|
+
coverage,
|
|
366
|
+
settings,
|
|
367
|
+
courtsWork: sharesOf({ reviewer: 0, author: 0, landing: 0 }),
|
|
368
|
+
medianLeadWorkHours: 0,
|
|
369
|
+
p75LeadWorkHours: 0,
|
|
370
|
+
workHeadline: null,
|
|
371
|
+
budgets: [],
|
|
372
|
+
prs: [],
|
|
373
|
+
prsCapped: false,
|
|
374
|
+
contrast: null,
|
|
375
|
+
sizeBands: [],
|
|
376
|
+
weekdays: [],
|
|
377
|
+
landingTail: null,
|
|
378
|
+
concentration: [],
|
|
379
|
+
prFigures: { overWorkingDay: 0, slowestTenthCount: 0, slowestTenthShare: 0, neverWentBack: 0 },
|
|
380
|
+
});
|
|
381
|
+
// `[]` is a real answer ("this workspace is empty"), never a widening to the whole account.
|
|
382
|
+
if (scope.repoIds.length === 0) {
|
|
383
|
+
for (const k of ['courts', 'unreviewed']) {
|
|
384
|
+
refuse(k, 'This workspace has no repositories yet.');
|
|
385
|
+
}
|
|
386
|
+
return empty({
|
|
387
|
+
reposInWorkspace: 0,
|
|
388
|
+
reposWithData: 0,
|
|
389
|
+
prsScanned: 0,
|
|
390
|
+
truncated: false,
|
|
391
|
+
excludedNoHumanTouch: 0,
|
|
392
|
+
excludedBotAuthored: 0,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const caps = { truncated: false };
|
|
396
|
+
const noteCap = (rows, cap) => {
|
|
397
|
+
if (rows >= cap)
|
|
398
|
+
caps.truncated = true;
|
|
399
|
+
};
|
|
400
|
+
const repoRows = await db
|
|
401
|
+
.select({ id: repos.id, owner: repos.owner, name: repos.name })
|
|
402
|
+
.from(repos)
|
|
403
|
+
.where(and(eq(repos.accountId, accountId), inArray(repos.id, scope.repoIds)))
|
|
404
|
+
.execute();
|
|
405
|
+
const repoName = new Map(repoRows.map((r) => [r.id, `${r.owner}/${r.name}`]));
|
|
406
|
+
// Merged in-window. TWO-SIDED, half-open — the window-purity rule.
|
|
407
|
+
const prRows = await db
|
|
408
|
+
.select({
|
|
409
|
+
id: pullRequests.id,
|
|
410
|
+
repoId: pullRequests.repoId,
|
|
411
|
+
number: pullRequests.number,
|
|
412
|
+
title: pullRequests.title,
|
|
413
|
+
authorId: pullRequests.authorId,
|
|
414
|
+
mergedById: pullRequests.mergedById,
|
|
415
|
+
headRefName: pullRequests.headRefName,
|
|
416
|
+
additions: pullRequests.additions,
|
|
417
|
+
deletions: pullRequests.deletions,
|
|
418
|
+
changedFiles: pullRequests.changedFiles,
|
|
419
|
+
files: pullRequests.files,
|
|
420
|
+
requestsSyncedAt: pullRequests.reviewRequestsSyncedAt,
|
|
421
|
+
openedAt: pullRequests.openedAt,
|
|
422
|
+
mergedAt: pullRequests.mergedAt,
|
|
423
|
+
})
|
|
424
|
+
.from(pullRequests)
|
|
425
|
+
.where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(pullRequests.state, 'merged'), gte(pullRequests.mergedAt, from), lt(pullRequests.mergedAt, to)))
|
|
426
|
+
.limit(FLOW_PR_CAP)
|
|
427
|
+
.execute();
|
|
428
|
+
noteCap(prRows.length, FLOW_PR_CAP);
|
|
429
|
+
const prs = [];
|
|
430
|
+
for (const r of prRows) {
|
|
431
|
+
if (r.openedAt == null || r.mergedAt == null)
|
|
432
|
+
continue;
|
|
433
|
+
const openedMs = r.openedAt.getTime();
|
|
434
|
+
const mergedMs = r.mergedAt.getTime();
|
|
435
|
+
if (!(mergedMs > openedMs))
|
|
436
|
+
continue;
|
|
437
|
+
prs.push({
|
|
438
|
+
id: r.id,
|
|
439
|
+
repoId: r.repoId,
|
|
440
|
+
number: r.number,
|
|
441
|
+
title: r.title,
|
|
442
|
+
authorId: r.authorId,
|
|
443
|
+
mergedById: r.mergedById,
|
|
444
|
+
headRefName: r.headRefName,
|
|
445
|
+
additions: r.additions,
|
|
446
|
+
deletions: r.deletions,
|
|
447
|
+
changedFiles: r.changedFiles,
|
|
448
|
+
files: r.files ?? null,
|
|
449
|
+
openedMs,
|
|
450
|
+
mergedMs,
|
|
451
|
+
requestsKnown: r.requestsSyncedAt != null,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (prs.length === 0) {
|
|
455
|
+
for (const k of ['courts', 'unreviewed']) {
|
|
456
|
+
refuse(k, `No pull request merged in the last ${windowDays} days.`);
|
|
457
|
+
}
|
|
458
|
+
return empty({
|
|
459
|
+
reposInWorkspace: scope.repoIds.length,
|
|
460
|
+
reposWithData: 0,
|
|
461
|
+
prsScanned: 0,
|
|
462
|
+
truncated: caps.truncated,
|
|
463
|
+
excludedNoHumanTouch: 0,
|
|
464
|
+
excludedBotAuthored: 0,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const byId = new Map(prs.map((p) => [p.id, p]));
|
|
468
|
+
const prIds = prs.map((p) => p.id);
|
|
469
|
+
const lanes = await resolveActorLanes(accountId, scope);
|
|
470
|
+
// ⚠ The lane resolver's UNION, never `users.isBot` alone — see the header.
|
|
471
|
+
const isHuman = (userId) => userId != null && lanes.laneOf(userId) === 'human';
|
|
472
|
+
const actions = new Map();
|
|
473
|
+
const requestEvents = new Map();
|
|
474
|
+
const ciObs = new Map();
|
|
475
|
+
const push = (prId, a) => {
|
|
476
|
+
const list = actions.get(prId);
|
|
477
|
+
if (list)
|
|
478
|
+
list.push(a);
|
|
479
|
+
else
|
|
480
|
+
actions.set(prId, [a]);
|
|
481
|
+
};
|
|
482
|
+
for (const ids of chunk(prIds, ID_CHUNK)) {
|
|
483
|
+
// Reviews. `state` carries the approval latch.
|
|
484
|
+
const rvRows = await db
|
|
485
|
+
.select({ prId: reviews.prId, authorId: reviews.authorId, at: reviews.submittedAt, state: reviews.state })
|
|
486
|
+
.from(reviews)
|
|
487
|
+
.where(inArray(reviews.prId, ids))
|
|
488
|
+
.limit(FLOW_ACTION_CAP)
|
|
489
|
+
.execute();
|
|
490
|
+
noteCap(rvRows.length, FLOW_ACTION_CAP);
|
|
491
|
+
for (const r of rvRows) {
|
|
492
|
+
if (r.at == null || !isHuman(r.authorId))
|
|
493
|
+
continue;
|
|
494
|
+
if (r.state === 'pending')
|
|
495
|
+
continue;
|
|
496
|
+
const pr = byId.get(r.prId);
|
|
497
|
+
if (pr == null)
|
|
498
|
+
continue;
|
|
499
|
+
// A review by the pull request's OWN author is a self-comment, not somebody reviewing it.
|
|
500
|
+
const self = r.authorId != null && r.authorId === pr.authorId;
|
|
501
|
+
push(r.prId, {
|
|
502
|
+
atMs: r.at.getTime(),
|
|
503
|
+
by: self ? 'author' : 'reviewer',
|
|
504
|
+
approves: !self && r.state === 'approved',
|
|
505
|
+
userId: r.authorId,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// Inline review comments.
|
|
509
|
+
const rcRows = await db
|
|
510
|
+
.select({ prId: reviewComments.prId, authorId: reviewComments.authorId, at: reviewComments.createdAt })
|
|
511
|
+
.from(reviewComments)
|
|
512
|
+
.where(inArray(reviewComments.prId, ids))
|
|
513
|
+
.limit(FLOW_ACTION_CAP)
|
|
514
|
+
.execute();
|
|
515
|
+
noteCap(rcRows.length, FLOW_ACTION_CAP);
|
|
516
|
+
for (const r of rcRows) {
|
|
517
|
+
if (r.at == null || !isHuman(r.authorId))
|
|
518
|
+
continue;
|
|
519
|
+
const pr = byId.get(r.prId);
|
|
520
|
+
if (pr == null)
|
|
521
|
+
continue;
|
|
522
|
+
push(r.prId, {
|
|
523
|
+
atMs: r.at.getTime(),
|
|
524
|
+
by: r.authorId === pr.authorId ? 'author' : 'reviewer',
|
|
525
|
+
approves: false,
|
|
526
|
+
userId: r.authorId,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
// Conversation comments.
|
|
530
|
+
const pcRows = await db
|
|
531
|
+
.select({ prId: prComments.prId, authorId: prComments.authorId, at: prComments.createdAt })
|
|
532
|
+
.from(prComments)
|
|
533
|
+
.where(inArray(prComments.prId, ids))
|
|
534
|
+
.limit(FLOW_ACTION_CAP)
|
|
535
|
+
.execute();
|
|
536
|
+
noteCap(pcRows.length, FLOW_ACTION_CAP);
|
|
537
|
+
for (const r of pcRows) {
|
|
538
|
+
if (r.at == null || !isHuman(r.authorId))
|
|
539
|
+
continue;
|
|
540
|
+
const pr = byId.get(r.prId);
|
|
541
|
+
if (pr == null)
|
|
542
|
+
continue;
|
|
543
|
+
push(r.prId, {
|
|
544
|
+
atMs: r.at.getTime(),
|
|
545
|
+
by: r.authorId === pr.authorId ? 'author' : 'reviewer',
|
|
546
|
+
approves: false,
|
|
547
|
+
userId: r.authorId,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
// Pushes. A commit is ALWAYS an author action whoever authored it — the ball moves because new
|
|
551
|
+
// code arrived on the branch, not because of whose name is on it.
|
|
552
|
+
const cmRows = await db
|
|
553
|
+
.select({ prId: commits.prId, at: commits.committedAt })
|
|
554
|
+
.from(commits)
|
|
555
|
+
.where(inArray(commits.prId, ids))
|
|
556
|
+
.limit(FLOW_ACTION_CAP)
|
|
557
|
+
.execute();
|
|
558
|
+
noteCap(cmRows.length, FLOW_ACTION_CAP);
|
|
559
|
+
for (const r of cmRows) {
|
|
560
|
+
if (r.at == null)
|
|
561
|
+
continue;
|
|
562
|
+
push(r.prId, { atMs: r.at.getTime(), by: 'author', approves: false });
|
|
563
|
+
}
|
|
564
|
+
// Review-request HISTORY — who was asked first, and when. Never moves the ball: a request is
|
|
565
|
+
// not a review. Read by PR id only (a PR child with no account_id; the ids are this account's).
|
|
566
|
+
const rrRows = await db
|
|
567
|
+
.select({
|
|
568
|
+
prId: reviewRequestEvents.prId,
|
|
569
|
+
at: reviewRequestEvents.occurredAt,
|
|
570
|
+
kind: reviewRequestEvents.kind,
|
|
571
|
+
reviewerKind: reviewRequestEvents.reviewerKind,
|
|
572
|
+
userId: reviewRequestEvents.reviewerUserId,
|
|
573
|
+
})
|
|
574
|
+
.from(reviewRequestEvents)
|
|
575
|
+
.where(inArray(reviewRequestEvents.prId, ids))
|
|
576
|
+
.limit(FLOW_ACTION_CAP)
|
|
577
|
+
.execute();
|
|
578
|
+
noteCap(rrRows.length, FLOW_ACTION_CAP);
|
|
579
|
+
for (const r of rrRows) {
|
|
580
|
+
if (r.at == null)
|
|
581
|
+
continue;
|
|
582
|
+
const list = requestEvents.get(r.prId);
|
|
583
|
+
const e = { atMs: r.at.getTime(), kind: r.kind, reviewerKind: r.reviewerKind, userId: r.userId };
|
|
584
|
+
if (list)
|
|
585
|
+
list.push(e);
|
|
586
|
+
else
|
|
587
|
+
requestEvents.set(r.prId, [e]);
|
|
588
|
+
}
|
|
589
|
+
// CI observations — for "checks went red", never for the ball. Red time is approximate: a
|
|
590
|
+
// failure counts until the next observation, and observations are only as dense as syncs.
|
|
591
|
+
const ciRows = await db
|
|
592
|
+
.select({ prId: ciStatusEvents.prId, at: ciStatusEvents.observedAt, status: ciStatusEvents.status })
|
|
593
|
+
.from(ciStatusEvents)
|
|
594
|
+
.where(and(eq(ciStatusEvents.accountId, accountId), inArray(ciStatusEvents.prId, ids)))
|
|
595
|
+
.limit(FLOW_ACTION_CAP)
|
|
596
|
+
.execute();
|
|
597
|
+
noteCap(ciRows.length, FLOW_ACTION_CAP);
|
|
598
|
+
for (const r of ciRows) {
|
|
599
|
+
if (r.at == null)
|
|
600
|
+
continue;
|
|
601
|
+
const list = ciObs.get(r.prId);
|
|
602
|
+
const o = { atMs: r.at.getTime(), red: r.status === 'failure' || r.status === 'error' };
|
|
603
|
+
if (list)
|
|
604
|
+
list.push(o);
|
|
605
|
+
else
|
|
606
|
+
ciObs.set(r.prId, [o]);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Author bot-ness. Read BY ID — `users` is one of the two GLOBAL tables and is never handed to a
|
|
610
|
+
// tenant as a listing. ⚠ This is not decoration on the evidence rows: it GATES the population.
|
|
611
|
+
const authorIds = [...new Set(prs.map((p) => p.authorId).filter((id) => id != null))];
|
|
612
|
+
const botAuthors = new Set();
|
|
613
|
+
for (const ids of chunk(authorIds, ID_CHUNK)) {
|
|
614
|
+
const uRows = await db
|
|
615
|
+
.select({ id: users.id, isBot: users.isBot })
|
|
616
|
+
.from(users)
|
|
617
|
+
.where(inArray(users.id, ids))
|
|
618
|
+
.execute();
|
|
619
|
+
for (const u of uRows)
|
|
620
|
+
if (u.isBot || !isHuman(u.id))
|
|
621
|
+
botAuthors.add(u.id);
|
|
622
|
+
}
|
|
623
|
+
const measured = [];
|
|
624
|
+
const unreviewedByRepo = new Map();
|
|
625
|
+
let excludedNoHumanTouch = 0;
|
|
626
|
+
let excludedBotAuthored = 0;
|
|
627
|
+
for (const pr of prs) {
|
|
628
|
+
// ⚠ AUTOMATION'S OWN PULL REQUESTS ARE NOT THIS SCREEN'S SUBJECT. See
|
|
629
|
+
// `FlowCoverage.excludedBotAuthored` — blending them moved every share and produced evidence
|
|
630
|
+
// rows citing dependency bumps as things people were waiting on.
|
|
631
|
+
if (pr.authorId != null && botAuthors.has(pr.authorId)) {
|
|
632
|
+
excludedBotAuthored += 1;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const acts = actions.get(pr.id) ?? [];
|
|
636
|
+
const humanReviewed = acts.some((a) => a.by === 'reviewer');
|
|
637
|
+
if (!humanReviewed) {
|
|
638
|
+
// Decision 3: excluded from the split, reported as governance.
|
|
639
|
+
excludedNoHumanTouch += 1;
|
|
640
|
+
const list = unreviewedByRepo.get(pr.repoId);
|
|
641
|
+
if (list)
|
|
642
|
+
list.push(pr);
|
|
643
|
+
else
|
|
644
|
+
unreviewedByRepo.set(pr.repoId, [pr]);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const walk = walkCourtIntervals(pr.openedMs, pr.mergedMs, acts);
|
|
648
|
+
const hours = { reviewer: 0, author: 0, landing: 0 };
|
|
649
|
+
for (const sp of walk.spells)
|
|
650
|
+
hours[sp.court] += (sp.toMs - sp.fromMs) / HOUR_MS;
|
|
651
|
+
measured.push({
|
|
652
|
+
pr,
|
|
653
|
+
hours,
|
|
654
|
+
leadHours: (pr.mergedMs - pr.openedMs) / HOUR_MS,
|
|
655
|
+
walk,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
// ⚠ The unreviewed-merge DENOMINATOR is the same human-authored population the shares are over.
|
|
659
|
+
// Counting bot merges into it would report "30% of merges went in unreviewed" against a total
|
|
660
|
+
// that includes work no human was ever meant to review.
|
|
661
|
+
const mergedByRepo = new Map();
|
|
662
|
+
for (const pr of prs) {
|
|
663
|
+
if (pr.authorId != null && botAuthors.has(pr.authorId))
|
|
664
|
+
continue;
|
|
665
|
+
mergedByRepo.set(pr.repoId, (mergedByRepo.get(pr.repoId) ?? 0) + 1);
|
|
666
|
+
}
|
|
667
|
+
const coverage = {
|
|
668
|
+
reposInWorkspace: scope.repoIds.length,
|
|
669
|
+
reposWithData: new Set(prs.map((p) => p.repoId)).size,
|
|
670
|
+
prsScanned: prs.length,
|
|
671
|
+
truncated: caps.truncated,
|
|
672
|
+
excludedNoHumanTouch,
|
|
673
|
+
excludedBotAuthored,
|
|
674
|
+
};
|
|
675
|
+
if (measured.length === 0) {
|
|
676
|
+
refuse('courts', noHumanReviewReason(windowDays));
|
|
677
|
+
}
|
|
678
|
+
// ── Workspace-wide ─────────────────────────────────────────────────────────────────────────
|
|
679
|
+
const wsHours = { reviewer: 0, author: 0, landing: 0 };
|
|
680
|
+
for (const m of measured) {
|
|
681
|
+
wsHours.reviewer += m.hours.reviewer;
|
|
682
|
+
wsHours.author += m.hours.author;
|
|
683
|
+
wsHours.landing += m.hours.landing;
|
|
684
|
+
}
|
|
685
|
+
const wsCourts = sharesOf(wsHours);
|
|
686
|
+
const wsLeads = measured.map((m) => m.leadHours);
|
|
687
|
+
const wsMedian = median(wsLeads);
|
|
688
|
+
const wsP75 = percentile(wsLeads, 0.75);
|
|
689
|
+
// ── Per repo ───────────────────────────────────────────────────────────────────────────────
|
|
690
|
+
const byRepo = new Map();
|
|
691
|
+
for (const m of measured) {
|
|
692
|
+
const list = byRepo.get(m.pr.repoId);
|
|
693
|
+
if (list)
|
|
694
|
+
list.push(m);
|
|
695
|
+
else
|
|
696
|
+
byRepo.set(m.pr.repoId, [m]);
|
|
697
|
+
}
|
|
698
|
+
const ghUrl = (repoId, number) => `https://github.com/${repoName.get(repoId) ?? ''}/pull/${number}`;
|
|
699
|
+
const evidenceOf = (rows, court) => [...rows]
|
|
700
|
+
.sort((a, b) => b.hours[court] - a.hours[court] || a.pr.id - b.pr.id)
|
|
701
|
+
.slice(0, FLOW_EVIDENCE_CAP)
|
|
702
|
+
.map((m) => ({
|
|
703
|
+
prId: m.pr.id,
|
|
704
|
+
repoFullName: repoName.get(m.pr.repoId) ?? '',
|
|
705
|
+
prNumber: m.pr.number,
|
|
706
|
+
prTitle: m.pr.title,
|
|
707
|
+
githubUrl: ghUrl(m.pr.repoId, m.pr.number),
|
|
708
|
+
hoursInCourt: m.hours[court],
|
|
709
|
+
leadHours: m.leadHours,
|
|
710
|
+
authorIsBot: m.pr.authorId != null && botAuthors.has(m.pr.authorId),
|
|
711
|
+
}));
|
|
712
|
+
let clearedFloor = 0;
|
|
713
|
+
const profiles = [];
|
|
714
|
+
for (const [repoId, rows] of byRepo) {
|
|
715
|
+
if (rows.length < FLOW_MIN_REPO_PRS)
|
|
716
|
+
continue;
|
|
717
|
+
clearedFloor += 1;
|
|
718
|
+
const h = { reviewer: 0, author: 0, landing: 0 };
|
|
719
|
+
for (const m of rows) {
|
|
720
|
+
h.reviewer += m.hours.reviewer;
|
|
721
|
+
h.author += m.hours.author;
|
|
722
|
+
h.landing += m.hours.landing;
|
|
723
|
+
}
|
|
724
|
+
const courts = sharesOf(h);
|
|
725
|
+
const leads = rows.map((m) => m.leadHours);
|
|
726
|
+
const p75 = percentile(leads, 0.75);
|
|
727
|
+
const top = [...courts].sort((a, b) => b.share - a.share)[0];
|
|
728
|
+
// ⚠ LOPSIDED **AND** SLOW. See FLOW_SLOW_P75_HOURS — a share alone invents a crisis in a repo
|
|
729
|
+
// whose pull requests clear in eighteen minutes.
|
|
730
|
+
const dominant = top != null && top.share >= FLOW_DOMINANT_SHARE && p75 >= FLOW_SLOW_P75_HOURS
|
|
731
|
+
? top.court
|
|
732
|
+
: null;
|
|
733
|
+
profiles.push({
|
|
734
|
+
repoId,
|
|
735
|
+
repoFullName: repoName.get(repoId) ?? '',
|
|
736
|
+
prs: rows.length,
|
|
737
|
+
courts,
|
|
738
|
+
medianLeadHours: median(leads),
|
|
739
|
+
p75LeadHours: p75,
|
|
740
|
+
dominant,
|
|
741
|
+
narrative: dominant == null ? null : narrate(dominant, top?.share ?? 0, rows.length, p75, median(leads)),
|
|
742
|
+
evidence: dominant == null ? [] : evidenceOf(rows, dominant),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
// Worst first: a named court outranks a quiet repo, then by how slow the tail is.
|
|
746
|
+
profiles.sort((a, b) => Number(b.dominant != null) - Number(a.dominant != null) ||
|
|
747
|
+
b.p75LeadHours - a.p75LeadHours ||
|
|
748
|
+
a.repoFullName.localeCompare(b.repoFullName));
|
|
749
|
+
const shownRepos = profiles.slice(0, FLOW_REPO_CAP);
|
|
750
|
+
// One directive per court that actually has a repository under it, in a fixed order so the
|
|
751
|
+
// screen does not reshuffle between polls.
|
|
752
|
+
const COURT_ORDER = ['reviewer', 'author', 'landing'];
|
|
753
|
+
const directives = COURT_ORDER.map((court) => ({
|
|
754
|
+
court,
|
|
755
|
+
repos: shownRepos.filter((r) => r.dominant === court).length,
|
|
756
|
+
}))
|
|
757
|
+
.filter((d) => d.repos > 0)
|
|
758
|
+
.map((d) => ({
|
|
759
|
+
...d,
|
|
760
|
+
directive: directiveFor(d.court, d.repos),
|
|
761
|
+
summary: directiveSummaryFor(d.court),
|
|
762
|
+
}));
|
|
763
|
+
if (measured.length > 0 && clearedFloor === 0) {
|
|
764
|
+
refuse('courts', `No repository reached ${FLOW_MIN_REPO_PRS} merged pull requests with a human review in the last ${windowDays} days.`);
|
|
765
|
+
}
|
|
766
|
+
else if (shownRepos.length > 0 && shownRepos.every((p) => p.dominant == null)) {
|
|
767
|
+
refuse('courts', noneStandsOutReason(clearedFloor, windowDays), 'measured_clean');
|
|
768
|
+
}
|
|
769
|
+
// ── Merged without a human review ──────────────────────────────────────────────────────────
|
|
770
|
+
const unreviewed = [];
|
|
771
|
+
for (const [repoId, rows] of unreviewedByRepo) {
|
|
772
|
+
const merged = mergedByRepo.get(repoId) ?? 0;
|
|
773
|
+
if (merged === 0)
|
|
774
|
+
continue;
|
|
775
|
+
const share = rows.length / merged;
|
|
776
|
+
if (rows.length < FLOW_UNREVIEWED_MIN_COUNT || share < FLOW_UNREVIEWED_MIN_SHARE)
|
|
777
|
+
continue;
|
|
778
|
+
unreviewed.push({
|
|
779
|
+
repoId,
|
|
780
|
+
repoFullName: repoName.get(repoId) ?? '',
|
|
781
|
+
merged,
|
|
782
|
+
withoutHumanReview: rows.length,
|
|
783
|
+
share,
|
|
784
|
+
evidence: [...rows]
|
|
785
|
+
.sort((a, b) => b.mergedMs - a.mergedMs || a.id - b.id)
|
|
786
|
+
.slice(0, FLOW_EVIDENCE_CAP)
|
|
787
|
+
.map((pr) => ({
|
|
788
|
+
prId: pr.id,
|
|
789
|
+
repoFullName: repoName.get(pr.repoId) ?? '',
|
|
790
|
+
prNumber: pr.number,
|
|
791
|
+
prTitle: pr.title,
|
|
792
|
+
githubUrl: ghUrl(pr.repoId, pr.number),
|
|
793
|
+
hoursInCourt: 0,
|
|
794
|
+
leadHours: (pr.mergedMs - pr.openedMs) / HOUR_MS,
|
|
795
|
+
authorIsBot: pr.authorId != null && botAuthors.has(pr.authorId),
|
|
796
|
+
})),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
unreviewed.sort((a, b) => b.share - a.share || a.repoFullName.localeCompare(b.repoFullName));
|
|
800
|
+
if (unreviewed.length === 0) {
|
|
801
|
+
refuse('unreviewed', excludedNoHumanTouch === 0
|
|
802
|
+
? `Every pull request merged in the last ${windowDays} days had a human review or comment on it.`
|
|
803
|
+
: unreviewedUnderFloorReason(), 'measured_clean');
|
|
804
|
+
}
|
|
805
|
+
// ── Working hours, budgets and the per-PR view (db/flow-detail.ts) ──────────────────────────
|
|
806
|
+
const facts = measured.map((m) => {
|
|
807
|
+
const reach = new Set();
|
|
808
|
+
for (const f of m.pr.files ?? []) {
|
|
809
|
+
if (typeof f?.path === 'string')
|
|
810
|
+
for (const s of surfacesForPath(f.path))
|
|
811
|
+
reach.add(s);
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
prId: m.pr.id,
|
|
815
|
+
repoId: m.pr.repoId,
|
|
816
|
+
repoFullName: repoName.get(m.pr.repoId) ?? '',
|
|
817
|
+
number: m.pr.number,
|
|
818
|
+
title: m.pr.title,
|
|
819
|
+
githubUrl: ghUrl(m.pr.repoId, m.pr.number),
|
|
820
|
+
openedMs: m.pr.openedMs,
|
|
821
|
+
mergedMs: m.pr.mergedMs,
|
|
822
|
+
spells: m.walk.spells,
|
|
823
|
+
rounds: m.walk.rounds,
|
|
824
|
+
firstLookMs: m.walk.firstLookMs,
|
|
825
|
+
approvedAtMs: m.walk.approvedAtMs,
|
|
826
|
+
firstReviewerId: m.walk.firstReviewerId,
|
|
827
|
+
lines: linesOf(m.pr),
|
|
828
|
+
files: linesOf(m.pr) == null ? null : m.pr.changedFiles || (m.pr.files?.length ?? null),
|
|
829
|
+
reachAreas: [...reach].sort(),
|
|
830
|
+
ciRedHours: ciRedHoursOf(ciObs.get(m.pr.id) ?? [], m.pr.openedMs, m.pr.mergedMs),
|
|
831
|
+
ticketKey: ticketKeyOf(m.pr.title, m.pr.headRefName),
|
|
832
|
+
selfMerged: m.pr.mergedById != null && m.pr.mergedById === m.pr.authorId,
|
|
833
|
+
...firstRequestOf(m.pr, requestEvents.get(m.pr.id) ?? [], isHuman),
|
|
834
|
+
};
|
|
835
|
+
});
|
|
836
|
+
const calFrom = facts.reduce((lo, f) => Math.min(lo, f.openedMs), toMs);
|
|
837
|
+
const calendar = buildWorkingCalendar(settings, calFrom, toMs);
|
|
838
|
+
const detail = buildFlowDetail(facts, calendar, settings, FLOW_MIN_REPO_PRS);
|
|
839
|
+
// The workspace's own answer to "named person or team?", when it has one — see directiveFor.
|
|
840
|
+
const person = detail.requests?.rows.find((r) => r.kind === 'person')?.medianRequestToLookWorkHours;
|
|
841
|
+
const team = detail.requests?.rows.find((r) => r.kind === 'team')?.medianRequestToLookWorkHours;
|
|
842
|
+
const namedBeatsTeam = person == null || team == null ? null : person < team;
|
|
843
|
+
if (namedBeatsTeam === false) {
|
|
844
|
+
for (const d of directives) {
|
|
845
|
+
if (d.court !== 'reviewer')
|
|
846
|
+
continue;
|
|
847
|
+
d.directive = directiveFor('reviewer', d.repos, false);
|
|
848
|
+
d.summary = directiveSummaryFor('reviewer', false);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
workspaceId: scope.workspaceId,
|
|
853
|
+
windowDays,
|
|
854
|
+
measuredPrs: measured.length,
|
|
855
|
+
courts: wsCourts,
|
|
856
|
+
medianLeadHours: wsMedian,
|
|
857
|
+
p75LeadHours: wsP75,
|
|
858
|
+
headline: measured.length === 0 ? null : headlineFor(wsCourts, wsMedian, wsP75, measured.length),
|
|
859
|
+
repos: shownRepos,
|
|
860
|
+
directives,
|
|
861
|
+
unreviewed,
|
|
862
|
+
refusals,
|
|
863
|
+
coverage,
|
|
864
|
+
settings,
|
|
865
|
+
...detail,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Who was asked FIRST, and when — from the stored request history.
|
|
870
|
+
*
|
|
871
|
+
* ⚠ NULL KIND WHEN THE HISTORY WAS NEVER RECEIVED. A PR with no rows reads "nobody was asked" only
|
|
872
|
+
* when we hold a positive statement of that (`requestsKnown`); otherwise it is "not known", and
|
|
873
|
+
* Chronology counts it apart rather than in the "nobody" row.
|
|
874
|
+
*
|
|
875
|
+
* The first MOMENT decides: requests made in the same instant (a person and a team together) count
|
|
876
|
+
* as a named person, because a person was named. Requests to automation (a Copilot review, a bot
|
|
877
|
+
* account) are skipped — asking a bot is not asking someone to look — so a PR whose only requests
|
|
878
|
+
* went to bots reads "nobody". A request dated before the PR opened is clamped to the opening.
|
|
879
|
+
*/
|
|
880
|
+
function firstRequestOf(pr, events, isHuman) {
|
|
881
|
+
if (!pr.requestsKnown)
|
|
882
|
+
return { requestKind: null, firstRequestMs: null };
|
|
883
|
+
const asks = events
|
|
884
|
+
.filter((e) => e.kind === 'requested' && e.atMs <= pr.mergedMs)
|
|
885
|
+
.filter((e) => e.reviewerKind === 'team' ||
|
|
886
|
+
(e.reviewerKind === 'user' && isHuman(e.userId) && e.userId !== pr.authorId))
|
|
887
|
+
.sort((a, b) => a.atMs - b.atMs);
|
|
888
|
+
const first = asks[0];
|
|
889
|
+
if (first == null)
|
|
890
|
+
return { requestKind: 'none', firstRequestMs: null };
|
|
891
|
+
const sameMoment = asks.filter((e) => e.atMs === first.atMs);
|
|
892
|
+
const kind = sameMoment.some((e) => e.reviewerKind === 'user') ? 'person' : 'team';
|
|
893
|
+
return { requestKind: kind, firstRequestMs: Math.max(first.atMs, pr.openedMs) };
|
|
894
|
+
}
|
|
895
|
+
/** Clock hours a PR's checks were observed red while it was open. */
|
|
896
|
+
function ciRedHoursOf(obs, openedMs, mergedMs) {
|
|
897
|
+
const sorted = [...obs].sort((a, b) => a.atMs - b.atMs);
|
|
898
|
+
let red = 0;
|
|
899
|
+
for (let i = 0; i < sorted.length; i += 1) {
|
|
900
|
+
const o = sorted[i];
|
|
901
|
+
if (!o.red)
|
|
902
|
+
continue;
|
|
903
|
+
const end = i + 1 < sorted.length ? sorted[i + 1].atMs : mergedMs;
|
|
904
|
+
red += Math.max(0, Math.min(end, mergedMs) - Math.max(o.atMs, openedMs));
|
|
905
|
+
}
|
|
906
|
+
return red / HOUR_MS;
|
|
907
|
+
}
|
|
908
|
+
/** Exposed for the unit test — the floors a fixture has to clear or miss. */
|
|
909
|
+
export const __flowTesting = {
|
|
910
|
+
FLOW_MIN_REPO_PRS,
|
|
911
|
+
FLOW_DOMINANT_SHARE,
|
|
912
|
+
FLOW_SLOW_P75_HOURS,
|
|
913
|
+
FLOW_EVIDENCE_CAP,
|
|
914
|
+
FLOW_UNREVIEWED_MIN_COUNT,
|
|
915
|
+
FLOW_UNREVIEWED_MIN_SHARE,
|
|
916
|
+
walkCourts,
|
|
917
|
+
walkCourtIntervals,
|
|
918
|
+
directiveFor,
|
|
919
|
+
directiveSummaryFor,
|
|
920
|
+
noHumanReviewReason,
|
|
921
|
+
noneStandsOutReason,
|
|
922
|
+
unreviewedUnderFloorReason,
|
|
923
|
+
ciRedHoursOf,
|
|
924
|
+
firstRequestOf,
|
|
925
|
+
percentile,
|
|
926
|
+
median,
|
|
927
|
+
fmtHours,
|
|
928
|
+
narrate,
|
|
929
|
+
};
|
|
930
|
+
//# sourceMappingURL=pr-intervals.js.map
|