carson 3.29.1 → 3.30.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdb70b55b0dddf63dd2e96b68d060472e3abfd10ae6c55010df2d43ad766c7b8
4
- data.tar.gz: 122ef3005263475ce82fce1177037ddd4f017662a5a74bbee16ada7a464e0fc6
3
+ metadata.gz: 5156cb5207a0ee95cad65c30d494a24ddcee97c90f2aab6861b34f9ec8e92069
4
+ data.tar.gz: 182a83aaa84ec1d9c0a548bdaf98e421b4eab9e4ab1248b0d67db58e42a7c5ae
5
5
  SHA512:
6
- metadata.gz: f526c974bf821f31a025e5a7c2a09644d3ef814c04f62a5d7021c0c6d102a9a883320c16ab20ab559159a95eee0198a26dfdec37c5684fd09936a13cbd7d081e
7
- data.tar.gz: 6e7b04be1bf3a96d607599af821f38b4fb9cbaadea9c54f69407bf5d5d90bd4f10b46eec1e9254b4bbc6f1fa0c3e3bd673e9be8343a3b6e4006fe3a3291b8913
6
+ metadata.gz: 3e42b39fad6ca26a05bd914f7653c9cd4e7bb4227c9eb03ba5288b5e54d30f87f4b8e69991236caab797964d293ec1e6b01c4ec6a8853b24f80f7a7bb1670cfe
7
+ data.tar.gz: fd147d7b1941670279ad3261b0cca81639d72043b3e429a1f0290aff4a9deb53000971c5bb5953d7f36b4b574a1c5b03547f9cf33fdb3bcc16bbe4f30e5b770a
data/README.md CHANGED
@@ -64,7 +64,7 @@ cd your/repo/path
64
64
  carson housekeep
65
65
  ```
66
66
 
67
- `carson deliver` runs Carson-owned branch delivery. Before any push, Carson verifies that the branch is fresh against the configured remote `main`. If freshness is behind or unknown, delivery stops with an explicit block and no PR side effect. Plain `deliver` transports existing commits only; `carson deliver --commit "..."` creates one all-dirty delivery commit first, then continues the same flow. If the branch is fresh, Carson pushes it, creates or refreshes the PR, watches the delivery for a bounded settle window, merges when clear, and syncs local `main`. If the settle window expires without integration, Carson exits with an explicit `Merge deferred` or `Merge blocked` handoff instead of leaving the PR mysteriously open. Deferred and blocked exits say whether Carson attempted merge and list the next commands in order.
67
+ `carson deliver` runs Carson-owned branch delivery. Before any push, Carson verifies that the branch is fresh against the configured remote `main` using local git. If freshness is behind or unknown, delivery stops with an explicit block and no PR side effect. After a PR exists, Carson delegates merge eligibility to GitHub's `mergeStateStatus` — if GitHub reports `CLEAN`, Carson proceeds regardless of local ancestor status. Plain `deliver` transports existing commits only; `carson deliver --commit "..."` creates one all-dirty delivery commit first, then continues the same flow. If the branch is fresh, Carson pushes it, creates or refreshes the PR, watches the delivery for a bounded settle window, merges when clear, and syncs local `main`. If the settle window expires without integration, Carson exits with an explicit `Merge deferred` or `Merge blocked` handoff instead of leaving the PR mysteriously open. Deferred and blocked exits say whether Carson attempted merge and list the next commands in order.
68
68
 
69
69
  When one Carson-governed required check is already red on the default branch and the current PR is the repair, use `carson recover --check "..."`. Recovery is the explicit exceptional path: Carson proves the baseline failure, keeps every other gate intact, records an audit event, and never teaches operators to step outside Carson first.
70
70
 
data/RELEASE.md CHANGED
@@ -5,6 +5,17 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 3.30.0
9
+
10
+ ### What changed
11
+
12
+ - **Post-PR merge eligibility is now delegated to GitHub** — After a PR exists, Carson uses GitHub's `mergeStateStatus` as the sole authority for merge eligibility instead of running a local `git merge-base --is-ancestor` check. If GitHub reports `CLEAN`, Carson proceeds regardless of local ancestor status. If GitHub reports `BEHIND`, the delivery is held. The pre-push freshness check (before any PR exists) is unchanged.
13
+ - **Govern and deliver no longer false-block in busy repos** — In repositories where govern merges PRs serially, each merge advances main and previously made every subsequent queued PR permanently stuck unless manually rebased. Carson now integrates any PR that GitHub considers merge-eligible.
14
+
15
+ ### No migration required
16
+
17
+ - Existing workflows continue to work unchanged. PRs that were previously false-blocked by local freshness checks will now integrate when GitHub reports them as merge-eligible.
18
+
8
19
  ## 3.29.1
9
20
 
10
21
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.29.1
1
+ 3.30.0
@@ -221,24 +221,8 @@ module Carson
221
221
  end
222
222
 
223
223
  # Assesses delivery readiness and records Carson's current branch state.
224
+ # Post-PR freshness is delegated to GitHub's mergeStateStatus via delivery_assessment.
224
225
  def assess_delivery!( delivery:, branch_name: )
225
- freshness = assess_branch_freshness(
226
- head_ref: delivery.head || branch_name,
227
- remote: config.git_remote,
228
- main: config.main_branch
229
- )
230
- unless freshness.fetch( :ready )
231
- return ledger.update_delivery(
232
- delivery: delivery,
233
- status: "gated",
234
- cause: "freshness",
235
- summary: freshness.fetch( :summary ),
236
- pr_number: delivery.pull_request_number,
237
- pr_url: delivery.pull_request_url,
238
- worktree_path: delivery.worktree_path
239
- )
240
- end
241
-
242
226
  review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
243
227
  ci = check_pr_ci( number: delivery.pull_request_number )
244
228
  pr_state = pull_request_state( number: delivery.pull_request_number )
@@ -278,7 +262,14 @@ module Carson
278
262
  last_evaluation = evaluation
279
263
  successful_assessments += 1 if evaluation[ :assessment_success ]
280
264
  result[ :ci ] = evaluation[ :ci ].to_s
281
- result[ :freshness ] = freshness_payload( freshness: evaluation.fetch( :freshness ) ) if evaluation[ :freshness ]
265
+ if evaluation[ :cause ] == "freshness"
266
+ result[ :freshness ] = {
267
+ status: "behind",
268
+ reason: "freshness_behind",
269
+ summary: evaluation[ :summary ],
270
+ base_ref: "#{config.git_remote}/#{main}"
271
+ }
272
+ end
282
273
 
283
274
  delivery = update_delivery_for_settle_evaluation( delivery: delivery, evaluation: evaluation )
284
275
  result[ :summary ] = delivery.summary
@@ -297,7 +288,7 @@ module Carson
297
288
  when :blocked
298
289
  result[ :outcome ] = "blocked"
299
290
  result[ :waited_seconds ] = elapsed_settle_seconds( started_at: started_at )
300
- result[ :recovery ] = freshness_recovery( freshness: evaluation.fetch( :freshness ) ) if evaluation[ :cause ] == "freshness" && evaluation[ :freshness ]
291
+ result[ :recovery ] = "git rebase #{config.git_remote}/#{main} && carson deliver" if evaluation[ :cause ] == "freshness"
301
292
  apply_handoff!(
302
293
  result: result,
303
294
  reason: evaluation.fetch( :reason ),
@@ -389,25 +380,8 @@ module Carson
389
380
  delivery
390
381
  end
391
382
 
383
+ # Post-PR freshness is delegated to GitHub's mergeStateStatus via settle_mergeability_assessment.
392
384
  def evaluate_delivery_for_settle( branch_name:, head_ref:, pr_number:, pr_url:, main: )
393
- freshness = assess_branch_freshness(
394
- branch_name: branch_name,
395
- head_ref: head_ref,
396
- remote: config.git_remote,
397
- main: main
398
- )
399
- unless freshness.fetch( :ready )
400
- return {
401
- phase: :blocked,
402
- reason: freshness.fetch( :reason ),
403
- cause: "freshness",
404
- summary: freshness.fetch( :summary ),
405
- assessment_success: freshness.fetch( :status ) != :unknown,
406
- ci: :none,
407
- freshness: freshness
408
- }
409
- end
410
-
411
385
  review = check_pr_review( number: pr_number, branch: branch_name, pr_url: pr_url )
412
386
  ci = settle_check_pr_ci( number: pr_number )
413
387
  pr_state = pull_request_state( number: pr_number )
@@ -419,7 +393,7 @@ module Carson
419
393
  summary: "waiting for GitHub assessment",
420
394
  assessment_success: false,
421
395
  ci: ci
422
- }.merge( freshness: freshness, pr_state: pr_state ) if ci == :error || review.fetch( :status, :pass ) == :error || !pr_state.is_a?( Hash )
396
+ }.merge( pr_state: pr_state ) if ci == :error || review.fetch( :status, :pass ) == :error || !pr_state.is_a?( Hash )
423
397
 
424
398
  return {
425
399
  phase: :integrated,
@@ -428,7 +402,7 @@ module Carson
428
402
  summary: "integrated into #{main}",
429
403
  assessment_success: true,
430
404
  ci: ci
431
- }.merge( freshness: freshness, pr_state: pr_state ) if pr_state[ "state" ] == "MERGED"
405
+ }.merge( pr_state: pr_state ) if pr_state[ "state" ] == "MERGED"
432
406
  return {
433
407
  phase: :blocked,
434
408
  reason: "pull_request_closed",
@@ -436,7 +410,7 @@ module Carson
436
410
  summary: "pull request closed without integration",
437
411
  assessment_success: true,
438
412
  ci: ci
439
- }.merge( freshness: freshness, pr_state: pr_state ) if pr_state[ "state" ] == "CLOSED"
413
+ }.merge( pr_state: pr_state ) if pr_state[ "state" ] == "CLOSED"
440
414
 
441
415
  return {
442
416
  phase: :blocked,
@@ -445,7 +419,7 @@ module Carson
445
419
  summary: "pull request is still a draft",
446
420
  assessment_success: true,
447
421
  ci: ci
448
- }.merge( freshness: freshness, pr_state: pr_state ) if pr_state[ "isDraft" ]
422
+ }.merge( pr_state: pr_state ) if pr_state[ "isDraft" ]
449
423
 
450
424
  return {
451
425
  phase: :waiting,
@@ -454,7 +428,7 @@ module Carson
454
428
  summary: "waiting for CI checks",
455
429
  assessment_success: true,
456
430
  ci: ci
457
- }.merge( freshness: freshness, pr_state: pr_state ) if ci == :pending
431
+ }.merge( pr_state: pr_state ) if ci == :pending
458
432
 
459
433
  return {
460
434
  phase: :blocked,
@@ -463,7 +437,7 @@ module Carson
463
437
  summary: "CI checks are failing",
464
438
  assessment_success: true,
465
439
  ci: ci
466
- }.merge( freshness: freshness, pr_state: pr_state ) if ci == :fail
440
+ }.merge( pr_state: pr_state ) if ci == :fail
467
441
 
468
442
  return {
469
443
  phase: :blocked,
@@ -472,7 +446,7 @@ module Carson
472
446
  summary: "review changes requested",
473
447
  assessment_success: true,
474
448
  ci: ci
475
- }.merge( freshness: freshness, pr_state: pr_state ) if review.fetch( :review, :none ) == :changes_requested
449
+ }.merge( pr_state: pr_state ) if review.fetch( :review, :none ) == :changes_requested
476
450
 
477
451
  return {
478
452
  phase: :waiting,
@@ -481,7 +455,7 @@ module Carson
481
455
  summary: "waiting for review",
482
456
  assessment_success: true,
483
457
  ci: ci
484
- }.merge( freshness: freshness, pr_state: pr_state ) if review.fetch( :review, :none ) == :review_required
458
+ }.merge( pr_state: pr_state ) if review.fetch( :review, :none ) == :review_required
485
459
 
486
460
  return {
487
461
  phase: :blocked,
@@ -490,49 +464,28 @@ module Carson
490
464
  summary: review.fetch( :detail ).to_s,
491
465
  assessment_success: true,
492
466
  ci: ci
493
- }.merge( freshness: freshness, pr_state: pr_state ) if review.fetch( :status, :pass ) == :fail
467
+ }.merge( pr_state: pr_state ) if review.fetch( :status, :pass ) == :fail
494
468
 
495
469
  mergeability = settle_mergeability_assessment( pr_state: pr_state, main: main )
496
- mergeability.merge( assessment_success: true, ci: ci, freshness: freshness, pr_state: pr_state )
470
+ mergeability.merge( assessment_success: true, ci: ci, pr_state: pr_state )
497
471
  end
498
472
 
499
473
  def settle_mergeability_assessment( pr_state:, main: )
500
- mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
501
- merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
502
-
503
- return {
504
- phase: :blocked,
505
- reason: "merge_conflict",
506
- cause: "merge",
507
- summary: "pull request has merge conflicts"
508
- } if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
509
-
510
- return {
511
- phase: :blocked,
512
- reason: "repository_policy_block",
513
- cause: "merge",
514
- summary: "merge is blocked by repository policy"
515
- } if merge_state == "BLOCKED"
516
-
517
- return {
518
- phase: :blocked,
519
- reason: "freshness_behind",
520
- cause: "freshness",
521
- summary: "branch is behind #{config.git_remote}/#{main}"
522
- } if merge_state == "BEHIND"
523
-
524
- return {
525
- phase: :ready,
526
- reason: "ready",
527
- cause: nil,
528
- summary: "ready to integrate into #{main}"
529
- } if merge_state == "CLEAN" || mergeable == "MERGEABLE"
530
-
474
+ assessment = github_merge_assessment( pr_state: pr_state )
475
+ phase = if assessment[ :ready ]
476
+ :ready
477
+ elsif [ "mergeability_pending", "assessment_unavailable" ].include?( assessment[ :reason ] )
478
+ :waiting
479
+ else
480
+ :blocked
481
+ end
482
+ # Preserve the settle-loop's "assessment" cause for pending/unavailable states
483
+ cause = phase == :waiting ? "assessment" : assessment[ :cause ]
531
484
  {
532
- phase: :waiting,
533
- reason: "mergeability_pending",
534
- cause: "assessment",
535
- summary: "waiting for GitHub mergeability"
485
+ phase: phase,
486
+ reason: assessment[ :reason ],
487
+ cause: cause,
488
+ summary: assessment[ :summary ]
536
489
  }
537
490
  end
538
491
 
@@ -582,29 +535,37 @@ module Carson
582
535
  end
583
536
  end
584
537
 
538
+ # Pre-merge recheck using GitHub as the authority for merge eligibility.
539
+ # Blocks on definite GitHub-reported issues (BEHIND, CONFLICTING, BLOCKED, draft).
540
+ # Allows through pending/unknown states — the merge attempt itself will succeed or fail.
585
541
  def attempt_delivery_merge!( delivery:, remote:, main:, result: )
586
- freshness = assess_branch_freshness(
587
- head_ref: delivery.head || delivery.branch,
588
- remote: remote,
589
- main: main
590
- )
591
- result[ :freshness ] = freshness_payload( freshness: freshness )
592
- unless freshness.fetch( :ready )
593
- result[ :recovery ] = freshness_recovery( freshness: freshness )
594
- return {
595
- phase: :blocked,
596
- attempted: false,
597
- reason: freshness.fetch( :reason ),
598
- delivery: ledger.update_delivery(
599
- delivery: delivery,
600
- status: "gated",
601
- cause: "freshness",
602
- summary: freshness.fetch( :summary )
603
- )
604
- }
605
- end
606
-
607
542
  pr_state = pull_request_state( number: delivery.pull_request_number )
543
+ merge_check = github_merge_assessment( pr_state: pr_state )
544
+ definite_blocker = !merge_check[ :ready ] &&
545
+ ![ "mergeability_pending", "assessment_unavailable" ].include?( merge_check[ :reason ] )
546
+ if definite_blocker
547
+ if merge_check[ :cause ] == "freshness"
548
+ result[ :freshness ] = {
549
+ status: "behind",
550
+ reason: "freshness_behind",
551
+ summary: merge_check[ :summary ],
552
+ base_ref: "#{remote}/#{main}"
553
+ }
554
+ end
555
+ result[ :recovery ] = merge_check[ :recovery ] if merge_check[ :recovery ]
556
+ return {
557
+ phase: :blocked,
558
+ attempted: false,
559
+ reason: merge_check[ :reason ],
560
+ delivery: ledger.update_delivery(
561
+ delivery: delivery,
562
+ status: "gated",
563
+ cause: merge_check[ :cause ],
564
+ summary: merge_check[ :summary ]
565
+ )
566
+ }
567
+ end
568
+
608
569
  observation = pull_request_observation_attributes( pr_state: pr_state )
609
570
  if pr_state && pr_state[ "state" ] == "MERGED"
610
571
  return {
@@ -890,19 +851,78 @@ module Carson
890
851
  [ "gated", "merge", "waiting for GitHub mergeability" ]
891
852
  end
892
853
 
893
- def mergeability_assessment( pr_state: )
894
- return nil unless pr_state.is_a?( Hash )
854
+ # Interprets GitHub's PR state into a merge readiness verdict.
855
+ # Pure method — no API calls. Accepts the already-fetched pr_state hash.
856
+ # Returns a standardised hash: { ready:, reason:, cause:, summary:, recovery: }
857
+ def github_merge_assessment( pr_state: )
858
+ unless pr_state.is_a?( Hash )
859
+ return {
860
+ ready: false,
861
+ reason: "assessment_unavailable",
862
+ cause: "merge",
863
+ summary: "waiting for GitHub mergeability",
864
+ recovery: nil
865
+ }
866
+ end
895
867
 
896
868
  mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
897
869
  merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
870
+ remote_main = "#{config.git_remote}/#{config.main_branch}"
898
871
 
899
- return [ "gated", "policy", "pull request is still a draft" ] if pr_state[ "isDraft" ]
900
- return [ "gated", "merge", "pull request has merge conflicts" ] if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
901
- return [ "gated", "merge", "merge is blocked by repository policy" ] if merge_state == "BLOCKED"
902
- return [ "gated", "freshness", "branch is behind #{config.git_remote}/#{config.main_branch}" ] if merge_state == "BEHIND"
903
- return [ "queued", nil, "ready to integrate into #{config.main_branch}" ] if merge_state == "CLEAN" || mergeable == "MERGEABLE"
872
+ return {
873
+ ready: false,
874
+ reason: "draft_pr",
875
+ cause: "policy",
876
+ summary: "pull request is still a draft",
877
+ recovery: nil
878
+ } if pr_state[ "isDraft" ]
904
879
 
905
- nil
880
+ return {
881
+ ready: false,
882
+ reason: "merge_conflict",
883
+ cause: "merge",
884
+ summary: "pull request has merge conflicts",
885
+ recovery: nil
886
+ } if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
887
+
888
+ return {
889
+ ready: false,
890
+ reason: "repository_policy_block",
891
+ cause: "merge",
892
+ summary: "merge is blocked by repository policy",
893
+ recovery: nil
894
+ } if merge_state == "BLOCKED"
895
+
896
+ return {
897
+ ready: false,
898
+ reason: "freshness_behind",
899
+ cause: "freshness",
900
+ summary: "branch is behind #{remote_main}",
901
+ recovery: "git rebase #{remote_main} && carson deliver"
902
+ } if merge_state == "BEHIND"
903
+
904
+ return {
905
+ ready: true,
906
+ reason: "ready",
907
+ cause: nil,
908
+ summary: "ready to integrate into #{config.main_branch}",
909
+ recovery: nil
910
+ } if merge_state == "CLEAN" || mergeable == "MERGEABLE"
911
+
912
+ {
913
+ ready: false,
914
+ reason: "mergeability_pending",
915
+ cause: "merge",
916
+ summary: "waiting for GitHub mergeability",
917
+ recovery: nil
918
+ }
919
+ end
920
+
921
+ def mergeability_assessment( pr_state: )
922
+ assessment = github_merge_assessment( pr_state: pr_state )
923
+ return nil if assessment[ :reason ] == "mergeability_pending" && pr_state.is_a?( Hash )
924
+ status = assessment[ :ready ] ? "queued" : "gated"
925
+ [ status, assessment[ :cause ], assessment[ :summary ] ]
906
926
  end
907
927
 
908
928
  def delivery_payload( delivery: )
@@ -216,19 +216,20 @@ module Carson
216
216
  end
217
217
  end
218
218
 
219
+ # Final pre-merge recheck using GitHub as authority for merge eligibility.
220
+ # Intentionally strict: blocks on ANY non-ready state (including pending/unavailable).
221
+ # Unlike deliver's attempt_delivery_merge! which allows speculative merges on pending states,
222
+ # govern runs unattended and should not speculatively attempt merges on uncertain state.
219
223
  def integrate_delivery!( delivery:, repo_path: )
220
224
  result = {}
221
- freshness = assess_branch_freshness(
222
- head_ref: delivery.head || delivery.branch,
223
- remote: config.git_remote,
224
- main: config.main_branch
225
- )
226
- unless freshness.fetch( :ready )
225
+ pr_state = pull_request_state( number: delivery.pull_request_number )
226
+ merge_check = github_merge_assessment( pr_state: pr_state )
227
+ unless merge_check[ :ready ]
227
228
  return ledger.update_delivery(
228
229
  delivery: delivery,
229
230
  status: "gated",
230
- cause: "freshness",
231
- summary: freshness.fetch( :summary )
231
+ cause: merge_check[ :cause ],
232
+ summary: merge_check[ :summary ]
232
233
  )
233
234
  end
234
235
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.29.1
4
+ version: 3.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang