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,1190 @@
|
|
|
1
|
+
import { and, desc, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { db, schema, runTransaction } from '../db/client.js';
|
|
3
|
+
import { searchText } from '../db/search.js';
|
|
4
|
+
// The single owner of "which workspace is this account's Default" — concurrent-safe (INSERT …
|
|
5
|
+
// ON CONFLICT DO NOTHING then re-SELECT) and carrying the name-collision ladder. Duplicating a
|
|
6
|
+
// bare `SELECT … WHERE is_default` here would be a second answer to the same question.
|
|
7
|
+
import { ensureDefaultWorkspace } from '../db/queries.js';
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
import { isLikelyBot } from './bot-detection.js';
|
|
10
|
+
import { notePrChanged } from './pr-change-signal.js';
|
|
11
|
+
import { classifyPrSecurity, } from './security-detect.js';
|
|
12
|
+
import { deriveThreadState, } from './derive-thread-state.js';
|
|
13
|
+
const { repos, users, pullRequests, reviews, reviewThreads, reviewComments, prComments, commits, events, reviewRequests, reviewRequestEvents, ciStatusEvents, searchIndex, workspaceRepos, } = schema;
|
|
14
|
+
function toDate(iso) {
|
|
15
|
+
return iso ? new Date(iso) : null;
|
|
16
|
+
}
|
|
17
|
+
function maxDate(dates) {
|
|
18
|
+
let max = null;
|
|
19
|
+
for (const d of dates) {
|
|
20
|
+
if (d && (!max || d > max))
|
|
21
|
+
max = d;
|
|
22
|
+
}
|
|
23
|
+
return max;
|
|
24
|
+
}
|
|
25
|
+
function minDate(dates) {
|
|
26
|
+
let min = null;
|
|
27
|
+
for (const d of dates) {
|
|
28
|
+
if (d && (!min || d < min))
|
|
29
|
+
min = d;
|
|
30
|
+
}
|
|
31
|
+
return min;
|
|
32
|
+
}
|
|
33
|
+
// Short single-line preview of a comment body. Stored even in lean mode (where the
|
|
34
|
+
// full body is dropped) so the triage path (getMyTurn/getThreadsAwaiting) and
|
|
35
|
+
// graceful UI degradation work without hydrating from GitHub.
|
|
36
|
+
function excerptOf(body) {
|
|
37
|
+
if (!body)
|
|
38
|
+
return null;
|
|
39
|
+
const oneLine = body.replace(/\s+/g, ' ').trim();
|
|
40
|
+
if (!oneLine)
|
|
41
|
+
return null;
|
|
42
|
+
return oneLine.length > 160 ? `${oneLine.slice(0, 159)}…` : oneLine;
|
|
43
|
+
}
|
|
44
|
+
/** Resolves GraphQL actors to local user ids, caching by login within a run. */
|
|
45
|
+
export function createUserResolver() {
|
|
46
|
+
const cache = new Map();
|
|
47
|
+
return {
|
|
48
|
+
async resolve(exec, actor) {
|
|
49
|
+
const login = actor?.login;
|
|
50
|
+
if (!login)
|
|
51
|
+
return null;
|
|
52
|
+
const cached = cache.get(login);
|
|
53
|
+
if (cached !== undefined)
|
|
54
|
+
return cached;
|
|
55
|
+
const nodeId = actor?.id ?? null;
|
|
56
|
+
// The GitHub node id is the STABLE identity; the login is NOT. The same
|
|
57
|
+
// account can surface under two different logins — even inside one sync
|
|
58
|
+
// payload: a bot's Bot-typed author field reads `dependabot[bot]` while its
|
|
59
|
+
// commit-author field reads the bare `dependabot`, both carrying the same
|
|
60
|
+
// node id; and humans get renamed. Because BOTH `github_login` AND
|
|
61
|
+
// `github_node_id` are UNIQUE, the login-keyed upsert below is unsafe when a
|
|
62
|
+
// DIFFERENT row already owns this node id: matching the login (or inserting a
|
|
63
|
+
// new one) while `coalesce`-stamping the node raises "UNIQUE constraint
|
|
64
|
+
// failed: users.github_node_id". So when we have a node id, resolve on it
|
|
65
|
+
// FIRST and reuse the row that owns it — the colliding insert/update is never
|
|
66
|
+
// issued. Only a brand-new (or absent) node id falls through to the
|
|
67
|
+
// login-keyed upsert, where stamping the node can't collide.
|
|
68
|
+
if (nodeId) {
|
|
69
|
+
const owner = (await exec
|
|
70
|
+
.select({
|
|
71
|
+
id: users.id,
|
|
72
|
+
isBot: users.isBot,
|
|
73
|
+
isBotOverridden: users.isBotOverridden,
|
|
74
|
+
})
|
|
75
|
+
.from(users)
|
|
76
|
+
.where(eq(users.githubNodeId, nodeId))
|
|
77
|
+
.limit(1)
|
|
78
|
+
.execute())[0];
|
|
79
|
+
if (owner) {
|
|
80
|
+
// Refresh volatile metadata but NEVER rewrite the login: the row already
|
|
81
|
+
// holds a canonical login for this node id, and rewriting it would risk the
|
|
82
|
+
// github_login UNIQUE (when the incoming login belongs to another live row)
|
|
83
|
+
// and thrash a bot that alternates login forms across a single sync. Only
|
|
84
|
+
// set fields the actor actually carries (a commit-author resolve has no
|
|
85
|
+
// name/avatar/typename) so we never clobber a known value with null — the
|
|
86
|
+
// same "coalesce" intent as the upsert below, via plain portable value
|
|
87
|
+
// binds (a raw `sql` boolean can't bind on sqlite; a bare null param can
|
|
88
|
+
// confuse pg type inference). isBot honours a manual override.
|
|
89
|
+
const set = { isBot: owner.isBotOverridden ? owner.isBot : isLikelyBot(login) };
|
|
90
|
+
if (actor?.name != null)
|
|
91
|
+
set.displayName = actor.name;
|
|
92
|
+
if (actor?.avatarUrl != null)
|
|
93
|
+
set.avatarUrl = actor.avatarUrl;
|
|
94
|
+
if (actor?.__typename != null)
|
|
95
|
+
set.githubType = actor.__typename;
|
|
96
|
+
await exec.update(users).set(set).where(eq(users.id, owner.id)).execute();
|
|
97
|
+
cache.set(login, owner.id);
|
|
98
|
+
return owner.id;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const row = (await exec
|
|
102
|
+
.insert(users)
|
|
103
|
+
.values({
|
|
104
|
+
githubLogin: login,
|
|
105
|
+
githubNodeId: nodeId,
|
|
106
|
+
displayName: actor?.name ?? null,
|
|
107
|
+
avatarUrl: actor?.avatarUrl ?? null,
|
|
108
|
+
// GraphQL __typename ('User'|'Bot'|…) when the actor carried it (the fat
|
|
109
|
+
// activity query selects it on every author); null for locally-synthesized
|
|
110
|
+
// actors (commit authors / review requests). Feeds the bot-triage classifier.
|
|
111
|
+
githubType: actor?.__typename ?? null,
|
|
112
|
+
isBot: isLikelyBot(login),
|
|
113
|
+
})
|
|
114
|
+
.onConflictDoUpdate({
|
|
115
|
+
target: users.githubLogin,
|
|
116
|
+
set: {
|
|
117
|
+
githubNodeId: sql `coalesce(excluded.github_node_id, ${users.githubNodeId})`,
|
|
118
|
+
displayName: sql `coalesce(excluded.display_name, ${users.displayName})`,
|
|
119
|
+
avatarUrl: sql `coalesce(excluded.avatar_url, ${users.avatarUrl})`,
|
|
120
|
+
// Never overwrite a known github_type with null (a later {login,id}-only
|
|
121
|
+
// resolve of the same actor must not wipe the type captured from GraphQL).
|
|
122
|
+
githubType: sql `coalesce(excluded.github_type, ${users.githubType})`,
|
|
123
|
+
// Never clobber a manual is_bot override.
|
|
124
|
+
isBot: sql `case when ${users.isBotOverridden} = true then ${users.isBot} else excluded.is_bot end`,
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
.returning({ id: users.id })
|
|
128
|
+
.execute())[0];
|
|
129
|
+
cache.set(login, row.id);
|
|
130
|
+
return row.id;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Upsert a repo by its GitHub node id; returns the local repo id.
|
|
136
|
+
*
|
|
137
|
+
* ── IT ALSO WRITES THE WORKSPACE MEMBERSHIP ROW, IN THE SAME TRANSACTION ─────────────────────
|
|
138
|
+
* A repo with NO `workspace_repos` row is INVISIBLE to every workspace-scoped read — no PRs, no
|
|
139
|
+
* feed rows, no bots, and silently. This is the WRITE side of closing that gap (the READ side is
|
|
140
|
+
* `ensureRepoMemberships`, which repairs stragglers on essentially every GET). Doing it here, in
|
|
141
|
+
* the same `runTransaction` as the repo row, means a repo is never committed without a home.
|
|
142
|
+
*
|
|
143
|
+
* ⚠ `ON CONFLICT (account_id, repo_id) DO NOTHING` IS LOAD-BEARING. That unique is "one workspace
|
|
144
|
+
* per repo", so an upsert on it would be a MOVE: a re-sync of an existing repo would silently drag
|
|
145
|
+
* it back out of the workspace a human deliberately put it in, on every sync tick.
|
|
146
|
+
*
|
|
147
|
+
* The membership row IS the whole of a repo's visibility now — there is no second "watched" axis
|
|
148
|
+
* to also set (`repos.inbox_watch` and its start stamp are dropped). A repo in a workspace is
|
|
149
|
+
* fully live: Feed, Activity, My Turn and Bots all cover it.
|
|
150
|
+
*
|
|
151
|
+
* The Default workspace is resolved BEFORE the transaction opens, deliberately:
|
|
152
|
+
* `ensureDefaultWorkspace` runs on `db`, and on Postgres `db` is a Pool — i.e. a DIFFERENT
|
|
153
|
+
* connection from the transaction's executor — so calling it inside would run outside the
|
|
154
|
+
* transaction on one dialect and inside it on the other.
|
|
155
|
+
*
|
|
156
|
+
* Safe against `runTransaction`'s bare `BEGIN`: `upsertRepo` has no caller already inside a
|
|
157
|
+
* transaction (`api/routes/repos.ts`, `sync/sync-repo.ts`).
|
|
158
|
+
*/
|
|
159
|
+
export async function upsertRepo(owner, name, githubNodeId, defaultBranch, accountId, viewerPermission, description) {
|
|
160
|
+
// default_branch: only overwrite when known (a branch is never revoked, and the
|
|
161
|
+
// lightweight add-repo path omits it) so we never null a synced value.
|
|
162
|
+
// viewer_permission: the activity sync ALWAYS fetches it (passing null when
|
|
163
|
+
// GitHub reports no permission, i.e. access was revoked), so `undefined` means
|
|
164
|
+
// "not fetched" (add path → preserve) while `null`/string is authoritative and
|
|
165
|
+
// overwrites — otherwise a revoked-to-null permission would stay stale-elevated.
|
|
166
|
+
// description: the three-state partial-response policy (sync/branch-status.ts is the
|
|
167
|
+
// reference) — `undefined` ⇒ the selection wasn't received (add path, a tolerant
|
|
168
|
+
// partial) ⇒ OMIT so the stored value survives; `null` ⇒ GitHub positively said the
|
|
169
|
+
// repo has no description ⇒ clear; a string overwrites.
|
|
170
|
+
const set = { owner, name };
|
|
171
|
+
if (defaultBranch != null)
|
|
172
|
+
set.defaultBranch = defaultBranch;
|
|
173
|
+
if (viewerPermission !== undefined)
|
|
174
|
+
set.viewerPermission = viewerPermission;
|
|
175
|
+
if (description !== undefined)
|
|
176
|
+
set.description = description;
|
|
177
|
+
const workspaceId = await ensureDefaultWorkspace(accountId);
|
|
178
|
+
return runTransaction(async (tx) => {
|
|
179
|
+
const row = (await tx
|
|
180
|
+
.insert(repos)
|
|
181
|
+
.values({
|
|
182
|
+
accountId,
|
|
183
|
+
owner,
|
|
184
|
+
name,
|
|
185
|
+
githubNodeId,
|
|
186
|
+
defaultBranch: defaultBranch ?? null,
|
|
187
|
+
viewerPermission: viewerPermission ?? null,
|
|
188
|
+
description: description ?? null,
|
|
189
|
+
})
|
|
190
|
+
.onConflictDoUpdate({
|
|
191
|
+
target: [repos.accountId, repos.githubNodeId],
|
|
192
|
+
set,
|
|
193
|
+
})
|
|
194
|
+
.returning({ id: repos.id })
|
|
195
|
+
.execute())[0];
|
|
196
|
+
await tx
|
|
197
|
+
.insert(workspaceRepos)
|
|
198
|
+
.values({ accountId, workspaceId, repoId: row.id })
|
|
199
|
+
.onConflictDoNothing({ target: [workspaceRepos.accountId, workspaceRepos.repoId] })
|
|
200
|
+
.execute();
|
|
201
|
+
return row.id;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function prState(s) {
|
|
205
|
+
if (s === 'MERGED')
|
|
206
|
+
return 'merged';
|
|
207
|
+
if (s === 'CLOSED')
|
|
208
|
+
return 'closed';
|
|
209
|
+
return 'open';
|
|
210
|
+
}
|
|
211
|
+
// Exported because the default-branch snapshot (sync/branch-status.ts) maps the SAME GitHub
|
|
212
|
+
// `StatusState` enum off a Commit's rollup. Two copies would drift silently.
|
|
213
|
+
export function ciStatusFrom(state) {
|
|
214
|
+
switch ((state ?? '').toUpperCase()) {
|
|
215
|
+
case 'SUCCESS':
|
|
216
|
+
return 'success';
|
|
217
|
+
case 'FAILURE':
|
|
218
|
+
return 'failure';
|
|
219
|
+
case 'PENDING':
|
|
220
|
+
return 'pending';
|
|
221
|
+
case 'ERROR':
|
|
222
|
+
return 'error';
|
|
223
|
+
case 'EXPECTED':
|
|
224
|
+
return 'expected';
|
|
225
|
+
default:
|
|
226
|
+
return 'unknown';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function mergeableFrom(state) {
|
|
230
|
+
switch ((state ?? '').toUpperCase()) {
|
|
231
|
+
case 'MERGEABLE':
|
|
232
|
+
return 'mergeable';
|
|
233
|
+
case 'CONFLICTING':
|
|
234
|
+
return 'conflicting';
|
|
235
|
+
default:
|
|
236
|
+
return 'unknown';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const MERGE_STATE_STATUSES = new Set([
|
|
240
|
+
'clean',
|
|
241
|
+
'dirty',
|
|
242
|
+
'unstable',
|
|
243
|
+
'blocked',
|
|
244
|
+
'behind',
|
|
245
|
+
'has_hooks',
|
|
246
|
+
'unknown',
|
|
247
|
+
]);
|
|
248
|
+
// GitHub's MergeStateStatus enum also has DRAFT — "the merge is blocked due to the pull
|
|
249
|
+
// request being a draft". We deliberately do NOT model it: `isDraft` is already stored as
|
|
250
|
+
// its own boolean, and every merge surface resolves draft-ness from that (lib/ui.ts
|
|
251
|
+
// `mergeVerdict` checks `isDraft` before it looks at the status). Folding DRAFT into the
|
|
252
|
+
// enum would mean a draft PR permanently reports mergeStateStatus='draft' and the moment it
|
|
253
|
+
// is marked ready we'd have no idea whether it is clean or blocked until the next sync.
|
|
254
|
+
// It lands in 'unknown' below, which is exactly what it is: not yet computed FOR A MERGE.
|
|
255
|
+
const DRAFT_MERGE_STATE = 'draft';
|
|
256
|
+
// GraphQL PullRequestReviewDecision → the stored lowercase enum. Null (the repo requires no
|
|
257
|
+
// review at all) is preserved as null — distinct from 'approved'.
|
|
258
|
+
/**
|
|
259
|
+
* GitHub's PullRequestReviewDecision → the stored enum. Exported because
|
|
260
|
+
* GET /api/prs/:id/merge-options surfaces the LIVE decision from its merge-queue probe and must
|
|
261
|
+
* map it exactly as the sync walk does — two spellings of one enum is how the merge control and
|
|
262
|
+
* the PR row start disagreeing about the same PR.
|
|
263
|
+
*
|
|
264
|
+
* ⚠ It cannot distinguish "GitHub said null" from "we were handed nothing": both return null.
|
|
265
|
+
* The caller owns that distinction (omit the key vs. write null) — see the partial-response rule
|
|
266
|
+
* in CLAUDE.md and sync/branch-status.ts.
|
|
267
|
+
*/
|
|
268
|
+
export function reviewDecisionFrom(decision) {
|
|
269
|
+
switch ((decision ?? '').toUpperCase()) {
|
|
270
|
+
case 'APPROVED':
|
|
271
|
+
return 'approved';
|
|
272
|
+
case 'CHANGES_REQUESTED':
|
|
273
|
+
return 'changes_requested';
|
|
274
|
+
case 'REVIEW_REQUIRED':
|
|
275
|
+
return 'review_required';
|
|
276
|
+
default:
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function mergeStateStatusFrom(state) {
|
|
281
|
+
const lower = (state ?? '').toLowerCase();
|
|
282
|
+
// DRAFT is a known value we intentionally map to 'unknown' (see DRAFT_MERGE_STATE above);
|
|
283
|
+
// naming it here keeps that a decision rather than an accident of the fallback.
|
|
284
|
+
if (lower === DRAFT_MERGE_STATE)
|
|
285
|
+
return 'unknown';
|
|
286
|
+
return (MERGE_STATE_STATUSES.has(lower) ? lower : 'unknown');
|
|
287
|
+
}
|
|
288
|
+
// GitHub's MergeQueueEntryState → the stored lowercase enum. Exported for the same reason
|
|
289
|
+
// `reviewDecisionFrom` is: the two merge-queue WRITE routes in api/routes/prs.ts stamp this
|
|
290
|
+
// column from a mutation response, and a second spelling of one enum is how the merge control
|
|
291
|
+
// and the PR row start disagreeing about the same PR.
|
|
292
|
+
//
|
|
293
|
+
// ⚠ Returns null for anything unrecognised, INCLUDING a state GitHub adds later. That is the
|
|
294
|
+
// safe direction here — an unknown string stored raw would leak straight onto the wire as a
|
|
295
|
+
// display value nobody wrote copy for — but it does mean "no entry" and "an entry in a state we
|
|
296
|
+
// do not model" are both null. The caller must therefore never infer membership from this
|
|
297
|
+
// column; `inMergeQueue` is the membership fact.
|
|
298
|
+
export function mergeQueueEntryStateFrom(state) {
|
|
299
|
+
switch ((state ?? '').toUpperCase()) {
|
|
300
|
+
case 'AWAITING_CHECKS':
|
|
301
|
+
return 'awaiting_checks';
|
|
302
|
+
case 'LOCKED':
|
|
303
|
+
return 'locked';
|
|
304
|
+
case 'MERGEABLE':
|
|
305
|
+
return 'mergeable';
|
|
306
|
+
case 'QUEUED':
|
|
307
|
+
return 'queued';
|
|
308
|
+
case 'UNMERGEABLE':
|
|
309
|
+
return 'unmergeable';
|
|
310
|
+
default:
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Exported because sync/branch-status.ts maps the SAME contexts union for default-branch
|
|
315
|
+
// commits. The CheckRun status/conclusion → CheckRunState table must not exist twice: if trunk
|
|
316
|
+
// and PR surfaces disagreed about what "failing" means, the same check would render differently
|
|
317
|
+
// depending on where you looked at it.
|
|
318
|
+
export function checkContextState(c) {
|
|
319
|
+
if (c.__typename === 'StatusContext') {
|
|
320
|
+
switch ((c.state ?? '').toUpperCase()) {
|
|
321
|
+
case 'SUCCESS':
|
|
322
|
+
return 'success';
|
|
323
|
+
case 'FAILURE':
|
|
324
|
+
return 'failure';
|
|
325
|
+
case 'ERROR':
|
|
326
|
+
return 'error';
|
|
327
|
+
case 'PENDING':
|
|
328
|
+
case 'EXPECTED':
|
|
329
|
+
return 'pending';
|
|
330
|
+
default:
|
|
331
|
+
return 'unknown';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// CheckRun: outcome is meaningful only once completed.
|
|
335
|
+
if ((c.status ?? '').toUpperCase() !== 'COMPLETED')
|
|
336
|
+
return 'pending';
|
|
337
|
+
switch ((c.conclusion ?? '').toUpperCase()) {
|
|
338
|
+
case 'SUCCESS':
|
|
339
|
+
return 'success';
|
|
340
|
+
case 'FAILURE':
|
|
341
|
+
case 'TIMED_OUT':
|
|
342
|
+
case 'STARTUP_FAILURE':
|
|
343
|
+
case 'ACTION_REQUIRED':
|
|
344
|
+
return 'failure';
|
|
345
|
+
case 'CANCELLED':
|
|
346
|
+
case 'STALE':
|
|
347
|
+
case 'NEUTRAL':
|
|
348
|
+
return 'neutral';
|
|
349
|
+
case 'SKIPPED':
|
|
350
|
+
return 'skipped';
|
|
351
|
+
default:
|
|
352
|
+
return 'unknown';
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// A GitHub Actions check's detailsUrl is .../actions/runs/<runId>/job/<jobId> — parse
|
|
356
|
+
// the two ids so the frontend can fetch that job's logs on demand. Third-party CI
|
|
357
|
+
// (StatusContext / external CheckRuns) has a detailsUrl pointing elsewhere; no match →
|
|
358
|
+
// null, and the UI keeps it as a plain external link (logs aren't retrievable).
|
|
359
|
+
const ACTIONS_JOB_RE = /\/actions\/runs\/(\d+)\/job\/(\d+)/;
|
|
360
|
+
// Exported for the default-branch commit checks, which parse the same detailsUrl shape.
|
|
361
|
+
export function parseActionsIds(url) {
|
|
362
|
+
const m = url ? ACTIONS_JOB_RE.exec(url) : null;
|
|
363
|
+
if (!m)
|
|
364
|
+
return { runId: null, jobId: null };
|
|
365
|
+
return { runId: Number(m[1]), jobId: Number(m[2]) };
|
|
366
|
+
}
|
|
367
|
+
export function checkRunsFrom(head) {
|
|
368
|
+
const nodes = head?.statusCheckRollup?.contexts?.nodes ?? [];
|
|
369
|
+
const mapped = nodes.map((c) => {
|
|
370
|
+
const url = c.__typename === 'CheckRun' ? c.detailsUrl : c.targetUrl;
|
|
371
|
+
const { runId, jobId } = c.__typename === 'CheckRun' ? parseActionsIds(url) : { runId: null, jobId: null };
|
|
372
|
+
return {
|
|
373
|
+
name: c.__typename === 'CheckRun' ? c.name : c.context,
|
|
374
|
+
state: checkContextState(c),
|
|
375
|
+
url,
|
|
376
|
+
runId,
|
|
377
|
+
jobId,
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
// GitHub's `statusCheckRollup.contexts` returns EVERY check attached to the head
|
|
381
|
+
// commit's check suites — it does NOT collapse to "latest per check name" the way the
|
|
382
|
+
// PR UI does. When a head commit is covered by more than one workflow RUN (a re-run,
|
|
383
|
+
// or a workflow fired by both the `push` and `pull_request` events — classic for
|
|
384
|
+
// Dependabot), each run contributes its own same-named CheckRun, so the same check
|
|
385
|
+
// appears two+ times. Dedupe by display name, keeping the NEWEST run (Actions run ids
|
|
386
|
+
// increase monotonically, so the highest runId is the most recent — matching the PR
|
|
387
|
+
// UI); a real CheckRun (has a runId) beats a same-named StatusContext (runId null).
|
|
388
|
+
// Map preserves first-insertion order, so distinct checks keep their order.
|
|
389
|
+
const byName = new Map();
|
|
390
|
+
for (const c of mapped) {
|
|
391
|
+
const prev = byName.get(c.name);
|
|
392
|
+
if (!prev || (c.runId ?? -1) > (prev.runId ?? -1))
|
|
393
|
+
byName.set(c.name, c);
|
|
394
|
+
}
|
|
395
|
+
return [...byName.values()];
|
|
396
|
+
}
|
|
397
|
+
const REVIEW_STATES = new Set([
|
|
398
|
+
'approved',
|
|
399
|
+
'changes_requested',
|
|
400
|
+
'commented',
|
|
401
|
+
'dismissed',
|
|
402
|
+
'pending',
|
|
403
|
+
]);
|
|
404
|
+
function reviewState(s) {
|
|
405
|
+
const lower = s.toLowerCase();
|
|
406
|
+
return (REVIEW_STATES.has(lower) ? lower : 'commented');
|
|
407
|
+
}
|
|
408
|
+
// A review warrants its own timeline marker only when it's substantive: a
|
|
409
|
+
// decision (approved/changes_requested/dismissed) or one carrying a summary
|
|
410
|
+
// body. An empty "commented" review is just GitHub's wrapper around inline
|
|
411
|
+
// comments — those already show as review_comment markers, so the wrapper would
|
|
412
|
+
// duplicate them. See db/cleanup.ts for backfilling existing rows.
|
|
413
|
+
export function isSubstantiveReview(state, body) {
|
|
414
|
+
return state !== 'commented' || !!body?.trim();
|
|
415
|
+
}
|
|
416
|
+
// Lifecycle transitions GitHub exposes no discrete event for, detected by comparing the
|
|
417
|
+
// PR's prior persisted row (`prev`) against the incoming sync (`next`). Returns the
|
|
418
|
+
// event types to emit: draft→ready (`pr_ready_for_review`) and closed→open
|
|
419
|
+
// (`pr_reopened`). `prev` is null on a PR's first sight, so nothing is emitted then —
|
|
420
|
+
// we never observed it as draft/closed, so there's no transition to report.
|
|
421
|
+
export function lifecycleTransitions(prev, next) {
|
|
422
|
+
const out = [];
|
|
423
|
+
if (prev?.isDraft === true && next.isDraft === false)
|
|
424
|
+
out.push('pr_ready_for_review');
|
|
425
|
+
if (prev?.state === 'closed' && next.state === 'open')
|
|
426
|
+
out.push('pr_reopened');
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
async function upsertEvent(exec, row) {
|
|
430
|
+
await exec
|
|
431
|
+
.insert(events)
|
|
432
|
+
.values(row)
|
|
433
|
+
.onConflictDoUpdate({
|
|
434
|
+
target: [events.accountId, events.dedupeKey],
|
|
435
|
+
set: {
|
|
436
|
+
actorId: row.actorId,
|
|
437
|
+
occurredAt: row.occurredAt,
|
|
438
|
+
refTable: row.refTable,
|
|
439
|
+
refId: row.refId,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
.execute();
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Persist a single PR and all its nested entities idempotently, derive thread
|
|
446
|
+
* states, and emit timeline events. `commitFilesBySha` must already be
|
|
447
|
+
* populated for any commits relevant to unresolved-thread derivation.
|
|
448
|
+
*/
|
|
449
|
+
// Decide a review thread's resolvedAt on this sync. We can't read GitHub's true resolution time,
|
|
450
|
+
// so we approximate it by the sync that FIRST observes the thread flip unresolved→resolved:
|
|
451
|
+
// • a prior stamp is preserved (idempotent — never moves once set),
|
|
452
|
+
// • a witnessed unresolved→resolved transition stamps `observedAt`,
|
|
453
|
+
// • a thread already resolved the first time we see it (no prior row, or prior already resolved
|
|
454
|
+
// with a null stamp) stays null — its true resolution time is unknowable, so it's excluded
|
|
455
|
+
// from the resolution-latency metric rather than counted with a bogus (deploy-time) latency.
|
|
456
|
+
export function nextResolvedAt(prior, isResolved, observedAt) {
|
|
457
|
+
if (prior?.resolvedAt != null)
|
|
458
|
+
return prior.resolvedAt;
|
|
459
|
+
if (isResolved && prior != null && !prior.isResolved)
|
|
460
|
+
return observedAt;
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Persist a PR's review-request HISTORY (`review_request_events`) and stamp that we received it.
|
|
465
|
+
* Shared by persistPr and the one-time backfill so the two write one shape.
|
|
466
|
+
*
|
|
467
|
+
* ⚠ `nodes === undefined | null` IS "NOT RECEIVED" AND WRITES NOTHING — not the events, not the
|
|
468
|
+
* stamp. A fixture without the selection and a token GitHub nulled it for both land here, and
|
|
469
|
+
* stamping them would tell Chronology "never requested" about PRs we simply could not see. An
|
|
470
|
+
* EMPTY array is a positive statement (no request ever made) and IS stamped.
|
|
471
|
+
*
|
|
472
|
+
* Events are immutable on GitHub, so each is inserted DO NOTHING on (pr_id, node id).
|
|
473
|
+
*/
|
|
474
|
+
export async function persistReviewRequestHistory(exec, prId, nodes, resolver) {
|
|
475
|
+
if (nodes == null)
|
|
476
|
+
return;
|
|
477
|
+
for (const n of nodes) {
|
|
478
|
+
if (n == null || !n.id || !n.createdAt)
|
|
479
|
+
continue;
|
|
480
|
+
const kind = n.__typename === 'ReviewRequestedEvent'
|
|
481
|
+
? 'requested'
|
|
482
|
+
: n.__typename === 'ReviewRequestRemovedEvent'
|
|
483
|
+
? 'removed'
|
|
484
|
+
: null;
|
|
485
|
+
if (kind == null)
|
|
486
|
+
continue;
|
|
487
|
+
const r = n.requestedReviewer;
|
|
488
|
+
let reviewerKind = 'unknown';
|
|
489
|
+
let reviewerUserId = null;
|
|
490
|
+
let teamSlug = null;
|
|
491
|
+
if (r && (r.__typename === 'User' || r.__typename === 'Bot') && r.login) {
|
|
492
|
+
reviewerKind = 'user';
|
|
493
|
+
reviewerUserId = await resolver.resolve(exec, {
|
|
494
|
+
login: r.login,
|
|
495
|
+
id: r.id,
|
|
496
|
+
__typename: r.__typename,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
else if (r && r.__typename === 'Team') {
|
|
500
|
+
reviewerKind = 'team';
|
|
501
|
+
teamSlug = r.slug ?? null;
|
|
502
|
+
}
|
|
503
|
+
await exec
|
|
504
|
+
.insert(reviewRequestEvents)
|
|
505
|
+
.values({
|
|
506
|
+
prId,
|
|
507
|
+
githubNodeId: n.id,
|
|
508
|
+
kind,
|
|
509
|
+
occurredAt: new Date(n.createdAt),
|
|
510
|
+
reviewerKind,
|
|
511
|
+
reviewerUserId,
|
|
512
|
+
teamSlug,
|
|
513
|
+
})
|
|
514
|
+
.onConflictDoNothing({ target: [reviewRequestEvents.prId, reviewRequestEvents.githubNodeId] })
|
|
515
|
+
.execute();
|
|
516
|
+
}
|
|
517
|
+
await exec
|
|
518
|
+
.update(pullRequests)
|
|
519
|
+
.set({ reviewRequestsSyncedAt: new Date() })
|
|
520
|
+
.where(eq(pullRequests.id, prId))
|
|
521
|
+
.execute();
|
|
522
|
+
}
|
|
523
|
+
/** One classification → the four columns, for BOTH writers (`persistPr` and the one-shot backfill
|
|
524
|
+
* in sync/backfill-pr-security.ts), so a walk and a backfill can never store the same PR two ways.
|
|
525
|
+
* An empty id list is stored as NULL. The caller owns the "only on a received `bodyText`" gate. */
|
|
526
|
+
export function securityColumnsFor(input) {
|
|
527
|
+
const s = classifyPrSecurity(input);
|
|
528
|
+
return {
|
|
529
|
+
dependencyVendor: s.dependencyVendor,
|
|
530
|
+
securityFix: s.securityFix,
|
|
531
|
+
advisoryIds: s.advisoryIds.length > 0 ? s.advisoryIds : null,
|
|
532
|
+
securityCheckedAt: new Date(),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
export async function persistPr(pr, repoId, resolver, commitFilesBySha, accountId) {
|
|
536
|
+
// DEPENDENCY + SECURITY SIGNALS — the three-state write (the `queueObserved` pattern below).
|
|
537
|
+
// GitHub types `bodyText` String!, so a null can only be a partial response and `undefined` a
|
|
538
|
+
// response that never carried the selection: both LEARN NOTHING and write nothing. A string —
|
|
539
|
+
// even '' — is a positive statement, and the classification is written whole, NULLs included.
|
|
540
|
+
// ⚠ The FULL bodyText, never `searchRows`/`search_index`: that copy is capped at 4,000
|
|
541
|
+
// characters and Dependabot's security footer is the last line of a body that runs to 65 KB.
|
|
542
|
+
// ⚠ Classified BEFORE the transaction: it is pure CPU over text the PR's author wrote, and no
|
|
543
|
+
// regex over that text should run while the write lock is held.
|
|
544
|
+
const securityObserved = typeof pr.bodyText === 'string'
|
|
545
|
+
? securityColumnsFor({
|
|
546
|
+
title: pr.title,
|
|
547
|
+
headRefName: pr.headRefName ?? null,
|
|
548
|
+
labels: (pr.labels?.nodes ?? []).map((l) => l.name),
|
|
549
|
+
bodyText: pr.bodyText,
|
|
550
|
+
})
|
|
551
|
+
: {};
|
|
552
|
+
// Set inside the transaction from the `prev` read; the SPA change signal is raised only AFTER
|
|
553
|
+
// the commit, so it can never announce a row a reader cannot see yet.
|
|
554
|
+
let boardMoved = false;
|
|
555
|
+
await runTransaction(async (tx) => {
|
|
556
|
+
const authorId = await resolver.resolve(tx, pr.author);
|
|
557
|
+
// The actual merger (null for non-merged PRs / when GitHub omits the actor).
|
|
558
|
+
const mergedById = await resolver.resolve(tx, pr.mergedBy);
|
|
559
|
+
const openedAt = new Date(pr.createdAt);
|
|
560
|
+
const mergedAt = toDate(pr.mergedAt);
|
|
561
|
+
const closedAt = toDate(pr.closedAt);
|
|
562
|
+
const commitDates = pr.commits.nodes.map((c) => new Date(c.commit.committedDate));
|
|
563
|
+
const lastCommitAt = maxDate(commitDates);
|
|
564
|
+
const firstReviewAt = minDate(pr.reviews.nodes.map((r) => toDate(r.submittedAt)));
|
|
565
|
+
// Earliest review request (timeline first:1 is chronological; min guards any ordering surprise).
|
|
566
|
+
const firstReviewRequestedAt = minDate((pr.firstReviewRequest?.nodes ?? []).map((n) => toDate(n.createdAt ?? null)));
|
|
567
|
+
const head = pr.headCommit?.nodes[0]?.commit;
|
|
568
|
+
const headSha = head?.oid ?? null;
|
|
569
|
+
const ciStatus = ciStatusFrom(head?.statusCheckRollup?.state);
|
|
570
|
+
const mergeable = mergeableFrom(pr.mergeable);
|
|
571
|
+
const mergeStateStatus = mergeStateStatusFrom(pr.mergeStateStatus);
|
|
572
|
+
const reviewDecision = reviewDecisionFrom(pr.reviewDecision);
|
|
573
|
+
// ---- merge queue: the THREE-STATE partial-response write --------------------------------
|
|
574
|
+
//
|
|
575
|
+
// `graphqlTolerant` hands back GitHub's partial data with a forbidden field NULLED — the key
|
|
576
|
+
// is PRESENT, not absent — so "GitHub says this PR is not queued" and "we never received the
|
|
577
|
+
// selection" look identical at the JSON level, and they demand opposite writes. Writing
|
|
578
|
+
// unconditionally would clear a real queue entry on every tick for a partially-forbidden
|
|
579
|
+
// token; writing only on non-null would leave a dequeued PR reading "queued" forever.
|
|
580
|
+
//
|
|
581
|
+
// The discriminator is free here and needs no `pagePartial` flag threaded down from
|
|
582
|
+
// sync-repo.ts (which is how `repos.description` resolves the same problem): GitHub types
|
|
583
|
+
// `isInMergeQueue` as `Boolean!`, so a real answer is only ever true or false. A null can
|
|
584
|
+
// ONLY be a nulled-by-partial-error field, and `undefined` can only be a response (or a
|
|
585
|
+
// hand-built fixture) that never carried the selection. Both mean "learn nothing".
|
|
586
|
+
//
|
|
587
|
+
// `false` IS a positive statement and MUST clear both columns — a repo with the merge queue
|
|
588
|
+
// switched off answers false with a null entry every walk, and so does a PR that just landed
|
|
589
|
+
// or was ejected. That is the case this whole column exists to make visible.
|
|
590
|
+
const queueObserved = pr.isInMergeQueue == null
|
|
591
|
+
? {}
|
|
592
|
+
: {
|
|
593
|
+
inMergeQueue: pr.isInMergeQueue,
|
|
594
|
+
// Not queued ⇒ no entry, whatever the (absent) object says. Queued ⇒ the entry's
|
|
595
|
+
// state, or null when GitHub gave us the membership but not a state we model — see
|
|
596
|
+
// mergeQueueEntryStateFrom on why an unmodelled state is not stored raw.
|
|
597
|
+
mergeQueueEntryState: pr.isInMergeQueue
|
|
598
|
+
? mergeQueueEntryStateFrom(pr.mergeQueueEntry?.state)
|
|
599
|
+
: null,
|
|
600
|
+
};
|
|
601
|
+
const labels = (pr.labels?.nodes ?? []).map((l) => ({
|
|
602
|
+
name: l.name,
|
|
603
|
+
color: l.color,
|
|
604
|
+
}));
|
|
605
|
+
const checkRuns = checkRunsFrom(head);
|
|
606
|
+
// Diff size — always stored (small metadata, not bulky text), so the PR-detail
|
|
607
|
+
// LOC label + "Changes" tab work in every mode straight from the DB. The files
|
|
608
|
+
// list is capped at 100 by the query; changedFiles keeps the true total.
|
|
609
|
+
const additions = pr.additions ?? 0;
|
|
610
|
+
const deletions = pr.deletions ?? 0;
|
|
611
|
+
const changedFiles = pr.changedFiles ?? 0;
|
|
612
|
+
const files = (pr.files?.nodes ?? []).map((f) => ({
|
|
613
|
+
path: f.path,
|
|
614
|
+
additions: f.additions ?? 0,
|
|
615
|
+
deletions: f.deletions ?? 0,
|
|
616
|
+
}));
|
|
617
|
+
// The PR's prior draft/state, read BEFORE the upsert below, so we can emit the
|
|
618
|
+
// lifecycle transitions GitHub doesn't expose as discrete events (draft → ready,
|
|
619
|
+
// reopened). null on first sight of a PR → no transition event.
|
|
620
|
+
//
|
|
621
|
+
// The board-visible columns ride the same read so the SPA's change signal
|
|
622
|
+
// (sync/pr-change-signal.ts) fires only when one of them actually MOVED — a walk that
|
|
623
|
+
// restates what is stored must not cascade a refetch over every screen.
|
|
624
|
+
const prev = (await tx
|
|
625
|
+
.select({
|
|
626
|
+
isDraft: pullRequests.isDraft,
|
|
627
|
+
state: pullRequests.state,
|
|
628
|
+
headSha: pullRequests.headSha,
|
|
629
|
+
ciStatus: pullRequests.ciStatus,
|
|
630
|
+
mergeable: pullRequests.mergeable,
|
|
631
|
+
mergeStateStatus: pullRequests.mergeStateStatus,
|
|
632
|
+
reviewDecision: pullRequests.reviewDecision,
|
|
633
|
+
inMergeQueue: pullRequests.inMergeQueue,
|
|
634
|
+
mergeQueueEntryState: pullRequests.mergeQueueEntryState,
|
|
635
|
+
updatedAt: pullRequests.updatedAt,
|
|
636
|
+
})
|
|
637
|
+
.from(pullRequests)
|
|
638
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.githubNodeId, pr.id)))
|
|
639
|
+
.execute())[0] ?? null;
|
|
640
|
+
boardMoved = boardVisibleMoved(prev, {
|
|
641
|
+
state: prState(pr.state),
|
|
642
|
+
isDraft: pr.isDraft,
|
|
643
|
+
headSha,
|
|
644
|
+
ciStatus,
|
|
645
|
+
mergeable,
|
|
646
|
+
mergeStateStatus,
|
|
647
|
+
reviewDecision,
|
|
648
|
+
updatedAt: new Date(pr.updatedAt),
|
|
649
|
+
...queueObserved,
|
|
650
|
+
});
|
|
651
|
+
const prRow = (await tx
|
|
652
|
+
.insert(pullRequests)
|
|
653
|
+
.values({
|
|
654
|
+
accountId,
|
|
655
|
+
githubNodeId: pr.id,
|
|
656
|
+
repoId,
|
|
657
|
+
number: pr.number,
|
|
658
|
+
title: pr.title,
|
|
659
|
+
// Lean mode (cloud): the PR description and the per-job checkRuns JSON are
|
|
660
|
+
// not stored — hydrated on demand. ciStatus (the summary enum) is kept.
|
|
661
|
+
body: config.persistBodies ? (pr.body ?? null) : null,
|
|
662
|
+
authorId,
|
|
663
|
+
mergedById,
|
|
664
|
+
baseRefName: pr.baseRefName ?? null,
|
|
665
|
+
headRefName: pr.headRefName ?? null,
|
|
666
|
+
state: prState(pr.state),
|
|
667
|
+
isDraft: pr.isDraft,
|
|
668
|
+
openedAt,
|
|
669
|
+
firstReviewAt,
|
|
670
|
+
firstReviewRequestedAt,
|
|
671
|
+
lastCommitAt,
|
|
672
|
+
mergedAt,
|
|
673
|
+
closedAt,
|
|
674
|
+
updatedAt: new Date(pr.updatedAt),
|
|
675
|
+
headSha,
|
|
676
|
+
ciStatus,
|
|
677
|
+
mergeable,
|
|
678
|
+
mergeStateStatus,
|
|
679
|
+
reviewDecision,
|
|
680
|
+
// Spread, never assigned: an omitted key leaves the column at its NULL default on
|
|
681
|
+
// INSERT and untouched on UPDATE, which is exactly what "we did not receive that
|
|
682
|
+
// selection" means. Same shape as branch-status.ts's `observed`.
|
|
683
|
+
...queueObserved,
|
|
684
|
+
labels,
|
|
685
|
+
...securityObserved,
|
|
686
|
+
checkRuns: config.persistBodies ? checkRuns : null,
|
|
687
|
+
additions,
|
|
688
|
+
deletions,
|
|
689
|
+
changedFiles,
|
|
690
|
+
files,
|
|
691
|
+
})
|
|
692
|
+
.onConflictDoUpdate({
|
|
693
|
+
target: [pullRequests.accountId, pullRequests.githubNodeId],
|
|
694
|
+
set: {
|
|
695
|
+
title: pr.title,
|
|
696
|
+
body: config.persistBodies ? (pr.body ?? null) : null,
|
|
697
|
+
authorId,
|
|
698
|
+
mergedById,
|
|
699
|
+
baseRefName: pr.baseRefName ?? null,
|
|
700
|
+
headRefName: pr.headRefName ?? null,
|
|
701
|
+
state: prState(pr.state),
|
|
702
|
+
isDraft: pr.isDraft,
|
|
703
|
+
firstReviewAt,
|
|
704
|
+
firstReviewRequestedAt,
|
|
705
|
+
lastCommitAt,
|
|
706
|
+
mergedAt,
|
|
707
|
+
closedAt,
|
|
708
|
+
updatedAt: new Date(pr.updatedAt),
|
|
709
|
+
headSha,
|
|
710
|
+
ciStatus,
|
|
711
|
+
mergeable,
|
|
712
|
+
mergeStateStatus,
|
|
713
|
+
reviewDecision,
|
|
714
|
+
...queueObserved,
|
|
715
|
+
labels,
|
|
716
|
+
...securityObserved,
|
|
717
|
+
checkRuns: config.persistBodies ? checkRuns : null,
|
|
718
|
+
additions,
|
|
719
|
+
deletions,
|
|
720
|
+
changedFiles,
|
|
721
|
+
files,
|
|
722
|
+
},
|
|
723
|
+
})
|
|
724
|
+
.returning({ id: pullRequests.id })
|
|
725
|
+
.execute())[0];
|
|
726
|
+
const prId = prRow.id;
|
|
727
|
+
// ---- cross-team search index rows (accumulated, written once at the end) ----
|
|
728
|
+
// `bodyText` (plain-text description) is fetched unconditionally, so the PR row indexes the
|
|
729
|
+
// description even under lean storage where pullRequests.body is null. Review / comment rows are
|
|
730
|
+
// pushed at their insert sites below. The whole set is rebuilt per-PR (delete + insert) so an
|
|
731
|
+
// edited/removed comment never leaves a stale hit.
|
|
732
|
+
const searchRows = [];
|
|
733
|
+
searchRows.push({
|
|
734
|
+
kind: 'pr',
|
|
735
|
+
refId: prId,
|
|
736
|
+
threadId: null,
|
|
737
|
+
authorId,
|
|
738
|
+
body: searchText(pr.title, pr.bodyText),
|
|
739
|
+
createdAt: openedAt,
|
|
740
|
+
});
|
|
741
|
+
// ---- CI status transition log (DORA-ish CI metrics) ----
|
|
742
|
+
// Record a row whenever this PR head's CI rollup / failing-check set / head SHA
|
|
743
|
+
// differs from the last snapshot — an append-only transition log (not a per-tick
|
|
744
|
+
// dump). Uses the fresh GraphQL checkRuns (populated even in lean mode, unlike the
|
|
745
|
+
// stored column), so the failing check NAMES are the stage-level failure reasons.
|
|
746
|
+
if (headSha != null && ciStatus !== 'unknown') {
|
|
747
|
+
const failing = [
|
|
748
|
+
...new Set(checkRuns
|
|
749
|
+
.filter((c) => c.state === 'failure' || c.state === 'error')
|
|
750
|
+
.map((c) => c.name)),
|
|
751
|
+
].sort();
|
|
752
|
+
const lastCi = (await tx
|
|
753
|
+
.select({
|
|
754
|
+
status: ciStatusEvents.status,
|
|
755
|
+
headSha: ciStatusEvents.headSha,
|
|
756
|
+
failingChecks: ciStatusEvents.failingChecks,
|
|
757
|
+
})
|
|
758
|
+
.from(ciStatusEvents)
|
|
759
|
+
.where(and(eq(ciStatusEvents.accountId, accountId), eq(ciStatusEvents.prId, prId)))
|
|
760
|
+
.orderBy(desc(ciStatusEvents.observedAt))
|
|
761
|
+
.limit(1)
|
|
762
|
+
.execute())[0];
|
|
763
|
+
const changed = lastCi == null ||
|
|
764
|
+
lastCi.status !== ciStatus ||
|
|
765
|
+
lastCi.headSha !== headSha ||
|
|
766
|
+
JSON.stringify([...(lastCi.failingChecks ?? [])].sort()) !== JSON.stringify(failing);
|
|
767
|
+
if (changed) {
|
|
768
|
+
await tx
|
|
769
|
+
.insert(ciStatusEvents)
|
|
770
|
+
.values({
|
|
771
|
+
accountId,
|
|
772
|
+
repoId,
|
|
773
|
+
prId,
|
|
774
|
+
headSha,
|
|
775
|
+
status: ciStatus,
|
|
776
|
+
failingChecks: failing,
|
|
777
|
+
observedAt: new Date(),
|
|
778
|
+
})
|
|
779
|
+
.execute();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// ---- review requests (outstanding) — reconcile by delete + reinsert ----
|
|
783
|
+
await tx.delete(reviewRequests).where(eq(reviewRequests.prId, prId)).execute();
|
|
784
|
+
for (const rr of pr.reviewRequests?.nodes ?? []) {
|
|
785
|
+
const reviewer = rr.requestedReviewer;
|
|
786
|
+
if (!reviewer)
|
|
787
|
+
continue;
|
|
788
|
+
if (reviewer.__typename === 'User') {
|
|
789
|
+
const userId = await resolver.resolve(tx, {
|
|
790
|
+
login: reviewer.login,
|
|
791
|
+
id: reviewer.id,
|
|
792
|
+
});
|
|
793
|
+
await tx.insert(reviewRequests).values({ prId, userId, teamName: null }).execute();
|
|
794
|
+
}
|
|
795
|
+
else if (reviewer.__typename === 'Team') {
|
|
796
|
+
await tx
|
|
797
|
+
.insert(reviewRequests)
|
|
798
|
+
.values({ prId, userId: null, teamName: reviewer.name })
|
|
799
|
+
.execute();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// ---- review-request HISTORY (Chronology's "asked to first look") ----
|
|
803
|
+
await persistReviewRequestHistory(tx, prId, pr.reviewRequestHistory?.nodes, resolver);
|
|
804
|
+
// ---- lifecycle events ----
|
|
805
|
+
await upsertEvent(tx, {
|
|
806
|
+
accountId,
|
|
807
|
+
repoId,
|
|
808
|
+
actorId: authorId,
|
|
809
|
+
prId,
|
|
810
|
+
type: 'pr_opened',
|
|
811
|
+
occurredAt: openedAt,
|
|
812
|
+
refTable: 'pull_requests',
|
|
813
|
+
refId: prId,
|
|
814
|
+
dedupeKey: `pr_opened:${pr.id}`,
|
|
815
|
+
});
|
|
816
|
+
if (pr.state === 'MERGED' && mergedAt) {
|
|
817
|
+
await upsertEvent(tx, {
|
|
818
|
+
accountId,
|
|
819
|
+
repoId,
|
|
820
|
+
actorId: authorId,
|
|
821
|
+
prId,
|
|
822
|
+
type: 'pr_merged',
|
|
823
|
+
occurredAt: mergedAt,
|
|
824
|
+
refTable: 'pull_requests',
|
|
825
|
+
refId: prId,
|
|
826
|
+
dedupeKey: `pr_merged:${pr.id}`,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
else if (pr.state === 'CLOSED' && closedAt) {
|
|
830
|
+
await upsertEvent(tx, {
|
|
831
|
+
accountId,
|
|
832
|
+
repoId,
|
|
833
|
+
actorId: authorId,
|
|
834
|
+
prId,
|
|
835
|
+
type: 'pr_closed',
|
|
836
|
+
occurredAt: closedAt,
|
|
837
|
+
refTable: 'pull_requests',
|
|
838
|
+
refId: prId,
|
|
839
|
+
dedupeKey: `pr_closed:${pr.id}`,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
// Transitions GitHub gives no discrete event for (draft→ready, reopened): emitted
|
|
843
|
+
// only when THIS sync observed the flip (`prev` is the pre-upsert row), so a PR
|
|
844
|
+
// first seen already ready/open emits nothing. dedupeKey is keyed on the PR (one
|
|
845
|
+
// ready / one reopened per PR) — re-toggling re-emits at most once. occurredAt =
|
|
846
|
+
// updatedAt is the closest available signal (GitHub gives no ready-at/reopened-at).
|
|
847
|
+
for (const type of lifecycleTransitions(prev, {
|
|
848
|
+
isDraft: pr.isDraft,
|
|
849
|
+
state: prState(pr.state),
|
|
850
|
+
})) {
|
|
851
|
+
await upsertEvent(tx, {
|
|
852
|
+
accountId,
|
|
853
|
+
repoId,
|
|
854
|
+
actorId: authorId,
|
|
855
|
+
prId,
|
|
856
|
+
type,
|
|
857
|
+
occurredAt: new Date(pr.updatedAt),
|
|
858
|
+
refTable: 'pull_requests',
|
|
859
|
+
refId: prId,
|
|
860
|
+
dedupeKey: `${type}:${pr.id}`,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
// ---- reviews ----
|
|
864
|
+
for (const r of pr.reviews.nodes) {
|
|
865
|
+
const reviewerId = await resolver.resolve(tx, r.author);
|
|
866
|
+
const submittedAt = toDate(r.submittedAt);
|
|
867
|
+
if (!submittedAt)
|
|
868
|
+
continue; // pending reviews have no timestamp
|
|
869
|
+
const reviewRow = (await tx
|
|
870
|
+
.insert(reviews)
|
|
871
|
+
.values({
|
|
872
|
+
githubNodeId: r.id,
|
|
873
|
+
prId,
|
|
874
|
+
authorId: reviewerId,
|
|
875
|
+
state: reviewState(r.state),
|
|
876
|
+
// Review bodies are always persisted (Feed renders them; also drives
|
|
877
|
+
// substantive-review detection) regardless of lean storage.
|
|
878
|
+
body: r.body ?? null,
|
|
879
|
+
databaseId: r.fullDatabaseId ?? null,
|
|
880
|
+
submittedAt,
|
|
881
|
+
})
|
|
882
|
+
.onConflictDoUpdate({
|
|
883
|
+
target: [reviews.prId, reviews.githubNodeId],
|
|
884
|
+
set: {
|
|
885
|
+
state: reviewState(r.state),
|
|
886
|
+
body: r.body ?? null,
|
|
887
|
+
databaseId: r.fullDatabaseId ?? null,
|
|
888
|
+
submittedAt,
|
|
889
|
+
},
|
|
890
|
+
})
|
|
891
|
+
.returning({ id: reviews.id })
|
|
892
|
+
.execute())[0];
|
|
893
|
+
const reviewBody = searchText(r.body);
|
|
894
|
+
if (reviewBody)
|
|
895
|
+
searchRows.push({
|
|
896
|
+
kind: 'review',
|
|
897
|
+
refId: reviewRow.id,
|
|
898
|
+
threadId: null,
|
|
899
|
+
authorId: reviewerId,
|
|
900
|
+
body: reviewBody,
|
|
901
|
+
createdAt: submittedAt,
|
|
902
|
+
});
|
|
903
|
+
if (isSubstantiveReview(reviewState(r.state), r.body)) {
|
|
904
|
+
await upsertEvent(tx, {
|
|
905
|
+
accountId,
|
|
906
|
+
repoId,
|
|
907
|
+
actorId: reviewerId,
|
|
908
|
+
prId,
|
|
909
|
+
type: 'review_submitted',
|
|
910
|
+
occurredAt: submittedAt,
|
|
911
|
+
refTable: 'reviews',
|
|
912
|
+
refId: reviewRow.id,
|
|
913
|
+
dedupeKey: `review_submitted:${r.id}`,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// ---- review threads + comments ----
|
|
918
|
+
const commitInputs = pr.commits.nodes.map((c) => ({
|
|
919
|
+
oid: c.commit.oid,
|
|
920
|
+
committedDate: c.commit.committedDate,
|
|
921
|
+
}));
|
|
922
|
+
// Prior resolved-state per thread (this PR) so we can stamp resolvedAt on a WITNESSED
|
|
923
|
+
// unresolved→resolved flip only. A thread already resolved the first time we see it keeps a
|
|
924
|
+
// null resolvedAt (its true resolution time is unknown). Read once, before the upserts.
|
|
925
|
+
const priorThreads = new Map();
|
|
926
|
+
for (const row of await tx
|
|
927
|
+
.select({
|
|
928
|
+
nodeId: reviewThreads.githubNodeId,
|
|
929
|
+
isResolved: reviewThreads.isResolved,
|
|
930
|
+
resolvedAt: reviewThreads.resolvedAt,
|
|
931
|
+
})
|
|
932
|
+
.from(reviewThreads)
|
|
933
|
+
.where(eq(reviewThreads.prId, prId))
|
|
934
|
+
.execute())
|
|
935
|
+
priorThreads.set(row.nodeId, { isResolved: row.isResolved, resolvedAt: row.resolvedAt });
|
|
936
|
+
const observedAt = new Date();
|
|
937
|
+
for (const t of pr.reviewThreads.nodes) {
|
|
938
|
+
const commentNodes = t.comments.nodes;
|
|
939
|
+
const originalCommenterId = await resolver.resolve(tx, commentNodes[0]?.author);
|
|
940
|
+
const resolvedByLogin = t.resolvedBy?.login ?? null;
|
|
941
|
+
// Preserve a prior stamp; set it fresh only when we observe unresolved→resolved this sync.
|
|
942
|
+
const resolvedAt = nextResolvedAt(priorThreads.get(t.id), t.isResolved, observedAt);
|
|
943
|
+
const { state: derivedState, addressedConfidence, addressedReason } = deriveThreadState({
|
|
944
|
+
isResolved: t.isResolved,
|
|
945
|
+
path: t.path,
|
|
946
|
+
isOutdated: t.isOutdated,
|
|
947
|
+
resolvedByLogin,
|
|
948
|
+
comments: commentNodes.map((c) => ({
|
|
949
|
+
author: c.author ? { login: c.author.login } : null,
|
|
950
|
+
createdAt: c.createdAt,
|
|
951
|
+
body: c.body ?? null,
|
|
952
|
+
})),
|
|
953
|
+
}, commitInputs, commitFilesBySha);
|
|
954
|
+
const threadRow = (await tx
|
|
955
|
+
.insert(reviewThreads)
|
|
956
|
+
.values({
|
|
957
|
+
githubNodeId: t.id,
|
|
958
|
+
prId,
|
|
959
|
+
path: t.path,
|
|
960
|
+
line: t.line,
|
|
961
|
+
isResolved: t.isResolved,
|
|
962
|
+
isOutdated: t.isOutdated,
|
|
963
|
+
derivedState,
|
|
964
|
+
addressedConfidence,
|
|
965
|
+
addressedReason,
|
|
966
|
+
resolvedByLogin,
|
|
967
|
+
resolvedAt,
|
|
968
|
+
originalCommenterId,
|
|
969
|
+
createdAt: commentNodes[0]
|
|
970
|
+
? new Date(commentNodes[0].createdAt)
|
|
971
|
+
: new Date(pr.createdAt),
|
|
972
|
+
})
|
|
973
|
+
.onConflictDoUpdate({
|
|
974
|
+
target: [reviewThreads.prId, reviewThreads.githubNodeId],
|
|
975
|
+
set: {
|
|
976
|
+
isResolved: t.isResolved,
|
|
977
|
+
isOutdated: t.isOutdated,
|
|
978
|
+
derivedState,
|
|
979
|
+
addressedConfidence,
|
|
980
|
+
addressedReason,
|
|
981
|
+
resolvedByLogin,
|
|
982
|
+
resolvedAt,
|
|
983
|
+
line: t.line,
|
|
984
|
+
},
|
|
985
|
+
})
|
|
986
|
+
.returning({ id: reviewThreads.id })
|
|
987
|
+
.execute())[0];
|
|
988
|
+
for (const c of commentNodes) {
|
|
989
|
+
const commenterId = await resolver.resolve(tx, c.author);
|
|
990
|
+
const createdAt = new Date(c.createdAt);
|
|
991
|
+
const rcRow = (await tx
|
|
992
|
+
.insert(reviewComments)
|
|
993
|
+
.values({
|
|
994
|
+
githubNodeId: c.id,
|
|
995
|
+
threadId: threadRow.id,
|
|
996
|
+
prId,
|
|
997
|
+
authorId: commenterId,
|
|
998
|
+
// Review-comment bodies are always persisted (Feed renders them); only
|
|
999
|
+
// the large diff hunk stays lean-gated. Excerpt kept for triage.
|
|
1000
|
+
body: c.body,
|
|
1001
|
+
excerpt: excerptOf(c.body),
|
|
1002
|
+
diffHunk: config.persistBodies ? (c.diffHunk ?? null) : null,
|
|
1003
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
1004
|
+
createdAt,
|
|
1005
|
+
})
|
|
1006
|
+
.onConflictDoUpdate({
|
|
1007
|
+
target: [reviewComments.prId, reviewComments.githubNodeId],
|
|
1008
|
+
set: {
|
|
1009
|
+
body: c.body,
|
|
1010
|
+
excerpt: excerptOf(c.body),
|
|
1011
|
+
diffHunk: config.persistBodies ? (c.diffHunk ?? null) : null,
|
|
1012
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
1013
|
+
},
|
|
1014
|
+
})
|
|
1015
|
+
.returning({ id: reviewComments.id })
|
|
1016
|
+
.execute())[0];
|
|
1017
|
+
const rcBody = searchText(c.body);
|
|
1018
|
+
if (rcBody)
|
|
1019
|
+
searchRows.push({
|
|
1020
|
+
kind: 'review_comment',
|
|
1021
|
+
refId: rcRow.id,
|
|
1022
|
+
// Deep-link target: a review-comment hit opens the PR's Threads tab on this thread.
|
|
1023
|
+
threadId: threadRow.id,
|
|
1024
|
+
authorId: commenterId,
|
|
1025
|
+
body: rcBody,
|
|
1026
|
+
createdAt,
|
|
1027
|
+
});
|
|
1028
|
+
await upsertEvent(tx, {
|
|
1029
|
+
accountId,
|
|
1030
|
+
repoId,
|
|
1031
|
+
actorId: commenterId,
|
|
1032
|
+
prId,
|
|
1033
|
+
type: 'review_comment',
|
|
1034
|
+
occurredAt: createdAt,
|
|
1035
|
+
// Point at the thread for in-app navigation.
|
|
1036
|
+
refTable: 'review_threads',
|
|
1037
|
+
refId: threadRow.id,
|
|
1038
|
+
dedupeKey: `review_comment:${c.id}`,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
// ---- general PR comments ----
|
|
1043
|
+
for (const c of pr.comments.nodes) {
|
|
1044
|
+
const commenterId = await resolver.resolve(tx, c.author);
|
|
1045
|
+
const createdAt = new Date(c.createdAt);
|
|
1046
|
+
const commentRow = (await tx
|
|
1047
|
+
.insert(prComments)
|
|
1048
|
+
.values({
|
|
1049
|
+
githubNodeId: c.id,
|
|
1050
|
+
prId,
|
|
1051
|
+
authorId: commenterId,
|
|
1052
|
+
// PR-comment bodies are always persisted (Feed renders them).
|
|
1053
|
+
body: c.body ?? null,
|
|
1054
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
1055
|
+
createdAt,
|
|
1056
|
+
})
|
|
1057
|
+
.onConflictDoUpdate({
|
|
1058
|
+
target: [prComments.prId, prComments.githubNodeId],
|
|
1059
|
+
set: {
|
|
1060
|
+
body: c.body ?? null,
|
|
1061
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
1062
|
+
},
|
|
1063
|
+
})
|
|
1064
|
+
.returning({ id: prComments.id })
|
|
1065
|
+
.execute())[0];
|
|
1066
|
+
const pcBody = searchText(c.body);
|
|
1067
|
+
if (pcBody)
|
|
1068
|
+
searchRows.push({
|
|
1069
|
+
kind: 'pr_comment',
|
|
1070
|
+
refId: commentRow.id,
|
|
1071
|
+
threadId: null,
|
|
1072
|
+
authorId: commenterId,
|
|
1073
|
+
body: pcBody,
|
|
1074
|
+
createdAt,
|
|
1075
|
+
});
|
|
1076
|
+
await upsertEvent(tx, {
|
|
1077
|
+
accountId,
|
|
1078
|
+
repoId,
|
|
1079
|
+
actorId: commenterId,
|
|
1080
|
+
prId,
|
|
1081
|
+
type: 'pr_comment',
|
|
1082
|
+
occurredAt: createdAt,
|
|
1083
|
+
refTable: 'pr_comments',
|
|
1084
|
+
refId: commentRow.id,
|
|
1085
|
+
dedupeKey: `pr_comment:${c.id}`,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
// ---- commits ----
|
|
1089
|
+
for (const node of pr.commits.nodes) {
|
|
1090
|
+
const c = node.commit;
|
|
1091
|
+
const commitAuthorId = await resolver.resolve(tx, c.author?.user
|
|
1092
|
+
? { login: c.author.user.login, id: c.author.user.id }
|
|
1093
|
+
: null);
|
|
1094
|
+
const committerId = await resolver.resolve(tx, c.committer?.user
|
|
1095
|
+
? { login: c.committer.user.login, id: c.committer.user.id }
|
|
1096
|
+
: null);
|
|
1097
|
+
const committedAt = new Date(c.committedDate);
|
|
1098
|
+
// The subject line, written ONLY when GitHub sent the key (a tolerant partial or an old
|
|
1099
|
+
// fixture omits it) — spreading `{}` leaves a stored headline alone instead of clearing it.
|
|
1100
|
+
const headline = c.messageHeadline === undefined
|
|
1101
|
+
? {}
|
|
1102
|
+
: { messageHeadline: c.messageHeadline == null ? null : c.messageHeadline.slice(0, 200) };
|
|
1103
|
+
// Upsert (not DoNothing) so we always get the row id back to point the
|
|
1104
|
+
// timeline event at — the marker modal resolves the commit via ref_id.
|
|
1105
|
+
const commitRow = (await tx
|
|
1106
|
+
.insert(commits)
|
|
1107
|
+
.values({
|
|
1108
|
+
sha: c.oid,
|
|
1109
|
+
prId,
|
|
1110
|
+
authorId: commitAuthorId,
|
|
1111
|
+
committerId,
|
|
1112
|
+
message: config.persistBodies ? c.message : null,
|
|
1113
|
+
...headline,
|
|
1114
|
+
committedAt,
|
|
1115
|
+
})
|
|
1116
|
+
.onConflictDoUpdate({
|
|
1117
|
+
target: [commits.sha, commits.prId],
|
|
1118
|
+
set: { message: config.persistBodies ? c.message : null, ...headline, committedAt },
|
|
1119
|
+
})
|
|
1120
|
+
.returning({ id: commits.id })
|
|
1121
|
+
.execute())[0];
|
|
1122
|
+
await upsertEvent(tx, {
|
|
1123
|
+
accountId,
|
|
1124
|
+
repoId,
|
|
1125
|
+
actorId: commitAuthorId ?? committerId,
|
|
1126
|
+
prId,
|
|
1127
|
+
type: 'commit_pushed',
|
|
1128
|
+
occurredAt: committedAt,
|
|
1129
|
+
refTable: 'commits',
|
|
1130
|
+
refId: commitRow.id,
|
|
1131
|
+
dedupeKey: `commit_pushed:${pr.id}:${c.oid}`,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
// ---- rebuild this PR's search-index rows ----
|
|
1135
|
+
// Delete-then-insert (inside the same tx) so an edited/removed comment leaves no stale hit and
|
|
1136
|
+
// re-sync is idempotent. Chunked so a comment-heavy PR stays under SQLite's bound variable limit
|
|
1137
|
+
// (999): ~7 columns × 100 rows = 700 < 999.
|
|
1138
|
+
await tx.delete(searchIndex).where(eq(searchIndex.prId, prId)).execute();
|
|
1139
|
+
for (let i = 0; i < searchRows.length; i += 100) {
|
|
1140
|
+
await tx
|
|
1141
|
+
.insert(searchIndex)
|
|
1142
|
+
.values(searchRows.slice(i, i + 100).map((r) => ({ accountId, repoId, prId, ...r })))
|
|
1143
|
+
.execute();
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
if (boardMoved)
|
|
1147
|
+
notePrChanged(accountId, repoId);
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Did this write move anything a reader can see?
|
|
1151
|
+
*
|
|
1152
|
+
* • A first sighting (`prev === null`) counts ONLY for an OPEN PR — a new card. A merged or
|
|
1153
|
+
* closed PR first seen by the 90-day deep backfill (hundreds of them, minutes apart) changes no
|
|
1154
|
+
* board, and counting it cascaded a whole-app refetch on every SPA poll of a backfill; the
|
|
1155
|
+
* walk's own completion stamp covers the history it brings in.
|
|
1156
|
+
* • `updatedAt` MOVING counts. GitHub bumps it for every new review, comment, reply and commit —
|
|
1157
|
+
* the rows the My turn / Unanswered threads / Waiting on review tabs fold — and a webhook or
|
|
1158
|
+
* settle sync that stored them otherwise raised nothing. It stays DIFF-GATED: a walk restating
|
|
1159
|
+
* an unchanged PR (the overlap window) carries the same `updatedAt`. What it cannot see is an
|
|
1160
|
+
* updatedAt-SILENT change (a thread resolve, CI finishing, mergeability computed) — the explicit
|
|
1161
|
+
* columns cover the last two.
|
|
1162
|
+
* • The merge-queue pair is compared ONLY when this response observed it (the three-state
|
|
1163
|
+
* `queueObserved` rule: an absent key wrote nothing, so it cannot have moved).
|
|
1164
|
+
* Exported for its unit test.
|
|
1165
|
+
*/
|
|
1166
|
+
export function boardVisibleMoved(prev, next) {
|
|
1167
|
+
if (prev == null)
|
|
1168
|
+
return next.state === 'open';
|
|
1169
|
+
if (next.updatedAt !== undefined &&
|
|
1170
|
+
(prev.updatedAt == null || prev.updatedAt.getTime() !== next.updatedAt.getTime())) {
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
if (prev.state !== next.state ||
|
|
1174
|
+
prev.isDraft !== next.isDraft ||
|
|
1175
|
+
prev.headSha !== next.headSha ||
|
|
1176
|
+
prev.ciStatus !== next.ciStatus ||
|
|
1177
|
+
prev.mergeable !== next.mergeable ||
|
|
1178
|
+
prev.mergeStateStatus !== next.mergeStateStatus ||
|
|
1179
|
+
prev.reviewDecision !== next.reviewDecision) {
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
if (next.inMergeQueue !== undefined) {
|
|
1183
|
+
if (prev.inMergeQueue !== next.inMergeQueue)
|
|
1184
|
+
return true;
|
|
1185
|
+
if (prev.mergeQueueEntryState !== (next.mergeQueueEntryState ?? null))
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
return false;
|
|
1189
|
+
}
|
|
1190
|
+
//# sourceMappingURL=upsert.js.map
|