carson 3.27.0 → 3.28.0

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.
@@ -3,6 +3,8 @@
3
3
  module Carson
4
4
  class Runtime
5
5
  module Deliver
6
+ DELIVER_MERGE_ATTEMPT_CAP = 3
7
+
6
8
  # Entry point for `carson deliver`.
7
9
  # Pushes the current branch, ensures a PR exists, records delivery state,
8
10
  # waits for merge readiness, and integrates when the path is clear.
@@ -11,7 +13,13 @@ module Carson
11
13
  branch_name = current_branch
12
14
  main_branch = config.main_branch
13
15
  remote_name = config.git_remote
14
- result = { command: "deliver", branch: branch_name }
16
+ result = {
17
+ command: "deliver",
18
+ branch: branch_name,
19
+ watch_window_seconds: config.govern_check_wait.to_i,
20
+ waited_seconds: 0,
21
+ merge_attempted: false
22
+ }
15
23
 
16
24
  if branch_name == main_branch
17
25
  result[ :error ] = "cannot deliver from #{main_branch}"
@@ -52,6 +60,20 @@ module Carson
52
60
  return deliver_finish( result: result, exit_code: commit_exit, json_output: json_output ) unless commit_exit == EXIT_OK
53
61
  end
54
62
 
63
+ freshness = assess_branch_freshness(
64
+ head_ref: current_head,
65
+ remote: remote_name,
66
+ main: main_branch
67
+ )
68
+ result[ :freshness ] = freshness_payload( freshness: freshness )
69
+ unless freshness.fetch( :ready )
70
+ result[ :summary ] = freshness.fetch( :summary )
71
+ result[ :error ] = freshness.fetch( :summary )
72
+ result[ :recovery ] = freshness_recovery( freshness: freshness )
73
+ result[ :main_branch ] = main_branch
74
+ return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
75
+ end
76
+
55
77
  push_exit = push_branch!( branch: branch_name, remote: remote_name, result: result )
56
78
  return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
57
79
 
@@ -75,19 +97,17 @@ module Carson
75
97
  summary: "delivery accepted",
76
98
  cause: nil
77
99
  )
78
- delivery = assess_delivery!( delivery: delivery, branch_name: branch.name )
79
- delivery = wait_for_delivery_readiness!( delivery: delivery, branch_name: branch.name )
80
- delivery = integrate_delivery_now!(
100
+ delivery = settle_delivery!(
81
101
  delivery: delivery,
82
102
  branch_name: branch.name,
83
103
  remote: remote_name,
84
104
  main: main_branch,
85
105
  result: result
86
- ) if delivery.ready?
106
+ )
87
107
 
88
108
  result[ :pr_number ] = pr_number
89
109
  result[ :pr_url ] = pr_url
90
- result[ :ci ] = delivery.integrated? ? "pass" : check_pr_ci( number: pr_number ).to_s
110
+ result[ :ci ] = "pass" if delivery.integrated?
91
111
  result[ :delivery ] = delivery_payload( delivery: delivery )
92
112
  result[ :main_branch ] = main_branch
93
113
  result[ :summary ] = delivery.summary
@@ -201,123 +221,672 @@ module Carson
201
221
 
202
222
  # Assesses delivery readiness and records Carson's current branch state.
203
223
  def assess_delivery!( delivery:, branch_name: )
204
- review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
205
- ci = check_pr_ci( number: delivery.pull_request_number )
206
- pr_state = pull_request_state( number: delivery.pull_request_number )
207
- status, cause, summary = delivery_assessment( ci: ci, review: review, pr_state: pr_state )
208
-
209
- ledger.update_delivery(
210
- delivery: delivery,
211
- status: status,
212
- cause: cause,
213
- summary: summary,
214
- pr_number: delivery.pull_request_number,
215
- pr_url: delivery.pull_request_url,
216
- worktree_path: delivery.worktree_path
224
+ freshness = assess_branch_freshness(
225
+ head_ref: delivery.head || branch_name,
226
+ remote: config.git_remote,
227
+ main: config.main_branch
217
228
  )
218
- end
229
+ unless freshness.fetch( :ready )
230
+ return ledger.update_delivery(
231
+ delivery: delivery,
232
+ status: "gated",
233
+ cause: "freshness",
234
+ summary: freshness.fetch( :summary ),
235
+ pr_number: delivery.pull_request_number,
236
+ pr_url: delivery.pull_request_url,
237
+ worktree_path: delivery.worktree_path
238
+ )
239
+ end
219
240
 
220
- def wait_for_delivery_readiness!( delivery:, branch_name: )
221
- return delivery unless delivery_gate_waitable?( delivery: delivery )
222
- return delivery unless config.govern_check_wait.positive?
241
+ review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
242
+ ci = check_pr_ci( number: delivery.pull_request_number )
243
+ pr_state = pull_request_state( number: delivery.pull_request_number )
244
+ status, cause, summary = delivery_assessment( ci: ci, review: review, pr_state: pr_state )
223
245
 
224
- deadline = Process.clock_gettime( Process::CLOCK_MONOTONIC ) + config.govern_check_wait
225
- interval = deliver_ci_poll_seconds
226
- puts_verbose "waiting up to #{config.govern_check_wait}s for delivery gates to settle"
246
+ ledger.update_delivery(
247
+ delivery: delivery,
248
+ status: status,
249
+ cause: cause,
250
+ summary: summary,
251
+ pr_number: delivery.pull_request_number,
252
+ pr_url: delivery.pull_request_url,
253
+ worktree_path: delivery.worktree_path,
254
+ **pull_request_observation_attributes( pr_state: pr_state )
255
+ )
256
+ end
257
+
258
+ def settle_delivery!( delivery:, branch_name:, remote:, main:, result: )
259
+ started_at = deliver_monotonic_now
260
+ watch_window_seconds = config.govern_check_wait.to_i
261
+ merge_attempts = 0
262
+ successful_assessments = 0
263
+ last_evaluation = nil
264
+
265
+ result[ :watch_window_seconds ] = watch_window_seconds
266
+ result[ :waited_seconds ] = 0
267
+ result[ :merge_attempted ] = false
227
268
 
228
269
  loop do
229
- remaining = deadline - Process.clock_gettime( Process::CLOCK_MONOTONIC )
270
+ evaluation = evaluate_delivery_for_settle(
271
+ branch_name: branch_name,
272
+ head_ref: delivery.head,
273
+ pr_number: delivery.pull_request_number,
274
+ pr_url: delivery.pull_request_url,
275
+ main: main
276
+ )
277
+ last_evaluation = evaluation
278
+ successful_assessments += 1 if evaluation[ :assessment_success ]
279
+ result[ :ci ] = evaluation[ :ci ].to_s
280
+ result[ :freshness ] = freshness_payload( freshness: evaluation.fetch( :freshness ) ) if evaluation[ :freshness ]
281
+
282
+ delivery = update_delivery_for_settle_evaluation( delivery: delivery, evaluation: evaluation )
283
+ result[ :summary ] = delivery.summary
284
+
285
+ case evaluation[ :phase ]
286
+ when :integrated
287
+ delivery = mark_delivery_integrated!(
288
+ delivery: delivery,
289
+ remote: remote,
290
+ main: main,
291
+ result: result
292
+ )
293
+ result[ :outcome ] = "integrated"
294
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
295
+ return delivery
296
+ when :blocked
297
+ result[ :outcome ] = "blocked"
298
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
299
+ result[ :recovery ] = freshness_recovery( freshness: evaluation.fetch( :freshness ) ) if evaluation[ :cause ] == "freshness" && evaluation[ :freshness ]
300
+ apply_handoff!(
301
+ result: result,
302
+ reason: evaluation.fetch( :reason ),
303
+ summary: delivery.summary,
304
+ outcome: "blocked"
305
+ )
306
+ return delivery
307
+ when :ready
308
+ merge_outcome = attempt_delivery_merge!(
309
+ delivery: delivery,
310
+ remote: remote,
311
+ main: main,
312
+ result: result
313
+ )
314
+ if merge_outcome.fetch( :attempted )
315
+ merge_attempts += 1
316
+ result[ :merge_attempted ] = true
317
+ end
318
+ delivery = merge_outcome.fetch( :delivery )
319
+ result[ :summary ] = delivery.summary
320
+
321
+ case merge_outcome.fetch( :phase )
322
+ when :integrated
323
+ result[ :outcome ] = "integrated"
324
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
325
+ return delivery
326
+ when :blocked
327
+ result[ :outcome ] = "blocked"
328
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
329
+ apply_handoff!(
330
+ result: result,
331
+ reason: merge_outcome.fetch( :reason ),
332
+ summary: delivery.summary,
333
+ outcome: "blocked"
334
+ )
335
+ return delivery
336
+ end
337
+ when :waiting
338
+ if evaluation.fetch( :reason ) == "mergeability_pending" &&
339
+ successful_assessments >= 2 &&
340
+ merge_attempts < deliver_merge_attempt_cap
341
+ merge_outcome = attempt_delivery_merge!(
342
+ delivery: delivery,
343
+ remote: remote,
344
+ main: main,
345
+ result: result
346
+ )
347
+ if merge_outcome.fetch( :attempted )
348
+ merge_attempts += 1
349
+ result[ :merge_attempted ] = true
350
+ end
351
+ delivery = merge_outcome.fetch( :delivery )
352
+ result[ :summary ] = delivery.summary
353
+
354
+ case merge_outcome.fetch( :phase )
355
+ when :integrated
356
+ result[ :outcome ] = "integrated"
357
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
358
+ return delivery
359
+ when :blocked
360
+ result[ :outcome ] = "blocked"
361
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
362
+ apply_handoff!(
363
+ result: result,
364
+ reason: merge_outcome.fetch( :reason ),
365
+ summary: delivery.summary,
366
+ outcome: "blocked"
367
+ )
368
+ return delivery
369
+ end
370
+ end
371
+ end
372
+
373
+ remaining = remaining_settle_seconds( started_at: started_at, watch_window_seconds: watch_window_seconds )
230
374
  break if remaining <= 0
231
375
 
232
- sleep [ interval, remaining ].min
233
- delivery = assess_delivery!( delivery: delivery, branch_name: branch_name )
234
- break unless delivery_gate_waitable?( delivery: delivery )
376
+ wait_seconds = [ deliver_ci_poll_seconds, remaining ].min
377
+ deliver_sleep( wait_seconds )
235
378
  end
236
379
 
380
+ result[ :outcome ] = "deferred"
381
+ result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
382
+ apply_handoff!(
383
+ result: result,
384
+ reason: deferred_handoff_reason( evaluation: last_evaluation ),
385
+ summary: delivery.summary,
386
+ outcome: "deferred"
387
+ )
237
388
  delivery
238
389
  end
239
390
 
240
- def delivery_gate_waitable?( delivery: )
241
- return false unless delivery.status == "gated"
242
- return true if delivery.cause == "ci"
391
+ def evaluate_delivery_for_settle( branch_name:, head_ref:, pr_number:, pr_url:, main: )
392
+ freshness = assess_branch_freshness(
393
+ branch_name: branch_name,
394
+ head_ref: head_ref,
395
+ remote: config.git_remote,
396
+ main: main
397
+ )
398
+ unless freshness.fetch( :ready )
399
+ return {
400
+ phase: :blocked,
401
+ reason: freshness.fetch( :reason ),
402
+ cause: "freshness",
403
+ summary: freshness.fetch( :summary ),
404
+ assessment_success: freshness.fetch( :status ) != :unknown,
405
+ ci: :none,
406
+ freshness: freshness
407
+ }
408
+ end
243
409
 
244
- delivery.cause == "review" && delivery.summary == "waiting for review"
245
- end
410
+ review = check_pr_review( number: pr_number, branch: branch_name, pr_url: pr_url )
411
+ ci = settle_check_pr_ci( number: pr_number )
412
+ pr_state = pull_request_state( number: pr_number )
413
+
414
+ return {
415
+ phase: :waiting,
416
+ reason: "assessment_unavailable",
417
+ cause: "assessment",
418
+ summary: "waiting for GitHub assessment",
419
+ assessment_success: false,
420
+ ci: ci
421
+ }.merge( freshness: freshness, pr_state: pr_state ) if ci == :error || review.fetch( :status, :pass ) == :error || !pr_state.is_a?( Hash )
422
+
423
+ return {
424
+ phase: :integrated,
425
+ reason: "already_merged",
426
+ cause: nil,
427
+ summary: "integrated into #{main}",
428
+ assessment_success: true,
429
+ ci: ci
430
+ }.merge( freshness: freshness, pr_state: pr_state ) if pr_state[ "state" ] == "MERGED"
431
+ return {
432
+ phase: :blocked,
433
+ reason: "pull_request_closed",
434
+ cause: "policy",
435
+ summary: "pull request closed without integration",
436
+ assessment_success: true,
437
+ ci: ci
438
+ }.merge( freshness: freshness, pr_state: pr_state ) if pr_state[ "state" ] == "CLOSED"
439
+
440
+ return {
441
+ phase: :blocked,
442
+ reason: "draft_pr",
443
+ cause: "policy",
444
+ summary: "pull request is still a draft",
445
+ assessment_success: true,
446
+ ci: ci
447
+ }.merge( freshness: freshness, pr_state: pr_state ) if pr_state[ "isDraft" ]
448
+
449
+ return {
450
+ phase: :waiting,
451
+ reason: "ci_pending",
452
+ cause: "ci",
453
+ summary: "waiting for CI checks",
454
+ assessment_success: true,
455
+ ci: ci
456
+ }.merge( freshness: freshness, pr_state: pr_state ) if ci == :pending
457
+
458
+ return {
459
+ phase: :blocked,
460
+ reason: "ci_failed",
461
+ cause: "ci",
462
+ summary: "CI checks are failing",
463
+ assessment_success: true,
464
+ ci: ci
465
+ }.merge( freshness: freshness, pr_state: pr_state ) if ci == :fail
466
+
467
+ return {
468
+ phase: :blocked,
469
+ reason: "review_changes_requested",
470
+ cause: "review",
471
+ summary: "review changes requested",
472
+ assessment_success: true,
473
+ ci: ci
474
+ }.merge( freshness: freshness, pr_state: pr_state ) if review.fetch( :review, :none ) == :changes_requested
475
+
476
+ return {
477
+ phase: :waiting,
478
+ reason: "review_pending",
479
+ cause: "review",
480
+ summary: "waiting for review",
481
+ assessment_success: true,
482
+ ci: ci
483
+ }.merge( freshness: freshness, pr_state: pr_state ) if review.fetch( :review, :none ) == :review_required
484
+
485
+ return {
486
+ phase: :blocked,
487
+ reason: "review_blocked",
488
+ cause: "review",
489
+ summary: review.fetch( :detail ).to_s,
490
+ assessment_success: true,
491
+ ci: ci
492
+ }.merge( freshness: freshness, pr_state: pr_state ) if review.fetch( :status, :pass ) == :fail
493
+
494
+ mergeability = settle_mergeability_assessment( pr_state: pr_state, main: main )
495
+ mergeability.merge( assessment_success: true, ci: ci, freshness: freshness, pr_state: pr_state )
496
+ end
246
497
 
247
- def deliver_ci_poll_seconds
248
- # Reuse the review poll interval for CI/review delivery polling.
249
- # The config key predates the synchronous deliver loop.
250
- seconds = config.review_poll_seconds.to_i
251
- seconds.positive? ? seconds : 5
498
+ def settle_mergeability_assessment( pr_state:, main: )
499
+ mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
500
+ merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
501
+
502
+ return {
503
+ phase: :blocked,
504
+ reason: "merge_conflict",
505
+ cause: "merge",
506
+ summary: "pull request has merge conflicts"
507
+ } if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
508
+
509
+ return {
510
+ phase: :blocked,
511
+ reason: "repository_policy_block",
512
+ cause: "merge",
513
+ summary: "merge is blocked by repository policy"
514
+ } if merge_state == "BLOCKED"
515
+
516
+ return {
517
+ phase: :blocked,
518
+ reason: "freshness_behind",
519
+ cause: "freshness",
520
+ summary: "branch is behind #{config.git_remote}/#{main}"
521
+ } if merge_state == "BEHIND"
522
+
523
+ return {
524
+ phase: :ready,
525
+ reason: "ready",
526
+ cause: nil,
527
+ summary: "ready to integrate into #{main}"
528
+ } if merge_state == "CLEAN" || mergeable == "MERGEABLE"
529
+
530
+ {
531
+ phase: :waiting,
532
+ reason: "mergeability_pending",
533
+ cause: "assessment",
534
+ summary: "waiting for GitHub mergeability"
535
+ }
252
536
  end
253
537
 
254
- def integrate_delivery_now!( delivery:, branch_name:, remote:, main:, result: )
255
- pr_state = pull_request_state( number: delivery.pull_request_number )
256
- if pr_state && pr_state[ "state" ] == "MERGED"
257
- integrated = ledger.update_delivery(
538
+ def update_delivery_for_settle_evaluation( delivery:, evaluation: )
539
+ observation = pull_request_observation_attributes( pr_state: evaluation[ :pr_state ] )
540
+
541
+ case evaluation.fetch( :phase )
542
+ when :integrated
543
+ ledger.update_delivery(
258
544
  delivery: delivery,
259
- status: "integrated",
260
- integrated_at: Time.now.utc.iso8601,
261
- summary: "integrated into #{main}"
545
+ **observation
262
546
  )
263
- sync_after_merge!( remote: remote, main: main, result: result )
264
- return integrated
265
- end
266
-
267
- if pr_state && pr_state[ "state" ] == "CLOSED"
268
- return ledger.update_delivery(
547
+ when :blocked
548
+ if evaluation.fetch( :reason ) == "pull_request_closed"
549
+ ledger.update_delivery(
550
+ delivery: delivery,
551
+ status: "failed",
552
+ cause: evaluation.fetch( :cause ),
553
+ summary: evaluation.fetch( :summary ),
554
+ **observation
555
+ )
556
+ else
557
+ ledger.update_delivery(
558
+ delivery: delivery,
559
+ status: "gated",
560
+ cause: evaluation.fetch( :cause ),
561
+ summary: evaluation.fetch( :summary ),
562
+ **observation
563
+ )
564
+ end
565
+ when :ready
566
+ ledger.update_delivery(
269
567
  delivery: delivery,
270
- status: "failed",
271
- cause: "policy",
272
- summary: "pull request closed without integration"
568
+ status: "queued",
569
+ cause: nil,
570
+ summary: evaluation.fetch( :summary ),
571
+ **observation
572
+ )
573
+ else
574
+ ledger.update_delivery(
575
+ delivery: delivery,
576
+ status: "gated",
577
+ cause: evaluation.fetch( :cause ),
578
+ summary: evaluation.fetch( :summary ),
579
+ **observation
273
580
  )
274
581
  end
582
+ end
275
583
 
276
- prepared = ledger.update_delivery(
277
- delivery: delivery,
278
- status: "integrating",
279
- summary: "integrating into #{main}"
584
+ def attempt_delivery_merge!( delivery:, remote:, main:, result: )
585
+ freshness = assess_branch_freshness(
586
+ head_ref: delivery.head || delivery.branch,
587
+ remote: remote,
588
+ main: main
280
589
  )
590
+ result[ :freshness ] = freshness_payload( freshness: freshness )
591
+ unless freshness.fetch( :ready )
592
+ result[ :recovery ] = freshness_recovery( freshness: freshness )
593
+ return {
594
+ phase: :blocked,
595
+ attempted: false,
596
+ reason: freshness.fetch( :reason ),
597
+ delivery: ledger.update_delivery(
598
+ delivery: delivery,
599
+ status: "gated",
600
+ cause: "freshness",
601
+ summary: freshness.fetch( :summary )
602
+ )
603
+ }
604
+ end
605
+
606
+ pr_state = pull_request_state( number: delivery.pull_request_number )
607
+ observation = pull_request_observation_attributes( pr_state: pr_state )
608
+ if pr_state && pr_state[ "state" ] == "MERGED"
609
+ return {
610
+ phase: :integrated,
611
+ attempted: false,
612
+ delivery: mark_delivery_integrated!(
613
+ delivery: delivery,
614
+ remote: remote,
615
+ main: main,
616
+ result: result,
617
+ pr_state: pr_state
618
+ )
619
+ }
620
+ end
621
+
622
+ if pr_state && pr_state[ "state" ] == "CLOSED"
623
+ return {
624
+ phase: :blocked,
625
+ attempted: false,
626
+ reason: "pull_request_closed",
627
+ delivery: ledger.update_delivery(
628
+ delivery: delivery,
629
+ status: "failed",
630
+ cause: "policy",
631
+ summary: "pull request closed without integration",
632
+ **observation
633
+ )
634
+ }
635
+ end
636
+
637
+ prepared = ledger.update_delivery(
638
+ delivery: delivery,
639
+ status: "integrating",
640
+ summary: "integrating into #{main}",
641
+ **observation
642
+ )
281
643
  merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
282
644
  if merge_exit == EXIT_OK
283
- integrated = ledger.update_delivery(
284
- delivery: prepared,
285
- status: "integrated",
286
- integrated_at: Time.now.utc.iso8601,
287
- summary: "integrated into #{main}"
288
- )
289
- sync_after_merge!( remote: remote, main: main, result: result )
290
- return integrated
645
+ return {
646
+ phase: :integrated,
647
+ attempted: true,
648
+ delivery: mark_delivery_integrated!(
649
+ delivery: prepared,
650
+ remote: remote,
651
+ main: main,
652
+ result: result,
653
+ pr_state: pr_state
654
+ )
655
+ }
291
656
  end
292
657
 
293
658
  merge_error = result.delete( :error )
294
659
  merge_recovery = result.delete( :recovery )
295
- result[ :merge ] = {
296
- status: "blocked",
297
- summary: merge_error || "merge failed",
298
- recovery: merge_recovery,
299
- method: result[ :merge_method ]
660
+ merge_assessment = classify_merge_failure( error_text: merge_error )
661
+
662
+ if merge_assessment.fetch( :phase ) == :blocked
663
+ result[ :merge ] = {
664
+ status: "blocked",
665
+ summary: merge_assessment.fetch( :summary ),
666
+ recovery: merge_recovery,
667
+ method: result[ :merge_method ]
668
+ }
669
+ end
670
+
671
+ {
672
+ phase: merge_assessment.fetch( :phase ),
673
+ attempted: true,
674
+ reason: merge_assessment.fetch( :reason ),
675
+ delivery: ledger.update_delivery(
676
+ delivery: prepared,
677
+ status: "gated",
678
+ cause: merge_assessment.fetch( :cause ),
679
+ summary: merge_assessment.fetch( :summary )
680
+ )
300
681
  }
301
- ledger.update_delivery(
302
- delivery: prepared,
303
- status: "gated",
682
+ end
683
+
684
+ def classify_merge_failure( error_text: )
685
+ text = error_text.to_s.strip
686
+ downcase = text.downcase
687
+
688
+ return {
689
+ phase: :blocked,
690
+ reason: "merge_conflict",
691
+ cause: "merge",
692
+ summary: "pull request has merge conflicts"
693
+ } if downcase.include?( "conflict" )
694
+
695
+ return {
696
+ phase: :blocked,
697
+ reason: "draft_pr",
304
698
  cause: "policy",
305
- summary: result.dig( :merge, :summary )
699
+ summary: "pull request is still a draft"
700
+ } if downcase.include?( "draft" )
701
+
702
+ return {
703
+ phase: :blocked,
704
+ reason: "review_changes_requested",
705
+ cause: "review",
706
+ summary: "review changes requested"
707
+ } if downcase.include?( "changes requested" )
708
+
709
+ return {
710
+ phase: :blocked,
711
+ reason: "repository_policy_block",
712
+ cause: "merge",
713
+ summary: "merge is blocked by repository policy"
714
+ } if downcase.include?( "required status check" ) ||
715
+ downcase.include?( "required checks" ) ||
716
+ downcase.include?( "protected branch" ) ||
717
+ downcase.include?( "blocked by repository policy" ) ||
718
+ downcase.include?( "review required" )
719
+
720
+ {
721
+ phase: :waiting,
722
+ reason: "mergeability_pending",
723
+ cause: "assessment",
724
+ summary: "waiting for GitHub mergeability"
725
+ }
726
+ end
727
+
728
+ def mark_delivery_integrated!( delivery:, remote:, main:, result:, pr_state: nil )
729
+ integrated_at = Time.now.utc.iso8601
730
+ integrated = ledger.update_delivery(
731
+ delivery: delivery,
732
+ status: "integrated",
733
+ integrated_at: integrated_at,
734
+ summary: "integrated into #{main}",
735
+ pull_request_state: "MERGED",
736
+ pull_request_draft: false,
737
+ pull_request_merged_at: pr_state&.fetch( "mergedAt", nil ) || integrated_at
738
+ )
739
+ sync_after_merge!( remote: remote, main: main, result: result )
740
+ proof = if result[ :synced ] == false
741
+ merge_proof_unavailable(
742
+ main_ref: main,
743
+ summary: "proof unavailable — local #{main} sync failed."
744
+ )
745
+ else
746
+ merge_proof_for_branch( branch: integrated.branch, main_ref: main )
747
+ end
748
+ result[ :merge_proof ] = merge_proof_payload( proof: proof )
749
+ ledger.update_delivery(
750
+ delivery: integrated,
751
+ merge_proof: proof
752
+ )
753
+ end
754
+
755
+ def deferred_handoff_reason( evaluation: )
756
+ return "assessment_unavailable" if evaluation.nil?
757
+
758
+ evaluation.fetch( :reason )
759
+ end
760
+
761
+ def apply_handoff!( result:, reason:, summary:, outcome: )
762
+ next_steps = deliver_handoff_next_steps
763
+ result[ :handoff ] = {
764
+ reason: reason,
765
+ expectation: handoff_expectation( reason: reason, outcome: outcome ),
766
+ next_steps: next_steps
767
+ }
768
+ result[ :next_step ] = next_steps.first
769
+ result[ :summary ] = summary
770
+ end
771
+
772
+ def handoff_expectation( reason:, outcome: )
773
+ return "the PR stays open until GitHub settles and Carson is run again" if outcome == "deferred" && reason == "mergeability_pending"
774
+ return "the PR stays open until GitHub can be assessed successfully and Carson is run again" if outcome == "deferred" && reason == "assessment_unavailable"
775
+ return "the PR stays open while required checks finish and Carson is run again" if outcome == "deferred" && reason == "ci_pending"
776
+ return "the PR stays open until review is approved and Carson is run again" if outcome == "deferred" && reason == "review_pending"
777
+ return "the PR stays open until the blocker is resolved" if outcome == "blocked"
778
+
779
+ "the PR stays open until Carson is run again"
780
+ end
781
+
782
+ def deliver_handoff_next_steps
783
+ [ "carson status", "carson deliver", "carson govern --loop 300" ]
784
+ end
785
+
786
+ def deliver_ci_poll_seconds
787
+ # Reuse the review poll interval for delivery reassessment polling.
788
+ seconds = config.review_poll_seconds.to_i
789
+ seconds.positive? ? seconds : 5
790
+ end
791
+
792
+ def assess_branch_freshness( branch_name: nil, head_ref: nil, remote:, main: )
793
+ subject_ref = head_ref || branch_name
794
+ remote_ref = "#{remote}/#{main}"
795
+ _fetch_stdout, fetch_stderr, fetch_success, = git_run( "fetch", remote, main )
796
+ unless fetch_success
797
+ return {
798
+ ready: false,
799
+ status: :unknown,
800
+ reason: "freshness_unknown",
801
+ summary: "could not verify freshness against #{remote_ref}",
802
+ remote_ref: remote_ref,
803
+ detail: fetch_stderr.to_s.strip
804
+ }
805
+ end
806
+
807
+ _merge_base_stdout, merge_base_stderr, ancestor_success, ancestor_exit = git_run(
808
+ "merge-base", "--is-ancestor", remote_ref, subject_ref
306
809
  )
810
+ return {
811
+ ready: true,
812
+ status: :fresh,
813
+ reason: "freshness_fresh",
814
+ summary: "verified freshness against #{remote_ref}",
815
+ remote_ref: remote_ref
816
+ } if ancestor_success
817
+
818
+ return {
819
+ ready: false,
820
+ status: :behind,
821
+ reason: "freshness_behind",
822
+ summary: "branch is behind #{remote_ref}",
823
+ remote_ref: remote_ref
824
+ } if ancestor_exit == 1
825
+
826
+ {
827
+ ready: false,
828
+ status: :unknown,
829
+ reason: "freshness_unknown",
830
+ summary: "could not verify freshness against #{remote_ref}",
831
+ remote_ref: remote_ref,
832
+ detail: merge_base_stderr.to_s.strip
833
+ }
834
+ end
835
+
836
+ def freshness_payload( freshness: )
837
+ payload = {
838
+ status: freshness.fetch( :status ).to_s,
839
+ reason: freshness.fetch( :reason ),
840
+ summary: freshness.fetch( :summary ),
841
+ base_ref: freshness.fetch( :remote_ref )
842
+ }
843
+ detail = freshness.fetch( :detail, "" ).to_s.strip
844
+ payload[ :detail ] = detail unless detail.empty?
845
+ payload
846
+ end
847
+
848
+ def freshness_recovery( freshness: )
849
+ remote_ref = freshness.fetch( :remote_ref )
850
+ return "git rebase #{remote_ref} && carson deliver" if freshness.fetch( :status ) == :behind
851
+
852
+ remote, main = remote_ref.split( "/", 2 )
853
+ "git fetch #{remote} #{main} && carson deliver"
854
+ end
855
+
856
+ def deliver_merge_attempt_cap
857
+ DELIVER_MERGE_ATTEMPT_CAP
858
+ end
859
+
860
+ def deliver_monotonic_now
861
+ Process.clock_gettime( Process::CLOCK_MONOTONIC )
862
+ end
863
+
864
+ def deliver_sleep( seconds )
865
+ sleep seconds
866
+ end
867
+
868
+ def elapsed_settle_seconds( started_at: )
869
+ [ ( deliver_monotonic_now - started_at ).round, 0 ].max
870
+ end
871
+
872
+ def remaining_settle_seconds( started_at:, watch_window_seconds: )
873
+ ( started_at + watch_window_seconds ) - deliver_monotonic_now
307
874
  end
308
875
 
309
876
  def delivery_assessment( ci:, review:, pr_state: )
877
+ return [ "gated", "policy", "unable to assess CI checks" ] if ci == :error
310
878
  return [ "gated", "ci", "waiting for CI checks" ] if ci == :pending
311
879
  return [ "gated", "ci", "CI checks are failing" ] if ci == :fail
312
880
  return [ "gated", "review", "review changes requested" ] if review.fetch( :review, :none ) == :changes_requested
313
881
  return [ "gated", "review", "waiting for review" ] if review.fetch( :review, :none ) == :review_required
314
882
  return [ "gated", "review", review.fetch( :detail ).to_s ] if review.fetch( :status, :pass ) == :fail
315
883
  return [ "gated", "policy", "unable to assess review gate: #{review.fetch( :detail )}" ] if review.fetch( :status, :pass ) == :error
884
+ return [ "gated", "merge", "waiting for GitHub mergeability" ] unless pr_state.is_a?( Hash )
316
885
 
317
886
  merge_result = mergeability_assessment( pr_state: pr_state )
318
887
  return merge_result if merge_result
319
888
 
320
- [ "queued", nil, "ready to integrate into #{config.main_branch}" ]
889
+ [ "gated", "merge", "waiting for GitHub mergeability" ]
321
890
  end
322
891
 
323
892
  def mergeability_assessment( pr_state: )
@@ -326,9 +895,11 @@ module Carson
326
895
  mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
327
896
  merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
328
897
 
898
+ return [ "gated", "policy", "pull request is still a draft" ] if pr_state[ "isDraft" ]
329
899
  return [ "gated", "merge", "pull request has merge conflicts" ] if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
330
900
  return [ "gated", "merge", "merge is blocked by repository policy" ] if merge_state == "BLOCKED"
331
- return [ "queued", nil, "ready to integrate into #{config.main_branch} (branch is behind base but still mergeable)" ] if merge_state == "BEHIND"
901
+ return [ "gated", "freshness", "branch is behind #{config.git_remote}/#{config.main_branch}" ] if merge_state == "BEHIND"
902
+ return [ "queued", nil, "ready to integrate into #{config.main_branch}" ] if merge_state == "CLEAN" || mergeable == "MERGEABLE"
332
903
 
333
904
  nil
334
905
  end
@@ -346,7 +917,9 @@ module Carson
346
917
 
347
918
  def deliver_next_step( delivery:, result: )
348
919
  return "carson sync" if delivery.integrated? && result[ :synced ] == false
920
+ return "carson status" if delivery.integrated? && merge_proof_needs_follow_up?( proof: result[ :merge_proof ] )
349
921
  return "carson housekeep" if delivery.integrated?
922
+ return result.dig( :handoff, :next_steps, 0 ) if result[ :handoff ]
350
923
  return "carson status" if delivery.blocked?
351
924
 
352
925
  nil
@@ -383,9 +956,10 @@ module Carson
383
956
  end
384
957
  puts_line "PR ##{result[ :pr_number ]} #{result[ :pr_url ]}" if result[ :pr_number ]
385
958
  if result[ :delivery ]
959
+ outcome = result[ :outcome ]
386
960
  status = result.dig( :delivery, :status )
387
961
  summary = result[ :summary ]
388
- if status == "integrated"
962
+ if outcome == "integrated" || status == "integrated"
389
963
  if result[ :merge_method ]
390
964
  puts_line "Merged into #{main} with #{result[ :merge_method ]}."
391
965
  else
@@ -396,16 +970,108 @@ module Carson
396
970
  elsif result[ :synced ]
397
971
  puts_line "Synced local #{main}."
398
972
  end
399
- elsif status == "gated"
400
- puts_line "Held at gate — #{summary}."
401
- puts_line " #{result.dig( :merge, :recovery )}" if result.dig( :merge, :recovery )
973
+ puts_line "Merge proof: #{result.dig( :merge_proof, :summary )}" if result[ :merge_proof ]
974
+ elsif outcome == "deferred"
975
+ puts_line "Merge deferred — #{summary}."
976
+ puts_line deferred_human_explanation( result: result )
977
+ print_handoff_next_steps( result: result )
978
+ elsif outcome == "blocked"
979
+ puts_line "Merge blocked — #{summary}."
980
+ puts_line blocked_human_explanation( result: result )
981
+ puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
982
+ puts_line " → #{result.dig( :merge, :recovery )}" if result.dig( :merge, :recovery )
983
+ print_handoff_next_steps( result: result )
402
984
  elsif status == "failed"
403
985
  puts_line "Delivery failed — #{summary}."
404
986
  else
405
987
  puts_line "All clear — #{summary}."
406
988
  end
407
989
  end
408
- puts_line "Check back with #{result[ :next_step ]}" if result[ :next_step ]
990
+ puts_line "Check back with #{result[ :next_step ]}" if result[ :next_step ] && !result[ :handoff ]
991
+ end
992
+
993
+ def deferred_human_explanation( result: )
994
+ attempted = result[ :merge_attempted ] ? "Carson attempted merge in this run." : "Carson did not attempt merge in this run."
995
+ "The PR is still open. Carson stopped watching after #{result[ :waited_seconds ]}s. #{attempted}"
996
+ end
997
+
998
+ def blocked_human_explanation( result: )
999
+ attempted = result[ :merge_attempted ] ? "Carson attempted merge in this run." : "Carson did not attempt merge in this run."
1000
+ "The PR is still open. #{attempted}"
1001
+ end
1002
+
1003
+ def print_handoff_next_steps( result: )
1004
+ Array( result.dig( :handoff, :next_steps ) ).each do |command|
1005
+ puts_line " → #{command}"
1006
+ end
1007
+ end
1008
+
1009
+ def merge_proof_needs_follow_up?( proof: )
1010
+ return false unless proof.is_a?( Hash )
1011
+
1012
+ !proof.fetch( :proven, false )
1013
+ end
1014
+
1015
+ def merge_proof_payload( proof: )
1016
+ return nil unless proof.is_a?( Hash )
1017
+
1018
+ {
1019
+ applicable: proof.fetch( :applicable ),
1020
+ proven: proof.fetch( :proven ),
1021
+ basis: proof.fetch( :basis ),
1022
+ summary: proof.fetch( :summary ),
1023
+ main_branch: proof.fetch( :main_branch ),
1024
+ changed_files_count: proof.fetch( :changed_files_count )
1025
+ }
1026
+ end
1027
+
1028
+ def pull_request_payload( delivery: )
1029
+ number = delivery.pull_request_number
1030
+ return nil if number.nil?
1031
+
1032
+ state = pull_request_state_for_delivery( delivery: delivery )
1033
+ draft = delivery.pull_request_draft
1034
+ merged_at = delivery.pull_request_merged_at
1035
+ merged_at ||= delivery.integrated_at if state == "MERGED"
1036
+
1037
+ {
1038
+ number: number,
1039
+ url: delivery.pull_request_url,
1040
+ state: state,
1041
+ draft: draft,
1042
+ merged_at: merged_at,
1043
+ summary: delivery_pull_request_summary(
1044
+ number: number,
1045
+ state: state,
1046
+ draft: draft
1047
+ )
1048
+ }
1049
+ end
1050
+
1051
+ def pull_request_state_for_delivery( delivery: )
1052
+ return delivery.pull_request_state unless delivery.pull_request_state.to_s.strip.empty?
1053
+ return "MERGED" if delivery.integrated?
1054
+
1055
+ nil
1056
+ end
1057
+
1058
+ def delivery_pull_request_summary( number:, state:, draft: )
1059
+ return "PR ##{number} is merged." if state == "MERGED"
1060
+ return "PR ##{number} is closed." if state == "CLOSED"
1061
+ return "PR ##{number} is open as draft." if state == "OPEN" && draft
1062
+ return "PR ##{number} is open." if state == "OPEN"
1063
+
1064
+ "PR ##{number} is tracked by Carson."
1065
+ end
1066
+
1067
+ def pull_request_observation_attributes( pr_state: )
1068
+ return {} unless pr_state.is_a?( Hash )
1069
+
1070
+ {
1071
+ pull_request_state: pr_state[ "state" ],
1072
+ pull_request_draft: pr_state[ "isDraft" ],
1073
+ pull_request_merged_at: pr_state[ "mergedAt" ]
1074
+ }
409
1075
  end
410
1076
 
411
1077
  # Pushes the branch to the remote with tracking.
@@ -519,6 +1185,23 @@ module Carson
519
1185
  :pass
520
1186
  end
521
1187
 
1188
+ def settle_check_pr_ci( number: )
1189
+ stdout, _, success, = gh_run(
1190
+ "pr", "checks", number.to_s,
1191
+ "--json", "name,bucket"
1192
+ )
1193
+ return :error unless success
1194
+
1195
+ checks = JSON.parse( stdout ) rescue []
1196
+ return :none if checks.empty?
1197
+
1198
+ buckets = checks.map { |entry| entry[ "bucket" ].to_s.downcase }
1199
+ return :fail if buckets.include?( "fail" )
1200
+ return :pending if buckets.include?( "pending" )
1201
+
1202
+ :pass
1203
+ end
1204
+
522
1205
  # Checks the full review gate on a PR. Returns a structured result hash.
523
1206
  def check_pr_review( number:, branch:, pr_url: nil )
524
1207
  owner, repo = repository_coordinates
@@ -543,7 +1226,7 @@ module Carson
543
1226
  def pull_request_state( number: )
544
1227
  stdout, _, success, = gh_run(
545
1228
  "pr", "view", number.to_s,
546
- "--json", "number,state,isDraft,url,mergeStateStatus,mergeable"
1229
+ "--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
547
1230
  )
548
1231
  return nil unless success
549
1232
 
@@ -574,8 +1257,19 @@ module Carson
574
1257
  end
575
1258
 
576
1259
  # Syncs main after a successful merge.
1260
+ # Ensures the main worktree is attached to the main branch before pulling,
1261
+ # because git pull --ff-only on a detached HEAD fast-forwards the detached
1262
+ # HEAD but does not update the local main branch ref.
577
1263
  def sync_after_merge!( remote:, main:, result: )
578
1264
  main_root = main_worktree_root
1265
+ attachment = ensure_main_attached!( main_root: main_root )
1266
+ unless attachment.fetch( :ok )
1267
+ result[ :synced ] = false
1268
+ result[ :sync_error ] = attachment.fetch( :error )
1269
+ puts_verbose "sync blocked: #{attachment.fetch( :error )}"
1270
+ return
1271
+ end
1272
+
579
1273
  _, pull_stderr, pull_status, = Open3.capture3(
580
1274
  "git", "-C", main_root, "pull", "--ff-only", remote, main
581
1275
  )