carson 3.23.3 → 3.27.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.
@@ -0,0 +1,238 @@
1
+ # Close abandoned delivery work and clean up its branch/worktree when safe.
2
+ module Carson
3
+ class Runtime
4
+ module Abandon
5
+ def abandon!( target:, json_output: false )
6
+ result = { command: "abandon", target: target }
7
+
8
+ unless gh_available?
9
+ result[ :error ] = "gh CLI is required for carson abandon"
10
+ result[ :recovery ] = "install and authenticate gh, then retry"
11
+ return abandon_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
12
+ end
13
+
14
+ resolution = resolve_abandon_target( target: target )
15
+ if resolution.nil?
16
+ result[ :error ] = "no branch or pull request found for #{target}"
17
+ result[ :recovery ] = "use a PR number, PR URL, or existing branch name"
18
+ return abandon_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
19
+ end
20
+
21
+ branch = resolution.fetch( :branch )
22
+ pull_request = resolution.fetch( :pull_request )
23
+ worktree = resolution.fetch( :worktree )
24
+
25
+ result[ :branch ] = branch
26
+ result[ :pr_number ] = pull_request&.fetch( :number, nil )
27
+ result[ :pr_url ] = pull_request&.fetch( :url, nil )
28
+ result[ :worktree_path ] = worktree&.path
29
+
30
+ preflight = abandon_preflight_issue( branch: branch, worktree: worktree )
31
+ unless preflight.nil?
32
+ result[ :error ] = preflight.fetch( :error )
33
+ result[ :recovery ] = preflight.fetch( :recovery )
34
+ return abandon_finish( result: result, exit_code: preflight.fetch( :exit_code ), json_output: json_output )
35
+ end
36
+
37
+ if pull_request&.fetch( :state ) == "OPEN"
38
+ close_exit = close_pull_request!( number: pull_request.fetch( :number ), result: result )
39
+ return abandon_finish( result: result, exit_code: close_exit, json_output: json_output ) unless close_exit == EXIT_OK
40
+ result[ :pull_request_closed ] = true
41
+ else
42
+ result[ :pull_request_closed ] = false
43
+ end
44
+
45
+ if worktree
46
+ remove_exit = with_captured_output do
47
+ worktree_remove!( worktree_path: worktree.path, json_output: false )
48
+ end
49
+ unless remove_exit == EXIT_OK
50
+ result[ :error ] = "worktree cleanup failed for #{worktree.path}"
51
+ result[ :recovery ] = "run carson worktree remove #{File.basename( worktree.path )}"
52
+ return abandon_finish( result: result, exit_code: remove_exit, json_output: json_output )
53
+ end
54
+
55
+ result[ :worktree_removed ] = true
56
+ result[ :branch_deleted ] = !local_branch_exists?( branch: branch )
57
+ result[ :remote_deleted ] = !remote_branch_exists?( branch: branch )
58
+ else
59
+ branch_deleted, remote_deleted = delete_branch_refs!( branch: branch )
60
+ result[ :worktree_removed ] = false
61
+ result[ :branch_deleted ] = branch_deleted
62
+ result[ :remote_deleted ] = remote_deleted
63
+ end
64
+
65
+ mark_delivery_abandoned!( branch: branch )
66
+ result[ :summary ] = "abandoned delivery cleaned up"
67
+ abandon_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
68
+ end
69
+
70
+ private
71
+
72
+ def resolve_abandon_target( target: )
73
+ pull_request = pull_request_from_target( target: target )
74
+ branch = pull_request&.fetch( :branch ) || target.to_s.strip
75
+ branch = branch_from_pull_request_url( target: target ) if branch.empty?
76
+ return nil if branch.to_s.strip.empty?
77
+
78
+ worktree = worktree_list.find { |entry| entry.branch == branch && entry.path != main_worktree_root }
79
+ branch_exists = local_branch_exists?( branch: branch ) || remote_branch_exists?( branch: branch ) || !pull_request.nil?
80
+ return nil unless branch_exists
81
+
82
+ {
83
+ branch: branch,
84
+ pull_request: pull_request,
85
+ worktree: worktree
86
+ }
87
+ end
88
+
89
+ def pull_request_from_target( target: )
90
+ number = pull_request_number_from_target( target: target )
91
+ return pull_request_details_for_number( number: number ) unless number.nil?
92
+
93
+ pull_request = worktree_pull_request( branch: target )
94
+ return nil if pull_request.fetch( :number ).nil?
95
+
96
+ {
97
+ number: pull_request.fetch( :number ),
98
+ url: pull_request.fetch( :url ),
99
+ state: pull_request.fetch( :state ),
100
+ branch: target
101
+ }
102
+ end
103
+
104
+ def pull_request_number_from_target( target: )
105
+ text = target.to_s.strip
106
+ return Integer( text ) if text.match?( /\A\d+\z/ )
107
+
108
+ match = text.match( %r{/pull/(\d+)} )
109
+ return nil if match.nil?
110
+
111
+ Integer( match[ 1 ] )
112
+ rescue ArgumentError
113
+ nil
114
+ end
115
+
116
+ def pull_request_details_for_number( number: )
117
+ stdout_text, stderr_text, success, = gh_run(
118
+ "pr", "view", number.to_s,
119
+ "--json", "number,url,state,headRefName"
120
+ )
121
+ return nil unless success
122
+
123
+ data = JSON.parse( stdout_text )
124
+ {
125
+ number: data.fetch( "number" ),
126
+ url: data.fetch( "url" ).to_s,
127
+ state: data.fetch( "state" ).to_s,
128
+ branch: data.fetch( "headRefName" ).to_s
129
+ }
130
+ rescue JSON::ParserError
131
+ nil
132
+ end
133
+
134
+ def abandon_preflight_issue( branch:, worktree: )
135
+ if config.protected_branches.include?( branch )
136
+ return { exit_code: EXIT_BLOCK, error: "cannot abandon protected branch #{branch}", recovery: "choose a feature branch instead" }
137
+ end
138
+
139
+ if worktree
140
+ check = Worktree.remove_check( path: worktree.path, runtime: self, force: false )
141
+ return nil if check.fetch( :status ) == :ok
142
+
143
+ return {
144
+ exit_code: check.fetch( :exit_code ),
145
+ error: check.fetch( :error ),
146
+ recovery: check.fetch( :recovery )
147
+ }
148
+ end
149
+
150
+ return { exit_code: EXIT_BLOCK, error: "current branch is #{branch}", recovery: "switch to main or a different branch, then retry" } if current_branch == branch
151
+ return nil unless local_branch_exists?( branch: branch )
152
+
153
+ unpushed = Worktree.branch_unpushed_issue( branch: branch, worktree_path: repo_root, runtime: self )
154
+ return nil if unpushed.nil?
155
+
156
+ { exit_code: EXIT_BLOCK, error: unpushed.fetch( :error ), recovery: unpushed.fetch( :recovery ) }
157
+ end
158
+
159
+ def close_pull_request!( number:, result: )
160
+ _, stderr_text, success, = gh_run( "pr", "close", number.to_s )
161
+ return EXIT_OK if success
162
+
163
+ result[ :error ] = gh_error_text( stdout_text: "", stderr_text: stderr_text, fallback: "unable to close pull request ##{number}" )
164
+ result[ :recovery ] = "gh pr close #{number}"
165
+ EXIT_ERROR
166
+ end
167
+
168
+ def delete_branch_refs!( branch: )
169
+ branch_deleted = false
170
+ remote_deleted = false
171
+
172
+ if local_branch_exists?( branch: branch ) && !config.protected_branches.include?( branch )
173
+ _, _, success, = git_run( "branch", "-D", branch )
174
+ branch_deleted = success
175
+ end
176
+
177
+ if remote_branch_exists?( branch: branch ) && !config.protected_branches.include?( branch )
178
+ _, _, success, = git_run( "push", config.git_remote, "--delete", branch )
179
+ remote_deleted = success
180
+ end
181
+
182
+ [ branch_deleted, remote_deleted ]
183
+ end
184
+
185
+ def local_branch_exists?( branch: )
186
+ _, _, success, = git_run( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
187
+ success
188
+ end
189
+
190
+ def remote_branch_exists?( branch: )
191
+ stdout_text, _, success, = git_run( "ls-remote", "--heads", config.git_remote, branch )
192
+ return false unless success
193
+
194
+ !stdout_text.to_s.strip.empty?
195
+ end
196
+
197
+ def mark_delivery_abandoned!( branch: )
198
+ delivery = ledger.active_delivery( repo_path: repository_record.path, branch_name: branch )
199
+ return if delivery.nil?
200
+
201
+ ledger.update_delivery(
202
+ delivery: delivery,
203
+ status: "failed",
204
+ cause: "abandoned",
205
+ summary: "abandoned by carson abandon"
206
+ )
207
+ end
208
+
209
+ def branch_from_pull_request_url( target: )
210
+ number = pull_request_number_from_target( target: target )
211
+ return "" if number.nil?
212
+
213
+ pull_request_details_for_number( number: number )&.fetch( :branch, "" ).to_s
214
+ end
215
+
216
+ def abandon_finish( result:, exit_code:, json_output: )
217
+ result[ :exit_code ] = exit_code
218
+
219
+ if json_output
220
+ output.puts JSON.pretty_generate( result )
221
+ else
222
+ if result[ :error ]
223
+ puts_line result.fetch( :error )
224
+ puts_line " → #{result.fetch( :recovery )}" if result[ :recovery ]
225
+ else
226
+ pr_ref = result[ :pr_number ] ? "PR ##{result[ :pr_number ]}" : "no PR"
227
+ puts_line "Abandoned #{result.fetch( :branch )} (#{pr_ref})."
228
+ puts_line " #{result.fetch( :summary )}"
229
+ end
230
+ end
231
+
232
+ exit_code
233
+ end
234
+ end
235
+
236
+ include Abandon
237
+ end
238
+ end
@@ -38,7 +38,7 @@ module Carson
38
38
  hooks_status = hooks_ok ? "ok" : "mismatch"
39
39
  unless hooks_ok
40
40
  audit_state = "block"
41
- audit_concise_problems << "Hooks: mismatch — run carson refresh."
41
+ audit_concise_problems << "Hooks don't match — run carson refresh."
42
42
  end
43
43
  puts_verbose ""
44
44
  puts_verbose "[Main Sync Status]"
@@ -158,7 +158,7 @@ module Carson
158
158
  puts_verbose( audit_state == "block" ? "ACTION: local policy block must be resolved before commit/push." : "ACTION: no local hard block detected." )
159
159
  unless verbose?
160
160
  audit_concise_problems.each { |problem| puts_line problem }
161
- puts_line "Audit: #{audit_state}"
161
+ puts_line format_audit_state( audit_state )
162
162
  end
163
163
  end
164
164
  exit_code
@@ -219,6 +219,16 @@ module Carson
219
219
  # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
220
220
  private
221
221
  # rubocop:enable Layout/AccessModifierIndentation
222
+
223
+ def format_audit_state( state )
224
+ case state
225
+ when "ok" then "Audit passed."
226
+ when "block" then "Audit blocked."
227
+ when "attention" then "Audit: needs attention."
228
+ else "Audit: #{state}"
229
+ end
230
+ end
231
+
222
232
  def audit_working_tree_report
223
233
  dirty_reason = dirty_worktree_reason
224
234
  return { dirty: false, context: nil, status: "ok" } if dirty_reason.nil?
@@ -1,10 +1,11 @@
1
- # Branch delivery lifecycle — push, create/update PR, and register Carson-owned delivery state.
2
- # `carson deliver` is now the async handoff point. It does not wait for merge.
1
+ # Branch delivery lifecycle — push, create/update PR, wait for merge readiness, and integrate when clear.
2
+ # `carson deliver` owns the synchronous happy path for single-branch delivery.
3
3
  module Carson
4
4
  class Runtime
5
5
  module Deliver
6
6
  # Entry point for `carson deliver`.
7
- # Pushes the current branch, ensures a PR exists, records delivery state, and returns.
7
+ # Pushes the current branch, ensures a PR exists, records delivery state,
8
+ # waits for merge readiness, and integrates when the path is clear.
8
9
  # When --commit is supplied, Carson creates one all-dirty agent-authored commit first.
9
10
  def deliver!( title: nil, body_file: nil, commit_message: nil, json_output: false )
10
11
  branch_name = current_branch
@@ -68,7 +69,6 @@ module Carson
68
69
  branch_name: branch.name,
69
70
  head: branch.head || current_head,
70
71
  worktree_path: branch.worktree || repo_root,
71
- authority: config.govern_authority,
72
72
  pr_number: pr_number,
73
73
  pr_url: pr_url,
74
74
  status: "preparing",
@@ -76,13 +76,22 @@ module Carson
76
76
  cause: nil
77
77
  )
78
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!(
81
+ delivery: delivery,
82
+ branch_name: branch.name,
83
+ remote: remote_name,
84
+ main: main_branch,
85
+ result: result
86
+ ) if delivery.ready?
79
87
 
80
88
  result[ :pr_number ] = pr_number
81
89
  result[ :pr_url ] = pr_url
82
- result[ :ci ] = check_pr_ci( number: pr_number ).to_s
90
+ result[ :ci ] = delivery.integrated? ? "pass" : check_pr_ci( number: pr_number ).to_s
83
91
  result[ :delivery ] = delivery_payload( delivery: delivery )
92
+ result[ :main_branch ] = main_branch
84
93
  result[ :summary ] = delivery.summary
85
- result[ :next_step ] = "carson status"
94
+ result[ :next_step ] = deliver_next_step( delivery: delivery, result: result )
86
95
 
87
96
  deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
88
97
  end
@@ -194,7 +203,8 @@ module Carson
194
203
  def assess_delivery!( delivery:, branch_name: )
195
204
  review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
196
205
  ci = check_pr_ci( number: delivery.pull_request_number )
197
- status, cause, summary = delivery_assessment( ci: ci, review: review )
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 )
198
208
 
199
209
  ledger.update_delivery(
200
210
  delivery: delivery,
@@ -207,7 +217,96 @@ module Carson
207
217
  )
208
218
  end
209
219
 
210
- def delivery_assessment( ci:, review: )
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?
223
+
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"
227
+
228
+ loop do
229
+ remaining = deadline - Process.clock_gettime( Process::CLOCK_MONOTONIC )
230
+ break if remaining <= 0
231
+
232
+ sleep [ interval, remaining ].min
233
+ delivery = assess_delivery!( delivery: delivery, branch_name: branch_name )
234
+ break unless delivery_gate_waitable?( delivery: delivery )
235
+ end
236
+
237
+ delivery
238
+ end
239
+
240
+ def delivery_gate_waitable?( delivery: )
241
+ return false unless delivery.status == "gated"
242
+ return true if delivery.cause == "ci"
243
+
244
+ delivery.cause == "review" && delivery.summary == "waiting for review"
245
+ end
246
+
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
252
+ end
253
+
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(
258
+ delivery: delivery,
259
+ status: "integrated",
260
+ integrated_at: Time.now.utc.iso8601,
261
+ summary: "integrated into #{main}"
262
+ )
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(
269
+ delivery: delivery,
270
+ status: "failed",
271
+ cause: "policy",
272
+ summary: "pull request closed without integration"
273
+ )
274
+ end
275
+
276
+ prepared = ledger.update_delivery(
277
+ delivery: delivery,
278
+ status: "integrating",
279
+ summary: "integrating into #{main}"
280
+ )
281
+ merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
282
+ 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
291
+ end
292
+
293
+ merge_error = result.delete( :error )
294
+ 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 ]
300
+ }
301
+ ledger.update_delivery(
302
+ delivery: prepared,
303
+ status: "gated",
304
+ cause: "policy",
305
+ summary: result.dig( :merge, :summary )
306
+ )
307
+ end
308
+
309
+ def delivery_assessment( ci:, review:, pr_state: )
211
310
  return [ "gated", "ci", "waiting for CI checks" ] if ci == :pending
212
311
  return [ "gated", "ci", "CI checks are failing" ] if ci == :fail
213
312
  return [ "gated", "review", "review changes requested" ] if review.fetch( :review, :none ) == :changes_requested
@@ -215,12 +314,28 @@ module Carson
215
314
  return [ "gated", "review", review.fetch( :detail ).to_s ] if review.fetch( :status, :pass ) == :fail
216
315
  return [ "gated", "policy", "unable to assess review gate: #{review.fetch( :detail )}" ] if review.fetch( :status, :pass ) == :error
217
316
 
317
+ merge_result = mergeability_assessment( pr_state: pr_state )
318
+ return merge_result if merge_result
319
+
218
320
  [ "queued", nil, "ready to integrate into #{config.main_branch}" ]
219
321
  end
220
322
 
323
+ def mergeability_assessment( pr_state: )
324
+ return nil unless pr_state.is_a?( Hash )
325
+
326
+ mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
327
+ merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
328
+
329
+ return [ "gated", "merge", "pull request has merge conflicts" ] if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
330
+ 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"
332
+
333
+ nil
334
+ end
335
+
221
336
  def delivery_payload( delivery: )
222
337
  {
223
- id: delivery.id,
338
+ key: delivery.key,
224
339
  status: delivery.status,
225
340
  head: delivery.head,
226
341
  worktree_path: delivery.worktree_path,
@@ -229,6 +344,14 @@ module Carson
229
344
  }
230
345
  end
231
346
 
347
+ def deliver_next_step( delivery:, result: )
348
+ return "carson sync" if delivery.integrated? && result[ :synced ] == false
349
+ return "carson housekeep" if delivery.integrated?
350
+ return "carson status" if delivery.blocked?
351
+
352
+ nil
353
+ end
354
+
232
355
  # Outputs the final result — JSON or human-readable — and returns exit code.
233
356
  def deliver_finish( result:, exit_code:, json_output: )
234
357
  result[ :exit_code ] = exit_code
@@ -250,15 +373,39 @@ module Carson
250
373
  return
251
374
  end
252
375
 
376
+ if result[ :delivery ]
377
+ branch = result[ :branch ]
378
+ main = result[ :main_branch ] || "main"
379
+ puts_line "Delivery: #{branch} → #{main}"
380
+ end
253
381
  if result[ :commit ]
254
- puts_line "Commit: #{result.dig( :commit, :summary )}"
382
+ puts_line "Committed: #{result.dig( :commit, :summary )}"
255
383
  end
256
- puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}" if result[ :pr_number ]
384
+ puts_line "PR ##{result[ :pr_number ]} #{result[ :pr_url ]}" if result[ :pr_number ]
257
385
  if result[ :delivery ]
258
- puts_line "Delivery: #{result.dig( :delivery, :status )}"
386
+ status = result.dig( :delivery, :status )
387
+ summary = result[ :summary ]
388
+ if status == "integrated"
389
+ if result[ :merge_method ]
390
+ puts_line "Merged into #{main} with #{result[ :merge_method ]}."
391
+ else
392
+ puts_line "Merged into #{main}."
393
+ end
394
+ if result[ :synced ] == false
395
+ puts_line "Local #{main} sync failed — #{result[ :sync_error ]}."
396
+ elsif result[ :synced ]
397
+ puts_line "Synced local #{main}."
398
+ end
399
+ elsif status == "gated"
400
+ puts_line "Held at gate — #{summary}."
401
+ puts_line " → #{result.dig( :merge, :recovery )}" if result.dig( :merge, :recovery )
402
+ elsif status == "failed"
403
+ puts_line "Delivery failed — #{summary}."
404
+ else
405
+ puts_line "All clear — #{summary}."
406
+ end
259
407
  end
260
- puts_line "Summary: #{result[ :summary ]}" if result[ :summary ]
261
- puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
408
+ puts_line "Check back with #{result[ :next_step ]}" if result[ :next_step ]
262
409
  end
263
410
 
264
411
  # Pushes the branch to the remote with tracking.
@@ -396,7 +543,7 @@ module Carson
396
543
  def pull_request_state( number: )
397
544
  stdout, _, success, = gh_run(
398
545
  "pr", "view", number.to_s,
399
- "--json", "number,state,isDraft,url"
546
+ "--json", "number,state,isDraft,url,mergeStateStatus,mergeable"
400
547
  )
401
548
  return nil unless success
402
549
 
@@ -16,10 +16,9 @@ module Carson
16
16
  end
17
17
 
18
18
  def govern_cycle!( dry_run:, json_output: )
19
- print_header "Carson Govern"
20
19
  repositories = governed_repo_paths
21
20
  repositories = [ repository_record.path ] if repositories.empty?
22
- puts_line "governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
21
+ print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
23
22
 
24
23
  report = {
25
24
  cycle_at: Time.now.utc.iso8601,
@@ -65,13 +64,12 @@ module Carson
65
64
 
66
65
  def govern_repo!( repo_path:, dry_run: )
67
66
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
68
- repository = Repository.new( path: repo_path, authority: scoped_runtime.config.govern_authority, runtime: scoped_runtime )
67
+ repository = Repository.new( path: repo_path, runtime: scoped_runtime )
69
68
  deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
70
69
 
71
70
  repo_report = {
72
71
  repository: repository.name,
73
72
  path: repo_path,
74
- authority: repository.authority,
75
73
  deliveries: [],
76
74
  error: nil
77
75
  }
@@ -84,7 +82,7 @@ module Carson
84
82
  puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}"
85
83
 
86
84
  reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
87
- next_integration_id = reconciled.find( &:ready? )&.id
85
+ next_to_integrate = reconciled.find( &:ready? )&.key
88
86
 
89
87
  reconciled.each do |delivery|
90
88
  delivery_report = scoped_runtime.send(
@@ -92,7 +90,7 @@ module Carson
92
90
  delivery: delivery,
93
91
  repo_path: repo_path,
94
92
  dry_run: dry_run,
95
- next_integration_id: next_integration_id
93
+ next_to_integrate: next_to_integrate
96
94
  )
97
95
  repo_report[ :deliveries ] << delivery_report
98
96
  end
@@ -140,9 +138,9 @@ module Carson
140
138
  assess_delivery!( delivery: delivery, branch_name: delivery.branch )
141
139
  end
142
140
 
143
- def decide_delivery_action( delivery:, repo_path:, dry_run:, next_integration_id: )
141
+ def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
144
142
  report = {
145
- id: delivery.id,
143
+ key: delivery.key,
146
144
  branch: delivery.branch,
147
145
  status: delivery.status,
148
146
  summary: delivery.summary,
@@ -154,13 +152,18 @@ module Carson
154
152
  return report
155
153
  end
156
154
 
157
- if delivery.ready? && delivery.id == next_integration_id
155
+ if delivery.ready? && delivery.key == next_to_integrate
158
156
  report[ :action ] = dry_run ? "would_integrate" : "integrate"
159
157
  report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
160
158
  return report
161
159
  end
162
160
 
163
161
  if delivery.blocked?
162
+ if merge_blocked_delivery?( delivery: delivery )
163
+ report[ :action ] = dry_run ? "would_hold" : "hold"
164
+ return report
165
+ end
166
+
164
167
  if delivery.revision_count >= 3
165
168
  report[ :action ] = dry_run ? "would_escalate" : "escalate"
166
169
  report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
@@ -244,8 +247,7 @@ module Carson
244
247
  updated = ledger.update_delivery(
245
248
  delivery: delivery,
246
249
  status: "gated",
247
- summary: "revision #{revision.number} completed — waiting for reassessment",
248
- revision_count: revision.number
250
+ summary: "revision #{revision.number} completed — waiting for reassessment"
249
251
  )
250
252
  return reconcile_delivery!( delivery: updated )
251
253
  end
@@ -256,8 +258,7 @@ module Carson
256
258
  ledger.update_delivery(
257
259
  delivery: delivery,
258
260
  status: "gated",
259
- summary: "revision #{revision.number} failed: #{result.summary}",
260
- revision_count: revision.number
261
+ summary: "revision #{revision.number} failed: #{result.summary}"
261
262
  )
262
263
  end
263
264
  end
@@ -287,10 +288,13 @@ module Carson
287
288
  end
288
289
  end
289
290
 
291
+ def merge_blocked_delivery?( delivery: )
292
+ delivery.cause == "merge"
293
+ end
294
+
290
295
  def housekeep_repo!( repo_path: )
291
296
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
292
- sync_status = scoped_runtime.sync!
293
- scoped_runtime.prune! if sync_status == EXIT_OK
297
+ scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
294
298
  end
295
299
 
296
300
  def select_agent_provider
@@ -394,7 +398,7 @@ module Carson
394
398
  end
395
399
 
396
400
  def prior_attempt( delivery: )
397
- revision = ledger.revisions_for_delivery( delivery_id: delivery.id ).last
401
+ revision = delivery.revisions.last
398
402
  return nil unless revision&.failed?
399
403
  { summary: revision.summary.to_s, dispatched_at: revision.started_at.to_s }
400
404
  end
@@ -429,17 +433,40 @@ module Carson
429
433
  next
430
434
  end
431
435
 
432
- if repo_report[ :deliveries ].empty?
433
- puts_line "#{repo_report[ :repository ]}: no active deliveries"
434
- next
435
- end
436
+ next if repo_report[ :deliveries ].empty?
436
437
 
437
438
  repo_report[ :deliveries ].each do |delivery|
438
- puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]}: #{delivery[ :status ]} -> #{delivery[ :action ]}"
439
+ action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ] )
440
+ puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
439
441
  puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
440
442
  end
441
443
  end
442
444
  end
445
+
446
+ def format_govern_action( status:, action: )
447
+ case action
448
+ when "integrate"
449
+ format_govern_integration_outcome( status: status )
450
+ when "would_integrate" then "ready to integrate (dry run)"
451
+ when "hold" then "held at gate"
452
+ when "would_hold" then "would hold at gate (dry run)"
453
+ when "revise" then "revision dispatched"
454
+ when "would_revise" then "would revise (dry run)"
455
+ when "escalate" then "escalated"
456
+ when "would_escalate" then "would escalate (dry run)"
457
+ else status
458
+ end
459
+ end
460
+
461
+ def format_govern_integration_outcome( status: )
462
+ case status
463
+ when "integrated" then "integrated"
464
+ when "gated" then "held at gate"
465
+ when "failed" then "integration failed"
466
+ when "escalated" then "integration escalated"
467
+ else status
468
+ end
469
+ end
443
470
  end
444
471
 
445
472
  include Govern