carson 3.22.1 → 3.23.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.
@@ -1,120 +1,118 @@
1
- # PR delivery lifecycle — push, create PR, and optionally merge.
2
- # Collapses the 8-step manual PR flow into one or two commands.
3
- # `carson deliver` pushes and creates the PR.
4
- # `carson deliver --merge` also merges if CI passes or no checks are configured.
5
- # `carson deliver --json` outputs structured result for agent consumption.
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.
6
3
  module Carson
7
4
  class Runtime
8
5
  module Deliver
9
6
  # Entry point for `carson deliver`.
10
- # Pushes current branch, creates a PR if needed, reports the PR URL.
11
- # With merge: true, also merges if CI passes and cleans up.
12
- def deliver!( merge: false, title: nil, body_file: nil, json_output: false )
13
- branch = current_branch
14
- main = config.main_branch
15
- remote = config.git_remote
16
- result = { command: "deliver", branch: branch }
17
-
18
- # Guard: cannot deliver from main.
19
- if branch == main
20
- result[ :error ] = "cannot deliver from #{main}"
7
+ # Pushes the current branch, ensures a PR exists, records delivery state, and returns.
8
+ def deliver!( title: nil, body_file: nil, json_output: false )
9
+ branch_name = current_branch
10
+ main_branch = config.main_branch
11
+ remote_name = config.git_remote
12
+ result = { command: "deliver", branch: branch_name }
13
+
14
+ if branch_name == main_branch
15
+ result[ :error ] = "cannot deliver from #{main_branch}"
21
16
  result[ :recovery ] = "carson worktree create <name>"
22
17
  return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
23
18
  end
24
19
 
25
- # Step 1: sync managed template files before push.
26
- # `push_prep: true` stages and commits managed drift so the subsequent
27
- # push carries the canonical content even though deliver uses --no-verify.
28
- # Output is captured to prevent pollution of --json mode.
29
- # Diagnostics are preserved for error reporting.
30
- sync_exit, sync_diagnostics = begin
31
- saved_output, saved_error = @output, @error
32
- captured_out = StringIO.new
33
- captured_err = StringIO.new
34
- @output = captured_out
35
- @error = captured_err
36
- exit_code = template_apply!( push_prep: true )
37
- [ exit_code, captured_out.string + captured_err.string ]
38
- rescue StandardError => exception
39
- [ EXIT_ERROR, "template sync error: #{exception.message}" ]
40
- ensure
41
- @output, @error = saved_output, saved_error
42
- end
43
-
20
+ sync_exit, sync_diagnostics = deliver_template_sync
44
21
  if sync_exit == EXIT_ERROR
45
22
  result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
46
23
  return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
47
24
  end
48
25
 
49
- # Step 2: push the branch.
50
- push_exit = push_branch!( branch: branch, remote: remote, result: result )
26
+ push_exit = push_branch!( branch: branch_name, remote: remote_name, result: result )
51
27
  return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
52
28
 
53
- # Step 3: find or create the PR.
54
29
  pr_number, pr_url = find_or_create_pr!(
55
- branch: branch, title: title, body_file: body_file, result: result
30
+ branch: branch_name,
31
+ title: title,
32
+ body_file: body_file,
33
+ result: result
56
34
  )
57
- if pr_number.nil?
58
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
59
- end
35
+ return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output ) if pr_number.nil?
36
+
37
+ branch = branch_record( name: branch_name )
38
+ delivery = ledger.upsert_delivery(
39
+ repository: repository_record,
40
+ branch_name: branch.name,
41
+ head: branch.head || current_head,
42
+ worktree_path: branch.worktree || repo_root,
43
+ authority: config.govern_authority,
44
+ pr_number: pr_number,
45
+ pr_url: pr_url,
46
+ status: "preparing",
47
+ summary: "delivery accepted",
48
+ cause: nil
49
+ )
50
+ delivery = assess_delivery!( delivery: delivery, branch_name: branch.name )
60
51
 
61
52
  result[ :pr_number ] = pr_number
62
53
  result[ :pr_url ] = pr_url
63
- # Without --merge, we are done.
64
- unless merge
65
- return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
66
- end
67
-
68
- # Step 4: check CI status.
69
- ci_status = check_pr_ci( number: pr_number )
70
- result[ :ci ] = ci_status.to_s
71
-
72
- case ci_status
73
- when :pass, :none
74
- # Continue to review gate. :none means no checks configured — nothing to wait for.
75
- when :pending
76
- result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
77
- return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
78
- when :fail
79
- result[ :recovery ] = "gh pr checks #{pr_number} — fix failures, push, then `carson deliver --merge`"
80
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
81
- end
54
+ result[ :ci ] = check_pr_ci( number: pr_number ).to_s
55
+ result[ :delivery ] = delivery_payload( delivery: delivery )
56
+ result[ :summary ] = delivery.summary
57
+ result[ :next_step ] = "carson status"
82
58
 
83
- # Step 5: check review gate block on unresolved review debt.
84
- review = check_pr_review( number: pr_number, branch: branch, pr_url: pr_url )
85
- result[ :review ] = review.fetch( :review ).to_s
86
- if review.fetch( :review ) == :changes_requested
87
- result[ :error ] = "review changes requested on PR ##{pr_number}"
88
- result[ :recovery ] = "address review comments, push, then `carson deliver --merge`"
89
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
90
- end
91
- if review.fetch( :status ) == :fail
92
- result[ :error ] = "review gate blocked on PR ##{pr_number}: #{review.fetch( :detail )}"
93
- result[ :recovery ] = "resolve review gate blockers, push, then `carson deliver --merge`"
94
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
95
- end
96
- if review.fetch( :status ) == :error
97
- result[ :error ] = "unable to evaluate review gate for PR ##{pr_number}: #{review.fetch( :detail )}"
98
- result[ :recovery ] = "run `carson review gate`, then retry `carson deliver --merge`"
99
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
100
- end
59
+ deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
60
+ end
101
61
 
102
- # Step 6: merge.
103
- merge_exit = merge_pr!( number: pr_number, result: result )
104
- return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
62
+ private
105
63
 
106
- result[ :merged ] = true
64
+ def deliver_template_sync
65
+ saved_output, saved_error = @output, @error
66
+ captured_out = StringIO.new
67
+ captured_err = StringIO.new
68
+ @output = captured_out
69
+ @error = captured_err
70
+ exit_code = template_apply!( push_prep: true )
71
+ [ exit_code, captured_out.string + captured_err.string ]
72
+ rescue StandardError => exception
73
+ [ EXIT_ERROR, "template sync error: #{exception.message}" ]
74
+ ensure
75
+ @output, @error = saved_output, saved_error
76
+ end
107
77
 
108
- # Step 7: sync main in the main worktree.
109
- sync_after_merge!( remote: remote, main: main, result: result )
78
+ # Assesses delivery readiness and records Carson's current branch state.
79
+ def assess_delivery!( delivery:, branch_name: )
80
+ review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
81
+ ci = check_pr_ci( number: delivery.pull_request_number )
82
+ status, cause, summary = delivery_assessment( ci: ci, review: review )
83
+
84
+ ledger.update_delivery(
85
+ delivery: delivery,
86
+ status: status,
87
+ cause: cause,
88
+ summary: summary,
89
+ pr_number: delivery.pull_request_number,
90
+ pr_url: delivery.pull_request_url,
91
+ worktree_path: delivery.worktree_path
92
+ )
93
+ end
110
94
 
111
- # Step 8: compute next-step guidance for the agent.
112
- compute_post_merge_next_step!( result: result )
95
+ def delivery_assessment( ci:, review: )
96
+ return [ "gated", "ci", "waiting for CI checks" ] if ci == :pending
97
+ return [ "gated", "ci", "CI checks are failing" ] if ci == :fail
98
+ return [ "gated", "review", "review changes requested" ] if review.fetch( :review, :none ) == :changes_requested
99
+ return [ "gated", "review", "waiting for review" ] if review.fetch( :review, :none ) == :review_required
100
+ return [ "gated", "review", review.fetch( :detail ).to_s ] if review.fetch( :status, :pass ) == :fail
101
+ return [ "gated", "policy", "unable to assess review gate: #{review.fetch( :detail )}" ] if review.fetch( :status, :pass ) == :error
113
102
 
114
- deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
103
+ [ "queued", nil, "ready to integrate into #{config.main_branch}" ]
115
104
  end
116
105
 
117
- private
106
+ def delivery_payload( delivery: )
107
+ {
108
+ id: delivery.id,
109
+ status: delivery.status,
110
+ head: delivery.head,
111
+ worktree_path: delivery.worktree_path,
112
+ revision_count: delivery.revision_count,
113
+ cause: delivery.cause
114
+ }
115
+ end
118
116
 
119
117
  # Outputs the final result — JSON or human-readable — and returns exit code.
120
118
  def deliver_finish( result:, exit_code:, json_output: )
@@ -131,47 +129,21 @@ module Carson
131
129
 
132
130
  # Human-readable output for deliver results.
133
131
  def print_deliver_human( result: )
134
- exit_code = result.fetch( :exit_code )
135
-
136
132
  if result[ :error ]
137
133
  puts_line result[ :error ]
138
134
  puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
139
135
  return
140
136
  end
141
137
 
142
- if result[ :pr_number ]
143
- puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}"
144
- end
145
-
146
- if result[ :ci ]
147
- ci = result[ :ci ]
148
- case ci
149
- when "pass"
150
- puts_line "CI: pass"
151
- when "none"
152
- puts_line "CI: none — no checks configured, proceeding."
153
- when "pending"
154
- puts_line "CI: pending — merge when checks complete."
155
- puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
156
- when "fail"
157
- puts_line "CI: not passing yet — fix before merging."
158
- puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
159
- end
160
- end
161
-
162
- if result[ :merged ]
163
- puts_line "Merged PR ##{result[ :pr_number ]} via #{result[ :merge_method ]}."
164
- puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
138
+ puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}" if result[ :pr_number ]
139
+ if result[ :delivery ]
140
+ puts_line "Delivery: #{result.dig( :delivery, :status )}"
165
141
  end
142
+ puts_line "Summary: #{result[ :summary ]}" if result[ :summary ]
143
+ puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
166
144
  end
167
145
 
168
146
  # Pushes the branch to the remote with tracking.
169
- # Uses --no-verify to skip the pre-push hook that Carson itself installed.
170
- # The hook blocks raw pushes unconditionally; Carson bypasses by skipping it.
171
- # Template sync (previously in the hook) now runs in deliver! before push.
172
- # On non-fast-forward rejection (typically after rebase), retries with
173
- # --force-with-lease — a protected force push that rejects if the remote
174
- # ref has been updated by another actor since the last fetch.
175
147
  def push_branch!( branch:, remote:, result: )
176
148
  _, push_stderr, push_success, = git_run( "push", "--no-verify", "-u", remote, branch )
177
149
 
@@ -190,9 +162,6 @@ module Carson
190
162
  end
191
163
 
192
164
  # Retries push with --force-with-lease after a non-fast-forward rejection.
193
- # The lease check compares the local tracking ref against the remote — if
194
- # another actor pushed since our last fetch, the push is refused ("stale info").
195
- # This is atomic and safe, unlike delete-and-re-push.
196
165
  def force_push_with_lease!( branch:, remote:, result: )
197
166
  puts_verbose "push rejected (non-fast-forward), retrying with --force-with-lease"
198
167
  _, lease_stderr, lease_success, = git_run( "push", "--no-verify", "--force-with-lease", "-u", remote, branch )
@@ -202,7 +171,6 @@ module Carson
202
171
  return EXIT_OK
203
172
  end
204
173
 
205
- # --force-with-lease rejected — another actor pushed to this branch.
206
174
  if lease_stderr.to_s.include?( "stale info" )
207
175
  result[ :error ] = "force-with-lease rejected — another push landed on #{branch} since your last fetch"
208
176
  result[ :recovery ] = "git fetch #{remote} #{branch} && carson deliver"
@@ -215,21 +183,14 @@ module Carson
215
183
  end
216
184
 
217
185
  # Finds an existing PR for the branch, or creates a new one.
218
- # Returns [number, url] or [nil, nil] on failure.
219
186
  def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
220
- # Check for existing PR.
221
187
  existing = find_existing_pr( branch: branch )
222
188
  return existing if existing.first
223
189
 
224
- # Create a new PR.
225
190
  create_pr!( branch: branch, title: title, body_file: body_file, result: result )
226
191
  end
227
192
 
228
193
  # Queries gh for an open PR on this branch.
229
- # Returns [number, url] or [nil, nil].
230
- # gh pr view returns any PR on the branch — open, merged, or closed.
231
- # We check state explicitly so merged/closed PRs are treated as absent,
232
- # letting find_or_create_pr! fall through to create a new PR.
233
194
  def find_existing_pr( branch: )
234
195
  stdout, _, success, = gh_run(
235
196
  "pr", "view", branch,
@@ -245,11 +206,10 @@ module Carson
245
206
  end
246
207
 
247
208
  # Creates a PR via gh. Title defaults to branch name humanised.
248
- # Returns [number, url] or [nil, nil] on failure.
249
209
  def create_pr!( branch:, title: nil, body_file: nil, result: )
250
210
  pr_title = title || default_pr_title( branch: branch )
251
-
252
211
  args = [ "pr", "create", "--title", pr_title, "--head", branch ]
212
+
253
213
  if body_file && File.exist?( body_file )
254
214
  args.push( "--body-file", body_file )
255
215
  else
@@ -265,24 +225,18 @@ module Carson
265
225
  return [ nil, nil ]
266
226
  end
267
227
 
268
- # gh pr create prints the URL on success. Parse number from it.
269
228
  pr_url = stdout.to_s.strip
270
229
  pr_number = pr_url.split( "/" ).last.to_i
271
- if pr_number > 0
272
- [ pr_number, pr_url ]
273
- else
274
- # Fallback: query the just-created PR.
275
- find_existing_pr( branch: branch )
276
- end
230
+ return [ pr_number, pr_url ] if pr_number > 0
231
+
232
+ find_existing_pr( branch: branch )
277
233
  end
278
234
 
279
- # Generates a default PR title from the branch name.
280
235
  def default_pr_title( branch: )
281
- branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { it.upcase }
236
+ branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { |character| character.upcase }
282
237
  end
283
238
 
284
239
  # Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
285
- # Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
286
240
  def check_pr_ci( number: )
287
241
  stdout, _, success, = gh_run(
288
242
  "pr", "checks", number.to_s,
@@ -293,7 +247,7 @@ module Carson
293
247
  checks = JSON.parse( stdout ) rescue []
294
248
  return :none if checks.empty?
295
249
 
296
- buckets = checks.map { it[ "bucket" ].to_s.downcase }
250
+ buckets = checks.map { |entry| entry[ "bucket" ].to_s.downcase }
297
251
  return :fail if buckets.include?( "fail" )
298
252
  return :pending if buckets.include?( "pending" )
299
253
 
@@ -320,10 +274,20 @@ module Carson
320
274
  { status: :error, review: :error, detail: exception.message }
321
275
  end
322
276
 
323
- # Merges the PR using the configured merge method.
324
- # Deliberately omits --delete-branch: gh tries to switch the local
325
- # checkout to main afterwards, which fails inside a worktree where
326
- # main is already checked output. Branch cleanup deferred to `carson prune`.
277
+ # Returns the current PR state for govern reconciliation.
278
+ def pull_request_state( number: )
279
+ stdout, _, success, = gh_run(
280
+ "pr", "view", number.to_s,
281
+ "--json", "number,state,isDraft,url"
282
+ )
283
+ return nil unless success
284
+
285
+ JSON.parse( stdout )
286
+ rescue JSON::ParserError
287
+ nil
288
+ end
289
+
290
+ # Merges the PR using the governed merge method.
327
291
  def merge_pr!( number:, result: )
328
292
  method = config.govern_merge_method
329
293
  result[ :merge_method ] = method
@@ -345,9 +309,6 @@ module Carson
345
309
  end
346
310
 
347
311
  # Syncs main after a successful merge.
348
- # Pulls into the main worktree directly — does not attempt checkout,
349
- # because checkout would fail when running inside a feature worktree
350
- # (main is already checked output in the main tree).
351
312
  def sync_after_merge!( remote:, main:, result: )
352
313
  main_root = main_worktree_root
353
314
  _, pull_stderr, pull_success, = Open3.capture3(
@@ -362,24 +323,6 @@ module Carson
362
323
  puts_verbose "sync failed: #{pull_stderr.to_s.strip}"
363
324
  end
364
325
  end
365
-
366
- # Builds next-step guidance after a successful merge.
367
- # Detects whether the agent is inside a worktree and suggests cleanup.
368
- def compute_post_merge_next_step!( result: )
369
- main_root = main_worktree_root
370
- current_wt = worktree_list
371
- .reject { it.path == realpath_safe( main_root ) }
372
- .find { it.holds_cwd? }
373
-
374
- if current_wt
375
- wt_name = File.basename( current_wt.path )
376
- result[ :next_step ] = "cd #{main_root} && carson worktree remove #{wt_name}"
377
- else
378
- result[ :next_step ] = "carson prune"
379
- end
380
- rescue StandardError
381
- # Best-effort — do not fail deliver because of next-step detection.
382
- end
383
326
  end
384
327
 
385
328
  include Deliver