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,1081 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { and, eq } from 'drizzle-orm';
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
import { getAccessToken } from '../auth/account.js';
|
|
7
|
+
import { db, schema } from '../db/client.js';
|
|
8
|
+
import { hasConflictMarkers } from '../coding/merge.js';
|
|
9
|
+
import { fetchPrHeadInfo } from '../github/mutations.js';
|
|
10
|
+
import { cleanupCloneCache, ensureClone, fetchRefIntoClone, withRepoLock, } from '../review/clone-manager.js';
|
|
11
|
+
import { gitSupportsMergeTree, gitTry } from './git.js';
|
|
12
|
+
import { diffTokens, threeWayChunks } from './diff.js';
|
|
13
|
+
import { parseMergeTree, stripMangledSuffix } from './parse.js';
|
|
14
|
+
import { regionFingerprint } from './hash.js';
|
|
15
|
+
import { TokenInterner, internAll, isWhitespaceToken, splitLines, tokenizeWords, } from './tokens.js';
|
|
16
|
+
const { pullRequests, repos } = schema;
|
|
17
|
+
/**
|
|
18
|
+
* THE CONFLICT MODEL BUILDER.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ NO WORKTREE. NONE, ANYWHERE, AT ANY PHASE. `merge-tree --write-tree` performs a full
|
|
21
|
+
* three-way merge with no index and no working tree, every other command here is
|
|
22
|
+
* worktree-free, and the oracle runs `merge-file` in `os.tmpdir()` over three loose files.
|
|
23
|
+
* That makes the resolver immune to the worktree defect classes rather than dependent on
|
|
24
|
+
* their fix, and it is why a session costs nothing to hold open.
|
|
25
|
+
*
|
|
26
|
+
* ⚠ NO GIT 2.40+ FLAGS. No `merge-tree --merge-base=`, no `-X ours|theirs`, no
|
|
27
|
+
* `merge-file --object-id`. v1 is local-only and the dev machine is on 2.54, but the cloud
|
|
28
|
+
* image is 2.39.5 where all three exit 129 — a later port must not be a rewrite. The one
|
|
29
|
+
* version floor we do have is 2.38 for `--write-tree`, probed once and refused as
|
|
30
|
+
* `git_too_old` at the open route.
|
|
31
|
+
*
|
|
32
|
+
* The lock is held for the GIT phase only (seconds; merge-tree itself is 17ms). The oracle
|
|
33
|
+
* and the region folding hold nothing.
|
|
34
|
+
*/
|
|
35
|
+
/** Above this, listing the files is not help — it is a wall of text. Distinct from
|
|
36
|
+
* `conflictMaxFiles`, which degrades the extras to `budget_exhausted` and still lists them. */
|
|
37
|
+
const HARD_CONFLICTED_PATH_CEILING = 1000;
|
|
38
|
+
/** `git fetch` takes the oids as argv, so they go in batches rather than one 2000-arg line. */
|
|
39
|
+
const PREFETCH_BATCH = 200;
|
|
40
|
+
/* ═════════════════════════════ the UTF-8 gate ═════════════════════════════ */
|
|
41
|
+
// `ignoreBOM: true` KEEPS a leading U+FEFF as a character instead of swallowing it, so the
|
|
42
|
+
// re-encode at land time writes the same bytes back. Dropping it would silently strip the BOM
|
|
43
|
+
// from every file that has one.
|
|
44
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
45
|
+
/**
|
|
46
|
+
* Strict decode, or nothing.
|
|
47
|
+
*
|
|
48
|
+
* ⚠ `Buffer.includes(0)` IS NOT SUFFICIENT to call something text. `[63 61 66 e9 0a]` — "caf"
|
|
49
|
+
* plus a Latin-1 é — has no NUL, so a naive check calls it text; `.toString('utf8')` yields
|
|
50
|
+
* `caf�` and re-encoding writes `EF BF BD`. That corrupts the WHOLE file, not just the
|
|
51
|
+
* region the user touched, because the land path replaces the entire path with the fold
|
|
52
|
+
* output. We refuse rather than guess an encoding.
|
|
53
|
+
*/
|
|
54
|
+
export function decodeStrict(buf) {
|
|
55
|
+
try {
|
|
56
|
+
return STRICT_UTF8.decode(buf);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/* ═════════════════════════════ classification ═════════════════════════════ */
|
|
63
|
+
/** The label under an unresolvable file. A NOUN PHRASE — no instruction, no full stop. The
|
|
64
|
+
* panel states "N files need resolving on GitHub." once, above the list. */
|
|
65
|
+
export const UNSUPPORTED_LABELS = {
|
|
66
|
+
binary: 'Binary file',
|
|
67
|
+
submodule: 'Submodule',
|
|
68
|
+
symlink: 'Symlink',
|
|
69
|
+
file_directory: 'A directory of the same name',
|
|
70
|
+
rename_rename: 'Renamed differently on each side',
|
|
71
|
+
rename_delete: 'Renamed on one side, deleted on the other',
|
|
72
|
+
modify_delete: 'Changed on one side, deleted on the other',
|
|
73
|
+
mode_change: 'File mode changed',
|
|
74
|
+
not_text: 'Not UTF-8 text',
|
|
75
|
+
too_large: 'Too large to resolve here',
|
|
76
|
+
too_many_conflicts: 'Too many conflicts to resolve here',
|
|
77
|
+
no_common_ancestor: 'No common ancestor',
|
|
78
|
+
engine_disagreement: 'Git splits this file differently',
|
|
79
|
+
budget_exhausted: 'Past this session’s limit',
|
|
80
|
+
};
|
|
81
|
+
const REGULAR_MODES = new Set(['100644', '100755']);
|
|
82
|
+
/**
|
|
83
|
+
* Classify a path from merge-tree's records.
|
|
84
|
+
*
|
|
85
|
+
* ⚠ PRECEDENCE-ORDERED OVER ALL RECORDS FOR THE PATH, NEVER A FIRST MATCH. A binary conflict
|
|
86
|
+
* emits BOTH `CONFLICT (binary)` and `CONFLICT (contents)`; a gitlink emits both
|
|
87
|
+
* `CONFLICT (submodule not initialized)` and `CONFLICT (contents)`. Taking the first record
|
|
88
|
+
* wins the race for whichever git happened to print first, and a binary file classified as
|
|
89
|
+
* text is a file we would offer to rewrite as UTF-8.
|
|
90
|
+
*
|
|
91
|
+
* Returns `null` for "text, resolvable here" — the blob-level NUL and UTF-8 checks happen
|
|
92
|
+
* afterwards, once the bytes are in hand.
|
|
93
|
+
*/
|
|
94
|
+
export function classifyPath(rec) {
|
|
95
|
+
const modes = [...rec.stages.values()].map((s) => s.mode);
|
|
96
|
+
if (modes.includes('160000'))
|
|
97
|
+
return 'submodule';
|
|
98
|
+
if (rec.types.has('CONFLICT (binary)'))
|
|
99
|
+
return 'binary';
|
|
100
|
+
if (rec.types.has('CONFLICT (distinct modes)') || modes.includes('120000'))
|
|
101
|
+
return 'symlink';
|
|
102
|
+
if (rec.types.has('CONFLICT (file/directory)'))
|
|
103
|
+
return 'file_directory';
|
|
104
|
+
if (rec.types.has('CONFLICT (rename/rename)'))
|
|
105
|
+
return 'rename_rename';
|
|
106
|
+
if (rec.types.has('CONFLICT (rename/delete)'))
|
|
107
|
+
return 'rename_delete';
|
|
108
|
+
if (rec.types.has('CONFLICT (modify/delete)'))
|
|
109
|
+
return 'modify_delete';
|
|
110
|
+
const s2 = rec.stages.get(2);
|
|
111
|
+
const s3 = rec.stages.get(3);
|
|
112
|
+
if (s2 && s3 && s2.mode !== s3.mode) {
|
|
113
|
+
return REGULAR_MODES.has(s2.mode) && REGULAR_MODES.has(s3.mode) ? 'mode_change' : 'symlink';
|
|
114
|
+
}
|
|
115
|
+
if (!s2 || !s3)
|
|
116
|
+
return 'modify_delete';
|
|
117
|
+
if (!rec.types.has('CONFLICT (contents)'))
|
|
118
|
+
return 'engine_disagreement';
|
|
119
|
+
if (!REGULAR_MODES.has(s2.mode))
|
|
120
|
+
return 'symlink';
|
|
121
|
+
// An ADD/ADD carries stages 2 and 3 with NO stage 1 (git types it `CONFLICT (contents)`
|
|
122
|
+
// and words the message `CONFLICT (add/add)`). We could model it with an empty base, but
|
|
123
|
+
// then "keep the ancestor" means DELETE THE FILE, offered as a plain third button beside
|
|
124
|
+
// the two versions. It is refused by name instead.
|
|
125
|
+
const s1 = rec.stages.get(1);
|
|
126
|
+
if (!s1)
|
|
127
|
+
return 'no_common_ancestor';
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The wand's word-level merge for ONE contested region: the same three-way chunker, run over
|
|
132
|
+
* word tokens instead of lines.
|
|
133
|
+
*
|
|
134
|
+
* The disjointness bar, and every clause of it earns its place:
|
|
135
|
+
* • no `conflict` chunk at word grain — the two edits must not touch the same words;
|
|
136
|
+
* • between two consecutive changed chunks of OPPOSITE sides, the intervening stable run
|
|
137
|
+
* must hold at least one NON-WHITESPACE token. `foo(a, b)` with one side on `a` and one
|
|
138
|
+
* on `b` is disjoint; `foo bar` is not — a single space is not evidence two authors were
|
|
139
|
+
* working on different things;
|
|
140
|
+
* • neither word diff exceeded its budget, and the token count is under the cap.
|
|
141
|
+
*
|
|
142
|
+
* Then three mechanical checks before we hand back a merge: no conflict marker survived,
|
|
143
|
+
* every changed run is present in order, every stable run is unchanged. Returns null on any
|
|
144
|
+
* failure — the region simply gets no wand, which is the safe outcome.
|
|
145
|
+
*/
|
|
146
|
+
export function disjointWordMerge(baseText, oursText, theirsText) {
|
|
147
|
+
const baseTok = tokenizeWords(baseText);
|
|
148
|
+
const oursTok = tokenizeWords(oursText);
|
|
149
|
+
const theirsTok = tokenizeWords(theirsText);
|
|
150
|
+
const total = baseTok.length + oursTok.length + theirsTok.length;
|
|
151
|
+
if (total > config.conflictWordTokenCap)
|
|
152
|
+
return null;
|
|
153
|
+
const interner = new TokenInterner();
|
|
154
|
+
const b = internAll(baseTok, interner);
|
|
155
|
+
const o = internAll(oursTok, interner);
|
|
156
|
+
const t = internAll(theirsTok, interner);
|
|
157
|
+
const oursDiff = diffTokens(b, o, config.conflictWordDiffMaxD);
|
|
158
|
+
const theirsDiff = diffTokens(b, t, config.conflictWordDiffMaxD);
|
|
159
|
+
if (!oursDiff || !theirsDiff)
|
|
160
|
+
return null;
|
|
161
|
+
const chunks = threeWayChunks(b, o, t, oursDiff, theirsDiff);
|
|
162
|
+
if (chunks.some((c) => c.kind === 'conflict'))
|
|
163
|
+
return null;
|
|
164
|
+
// The "opposite sides need real evidence between them" rule.
|
|
165
|
+
let lastChangedSide = null;
|
|
166
|
+
let stableSinceLastChange = [];
|
|
167
|
+
for (const c of chunks) {
|
|
168
|
+
if (c.kind === 'unchanged') {
|
|
169
|
+
stableSinceLastChange.push(...baseTok.slice(c.baseStart, c.baseEnd));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const side = c.kind === 'ours_only' ? 'ours' : c.kind === 'theirs_only' ? 'theirs' : 'both';
|
|
173
|
+
if (side === 'both') {
|
|
174
|
+
// `both_same` needs no separation argument: neither author is being overruled.
|
|
175
|
+
lastChangedSide = null;
|
|
176
|
+
stableSinceLastChange = [];
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (lastChangedSide && lastChangedSide !== side) {
|
|
180
|
+
if (!stableSinceLastChange.some((tok) => !isWhitespaceToken(tok)))
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
lastChangedSide = side;
|
|
184
|
+
stableSinceLastChange = [];
|
|
185
|
+
}
|
|
186
|
+
// Assemble, then verify the assembly independently of the loop that produced it.
|
|
187
|
+
const merged = [];
|
|
188
|
+
const expected = [];
|
|
189
|
+
for (const c of chunks) {
|
|
190
|
+
const run = c.kind === 'theirs_only'
|
|
191
|
+
? theirsTok.slice(c.theirsStart, c.theirsEnd)
|
|
192
|
+
: c.kind === 'unchanged'
|
|
193
|
+
? baseTok.slice(c.baseStart, c.baseEnd)
|
|
194
|
+
: oursTok.slice(c.oursStart, c.oursEnd);
|
|
195
|
+
expected.push(run);
|
|
196
|
+
merged.push(...run);
|
|
197
|
+
}
|
|
198
|
+
let pos = 0;
|
|
199
|
+
for (const run of expected) {
|
|
200
|
+
for (const tok of run) {
|
|
201
|
+
if (merged[pos] !== tok)
|
|
202
|
+
return null;
|
|
203
|
+
pos++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (pos !== merged.length)
|
|
207
|
+
return null;
|
|
208
|
+
const mergedText = merged.join('');
|
|
209
|
+
// `MARKER_RE` deliberately ignores a bare `=======`, which real markdown contains.
|
|
210
|
+
if (hasConflictMarkers(mergedText))
|
|
211
|
+
return null;
|
|
212
|
+
return { lines: splitLines(mergedText).lines };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Build the region list for ONE text file from its three decoded sides.
|
|
216
|
+
*
|
|
217
|
+
* A diff that runs out of budget degrades this file to ONE conflict spanning everything —
|
|
218
|
+
* honest and still resolvable, rather than a wrong split.
|
|
219
|
+
*/
|
|
220
|
+
export function buildFileRegions(baseLines, oursLines, theirsLines) {
|
|
221
|
+
const interner = new TokenInterner();
|
|
222
|
+
const b = internAll(baseLines, interner);
|
|
223
|
+
const o = internAll(oursLines, interner);
|
|
224
|
+
const t = internAll(theirsLines, interner);
|
|
225
|
+
const oursDiff = diffTokens(b, o, config.conflictDiffMaxD);
|
|
226
|
+
const theirsDiff = diffTokens(b, t, config.conflictDiffMaxD);
|
|
227
|
+
let chunks;
|
|
228
|
+
if (!oursDiff || !theirsDiff) {
|
|
229
|
+
chunks = [
|
|
230
|
+
{
|
|
231
|
+
kind: 'conflict',
|
|
232
|
+
baseStart: 0,
|
|
233
|
+
baseEnd: baseLines.length,
|
|
234
|
+
oursStart: 0,
|
|
235
|
+
oursEnd: oursLines.length,
|
|
236
|
+
theirsStart: 0,
|
|
237
|
+
theirsEnd: theirsLines.length,
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
chunks = threeWayChunks(b, o, t, oursDiff, theirsDiff);
|
|
243
|
+
}
|
|
244
|
+
const conflictCount = chunks.filter((c) => c.kind === 'conflict').length;
|
|
245
|
+
if (conflictCount > config.conflictMaxRegions) {
|
|
246
|
+
return { regions: [], unsupported: 'too_many_conflicts' };
|
|
247
|
+
}
|
|
248
|
+
const regions = chunks.map((c, i) => {
|
|
249
|
+
const base = baseLines.slice(c.baseStart, c.baseEnd);
|
|
250
|
+
// ⚠ On `unchanged`, ours and theirs are EMPTY — not copies of base. Three copies of every
|
|
251
|
+
// untouched line is what turns a 30-file conflict into a multi-megabyte model, and the
|
|
252
|
+
// fold reads `base` there anyway. Their LENGTHS are still recoverable from base, which is
|
|
253
|
+
// what the per-side line numbering and the oracle walk rely on.
|
|
254
|
+
const ours = c.kind === 'unchanged' ? [] : oursLines.slice(c.oursStart, c.oursEnd);
|
|
255
|
+
const theirs = c.kind === 'unchanged' ? [] : theirsLines.slice(c.theirsStart, c.theirsEnd);
|
|
256
|
+
const wandAndMerge = wandFor(c.kind, base, ours, theirs);
|
|
257
|
+
return {
|
|
258
|
+
id: i + 1,
|
|
259
|
+
kind: c.kind,
|
|
260
|
+
base,
|
|
261
|
+
ours,
|
|
262
|
+
theirs,
|
|
263
|
+
fingerprint: regionFingerprint(base, ours, theirs),
|
|
264
|
+
wand: wandAndMerge.wand,
|
|
265
|
+
mergedLines: wandAndMerge.mergedLines,
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
return { regions, unsupported: null };
|
|
269
|
+
}
|
|
270
|
+
function wandFor(kind, base, ours, theirs) {
|
|
271
|
+
switch (kind) {
|
|
272
|
+
case 'unchanged':
|
|
273
|
+
return { wand: null, mergedLines: null };
|
|
274
|
+
case 'ours_only':
|
|
275
|
+
return { wand: { decision: 'ours', reason: 'only_ours' }, mergedLines: null };
|
|
276
|
+
case 'theirs_only':
|
|
277
|
+
return { wand: { decision: 'theirs', reason: 'only_theirs' }, mergedLines: null };
|
|
278
|
+
case 'both_same':
|
|
279
|
+
return { wand: { decision: 'ours', reason: 'both_same' }, mergedLines: null };
|
|
280
|
+
case 'conflict': {
|
|
281
|
+
// Computed EAGERLY for every contested region: it is pure CPU over strings already in
|
|
282
|
+
// memory, it is what the wand button keys on, and a lazy second path could disagree
|
|
283
|
+
// with the first.
|
|
284
|
+
const merged = disjointWordMerge(joinRegion(base), joinRegion(ours), joinRegion(theirs));
|
|
285
|
+
if (!merged)
|
|
286
|
+
return { wand: null, mergedLines: null };
|
|
287
|
+
return {
|
|
288
|
+
wand: { decision: 'disjoint_merge', reason: 'disjoint_words' },
|
|
289
|
+
mergedLines: merged.lines,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** A region's lines as one string for the word pass. The trailing newline is deliberate: it
|
|
295
|
+
* keeps line boundaries as real tokens, so a word-level merge cannot silently join two
|
|
296
|
+
* lines that were separate on both sides. */
|
|
297
|
+
function joinRegion(lines) {
|
|
298
|
+
return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Parse `git merge-file -p --diff3` output into the OURS-side ranges git contested.
|
|
302
|
+
*
|
|
303
|
+
* ⚠ POSITIONS, NOT COUNTS. `merge-file`'s exit status is the conflict count CAPPED AT 127, so
|
|
304
|
+
* a file with 200 contested regions reports 127 and a count comparison silently passes.
|
|
305
|
+
*
|
|
306
|
+
* The ours section of each block is verbatim ours text, so each block is located by searching
|
|
307
|
+
* ours FORWARD from the previous block's end — monotonic, and a block we cannot place is a
|
|
308
|
+
* refusal rather than a guess. An empty ours section (both sides added at the same point) is
|
|
309
|
+
* an insertion point, recorded as a zero-length range at the cursor.
|
|
310
|
+
*/
|
|
311
|
+
export function oracleConflictRanges(mergedText, oursLines, oursFinalNewline = true) {
|
|
312
|
+
const out = [];
|
|
313
|
+
const lines = splitLines(mergedText).lines;
|
|
314
|
+
let cursor = 0;
|
|
315
|
+
let i = 0;
|
|
316
|
+
while (i < lines.length) {
|
|
317
|
+
const line = lines[i];
|
|
318
|
+
if (!line.startsWith('<<<<<<<')) {
|
|
319
|
+
i++;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
i++;
|
|
323
|
+
const oursSection = [];
|
|
324
|
+
while (i < lines.length && !lines[i].startsWith('|||||||')) {
|
|
325
|
+
oursSection.push(lines[i]);
|
|
326
|
+
i++;
|
|
327
|
+
}
|
|
328
|
+
// Skip the base and theirs sections; only the ours side is used for placement.
|
|
329
|
+
while (i < lines.length && !lines[i].startsWith('>>>>>>>'))
|
|
330
|
+
i++;
|
|
331
|
+
if (i >= lines.length)
|
|
332
|
+
return null; // unterminated block — refuse rather than guess
|
|
333
|
+
i++;
|
|
334
|
+
if (oursSection.length === 0) {
|
|
335
|
+
out.push({ start: cursor, end: cursor });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const at = findSequence(oursLines, oursSection, cursor, oursFinalNewline);
|
|
339
|
+
if (at < 0)
|
|
340
|
+
return null;
|
|
341
|
+
out.push({ start: at, end: at + oursSection.length });
|
|
342
|
+
cursor = at + oursSection.length;
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
function findSequence(haystack, needle, from, haystackFinalNewline) {
|
|
347
|
+
outer: for (let s = from; s + needle.length <= haystack.length; s++) {
|
|
348
|
+
for (let k = 0; k < needle.length; k++) {
|
|
349
|
+
if (!lineMatches(haystack, s + k, needle[k], haystackFinalNewline)) {
|
|
350
|
+
continue outer;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return s;
|
|
354
|
+
}
|
|
355
|
+
return -1;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* ⚠ `merge-file` RE-TERMINATES a file that ended without a newline, and on a CRLF file the
|
|
359
|
+
* terminator it adds is `\r\n` — so ours' final line comes back one `\r` longer than it
|
|
360
|
+
* actually is. That is an artefact of the oracle's own output, not a disagreement about where
|
|
361
|
+
* the conflict is, and treating it as one made every CRLF file with no trailing newline
|
|
362
|
+
* `engine_disagreement`. The relaxation is deliberately confined to that exact case: the LAST
|
|
363
|
+
* line of ours, and only when ours has no terminator.
|
|
364
|
+
*/
|
|
365
|
+
function lineMatches(haystack, at, needle, haystackFinalNewline) {
|
|
366
|
+
const hay = haystack[at];
|
|
367
|
+
if (hay === undefined)
|
|
368
|
+
return false;
|
|
369
|
+
if (hay === needle)
|
|
370
|
+
return true;
|
|
371
|
+
return at === haystack.length - 1 && !haystackFinalNewline && `${hay}\r` === needle;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Every region git contested must overlap one of ours. OUR extra regions are fine — being
|
|
375
|
+
* more conservative than git is allowed; contesting LESS than git is not, because that is a
|
|
376
|
+
* region we would merge silently and git would not.
|
|
377
|
+
*/
|
|
378
|
+
export function oracleAgrees(ourConflictRanges, gitRanges) {
|
|
379
|
+
for (const g of gitRanges) {
|
|
380
|
+
const hit = ourConflictRanges.some((r) => g.start <= r.end && g.end >= r.start);
|
|
381
|
+
if (!hit)
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
/** Run the oracle over one file's three sides. Returns true when git agrees, false when it
|
|
387
|
+
* contests something we merged silently — which makes the file `engine_disagreement`. */
|
|
388
|
+
async function runOracle(baseText, oursText, theirsText, oursLines, oursFinalNewline, ourConflictRanges) {
|
|
389
|
+
const dir = mkdtempSync(join(tmpdir(), 'pierre-oracle-'));
|
|
390
|
+
try {
|
|
391
|
+
const o = join(dir, 'ours');
|
|
392
|
+
const b = join(dir, 'base');
|
|
393
|
+
const t = join(dir, 'theirs');
|
|
394
|
+
writeFileSync(o, oursText, 'utf8');
|
|
395
|
+
writeFileSync(b, baseText, 'utf8');
|
|
396
|
+
writeFileSync(t, theirsText, 'utf8');
|
|
397
|
+
const res = await gitTry(['merge-file', '-p', '--diff3', '-L', 'ours', '-L', 'base', '-L', 'theirs', o, b, t], dir);
|
|
398
|
+
// ⚠ merge-file's exit status is the CONFLICT COUNT, capped at 127 — which is why we do
|
|
399
|
+
// not read it. Above 127 it is a real failure (unreadable input), and a cross-check that
|
|
400
|
+
// cannot run must not turn every file into a refusal.
|
|
401
|
+
if (res.code > 127)
|
|
402
|
+
return true;
|
|
403
|
+
const gitRanges = oracleConflictRanges(res.stdout.toString('utf8'), oursLines, oursFinalNewline);
|
|
404
|
+
if (gitRanges === null)
|
|
405
|
+
return false;
|
|
406
|
+
return oracleAgrees(ourConflictRanges, gitRanges);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// The oracle is a cross-check, not a dependency: if it cannot run we do not turn every
|
|
410
|
+
// file into a refusal.
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
try {
|
|
415
|
+
rmSync(dir, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
/* a temp dir we could not remove is not worth failing a merge over */
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Build the model inside an EXISTING clone that already has both commits.
|
|
424
|
+
*
|
|
425
|
+
* Exported separately from `buildConflictModel` so the tests can drive it against real temp
|
|
426
|
+
* repositories with no GitHub and no database — the git behaviour this whole feature rests on
|
|
427
|
+
* is the half worth testing against real git.
|
|
428
|
+
*/
|
|
429
|
+
export async function buildConflictModelInClone(ctx, opts = {}) {
|
|
430
|
+
const deadline = opts.deadlineMs ?? Date.now() + config.conflictModelTimeoutMs;
|
|
431
|
+
const expired = () => Date.now() > deadline;
|
|
432
|
+
if (!(await gitSupportsMergeTree(ctx.cloneDir))) {
|
|
433
|
+
return {
|
|
434
|
+
status: 'failed',
|
|
435
|
+
code: 'git_too_old',
|
|
436
|
+
message: 'This machine’s git is older than 2.38. Update git to resolve conflicts here.',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
opts.onPhase?.('merging');
|
|
440
|
+
// ---- the merge base ----
|
|
441
|
+
// ⚠ Used for the REBASE COMMIT COUNT and the blob prefetch, never for the `base` TEXT of a
|
|
442
|
+
// region — that always comes from merge-tree's stage 1. MEASURED on a real criss-cross:
|
|
443
|
+
// `merge-base --all` returned two commits whose file read `x/MID/yA` and `xB/MID/y` while
|
|
444
|
+
// stage 1 was `xB/MID/yA`, different from BOTH. A second base resolution is how "keep the
|
|
445
|
+
// ancestor" comes to commit bytes the pane never showed.
|
|
446
|
+
const mb = await gitTry(['merge-base', '--all', ctx.headSha, ctx.baseSha], ctx.cloneDir);
|
|
447
|
+
const mbCandidates = mb.stdout
|
|
448
|
+
.toString('utf8')
|
|
449
|
+
.split('\n')
|
|
450
|
+
.map((s) => s.trim())
|
|
451
|
+
.filter((s) => s.length > 0);
|
|
452
|
+
if (mbCandidates.length === 0) {
|
|
453
|
+
return {
|
|
454
|
+
status: 'failed',
|
|
455
|
+
code: 'unrelated_histories',
|
|
456
|
+
message: 'This branch and its base share no history.',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const mergeBaseIsVirtual = mbCandidates.length > 1;
|
|
460
|
+
const mergeBaseSha = mergeBaseIsVirtual ? null : mbCandidates[0];
|
|
461
|
+
const rebaseBase = mbCandidates[0];
|
|
462
|
+
// ---- prefetch the blobs merge-tree will need ----
|
|
463
|
+
opts.onPhase?.('fetching');
|
|
464
|
+
const prefetch = await prefetchBlobs(ctx, mbCandidates);
|
|
465
|
+
if (prefetch === 'unavailable') {
|
|
466
|
+
return {
|
|
467
|
+
status: 'failed',
|
|
468
|
+
code: 'objects_unavailable',
|
|
469
|
+
message: 'Some of this merge’s file contents could not be fetched from GitHub.',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (expired())
|
|
473
|
+
return timedOut();
|
|
474
|
+
// ---- the merge ----
|
|
475
|
+
opts.onPhase?.('merging');
|
|
476
|
+
const mtArgs = ['merge-tree', '--write-tree', '-z'];
|
|
477
|
+
if (prefetch === 'narrowed') {
|
|
478
|
+
// A CONFIG KEY, not a 2.40 flag: `-c merge.renames=false` works everywhere `merge-tree`
|
|
479
|
+
// does, and rename detection is what makes the blob budget explode.
|
|
480
|
+
mtArgs.unshift('-c', 'merge.renames=false');
|
|
481
|
+
}
|
|
482
|
+
const mt = await gitTry([...mtArgs, ctx.headSha, ctx.baseSha], ctx.cloneDir);
|
|
483
|
+
if (mt.stdout.length === 0) {
|
|
484
|
+
// ⚠ A non-zero exit with EMPTY stdout is an error; a non-zero exit with a leading tree
|
|
485
|
+
// oid is conflicts. Branching on the code alone turns "not something we can merge" into
|
|
486
|
+
// a model with no files in it.
|
|
487
|
+
const stderr = mt.stderr.trim();
|
|
488
|
+
if (/unrelated histories/i.test(stderr)) {
|
|
489
|
+
return {
|
|
490
|
+
status: 'failed',
|
|
491
|
+
code: 'unrelated_histories',
|
|
492
|
+
message: 'This branch and its base share no history.',
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
status: 'failed',
|
|
497
|
+
code: 'objects_unavailable',
|
|
498
|
+
message: stderr.slice(0, 300) || 'git could not merge these two commits.',
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const parsed = parseMergeTree(mt.stdout);
|
|
502
|
+
const commitsAboveBase = await countCommits(ctx.cloneDir, rebaseBase, ctx.headSha);
|
|
503
|
+
const skeleton = {
|
|
504
|
+
accountId: ctx.accountId,
|
|
505
|
+
prId: ctx.prId,
|
|
506
|
+
owner: ctx.owner,
|
|
507
|
+
name: ctx.name,
|
|
508
|
+
number: ctx.number,
|
|
509
|
+
headSha: ctx.headSha,
|
|
510
|
+
baseSha: ctx.baseSha,
|
|
511
|
+
headRef: ctx.headRef,
|
|
512
|
+
baseRef: ctx.baseRef,
|
|
513
|
+
mergeBaseSha,
|
|
514
|
+
mergeBaseIsVirtual,
|
|
515
|
+
mergedTreeSha: parsed.treeOid,
|
|
516
|
+
commitsAboveBase,
|
|
517
|
+
reservedBranchNames: ctx.reservedBranchNames,
|
|
518
|
+
prBranchPushable: ctx.prBranchPushable ?? true,
|
|
519
|
+
prBranchUnavailableReason: ctx.prBranchUnavailableReason ?? null,
|
|
520
|
+
};
|
|
521
|
+
if (parsed.stages.length === 0) {
|
|
522
|
+
const model = {
|
|
523
|
+
...skeleton,
|
|
524
|
+
files: [],
|
|
525
|
+
totalConflictedPaths: 0,
|
|
526
|
+
truncated: false,
|
|
527
|
+
renameDetection: prefetch === 'narrowed' ? 'off' : 'on',
|
|
528
|
+
strategies: ['merge'],
|
|
529
|
+
rebaseUnavailableReason: null,
|
|
530
|
+
};
|
|
531
|
+
return { status: 'clean', model };
|
|
532
|
+
}
|
|
533
|
+
// ---- group every record by path ----
|
|
534
|
+
const byPath = groupRecords(parsed.stages, parsed.messages);
|
|
535
|
+
if (byPath.size > HARD_CONFLICTED_PATH_CEILING) {
|
|
536
|
+
return {
|
|
537
|
+
status: 'failed',
|
|
538
|
+
code: 'too_many_files',
|
|
539
|
+
message: `This merge conflicts in ${byPath.size} files. Resolve it on GitHub.`,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
opts.onPhase?.('reading');
|
|
543
|
+
const files = await buildFiles(ctx, [...byPath.values()], deadline);
|
|
544
|
+
const resolvable = files.filter((f) => f.unsupported === null);
|
|
545
|
+
const strategies = ['merge'];
|
|
546
|
+
let rebaseUnavailableReason = null;
|
|
547
|
+
if (commitsAboveBase !== 1) {
|
|
548
|
+
rebaseUnavailableReason = `This branch has ${commitsAboveBase} commits. Rebasing can conflict once per commit — merge instead.`;
|
|
549
|
+
}
|
|
550
|
+
else if (resolvable.length !== files.length) {
|
|
551
|
+
const stuck = files.length - resolvable.length;
|
|
552
|
+
rebaseUnavailableReason = `Rebase needs every conflicted file resolved here. ${stuck} of ${files.length} must be resolved on GitHub.`;
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
strategies.push('rebase');
|
|
556
|
+
}
|
|
557
|
+
const model = {
|
|
558
|
+
...skeleton,
|
|
559
|
+
files,
|
|
560
|
+
totalConflictedPaths: byPath.size,
|
|
561
|
+
truncated: files.some((f) => f.unsupported === 'budget_exhausted'),
|
|
562
|
+
renameDetection: prefetch === 'narrowed' ? 'off' : 'on',
|
|
563
|
+
strategies,
|
|
564
|
+
rebaseUnavailableReason,
|
|
565
|
+
};
|
|
566
|
+
return { status: 'ready', model };
|
|
567
|
+
}
|
|
568
|
+
function timedOut() {
|
|
569
|
+
return {
|
|
570
|
+
status: 'failed',
|
|
571
|
+
code: 'timeout',
|
|
572
|
+
message: 'Working out this merge took too long.',
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function groupRecords(stages, messages) {
|
|
576
|
+
const byPath = new Map();
|
|
577
|
+
const get = (rawPath) => {
|
|
578
|
+
let rec = byPath.get(rawPath);
|
|
579
|
+
if (!rec) {
|
|
580
|
+
rec = { rawPath, stages: new Map(), types: new Set(), relatedRaw: new Set() };
|
|
581
|
+
byPath.set(rawPath, rec);
|
|
582
|
+
}
|
|
583
|
+
return rec;
|
|
584
|
+
};
|
|
585
|
+
// Only STAGE entries mint a file. A path that appears solely in a message — the directory
|
|
586
|
+
// half of a file/directory conflict, say — is carried as a related path, because there is
|
|
587
|
+
// no blob triple to resolve there.
|
|
588
|
+
for (const s of stages)
|
|
589
|
+
get(s.path).stages.set(s.stage, s);
|
|
590
|
+
for (const m of messages) {
|
|
591
|
+
for (const p of m.paths) {
|
|
592
|
+
const rec = byPath.get(p);
|
|
593
|
+
if (!rec)
|
|
594
|
+
continue;
|
|
595
|
+
rec.types.add(m.type);
|
|
596
|
+
for (const other of m.paths)
|
|
597
|
+
if (other !== p)
|
|
598
|
+
rec.relatedRaw.add(other);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return byPath;
|
|
602
|
+
}
|
|
603
|
+
async function buildFiles(ctx, records, deadline) {
|
|
604
|
+
const committishes = [ctx.headSha, ctx.baseSha];
|
|
605
|
+
const sorted = [...records].sort((a, b) => (a.rawPath < b.rawPath ? -1 : 1));
|
|
606
|
+
const files = [];
|
|
607
|
+
let bytesAttached = 0;
|
|
608
|
+
for (let index = 0; index < sorted.length; index++) {
|
|
609
|
+
const rec = sorted[index];
|
|
610
|
+
const path = stripMangledSuffix(rec.rawPath, committishes);
|
|
611
|
+
const relatedPaths = [...rec.relatedRaw]
|
|
612
|
+
.map((p) => stripMangledSuffix(p, committishes))
|
|
613
|
+
.filter((p) => p !== path);
|
|
614
|
+
const s2 = rec.stages.get(2);
|
|
615
|
+
const base = (reason) => ({
|
|
616
|
+
index,
|
|
617
|
+
path,
|
|
618
|
+
relatedPaths,
|
|
619
|
+
unsupported: reason,
|
|
620
|
+
unsupportedLabel: reason === null ? null : UNSUPPORTED_LABELS[reason],
|
|
621
|
+
regions: [],
|
|
622
|
+
terminators: { base: false, ours: false, theirs: false },
|
|
623
|
+
maxSideBytes: 0,
|
|
624
|
+
stage2Mode: s2?.mode ?? null,
|
|
625
|
+
});
|
|
626
|
+
const structural = classifyPath(rec);
|
|
627
|
+
if (structural !== null) {
|
|
628
|
+
files.push(base(structural));
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (index >= config.conflictMaxFiles || Date.now() > deadline) {
|
|
632
|
+
// Still listed and still classified — an unlisted file is why the PR stays conflicted
|
|
633
|
+
// after a commit, with nothing on screen to explain it.
|
|
634
|
+
files.push(base('budget_exhausted'));
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const s1 = rec.stages.get(1);
|
|
638
|
+
const s3 = rec.stages.get(3);
|
|
639
|
+
if (!s1 || !s2 || !s3) {
|
|
640
|
+
files.push(base('no_common_ancestor'));
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const blobs = await readBlobs(ctx.cloneDir, [s1.oid, s2.oid, s3.oid]);
|
|
644
|
+
if (!blobs) {
|
|
645
|
+
files.push(base('budget_exhausted'));
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const [baseBuf, oursBuf, theirsBuf] = blobs;
|
|
649
|
+
const maxSideBytes = Math.max(baseBuf.length, oursBuf.length, theirsBuf.length);
|
|
650
|
+
if (maxSideBytes > config.conflictMaxFileBytes) {
|
|
651
|
+
files.push({ ...base('too_large'), maxSideBytes });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (bytesAttached + baseBuf.length + oursBuf.length + theirsBuf.length >
|
|
655
|
+
config.conflictMaxTotalBytes) {
|
|
656
|
+
files.push({ ...base('budget_exhausted'), maxSideBytes });
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
// The NUL check is the cheap half of the binary test and it comes first; the strict
|
|
660
|
+
// decode is the half that catches Latin-1, which has no NUL at all.
|
|
661
|
+
if (baseBuf.includes(0) || oursBuf.includes(0) || theirsBuf.includes(0)) {
|
|
662
|
+
files.push({ ...base('binary'), maxSideBytes });
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const baseText = decodeStrict(baseBuf);
|
|
666
|
+
const oursText = decodeStrict(oursBuf);
|
|
667
|
+
const theirsText = decodeStrict(theirsBuf);
|
|
668
|
+
if (baseText === null || oursText === null || theirsText === null) {
|
|
669
|
+
files.push({ ...base('not_text'), maxSideBytes });
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const baseSplit = splitLines(baseText);
|
|
673
|
+
const oursSplit = splitLines(oursText);
|
|
674
|
+
const theirsSplit = splitLines(theirsText);
|
|
675
|
+
const built = buildFileRegions(baseSplit.lines, oursSplit.lines, theirsSplit.lines);
|
|
676
|
+
const terminators = {
|
|
677
|
+
base: baseSplit.finalNewline,
|
|
678
|
+
ours: oursSplit.finalNewline,
|
|
679
|
+
theirs: theirsSplit.finalNewline,
|
|
680
|
+
};
|
|
681
|
+
if (built.unsupported !== null) {
|
|
682
|
+
files.push({ ...base(built.unsupported), maxSideBytes, terminators });
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
const ourRanges = ourConflictRangesOf(built.regions, oursSplit.lines);
|
|
686
|
+
const agrees = await runOracle(baseText, oursText, theirsText, oursSplit.lines, oursSplit.finalNewline, ourRanges);
|
|
687
|
+
if (!agrees) {
|
|
688
|
+
files.push({ ...base('engine_disagreement'), maxSideBytes, terminators });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
bytesAttached += baseBuf.length + oursBuf.length + theirsBuf.length;
|
|
692
|
+
files.push({
|
|
693
|
+
index,
|
|
694
|
+
path,
|
|
695
|
+
relatedPaths,
|
|
696
|
+
unsupported: null,
|
|
697
|
+
unsupportedLabel: null,
|
|
698
|
+
regions: built.regions,
|
|
699
|
+
terminators,
|
|
700
|
+
maxSideBytes,
|
|
701
|
+
stage2Mode: s2.mode,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return files;
|
|
705
|
+
}
|
|
706
|
+
/** Our contested regions expressed as OURS-side line ranges, for the oracle comparison. */
|
|
707
|
+
function ourConflictRangesOf(regions, oursLines) {
|
|
708
|
+
const out = [];
|
|
709
|
+
let cursor = 0;
|
|
710
|
+
for (const r of regions) {
|
|
711
|
+
const len = r.kind === 'unchanged' ? r.base.length : r.ours.length;
|
|
712
|
+
if (r.kind === 'conflict')
|
|
713
|
+
out.push({ start: cursor, end: cursor + len });
|
|
714
|
+
cursor += len;
|
|
715
|
+
}
|
|
716
|
+
// A defensive clamp: the walk above must land exactly on the ours side's length.
|
|
717
|
+
if (cursor !== oursLines.length)
|
|
718
|
+
return out;
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
/* ═════════════════════════════ git plumbing ═════════════════════════════ */
|
|
722
|
+
async function countCommits(cloneDir, from, to) {
|
|
723
|
+
const res = await gitTry(['rev-list', '--count', `${from}..${to}`], cloneDir);
|
|
724
|
+
if (res.code !== 0)
|
|
725
|
+
return -1;
|
|
726
|
+
const n = Number(res.stdout.toString('utf8').trim());
|
|
727
|
+
return Number.isInteger(n) ? n : -1;
|
|
728
|
+
}
|
|
729
|
+
/** Read the merge base / ours / theirs blobs. `null` when any of them is missing, which the
|
|
730
|
+
* caller treats as a budget problem for that ONE file rather than a failure of the model. */
|
|
731
|
+
async function readBlobs(cloneDir, oids) {
|
|
732
|
+
const out = [];
|
|
733
|
+
for (const oid of oids) {
|
|
734
|
+
const res = await gitTry(['cat-file', 'blob', oid], cloneDir);
|
|
735
|
+
if (res.code !== 0)
|
|
736
|
+
return null;
|
|
737
|
+
out.push(res.stdout);
|
|
738
|
+
}
|
|
739
|
+
const [a, b, c] = out;
|
|
740
|
+
if (!a || !b || !c)
|
|
741
|
+
return null;
|
|
742
|
+
return [a, b, c];
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Make sure every blob merge-tree will read is in the local store.
|
|
746
|
+
*
|
|
747
|
+
* The clone is BLOBLESS and its `origin` carries no credential, so git cannot lazily fetch
|
|
748
|
+
* on its own. We name the oids explicitly.
|
|
749
|
+
*
|
|
750
|
+
* ⚠ THE BY-OID FETCH EXITS NON-ZERO ON COMPLETE SUCCESS — `fatal: bad revision '<oid>'`,
|
|
751
|
+
* `error: … did not send all necessary objects` — because git cannot make a ref out of a
|
|
752
|
+
* blob oid. SUCCESS IS PROVEN ONLY BY THE FOLLOW-UP `cat-file --batch-check`. Any
|
|
753
|
+
* implementation that checks the exit code refuses every PR.
|
|
754
|
+
*
|
|
755
|
+
* ⚠ `--filter=blob:none` on that fetch is load-bearing: without it the blob does not land.
|
|
756
|
+
*/
|
|
757
|
+
async function prefetchBlobs(ctx, mergeBaseCandidates) {
|
|
758
|
+
// ⚠ EVERY candidate, not just the first. On a criss-cross history merge-tree builds a
|
|
759
|
+
// VIRTUAL base by merging the candidates, so it reads content from all of them; prefetching
|
|
760
|
+
// against one leaves the others' blobs missing in a blobless clone, and the merge then
|
|
761
|
+
// fails for a reason that looks nothing like its cause.
|
|
762
|
+
const ours = new Map();
|
|
763
|
+
const theirs = new Map();
|
|
764
|
+
// Stage-1 material: the base side's own blobs, which the two maps above (keyed on the
|
|
765
|
+
// DESTINATION oid) do not carry.
|
|
766
|
+
const ancestors = new Set();
|
|
767
|
+
for (const mergeBase of mergeBaseCandidates) {
|
|
768
|
+
for (const [path, oid] of await diffTreeOids(ctx.cloneDir, mergeBase, ctx.headSha)) {
|
|
769
|
+
ours.set(path, oid);
|
|
770
|
+
}
|
|
771
|
+
for (const [path, oid] of await diffTreeOids(ctx.cloneDir, mergeBase, ctx.baseSha)) {
|
|
772
|
+
theirs.set(path, oid);
|
|
773
|
+
}
|
|
774
|
+
for (const oid of (await diffTreeOids(ctx.cloneDir, ctx.headSha, mergeBase)).values()) {
|
|
775
|
+
ancestors.add(oid);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
let narrowed = false;
|
|
779
|
+
let wanted = new Set([...ours.values(), ...theirs.values(), ...ancestors]);
|
|
780
|
+
if (wanted.size > config.conflictPrefetchMaxBlobs) {
|
|
781
|
+
// Narrow to the paths BOTH sides touched — with rename detection off those are the only
|
|
782
|
+
// ones that can conflict, so they are the only ones whose content merge-tree must read.
|
|
783
|
+
narrowed = true;
|
|
784
|
+
wanted = new Set(ancestors);
|
|
785
|
+
for (const [path, oid] of ours) {
|
|
786
|
+
if (!theirs.has(path))
|
|
787
|
+
continue;
|
|
788
|
+
wanted.add(oid);
|
|
789
|
+
const t = theirs.get(path);
|
|
790
|
+
if (t)
|
|
791
|
+
wanted.add(t);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const missing = await missingOids(ctx.cloneDir, [...wanted]);
|
|
795
|
+
if (missing.length === 0)
|
|
796
|
+
return narrowed ? 'narrowed' : 'ok';
|
|
797
|
+
if (!ctx.token)
|
|
798
|
+
return 'unavailable';
|
|
799
|
+
const url = `https://x-access-token:${ctx.token}@github.com/${ctx.owner}/${ctx.name}.git`;
|
|
800
|
+
for (let i = 0; i < missing.length; i += PREFETCH_BATCH) {
|
|
801
|
+
const batch = missing.slice(i, i + PREFETCH_BATCH);
|
|
802
|
+
await gitTry([
|
|
803
|
+
'fetch',
|
|
804
|
+
'--no-tags',
|
|
805
|
+
'--no-write-fetch-head',
|
|
806
|
+
'--recurse-submodules=no',
|
|
807
|
+
'--filter=blob:none',
|
|
808
|
+
url,
|
|
809
|
+
...batch,
|
|
810
|
+
], ctx.cloneDir);
|
|
811
|
+
}
|
|
812
|
+
const stillMissing = await missingOids(ctx.cloneDir, missing);
|
|
813
|
+
if (stillMissing.length > 0)
|
|
814
|
+
return 'unavailable';
|
|
815
|
+
return narrowed ? 'narrowed' : 'ok';
|
|
816
|
+
}
|
|
817
|
+
/** path → blob oid on the `to` side, for every path that differs. `--no-renames` keeps this
|
|
818
|
+
* a plain per-path list; rename detection here would cost blob content we have not fetched. */
|
|
819
|
+
async function diffTreeOids(cloneDir, from, to) {
|
|
820
|
+
const out = new Map();
|
|
821
|
+
const res = await gitTry(['diff-tree', '-r', '-z', '--no-renames', from, to], cloneDir);
|
|
822
|
+
if (res.code !== 0)
|
|
823
|
+
return out;
|
|
824
|
+
// `:<srcmode> <dstmode> <srcoid> <dstoid> <status>\0<path>\0`
|
|
825
|
+
const fields = res.stdout.toString('utf8').split('\0');
|
|
826
|
+
for (let i = 0; i < fields.length; i++) {
|
|
827
|
+
const f = fields[i];
|
|
828
|
+
if (f === undefined || !f.startsWith(':'))
|
|
829
|
+
continue;
|
|
830
|
+
const bits = f.slice(1).split(' ');
|
|
831
|
+
const dstOid = bits[3];
|
|
832
|
+
const path = fields[i + 1];
|
|
833
|
+
if (dstOid === undefined || path === undefined)
|
|
834
|
+
continue;
|
|
835
|
+
if (/^0+$/.test(dstOid))
|
|
836
|
+
continue; // deleted on this side — no blob to fetch
|
|
837
|
+
out.set(path, dstOid);
|
|
838
|
+
i++;
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
async function missingOids(cloneDir, oids) {
|
|
843
|
+
if (oids.length === 0)
|
|
844
|
+
return [];
|
|
845
|
+
const stdin = Buffer.from(`${oids.join('\n')}\n`, 'utf8');
|
|
846
|
+
const res = await gitTry(['cat-file', '--batch-check', '--buffer'], cloneDir, stdin);
|
|
847
|
+
const lines = res.stdout.toString('utf8').split('\n');
|
|
848
|
+
const missing = [];
|
|
849
|
+
for (const line of lines) {
|
|
850
|
+
if (!line)
|
|
851
|
+
continue;
|
|
852
|
+
if (line.endsWith(' missing')) {
|
|
853
|
+
const oid = line.slice(0, line.length - ' missing'.length);
|
|
854
|
+
if (oid)
|
|
855
|
+
missing.push(oid);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return missing;
|
|
859
|
+
}
|
|
860
|
+
/* ═════════════════════════════ the public entry point ═════════════════════════════ */
|
|
861
|
+
/**
|
|
862
|
+
* Resolve the PR, make sure the clone has both commits, and build the model.
|
|
863
|
+
*
|
|
864
|
+
* Returns `null` when `prId` is not this account's PR — the caller 404s, so the route never
|
|
865
|
+
* becomes an existence oracle for another tenant's ids.
|
|
866
|
+
*/
|
|
867
|
+
export async function buildConflictModel(args) {
|
|
868
|
+
const rows = await db
|
|
869
|
+
.select({
|
|
870
|
+
prId: pullRequests.id,
|
|
871
|
+
number: pullRequests.number,
|
|
872
|
+
state: pullRequests.state,
|
|
873
|
+
headRefName: pullRequests.headRefName,
|
|
874
|
+
baseRefName: pullRequests.baseRefName,
|
|
875
|
+
owner: repos.owner,
|
|
876
|
+
name: repos.name,
|
|
877
|
+
defaultBranch: repos.defaultBranch,
|
|
878
|
+
defaultBranchName: repos.defaultBranchName,
|
|
879
|
+
})
|
|
880
|
+
.from(pullRequests)
|
|
881
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
882
|
+
.where(and(eq(pullRequests.id, args.prId), eq(repos.accountId, args.accountId)))
|
|
883
|
+
.limit(1)
|
|
884
|
+
.execute();
|
|
885
|
+
const pr = rows[0];
|
|
886
|
+
if (!pr)
|
|
887
|
+
return null;
|
|
888
|
+
if (pr.state !== 'open') {
|
|
889
|
+
return {
|
|
890
|
+
status: 'failed',
|
|
891
|
+
code: 'not_open',
|
|
892
|
+
message: 'This pull request is closed.',
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
const baseRef = pr.baseRefName;
|
|
896
|
+
if (!baseRef) {
|
|
897
|
+
return {
|
|
898
|
+
status: 'failed',
|
|
899
|
+
code: 'objects_unavailable',
|
|
900
|
+
message: 'This pull request’s base branch hasn’t been synced yet.',
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const token = await getAccessToken(args.accountId);
|
|
904
|
+
const reserved = [...new Set([pr.defaultBranchName, pr.defaultBranch, baseRef].filter((b) => typeof b === 'string' && b.length > 0))];
|
|
905
|
+
// ── CAN WE PUSH TO THE PR'S OWN BRANCH? ──────────────────────────────────────────────────
|
|
906
|
+
// `isFork` / `maintainerCanModify` are NOT synced columns, so the only way to know is to ask,
|
|
907
|
+
// and the answer has to be known BEFORE the reader fills in the landing form — otherwise the
|
|
908
|
+
// screen offers "Push to <headRef>" for a head we cannot write to and the refusal arrives
|
|
909
|
+
// after the button. One REST call, on a path that is about to clone and fetch two branches
|
|
910
|
+
// and is click-gated besides.
|
|
911
|
+
//
|
|
912
|
+
// ⚠ NON-FATAL, AND TRUE IS THE FALLBACK. A head-info failure must not cost the whole session:
|
|
913
|
+
// we say "pushable", the landing step offers the option, and `landConflictResolution` —
|
|
914
|
+
// which re-reads exactly these two fields immediately before the push — refuses with
|
|
915
|
+
// `PushDenied`. That is the behaviour that shipped before this field existed.
|
|
916
|
+
let prBranchPushable = true;
|
|
917
|
+
let prBranchUnavailableReason = null;
|
|
918
|
+
try {
|
|
919
|
+
const head = await fetchPrHeadInfo(token, pr.owner, pr.name, pr.number);
|
|
920
|
+
if (head.isFork && !head.maintainerCanModify) {
|
|
921
|
+
prBranchPushable = false;
|
|
922
|
+
prBranchUnavailableReason =
|
|
923
|
+
'This pull request comes from a fork that does not allow maintainer edits, so its branch can’t be pushed to.';
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
/* see above: the land route is the authorisation, not this read */
|
|
928
|
+
}
|
|
929
|
+
args.onPhase?.('cloning');
|
|
930
|
+
try {
|
|
931
|
+
// ⚠ ONE lock across the whole git phase — clone, fetch, merge-tree, blob reads. Taking it
|
|
932
|
+
// twice leaves a window in which another job's cleanup can reach the refs we just
|
|
933
|
+
// fetched, and the shas we pinned would then resolve to nothing.
|
|
934
|
+
return await withRepoLock(`${pr.owner}/${pr.name}`, async () => {
|
|
935
|
+
const cloneDir = await ensureClone(pr.owner, pr.name, token);
|
|
936
|
+
args.onPhase?.('fetching');
|
|
937
|
+
// ⚠ SESSION-NAMESPACED, NEVER FETCH_HEAD. The clone cache is keyed `owner__name` and
|
|
938
|
+
// shared across accounts and jobs; FETCH_HEAD is ONE FILE per repository, so a
|
|
939
|
+
// concurrent job's fetch would hand us its commit.
|
|
940
|
+
const ns = `refs/pierre/conflict/${args.sessionId}`;
|
|
941
|
+
const headSha = await fetchRefIntoClone({
|
|
942
|
+
cloneDir,
|
|
943
|
+
owner: pr.owner,
|
|
944
|
+
name: pr.name,
|
|
945
|
+
token,
|
|
946
|
+
remoteRef: `refs/pull/${pr.number}/head`,
|
|
947
|
+
destRef: `${ns}/head`,
|
|
948
|
+
});
|
|
949
|
+
const baseSha = await fetchRefIntoClone({
|
|
950
|
+
cloneDir,
|
|
951
|
+
owner: pr.owner,
|
|
952
|
+
name: pr.name,
|
|
953
|
+
token,
|
|
954
|
+
remoteRef: `refs/heads/${baseRef}`,
|
|
955
|
+
destRef: `${ns}/base`,
|
|
956
|
+
});
|
|
957
|
+
if ((args.expectHeadSha && args.expectHeadSha !== headSha) ||
|
|
958
|
+
(args.expectBaseSha && args.expectBaseSha !== baseSha)) {
|
|
959
|
+
return { status: 'moved', headSha, baseSha };
|
|
960
|
+
}
|
|
961
|
+
return buildConflictModelInClone({
|
|
962
|
+
accountId: args.accountId,
|
|
963
|
+
prId: args.prId,
|
|
964
|
+
cloneDir,
|
|
965
|
+
owner: pr.owner,
|
|
966
|
+
name: pr.name,
|
|
967
|
+
number: pr.number,
|
|
968
|
+
headSha,
|
|
969
|
+
baseSha,
|
|
970
|
+
headRef: pr.headRefName ?? '',
|
|
971
|
+
baseRef,
|
|
972
|
+
reservedBranchNames: reserved,
|
|
973
|
+
prBranchPushable,
|
|
974
|
+
prBranchUnavailableReason,
|
|
975
|
+
token,
|
|
976
|
+
}, { onPhase: args.onPhase, deadlineMs: args.deadlineMs });
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
finally {
|
|
980
|
+
// The clone cache is a cache: sweep it off the hot path, never inside the lock.
|
|
981
|
+
setImmediate(() => {
|
|
982
|
+
void cleanupCloneCache();
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/* ═════════════════════════════ the wire projection ═════════════════════════════ */
|
|
987
|
+
/**
|
|
988
|
+
* The manifest rows. `regionCount` counts EVERY region; `conflictCount` counts the contested
|
|
989
|
+
* ones; `decidableCount` counts every region that takes a decision — the population the commit
|
|
990
|
+
* gate and the header both work in — and `wandResolvableCount` is the subset of the contested
|
|
991
|
+
* ones the wand can settle without picking a side.
|
|
992
|
+
*/
|
|
993
|
+
export function conflictFileEntries(model) {
|
|
994
|
+
return model.files.map((f) => ({
|
|
995
|
+
index: f.index,
|
|
996
|
+
path: f.path,
|
|
997
|
+
relatedPaths: f.relatedPaths,
|
|
998
|
+
unsupported: f.unsupported,
|
|
999
|
+
unsupportedLabel: f.unsupportedLabel,
|
|
1000
|
+
regionCount: f.regions.length,
|
|
1001
|
+
conflictCount: f.regions.filter((r) => r.kind === 'conflict').length,
|
|
1002
|
+
decidableCount: f.regions.filter((r) => r.kind !== 'unchanged').length,
|
|
1003
|
+
wandResolvableCount: f.regions.filter((r) => r.kind === 'conflict' && r.wand !== null).length,
|
|
1004
|
+
maxSideBytes: f.maxSideBytes,
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
1007
|
+
/** Which buttons a region offers. ⚠ `'suggestion'` never appears — it is a session fact, not
|
|
1008
|
+
* a model fact, and a model that advertised it would be advertising a Pro capability the
|
|
1009
|
+
* account may not have. */
|
|
1010
|
+
export function allowedDecisions(region) {
|
|
1011
|
+
switch (region.kind) {
|
|
1012
|
+
case 'unchanged':
|
|
1013
|
+
return ['base'];
|
|
1014
|
+
case 'ours_only':
|
|
1015
|
+
return ['ours', 'base'];
|
|
1016
|
+
case 'theirs_only':
|
|
1017
|
+
return ['theirs', 'base'];
|
|
1018
|
+
case 'both_same':
|
|
1019
|
+
return ['ours', 'base'];
|
|
1020
|
+
case 'conflict': {
|
|
1021
|
+
const out = [
|
|
1022
|
+
'ours',
|
|
1023
|
+
'theirs',
|
|
1024
|
+
'both_ours_first',
|
|
1025
|
+
'both_theirs_first',
|
|
1026
|
+
'base',
|
|
1027
|
+
];
|
|
1028
|
+
if (region.wand?.reason === 'disjoint_words')
|
|
1029
|
+
out.push('disjoint_merge');
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** What the centre pane starts on. With auto-apply (the default) a one-sided region defaults
|
|
1035
|
+
* to its own side and `both_same` to `ours`; a genuine conflict always starts at `base`,
|
|
1036
|
+
* undecided, because there is nothing to apply. */
|
|
1037
|
+
export function defaultDecisionFor(region, autoApply) {
|
|
1038
|
+
if (!autoApply || region.kind === 'conflict' || region.kind === 'unchanged')
|
|
1039
|
+
return 'base';
|
|
1040
|
+
return region.wand?.decision ?? 'base';
|
|
1041
|
+
}
|
|
1042
|
+
export function conflictFileContent(model, index, autoApply) {
|
|
1043
|
+
const file = model.files.find((f) => f.index === index);
|
|
1044
|
+
if (!file)
|
|
1045
|
+
return null;
|
|
1046
|
+
// `n` is that SIDE's own 1-based file line number, so the three panes can be read against
|
|
1047
|
+
// the real file. Walked once, in order — every side is covered end to end.
|
|
1048
|
+
let baseLine = 1;
|
|
1049
|
+
let oursLine = 1;
|
|
1050
|
+
let theirsLine = 1;
|
|
1051
|
+
const regions = file.regions.map((r) => {
|
|
1052
|
+
const region = {
|
|
1053
|
+
id: r.id,
|
|
1054
|
+
kind: r.kind,
|
|
1055
|
+
fingerprint: r.fingerprint,
|
|
1056
|
+
base: numbered(r.base, baseLine),
|
|
1057
|
+
ours: numbered(r.ours, oursLine),
|
|
1058
|
+
theirs: numbered(r.theirs, theirsLine),
|
|
1059
|
+
defaultDecision: defaultDecisionFor(r, autoApply),
|
|
1060
|
+
allowed: allowedDecisions(r),
|
|
1061
|
+
wand: r.wand,
|
|
1062
|
+
// ⚠ THE BYTES, NOT A PROMISE THE CLIENT CAN RECOMPUTE. `land.ts` splices exactly this
|
|
1063
|
+
// array for a `'disjoint_merge'` decision, so the centre pane must render exactly this
|
|
1064
|
+
// array — see the ⚠ on `ConflictRegion.mergedLines` for the divergence that made the
|
|
1065
|
+
// SPA's own word merge print the ancestor for a region the commit landed merged.
|
|
1066
|
+
mergedLines: r.mergedLines,
|
|
1067
|
+
};
|
|
1068
|
+
// An `unchanged` region carries empty `ours`/`theirs` but still occupies those lines,
|
|
1069
|
+
// and its length there is the base's by construction.
|
|
1070
|
+
const sideLen = r.kind === 'unchanged' ? r.base.length : 0;
|
|
1071
|
+
baseLine += r.base.length;
|
|
1072
|
+
oursLine += r.ours.length + sideLen;
|
|
1073
|
+
theirsLine += r.theirs.length + sideLen;
|
|
1074
|
+
return region;
|
|
1075
|
+
});
|
|
1076
|
+
return { index: file.index, path: file.path, terminators: file.terminators, regions };
|
|
1077
|
+
}
|
|
1078
|
+
function numbered(lines, from) {
|
|
1079
|
+
return lines.map((text, i) => ({ n: from + i, text }));
|
|
1080
|
+
}
|
|
1081
|
+
//# sourceMappingURL=model.js.map
|