carson 3.29.1 → 3.30.1
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 +4 -4
- data/MANUAL.md +3 -3
- data/README.md +1 -1
- data/RELEASE.md +23 -0
- data/VERSION +1 -1
- data/hooks/command-guard +27 -4
- data/lib/carson/runtime/deliver.rb +131 -111
- data/lib/carson/runtime/govern.rb +9 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f1c9bdf439edf6f4c50dbf4c2640ec87cf0cf7f5aacd7960d16c2d32600a0c3
|
|
4
|
+
data.tar.gz: b82620a9e4524b9e08c7851f9de133967ce64284c7d2c57a71d87df7055ce8a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9433570c67b8f8b793eba64969356c6ec92775515e8291079518b0be7a4f051183eb20e19d23e0d6456a38a95c875f06d9e6d7536b434c30ec69fe36d63b0c4e
|
|
7
|
+
data.tar.gz: 7b82e7e07750d8e0ac4d0502a95e86925e210c1cca5b0c0e33355604cc4910819f46c57b002c9b7d9fd8dd4c312b4bc4cfba26192875e1a23818498343a4b0d0
|
data/MANUAL.md
CHANGED
|
@@ -66,12 +66,12 @@ on:
|
|
|
66
66
|
|
|
67
67
|
jobs:
|
|
68
68
|
governance:
|
|
69
|
-
uses: wanghailei/carson/.github/workflows/carson_policy.yml@v3.
|
|
69
|
+
uses: wanghailei/carson/.github/workflows/carson_policy.yml@v3.30.0
|
|
70
70
|
secrets:
|
|
71
71
|
CARSON_READ_TOKEN: ${{ secrets.CARSON_READ_TOKEN }}
|
|
72
72
|
with:
|
|
73
|
-
carson_ref: "v3.
|
|
74
|
-
carson_version: "3.
|
|
73
|
+
carson_ref: "v3.30.0"
|
|
74
|
+
carson_version: "3.30.0"
|
|
75
75
|
rubocop_version: "1.81.0"
|
|
76
76
|
```
|
|
77
77
|
|
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
|
|
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,29 @@ 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.1
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **MANUAL.md CI example updated to v3.30.0** — The CI workflow reference, `carson_ref`, and `carson_version` in the setup example were still pinned to v3.29.0. Now aligned with the current release.
|
|
13
|
+
- **CI split into fast PR gate and full smoke on main** — PR checks run the unit test suite only; the full smoke script runs on main after merge. Reduces PR feedback time.
|
|
14
|
+
- **Command guard respects leading `cd` in Bash commands** — `cd /path && gh pr create` is now correctly detected as a governed command.
|
|
15
|
+
|
|
16
|
+
### No migration required
|
|
17
|
+
|
|
18
|
+
- Existing workflows continue to work unchanged.
|
|
19
|
+
|
|
20
|
+
## 3.30.0
|
|
21
|
+
|
|
22
|
+
### What changed
|
|
23
|
+
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
### No migration required
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
8
31
|
## 3.29.1
|
|
9
32
|
|
|
10
33
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.30.1
|
data/hooks/command-guard
CHANGED
|
@@ -31,6 +31,28 @@ block_command() {
|
|
|
31
31
|
exit 2
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
resolve_command_pwd() {
|
|
35
|
+
local command_text="$1"
|
|
36
|
+
local base_pwd
|
|
37
|
+
local raw_cd_target
|
|
38
|
+
local cd_target
|
|
39
|
+
local resolved_pwd
|
|
40
|
+
|
|
41
|
+
base_pwd="$(pwd -P)"
|
|
42
|
+
if [[ ! "$command_text" =~ ^[[:space:]]*cd[[:space:]]+([^;&|]+)[[:space:]]*(&&|;) ]]; then
|
|
43
|
+
echo "$base_pwd"
|
|
44
|
+
return
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
raw_cd_target="${BASH_REMATCH[1]}"
|
|
48
|
+
cd_target="$(printf '%s' "$raw_cd_target" | sed -E "s/^[[:space:]]+//; s/[[:space:]]+$//; s/^'(.*)'$/\\1/; s/^\"(.*)\"$/\\1/")"
|
|
49
|
+
if resolved_pwd="$(cd "$base_pwd" && cd "$cd_target" 2>/dev/null && pwd -P)"; then
|
|
50
|
+
echo "$resolved_pwd"
|
|
51
|
+
else
|
|
52
|
+
echo "$base_pwd"
|
|
53
|
+
fi
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
# Read the tool call JSON from stdin.
|
|
35
57
|
input="$(cat)"
|
|
36
58
|
|
|
@@ -40,16 +62,17 @@ tool_name="$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null)"
|
|
|
40
62
|
|
|
41
63
|
command_text="$(echo "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)"
|
|
42
64
|
[ -n "$command_text" ] || exit 0
|
|
65
|
+
command_pwd="$(resolve_command_pwd "$command_text")"
|
|
43
66
|
|
|
44
67
|
# Check if the current directory is inside a governed repository.
|
|
45
68
|
config_file="${HOME}/.carson/config.json"
|
|
46
69
|
[ -f "$config_file" ] || exit 0
|
|
47
70
|
|
|
48
|
-
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
|
|
71
|
+
repo_root="$(git -C "$command_pwd" rev-parse --show-toplevel 2>/dev/null || echo "")"
|
|
49
72
|
[ -n "$repo_root" ] || exit 0
|
|
50
73
|
|
|
51
74
|
current_root="$(cd "$repo_root" && pwd -P)"
|
|
52
|
-
common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "")"
|
|
75
|
+
common_dir="$(git -C "$command_pwd" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "")"
|
|
53
76
|
if [ -n "$common_dir" ]; then
|
|
54
77
|
governed_root="$(cd "$(dirname "$common_dir")" && pwd -P)"
|
|
55
78
|
else
|
|
@@ -62,7 +85,7 @@ fi
|
|
|
62
85
|
|
|
63
86
|
main_branch="$(jq -r '.git.main_branch // "main"' "$config_file" 2>/dev/null || echo "main")"
|
|
64
87
|
[ -n "$main_branch" ] || main_branch="main"
|
|
65
|
-
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
|
|
88
|
+
current_branch="$(git -C "$command_pwd" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
|
|
66
89
|
on_main_worktree=false
|
|
67
90
|
on_main_branch=false
|
|
68
91
|
[ "$current_root" = "$governed_root" ] && on_main_worktree=true
|
|
@@ -96,7 +119,7 @@ fi
|
|
|
96
119
|
if [ "$on_main_worktree" = true ] && [ "$on_main_branch" = true ] && grep -qE "$main_mutation_pattern" <<<"$command_text"; then
|
|
97
120
|
block_command \
|
|
98
121
|
"Main working tree is read-only in this Carson-governed repo." \
|
|
99
|
-
"Use \`carson worktree create <name>\` first, then
|
|
122
|
+
"Use \`carson worktree create <name>\` first, then commit inside that worktree or let Carson do it with \`carson deliver --commit \"...\"\`."
|
|
100
123
|
fi
|
|
101
124
|
|
|
102
125
|
exit 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
|
-
|
|
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 ] =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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:
|
|
533
|
-
reason:
|
|
534
|
-
cause:
|
|
535
|
-
summary:
|
|
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
|
-
|
|
894
|
-
|
|
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
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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:
|
|
231
|
-
summary:
|
|
231
|
+
cause: merge_check[ :cause ],
|
|
232
|
+
summary: merge_check[ :summary ]
|
|
232
233
|
)
|
|
233
234
|
end
|
|
234
235
|
|