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,318 @@
|
|
|
1
|
+
// The public contact form: POST /api/contact, delivered to a Slack incoming webhook.
|
|
2
|
+
//
|
|
3
|
+
// WHY SLACK AND NOT EMAIL. A contact form's job is to make sure a message ARRIVES.
|
|
4
|
+
// Email is the medium with the worst odds of that: it bounces, it lands in spam, and
|
|
5
|
+
// it needs SPF/DKIM/DMARC on the domain plus a transactional provider before the first
|
|
6
|
+
// message even has a chance. A webhook POST either returns 2xx or throws, and the
|
|
7
|
+
// failure is visible in this process's logs at the moment it happens. There is also
|
|
8
|
+
// precedent and a working shape already in the repo — the Pro plugin's per-workspace
|
|
9
|
+
// digest delivery (packages/pro/src/slack/) — though core cannot import from the
|
|
10
|
+
// plugin, so the URL validation below is a deliberate small copy rather than a shared
|
|
11
|
+
// helper. Email can be added later as a SECOND sink; it should never be the only one.
|
|
12
|
+
//
|
|
13
|
+
// ⚠ NO NEW DEPENDENCY, AND THAT IS A CONSTRAINT RATHER THAN A PREFERENCE.
|
|
14
|
+
// scripts/build-release.mjs generates release/package.json from a CURATED dependency
|
|
15
|
+
// list, so an SDK here ships to every `npx pierre-review` install for the sake of a
|
|
16
|
+
// hosted-only marketing page. Node ≥20's global fetch is the whole client.
|
|
17
|
+
//
|
|
18
|
+
// ---- Deciding a submission is genuine ----
|
|
19
|
+
//
|
|
20
|
+
// Three layers, none of which needs a third party, a script tag or a CSP change (the
|
|
21
|
+
// landing's CSP is `default-src 'self'` with `frame-src` absent entirely — a Turnstile
|
|
22
|
+
// or reCAPTCHA widget would need `script-src` AND `frame-src` widened, plus a line in
|
|
23
|
+
// the privacy policy, which is a decision worth taking deliberately rather than as a
|
|
24
|
+
// side effect of shipping a form):
|
|
25
|
+
//
|
|
26
|
+
// 1. A HONEYPOT field. `website` is rendered, labelled and reachable in the DOM, and
|
|
27
|
+
// hidden from sighted users by CSS + aria-hidden + tabindex=-1. A person never
|
|
28
|
+
// fills it; a form-filling bot fills every input it finds. A filled honeypot is
|
|
29
|
+
// answered with 200 and DROPPED — never a 400, because an error tells whoever is
|
|
30
|
+
// running the bot exactly which field gave them away.
|
|
31
|
+
//
|
|
32
|
+
// 2. A SIGNED TICKET with a minimum fill time. GET /api/contact/ticket returns
|
|
33
|
+
// `<issuedAtMs>.<hmac>`; the POST verifies the signature and requires the age to
|
|
34
|
+
// sit between MIN_FILL_MS and MAX_TICKET_MS. This is the layer that a client-sent
|
|
35
|
+
// "elapsed" number cannot be: the timestamp is signed by this server, so a bot
|
|
36
|
+
// cannot forge an age, and to get a valid one it has to make a second request and
|
|
37
|
+
// then WAIT. Stateless on purpose — no store, no replay table. A ticket is
|
|
38
|
+
// therefore reusable within its window, which the rate limit below bounds.
|
|
39
|
+
//
|
|
40
|
+
// 3. A RATE LIMIT. The `contact` tier in api/plugins/rate-limit.ts, keyed by IP
|
|
41
|
+
// because this route is deliberately anonymous. Five an hour.
|
|
42
|
+
//
|
|
43
|
+
// A determined human will still get a message through, which is correct — the target
|
|
44
|
+
// is automated submission volume, not a wall.
|
|
45
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
46
|
+
import { config } from '../../config.js';
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// The ticket
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/** A person needs longer than this to write a message. Below it, it was not typed. */
|
|
51
|
+
export const MIN_FILL_MS = 3_000;
|
|
52
|
+
/** Past this the ticket is stale — a page left open, or one harvested days ago. */
|
|
53
|
+
export const MAX_TICKET_MS = 6 * 60 * 60 * 1000;
|
|
54
|
+
// The signing key. SESSION_SECRET where there is one (cloud, where this form actually
|
|
55
|
+
// runs), otherwise a per-process random — which means a restart invalidates every
|
|
56
|
+
// outstanding ticket. That is acceptable and better than a constant: the failure mode
|
|
57
|
+
// is one visitor being asked to reload, and the alternative is a key an attacker can
|
|
58
|
+
// read out of the source.
|
|
59
|
+
//
|
|
60
|
+
// Derived rather than used directly so this never shares key material with the session
|
|
61
|
+
// cookie: the same secret signing two different things is how one forgery becomes two.
|
|
62
|
+
const TICKET_KEY = createHmac('sha256', config.sessionSecret || randomBytes(32))
|
|
63
|
+
.update('pierre:contact-ticket:v1')
|
|
64
|
+
.digest();
|
|
65
|
+
function sign(issuedAtMs) {
|
|
66
|
+
return createHmac('sha256', TICKET_KEY).update(String(issuedAtMs)).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
/** Mint a ticket for a form that is being opened now. */
|
|
69
|
+
export function issueTicket(nowMs = Date.now()) {
|
|
70
|
+
return `${nowMs}.${sign(nowMs)}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Check a ticket's signature and age. Pure — `nowMs` is injected so the windows can be
|
|
74
|
+
* tested without waiting for them.
|
|
75
|
+
*/
|
|
76
|
+
export function verifyTicket(raw, nowMs = Date.now()) {
|
|
77
|
+
if (typeof raw !== 'string')
|
|
78
|
+
return 'malformed';
|
|
79
|
+
const dot = raw.indexOf('.');
|
|
80
|
+
if (dot <= 0)
|
|
81
|
+
return 'malformed';
|
|
82
|
+
const issuedAt = Number.parseInt(raw.slice(0, dot), 10);
|
|
83
|
+
const mac = raw.slice(dot + 1);
|
|
84
|
+
if (!Number.isFinite(issuedAt) || mac === '')
|
|
85
|
+
return 'malformed';
|
|
86
|
+
// Constant-time, and length-checked first: timingSafeEqual THROWS on a length
|
|
87
|
+
// mismatch rather than returning false, so an attacker-supplied short mac would be a
|
|
88
|
+
// 500 instead of a 400.
|
|
89
|
+
const expected = Buffer.from(sign(issuedAt), 'utf-8');
|
|
90
|
+
const got = Buffer.from(mac, 'utf-8');
|
|
91
|
+
if (got.length !== expected.length || !timingSafeEqual(got, expected)) {
|
|
92
|
+
return 'bad_signature';
|
|
93
|
+
}
|
|
94
|
+
const age = nowMs - issuedAt;
|
|
95
|
+
// A negative age means a clock moved, not an attack — it is signed, so it can only
|
|
96
|
+
// have been minted here. Treated as "too fast", which is the safe side.
|
|
97
|
+
if (age < MIN_FILL_MS)
|
|
98
|
+
return 'too_fast';
|
|
99
|
+
if (age > MAX_TICKET_MS)
|
|
100
|
+
return 'expired';
|
|
101
|
+
return 'ok';
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// The webhook URL
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
const SLACK_WEBHOOK_HOST = 'hooks.slack.com';
|
|
107
|
+
/**
|
|
108
|
+
* Validate and normalise a Slack incoming-webhook URL.
|
|
109
|
+
*
|
|
110
|
+
* A near-copy of the Pro plugin's `normalizeSlackWebhookUrl` — core cannot import from
|
|
111
|
+
* `packages/pro` (it is a private submodule loaded by path, absent in OSS builds), so
|
|
112
|
+
* this is duplicated deliberately rather than shared. Keep the two in step.
|
|
113
|
+
*
|
|
114
|
+
* The value arrives from the environment, so the threat is a misconfiguration or a
|
|
115
|
+
* compromised variable turning this process into an SSRF proxy, not a user submission.
|
|
116
|
+
* Validating at the SINK makes the guarantee structural.
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeContactWebhookUrl(raw) {
|
|
119
|
+
const t = raw.trim();
|
|
120
|
+
if (t === '')
|
|
121
|
+
return null;
|
|
122
|
+
let url;
|
|
123
|
+
try {
|
|
124
|
+
url = new URL(t);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
// https only: the secret is in the path, so http would leak it on the wire, and the
|
|
130
|
+
// scheme check also rules out file:/gopher:/ftp: redirect tricks.
|
|
131
|
+
if (url.protocol !== 'https:')
|
|
132
|
+
return null;
|
|
133
|
+
// Exact host. NOT endsWith('slack.com'), which accepts hooks.slack.com.evil.example.
|
|
134
|
+
if (url.hostname.toLowerCase() !== SLACK_WEBHOOK_HOST)
|
|
135
|
+
return null;
|
|
136
|
+
// A real webhook is /services/<team>/<bot>/<token>.
|
|
137
|
+
if (!url.pathname.startsWith('/services/'))
|
|
138
|
+
return null;
|
|
139
|
+
// Query and fragment are dropped — nothing legitimate uses them, and they are a
|
|
140
|
+
// place to hide a redirect target.
|
|
141
|
+
return `https://${SLACK_WEBHOOK_HOST}${url.pathname}`;
|
|
142
|
+
}
|
|
143
|
+
/** What the form offers, and what each one means when it lands in Slack. */
|
|
144
|
+
export const CONTACT_TOPICS = {
|
|
145
|
+
pro_trial: 'Free Pro month',
|
|
146
|
+
question: 'Question about the product',
|
|
147
|
+
problem: 'Something is broken',
|
|
148
|
+
other: 'Something else',
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Slack's own escaping rules: `&`, `<` and `>` are the only three characters that are
|
|
152
|
+
* special in message text, and they must be escaped BEFORE the markup is assembled.
|
|
153
|
+
* Applied to every visitor-supplied string so a submission cannot forge a link, a
|
|
154
|
+
* channel reference or an @-mention in the notification it produces.
|
|
155
|
+
*/
|
|
156
|
+
function slackEscape(s) {
|
|
157
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build the Block Kit payload. Pure, so the test asserts the shape and the escaping
|
|
161
|
+
* without a network.
|
|
162
|
+
*
|
|
163
|
+
* The message is ONE block per fact plus the body, because a contact form's whole
|
|
164
|
+
* value is that the reply address is readable at a glance on a phone.
|
|
165
|
+
*/
|
|
166
|
+
export function buildContactMessage(s) {
|
|
167
|
+
// `?? 'Something else'` rather than `?? CONTACT_TOPICS['other']`: under
|
|
168
|
+
// noUncheckedIndexedAccess the second lookup is `string | undefined` too, so it
|
|
169
|
+
// would not narrow. The literal is the same string the 'other' key holds.
|
|
170
|
+
const topic = CONTACT_TOPICS[s.topic] ?? 'Something else';
|
|
171
|
+
// Angle brackets around the address would only be escaped back into literal ones, so
|
|
172
|
+
// the notification line uses parentheses and reads the same in every client.
|
|
173
|
+
const summary = `${topic} — ${s.name} (${s.email})`;
|
|
174
|
+
return {
|
|
175
|
+
// `text` is the notification line and the fallback for any client that cannot
|
|
176
|
+
// render blocks. Escaped like everything else.
|
|
177
|
+
text: slackEscape(summary),
|
|
178
|
+
blocks: [
|
|
179
|
+
{
|
|
180
|
+
type: 'section',
|
|
181
|
+
text: { type: 'mrkdwn', text: `*${slackEscape(topic)}*` },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: 'section',
|
|
185
|
+
fields: [
|
|
186
|
+
{ type: 'mrkdwn', text: `*From*\n${slackEscape(s.name)}` },
|
|
187
|
+
{ type: 'mrkdwn', text: `*Reply to*\n${slackEscape(s.email)}` },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
type: 'section',
|
|
192
|
+
text: { type: 'mrkdwn', text: slackEscape(s.message) },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** POST the message. Throws on a non-2xx so the route can log it and 502. */
|
|
198
|
+
export async function postContactToSlack(webhookUrl, message) {
|
|
199
|
+
const safe = normalizeContactWebhookUrl(webhookUrl);
|
|
200
|
+
if (!safe)
|
|
201
|
+
throw new Error('CONTACT_SLACK_WEBHOOK_URL is not a Slack webhook URL');
|
|
202
|
+
const res = await fetch(safe, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify(message),
|
|
206
|
+
// Never follow a redirect: a 30x is the standard way past a host allowlist that
|
|
207
|
+
// was only applied to the first URL.
|
|
208
|
+
redirect: 'error',
|
|
209
|
+
signal: AbortSignal.timeout(10_000),
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
// The response BODY is deliberately not surfaced — including it would turn a
|
|
213
|
+
// failed request into a read primitive (point the webhook at something internal
|
|
214
|
+
// and the error tells you what it said). The status is enough to diagnose.
|
|
215
|
+
throw new Error(`Slack webhook responded ${res.status}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// The routes
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Length caps are on the SCHEMA, so an oversized body is rejected by Fastify before any
|
|
222
|
+
// of this file runs. Sized for a real message rather than for an essay: Slack truncates
|
|
223
|
+
// a section block past 3000 characters anyway, and a 4000-character contact-form
|
|
224
|
+
// message is not a contact-form message.
|
|
225
|
+
const BODY_SCHEMA = {
|
|
226
|
+
type: 'object',
|
|
227
|
+
required: ['name', 'email', 'message', 'ticket'],
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
properties: {
|
|
230
|
+
name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
231
|
+
// A deliberately loose pattern. Email addresses that are legal and that a strict
|
|
232
|
+
// regex rejects are common; the address is only ever used as a reply-to that a
|
|
233
|
+
// human reads, never as a delivery target, so a wrong one costs a reply and
|
|
234
|
+
// nothing else.
|
|
235
|
+
email: { type: 'string', minLength: 3, maxLength: 254, pattern: '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$' },
|
|
236
|
+
topic: { type: 'string', enum: Object.keys(CONTACT_TOPICS) },
|
|
237
|
+
message: { type: 'string', minLength: 1, maxLength: 2500 },
|
|
238
|
+
ticket: { type: 'string', minLength: 1, maxLength: 200 },
|
|
239
|
+
// The honeypot, and IT MUST BE DECLARED HERE OR THE WHOLE LAYER IS INERT. Fastify
|
|
240
|
+
// configures ajv with `removeAdditional: true`, so an undeclared property is
|
|
241
|
+
// SILENTLY STRIPPED from req.body before the handler runs — the request still 200s,
|
|
242
|
+
// the check below still sees `undefined`, and every bot passes. Verified by injection,
|
|
243
|
+
// not assumed: an unknown field posted to this route returns 200 with the field gone.
|
|
244
|
+
website: { type: 'string', maxLength: 200 },
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
export async function contactRoutes(app) {
|
|
248
|
+
const configured = normalizeContactWebhookUrl(config.contactSlackWebhookUrl) != null;
|
|
249
|
+
// Mint a ticket. Also the form's readiness probe: with no webhook configured it 503s
|
|
250
|
+
// here, so the page can say so BEFORE someone writes two hundred words into a form
|
|
251
|
+
// that has nowhere to send them.
|
|
252
|
+
app.get('/api/contact/ticket', async (_req, reply) => {
|
|
253
|
+
if (!configured) {
|
|
254
|
+
return reply.code(503).send({ error: 'contact_unavailable' });
|
|
255
|
+
}
|
|
256
|
+
// A ticket is a signed timestamp and nothing else — it identifies no one and
|
|
257
|
+
// carries no state — but it must not be cached by anything in front of this
|
|
258
|
+
// server, or every visitor shares one issue time.
|
|
259
|
+
reply.header('cache-control', 'no-store');
|
|
260
|
+
return { ticket: issueTicket() };
|
|
261
|
+
});
|
|
262
|
+
app.post('/api/contact', { schema: { body: BODY_SCHEMA } }, async (req, reply) => {
|
|
263
|
+
if (!configured) {
|
|
264
|
+
return reply.code(503).send({
|
|
265
|
+
error: 'contact_unavailable',
|
|
266
|
+
message: 'The contact form is not available right now.',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const body = req.body;
|
|
270
|
+
// Layer 1. Answered with the SUCCESS shape on purpose: a bot that is told it failed
|
|
271
|
+
// learns which field to leave alone next time, and a person can never reach this.
|
|
272
|
+
if (typeof body.website === 'string' && body.website.trim() !== '') {
|
|
273
|
+
req.log.info({ ip: req.ip }, 'contact form: honeypot filled, dropped');
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
// Layer 2.
|
|
277
|
+
const verdict = verifyTicket(body.ticket);
|
|
278
|
+
if (verdict !== 'ok') {
|
|
279
|
+
req.log.warn({ ip: req.ip, verdict }, 'contact form: ticket rejected');
|
|
280
|
+
// Two answers, because they mean different things to a person. An expired ticket
|
|
281
|
+
// is a page that sat open and the fix is to reload; anything else is either a bot
|
|
282
|
+
// or a genuinely broken client, and the message says what to do without saying
|
|
283
|
+
// which check failed.
|
|
284
|
+
return reply.code(400).send({
|
|
285
|
+
error: verdict === 'expired' ? 'ticket_expired' : 'ticket_invalid',
|
|
286
|
+
message: verdict === 'expired'
|
|
287
|
+
? 'This form has been open for a while. Reload the page and send it again.'
|
|
288
|
+
: 'We could not verify that form. Reload the page and try again.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await postContactToSlack(config.contactSlackWebhookUrl, buildContactMessage({
|
|
293
|
+
name: body.name.trim(),
|
|
294
|
+
email: body.email.trim(),
|
|
295
|
+
topic: body.topic ?? 'other',
|
|
296
|
+
message: body.message.trim(),
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
// The submitter's words only exist in the request they just made, so a failure
|
|
301
|
+
// here loses them. Log enough to reconstruct the message by hand, and tell the
|
|
302
|
+
// visitor plainly that it did not send rather than showing a success screen over
|
|
303
|
+
// a dropped message.
|
|
304
|
+
// ⚠ `from:`, NEVER `name:`. Pino treats `name` as the LOGGER's name, so a `name`
|
|
305
|
+
// key here is swallowed into the log prefix — the line came out as
|
|
306
|
+
// `ERROR (Sam Okafor/19949)` with the submitter's name where the process name
|
|
307
|
+
// belongs and no `name` field in the body at all. Observed, not theorised.
|
|
308
|
+
req.log.error({ err, from: body.name, email: body.email, topic: body.topic, message: body.message }, 'contact form: delivery FAILED — the message is in this log line and nowhere else');
|
|
309
|
+
return reply.code(502).send({
|
|
310
|
+
error: 'delivery_failed',
|
|
311
|
+
message: 'That did not send. Please try again in a minute.',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
req.log.info({ topic: body.topic }, 'contact form: delivered');
|
|
315
|
+
return { ok: true };
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
//# sourceMappingURL=contact.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getDailyBriefCounts, getDailyBriefEntry } from '../../db/daily-brief.js';
|
|
2
|
+
import { listWorkspaces, resolveWorkspaceScope } from '../../db/queries.js';
|
|
3
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
4
|
+
// The daily brief (plan P3.1/N1 + the P3.3/N5 roll-up) — CORE / FREE, counts only. The Pro
|
|
5
|
+
// narration lives entirely on the plugin's synthesis seam (`kind: 'brief'` / `'rollup'`) and is
|
|
6
|
+
// fetched separately by the SPA; this route never touches AI and never carries cost/money.
|
|
7
|
+
//
|
|
8
|
+
// `?workspace=` is the ONE scope parameter (resolveWorkspaceScope: absent/unknown/foreign →
|
|
9
|
+
// Default, never a 404) and the resolved id is echoed. `?rollup=1` additionally returns one
|
|
10
|
+
// count line per OTHER workspace of the account (the "Elsewhere" strip line) — workspaces are
|
|
11
|
+
// few, and each line rides the fold's 5-min TTL cache (db/daily-brief.ts), so the loop is
|
|
12
|
+
// bounded and mostly cache hits. Defensively capped all the same.
|
|
13
|
+
//
|
|
14
|
+
// ⚠ THE TWO LINES ARE NOT SERVED THE SAME WAY, ON PURPOSE. The ACTIVE workspace's `counts` go
|
|
15
|
+
// through `getDailyBriefEntry`, which computes them FRESH: every one of those figures is the
|
|
16
|
+
// count of a list a strip line CLICKS INTO (`myTurn` is literally the my_turn card count that
|
|
17
|
+
// GET /api/attention recomputes per request), and a cached headline over a live list is a number
|
|
18
|
+
// the click disproves — which is exactly the "5 items need your review" over a board of 3 this
|
|
19
|
+
// split fixes. The `?rollup=1` lines describe OTHER workspaces and keep the cache: clicking one
|
|
20
|
+
// switches workspace, which re-fetches THAT workspace's fresh brief before any list is shown.
|
|
21
|
+
//
|
|
22
|
+
// ⚠ `generatedAt` therefore stamps the COUNTS (== this request); the `botAnomalies` array inside
|
|
23
|
+
// them keeps its own ≤5-min window (an 84-day events scan behind no clickable list). Two
|
|
24
|
+
// computation times in one response, deliberately — see db/daily-brief.ts's header before
|
|
25
|
+
// "simplifying" either half.
|
|
26
|
+
//
|
|
27
|
+
// Rate tier: `search` (60/min) — a DELIBERATE entry in tierFor, not the blanket read bucket:
|
|
28
|
+
// one call folds the insights cards, the resolve backlog and the trunk snapshot (× workspace
|
|
29
|
+
// count under `?rollup=1`). Pinned in rate-limit.test.ts.
|
|
30
|
+
const ROLLUP_WORKSPACE_CAP = 12;
|
|
31
|
+
export async function dailyBriefRoutes(app) {
|
|
32
|
+
app.get('/api/daily-brief', async (req) => {
|
|
33
|
+
const q = req.query;
|
|
34
|
+
const accountId = accountIdOf(req);
|
|
35
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace);
|
|
36
|
+
const { counts, computedAt } = await getDailyBriefEntry(accountId, scope.workspaceId);
|
|
37
|
+
const resp = {
|
|
38
|
+
workspaceId: scope.workspaceId,
|
|
39
|
+
counts,
|
|
40
|
+
generatedAt: computedAt.toISOString(),
|
|
41
|
+
};
|
|
42
|
+
if (q.rollup === '1' || q.rollup === 'true') {
|
|
43
|
+
const others = (await listWorkspaces(accountId))
|
|
44
|
+
.filter((w) => w.id !== scope.workspaceId)
|
|
45
|
+
.slice(0, ROLLUP_WORKSPACE_CAP);
|
|
46
|
+
if (others.length > 0) {
|
|
47
|
+
const lines = await Promise.all(others.map(async (w) => ({
|
|
48
|
+
workspaceId: w.id,
|
|
49
|
+
name: w.name,
|
|
50
|
+
counts: await getDailyBriefCounts(accountId, w.id),
|
|
51
|
+
})));
|
|
52
|
+
resp.rollup = lines;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return resp;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=daily-brief.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { FLOW_DEFAULT_WINDOW_DAYS, getFlowCourts } from '../../db/pr-intervals.js';
|
|
2
|
+
import { resolveWorkspaceScope } from '../../db/queries.js';
|
|
3
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
4
|
+
import { entitledProCapabilities } from '../../pro/contract.js';
|
|
5
|
+
// GET /api/flow-findings — "Chronology": the COURT LEDGER for a workspace.
|
|
6
|
+
//
|
|
7
|
+
// PAID, on the `periodReports` capability. Deterministic all the same — no model, no plugin call,
|
|
8
|
+
// no GitHub call — and every sentence it returns is still templated in db/pr-intervals.ts; what it
|
|
9
|
+
// costs is DB work, not tokens.
|
|
10
|
+
//
|
|
11
|
+
// ⚠ IT RIDES `periodReports` RATHER THAN A CAPABILITY OF ITS OWN, deliberately. Chronology and the
|
|
12
|
+
// period report answer the same question at two grains — "where did a completed stretch of work
|
|
13
|
+
// actually go" — and a fifteenth member on `ProCapabilities` would mean bumping `apiVersion` across
|
|
14
|
+
// four literals in two repositories (host contract, plugin index, plugin contract-types, the
|
|
15
|
+
// runtime gate in pro/bind.ts) to gate one route. One flag, no wire change, no plugin edit.
|
|
16
|
+
//
|
|
17
|
+
// ⚠ THE GATE IS A ROUTING DECISION AND MUST STAY ONE. `getFlowCourts` reads no capability and takes
|
|
18
|
+
// no flag: `verify:isolation` calls that fold DIRECTLY — no request, no account row, just the ids
|
|
19
|
+
// it is handed — and the day entitlement is threaded into the engine is the day every one of those
|
|
20
|
+
// cross-tenant assertions needs an account to run. (The `[7, 90]` window clamp is the mirror case:
|
|
21
|
+
// it lives in the ENGINE so a second caller cannot skip it, while this check lives HERE so the
|
|
22
|
+
// engine stays testable.)
|
|
23
|
+
//
|
|
24
|
+
// ⚠ THE SPA MUST GATE `useFlowFindings`' `enabled` ON THE SAME FLAG (it does). This route is
|
|
25
|
+
// POLLED — the panel refetches every five minutes — so an ungated hook against a 402 is a request
|
|
26
|
+
// every five minutes, per mounted pane, forever, and the panel renders "Could not load this
|
|
27
|
+
// workspace's flow.": an ERROR where the truth is a paywall, which is the worst of both. The
|
|
28
|
+
// unentitled SPA issues no request at all and paints the locked pane instead.
|
|
29
|
+
//
|
|
30
|
+
// `?workspace=<int>` is THE scope parameter and goes through `resolveWorkspaceScope`: absent,
|
|
31
|
+
// unparseable, unknown or ANOTHER TENANT'S id all resolve to the account's DEFAULT workspace —
|
|
32
|
+
// never a 404, so the route is not an existence oracle. The resolved id is echoed on the response
|
|
33
|
+
// so a client can correct a stale bookmark.
|
|
34
|
+
//
|
|
35
|
+
// `?days=<int>` is the window, default 30. The engine CLAMPS it to [7, 90] rather than trusting
|
|
36
|
+
// it: below seven days a median rests on three observations, and above ninety the retroactive
|
|
37
|
+
// COVERAGE BIAS dominates (docs/PERIOD-REPORTING.md — a workspace that onboarded repos over the
|
|
38
|
+
// span shows a "trend" that is entirely onboarding). `coverage` rides every response for the same
|
|
39
|
+
// reason. The clamp lives in the engine, not here, so a future second caller cannot skip it.
|
|
40
|
+
//
|
|
41
|
+
// Rate tier: `search` (60/min), a DELIBERATE entry in `tierFor` and pinned in rate-limit.test.ts.
|
|
42
|
+
// It is DB-only, but "this route is DB-only" is exactly the sentence that file exists to distrust:
|
|
43
|
+
// one call runs the lane resolver and then FOUR chunked action scans (reviews, review comments,
|
|
44
|
+
// conversation comments, commits) across every pull request merged in the window — the same shape
|
|
45
|
+
// of cost that put `GET /api/attention` and `GET /api/daily-brief` on this bucket.
|
|
46
|
+
export async function flowRoutes(app) {
|
|
47
|
+
app.get('/api/flow-findings', async (req, reply) => {
|
|
48
|
+
// The same entitlement view /api/me hands the SPA and the /api/pro/* 402 gate mirrors, so the
|
|
49
|
+
// tab's locked pane and this status code can never disagree about one account: a local account
|
|
50
|
+
// is entitled whenever the bound plugin advertises the capability, a free cloud account gets
|
|
51
|
+
// the 402. `!req.account` cannot happen in the running app (local synthesizes one, cloud 401s
|
|
52
|
+
// first) — it is here so a bare test harness fails closed rather than open.
|
|
53
|
+
//
|
|
54
|
+
// Ahead of `resolveWorkspaceScope` on purpose: an unentitled caller does no DB work, and the
|
|
55
|
+
// 402 is identical for every `?workspace=` value, so the gate cannot be read as an oracle for
|
|
56
|
+
// which workspaces exist.
|
|
57
|
+
if (!req.account || !entitledProCapabilities(req.account).periodReports) {
|
|
58
|
+
reply.status(402);
|
|
59
|
+
return { error: 'pro required' };
|
|
60
|
+
}
|
|
61
|
+
const q = req.query;
|
|
62
|
+
const accountId = accountIdOf(req);
|
|
63
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace);
|
|
64
|
+
// A garbage `?days=` is the DEFAULT, not a 400: this is a dashboard parameter carried in a
|
|
65
|
+
// bookmark, and the same "never an error out of a scope hint" rule `?workspace=` follows.
|
|
66
|
+
const parsed = q.days == null ? Number.NaN : Number.parseInt(q.days, 10);
|
|
67
|
+
const days = Number.isInteger(parsed) && parsed > 0 ? parsed : FLOW_DEFAULT_WINDOW_DAYS;
|
|
68
|
+
return getFlowCourts(accountId, scope, days);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=flow.js.map
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { getInsights, getRepoAnalytics, getWorkspaceMetricsDetail, getWorkspaceMetricsForScope, resolveWorkspaceScope, } from '../../db/queries.js';
|
|
2
|
+
import { getWorkspaceRepoActivity } from '../../db/repo-activity.js';
|
|
3
|
+
import { buildPendingBoard } from '../../db/pending-tabs.js';
|
|
4
|
+
import { PR_LIVENESS_MAX_IDS, sweepPrLiveness, } from '../../sync/pr-liveness-sweep.js';
|
|
5
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
6
|
+
// The liveness body. `maxItems` is a coarse guard only — the real cap is applied in the handler
|
|
7
|
+
// AFTER de-duplication (a board legitimately renders several cards for one PR), and it 400s
|
|
8
|
+
// rather than truncating.
|
|
9
|
+
const livenessSchema = {
|
|
10
|
+
querystring: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: { workspace: { type: 'string' } },
|
|
13
|
+
},
|
|
14
|
+
body: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
required: ['prIds'],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
properties: {
|
|
19
|
+
prIds: {
|
|
20
|
+
type: 'array',
|
|
21
|
+
items: { type: 'integer', minimum: 1 },
|
|
22
|
+
minItems: 1,
|
|
23
|
+
maxItems: 400,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
function parseIntList(raw) {
|
|
29
|
+
if (!raw)
|
|
30
|
+
return null;
|
|
31
|
+
const ids = raw
|
|
32
|
+
.split(',')
|
|
33
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
34
|
+
.filter((n) => Number.isFinite(n));
|
|
35
|
+
return ids.length > 0 ? ids : null;
|
|
36
|
+
}
|
|
37
|
+
export async function insightsRoutes(app) {
|
|
38
|
+
// Per-repo sprint stats for the Insights panel. Scoped to the account; `repoIds` narrows to the
|
|
39
|
+
// repos the CALLER names. NOT workspace-scoped: it is a per-repo snapshot the caller already
|
|
40
|
+
// names its repos for, so there is no scope for a workspace to decide.
|
|
41
|
+
app.get('/api/insights', async (req) => {
|
|
42
|
+
const q = req.query;
|
|
43
|
+
return getInsights({
|
|
44
|
+
accountId: accountIdOf(req),
|
|
45
|
+
repoIds: parseIntList(q.repoIds),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
// Workspace flow-metric header (DORA-ish tiles + trend charts) — CORE/free. It sits under the
|
|
49
|
+
// "Flow metrics" heading on the REPORTS rail entry, not the Feed: a workspace-wide survey on top
|
|
50
|
+
// of a chronological stream pushed the feed two screens down, and it is precisely why the Reports
|
|
51
|
+
// entry is ungated on every tier.
|
|
52
|
+
//
|
|
53
|
+
// `?workspace=<id>` is the ONE scope parameter: `resolveWorkspaceScope` resolves an absent,
|
|
54
|
+
// unparseable, unknown or foreign id to the account's DEFAULT workspace (never a 404 — every id
|
|
55
|
+
// yields the same response shape, so it is not an existence oracle, and the resolved id is always
|
|
56
|
+
// one the caller owns). Its `repoIds` are that workspace's membership, and `[]` — a workspace
|
|
57
|
+
// with no repos — is a legal state that yields `metrics: null`, NOT "every repo in the account".
|
|
58
|
+
// NO `narrow` is passed: Reports covers every repo in the workspace (the repo picker is
|
|
59
|
+
// Timeline-only), so this route takes no `?repoIds=` and must not gain one.
|
|
60
|
+
//
|
|
61
|
+
// It also carries `repoActivity` — the "where is the work happening?" per-repo breakdown under
|
|
62
|
+
// the same heading. RIDING THIS RESPONSE RATHER THAN A ROUTE OF ITS OWN IS THE DECISION: it is
|
|
63
|
+
// the same scope resolved once, the same 14-day window, painted in the same section by the same
|
|
64
|
+
// component, and it multiplies by nothing (two indexed scans plus the lane resolver's fixed
|
|
65
|
+
// handful), so `/api/workspace-metrics` keeps the `read` fall-through that api/plugins/
|
|
66
|
+
// rate-limit.ts records for it. A second route would have re-resolved the scope, doubled the
|
|
67
|
+
// round trips for one panel, and needed a tier decision to say the same thing.
|
|
68
|
+
app.get('/api/workspace-metrics', async (req) => {
|
|
69
|
+
const q = req.query;
|
|
70
|
+
const accountId = accountIdOf(req);
|
|
71
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace);
|
|
72
|
+
const [metrics, repoActivity] = await Promise.all([
|
|
73
|
+
getWorkspaceMetricsForScope(accountId, scope.repoIds),
|
|
74
|
+
getWorkspaceRepoActivity(accountId, scope, Date.now()),
|
|
75
|
+
]);
|
|
76
|
+
// `workspaceId` is the scope echo every scoped response owes the client (docs/API.md) — this
|
|
77
|
+
// route was the one that never sent it, so a SPA holding a stale `?workspace=` had no way to
|
|
78
|
+
// learn it had been resolved to Default.
|
|
79
|
+
return {
|
|
80
|
+
metrics,
|
|
81
|
+
workspaceId: scope.workspaceId,
|
|
82
|
+
...(repoActivity != null ? { repoActivity } : {}),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
// The PR lists behind each flow-metric tile (the tile drill-down) — also CORE/free, so a Feed
|
|
86
|
+
// tile opens the same drill-down for everyone. Mirrors the Pro route's `{enabled, detail}` shape
|
|
87
|
+
// (enabled always true here). Same workspace resolution as its sibling above.
|
|
88
|
+
app.get('/api/workspace-metrics/detail', async (req) => {
|
|
89
|
+
const q = req.query;
|
|
90
|
+
const accountId = accountIdOf(req);
|
|
91
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace);
|
|
92
|
+
const detail = await getWorkspaceMetricsDetail(accountId, undefined, scope.repoIds);
|
|
93
|
+
return { enabled: true, detail };
|
|
94
|
+
});
|
|
95
|
+
// (GET /api/workspace-metrics/compare was DELETED with the "Compare workspaces" rail entry —
|
|
96
|
+
// cross-workspace comparison now lives inside Reports as the plugin-served "By workspace" axis,
|
|
97
|
+
// which rides the window-pure getPeriodMetricsForWorkspaces seam instead of the snapshot
|
|
98
|
+
// WorkspaceMetrics matrix.)
|
|
99
|
+
// The attention cards (your turn / red builds that are yours / stalled reviews / untouched
|
|
100
|
+
// threads / reviewer load / needs-a-reviewer / ready-to-land / behind-trunk) — CORE/free (the
|
|
101
|
+
// same cards Pro Insights computes in core getWorkspaceInsights), for the **Pending** rail
|
|
102
|
+
// entry. The bot cards are excluded (they live in the free Bots console).
|
|
103
|
+
//
|
|
104
|
+
// SERVED AS SIX TABS (`db/pending-tabs.ts`), each a scored list with its uncapped count — My
|
|
105
|
+
// turn grouped by the reader's type order and Dependencies by kind (security, then bumps) — free
|
|
106
|
+
// on every tier. The rank is code, with the reader's weights; only the Pro plan's NARRATION is
|
|
107
|
+
// paid.
|
|
108
|
+
//
|
|
109
|
+
// ⚠ THE TABS ARE AN ALLOW-LIST (`PENDING_TABS`), so a NEW InsightKind reaches this board only
|
|
110
|
+
// once it is given a tab — otherwise it is folded, counted and never listed. pending-tabs.test.ts
|
|
111
|
+
// fails when a non-bot kind has no tab, rather than letting it vanish quietly.
|
|
112
|
+
app.get('/api/attention', async (req) => {
|
|
113
|
+
const q = req.query;
|
|
114
|
+
// THE ONE FOLD (`buildPendingBoard`, db/pending-tabs.ts) — the Pro Slack digest reads the same
|
|
115
|
+
// function through `ProHostQueries.getPendingBoard`, so the two cannot drift. `live: true` is
|
|
116
|
+
// THIS route only: failing-check names on red cards (the brief, the work plan and the Pro
|
|
117
|
+
// payloads render no names, and must not pay for them or hash them) and suggested reviewers
|
|
118
|
+
// for the top "Needs a reviewer" cards.
|
|
119
|
+
const { workspaceId: _ws, ...board } = await buildPendingBoard(accountIdOf(req), q.workspace, {
|
|
120
|
+
live: true,
|
|
121
|
+
});
|
|
122
|
+
return board;
|
|
123
|
+
});
|
|
124
|
+
// POST /api/attention/liveness — THE BOARD'S ONE GITHUB QUESTION.
|
|
125
|
+
//
|
|
126
|
+
// The route above is DB-only and must stay that way: it is the board's paint path, and a
|
|
127
|
+
// GitHub call inside it would be the 200-calls-to-render-fifty-cards failure the board exists
|
|
128
|
+
// to avoid. This is its sibling — one batched `nodes(ids:)` sweep for the WHOLE board, so a PR
|
|
129
|
+
// merged by somebody else stops being a card within seconds instead of within an adaptive
|
|
130
|
+
// bucket (2-15 min). Cost is 2 GraphQL points per sweep, measured; the mechanics and the
|
|
131
|
+
// measured wall-time cliff that forces two passes are in sync/pr-liveness-sweep.ts and
|
|
132
|
+
// github/queries.ts's PR_LIVENESS_NODES_QUERY header.
|
|
133
|
+
//
|
|
134
|
+
// ⚠ IT RETURNS COUNTS, NEVER CARDS. `changed > 0` tells the SPA to refetch the board and the
|
|
135
|
+
// brief TOGETHER; it must never splice a card out locally (that breaks `capFor`'s
|
|
136
|
+
// `shown === count` guard and silently deletes the cap disclosure). Keeping cards out of this
|
|
137
|
+
// response is what makes that structural rather than a rule someone has to remember.
|
|
138
|
+
//
|
|
139
|
+
// ⚠ A POST WITH A GET'S COST, deliberately: the id list is a body (a query string of 90 ids is
|
|
140
|
+
// a header-size question nobody should have), and a POST puts it behind the cross-origin guard
|
|
141
|
+
// in cloud. That is the `POST /api/reactions/lookup` shape exactly, and it takes the same
|
|
142
|
+
// `prDetail` tier — spelled as an EXACT match in tierFor, because nothing else would catch it.
|
|
143
|
+
//
|
|
144
|
+
// Rate-limit PAUSES are reported, never thrown: `paused` is a fact about the tenant's GitHub
|
|
145
|
+
// window, and the board renders its synced rows through it.
|
|
146
|
+
app.post('/api/attention/liveness', { schema: livenessSchema }, async (req, reply) => {
|
|
147
|
+
const q = req.query;
|
|
148
|
+
const { prIds } = req.body;
|
|
149
|
+
const accountId = accountIdOf(req);
|
|
150
|
+
// ⚠ 400 ON OVER-CAP, NEVER A TRUNCATION (the peer benchmark's `?cells=` rule): a caller
|
|
151
|
+
// whose tail was silently dropped believes its whole board was freshened. `additionalItems`
|
|
152
|
+
// in the schema cannot express "dedupe then count", so the cap is checked here after the
|
|
153
|
+
// dedupe the client is asked to do anyway.
|
|
154
|
+
const unique = [...new Set(prIds)];
|
|
155
|
+
if (unique.length > PR_LIVENESS_MAX_IDS) {
|
|
156
|
+
return reply.code(400).send({
|
|
157
|
+
error: 'TooManyIds',
|
|
158
|
+
message: `At most ${PR_LIVENESS_MAX_IDS} PR ids per liveness sweep (received ${unique.length}).`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// `?workspace=` is THE scope parameter — absent/unknown/foreign resolves to the account's
|
|
162
|
+
// DEFAULT workspace, never a 404, and the resolved id is echoed. Its `repoIds` are what
|
|
163
|
+
// bound the id resolve below, so a PR the caller owns in ANOTHER workspace is not swept
|
|
164
|
+
// here: the board that asked is the board that gets refreshed.
|
|
165
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace);
|
|
166
|
+
const swept = await sweepPrLiveness({
|
|
167
|
+
accountId,
|
|
168
|
+
repoIds: scope.repoIds,
|
|
169
|
+
prIds: unique,
|
|
170
|
+
log: req.log,
|
|
171
|
+
});
|
|
172
|
+
// null = a sweep for this account is already in flight (two tabs, or an interval overlapping
|
|
173
|
+
// its own focus refetch). A no-op report, not an error: the running sweep is about to
|
|
174
|
+
// produce this very answer, and the caller's next tick will see its effect.
|
|
175
|
+
if (!swept) {
|
|
176
|
+
return {
|
|
177
|
+
workspaceId: scope.workspaceId,
|
|
178
|
+
checked: 0,
|
|
179
|
+
mergeStateChecked: 0,
|
|
180
|
+
changed: 0,
|
|
181
|
+
leftOpenSet: 0,
|
|
182
|
+
paused: null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { workspaceId: scope.workspaceId, ...swept };
|
|
186
|
+
});
|
|
187
|
+
// Heavier per-repo analytics for the drill-down chart panel — loaded on demand.
|
|
188
|
+
// Ownership-scoped: a repo not owned by the account 404s.
|
|
189
|
+
app.get('/api/insights/:repoId/analytics', async (req, reply) => {
|
|
190
|
+
const { repoId } = req.params;
|
|
191
|
+
const id = Number.parseInt(repoId, 10);
|
|
192
|
+
const data = Number.isFinite(id)
|
|
193
|
+
? await getRepoAnalytics(accountIdOf(req), id)
|
|
194
|
+
: null;
|
|
195
|
+
if (!data)
|
|
196
|
+
return reply.code(404).send({ error: 'repo not found' });
|
|
197
|
+
return data;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=insights.js.map
|