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.
- checksums.yaml +4 -4
- data/.github/workflows/carson_policy.yml +1 -1
- data/API.md +34 -7
- data/MANUAL.md +10 -9
- data/README.md +15 -8
- data/RELEASE.md +27 -1
- data/VERSION +1 -1
- data/carson.gemspec +1 -0
- data/lib/carson/delivery.rb +9 -2
- data/lib/carson/ledger.rb +318 -34
- data/lib/carson/runtime/deliver.rb +779 -85
- data/lib/carson/runtime/govern.rb +118 -66
- data/lib/carson/runtime/local/merge_proof.rb +199 -0
- data/lib/carson/runtime/local/sync.rb +89 -0
- data/lib/carson/runtime/local/worktree.rb +7 -21
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/status.rb +34 -1
- data/lib/carson/worktree.rb +95 -18
- metadata +24 -3
|
@@ -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 = {
|
|
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 =
|
|
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
|
-
)
|
|
106
|
+
)
|
|
87
107
|
|
|
88
108
|
result[ :pr_number ] = pr_number
|
|
89
109
|
result[ :pr_url ] = pr_url
|
|
90
|
-
result[ :ci ] =
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
integrated_at: Time.now.utc.iso8601,
|
|
261
|
-
summary: "integrated into #{main}"
|
|
545
|
+
**observation
|
|
262
546
|
)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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: "
|
|
271
|
-
cause:
|
|
272
|
-
summary:
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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:
|
|
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
|
-
[ "
|
|
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 [ "
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
)
|