carson 3.21.1 → 3.22.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,7 +1,7 @@
1
1
  # PR delivery lifecycle — push, create PR, and optionally merge.
2
2
  # Collapses the 8-step manual PR flow into one or two commands.
3
3
  # `carson deliver` pushes and creates the PR.
4
- # `carson deliver --merge` also merges if CI is green.
4
+ # `carson deliver --merge` also merges if CI passes or no checks are configured.
5
5
  # `carson deliver --json` outputs structured result for agent consumption.
6
6
  module Carson
7
7
  class Runtime
@@ -18,15 +18,39 @@ module Carson
18
18
  # Guard: cannot deliver from main.
19
19
  if branch == main
20
20
  result[ :error ] = "cannot deliver from #{main}"
21
- result[ :recovery ] = "git checkout -b <branch-name>"
22
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
21
+ result[ :recovery ] = "carson worktree create <name>"
22
+ return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
23
+ end
24
+
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
23
42
  end
24
43
 
25
- # Step 1: push the branch.
44
+ if sync_exit == EXIT_ERROR
45
+ result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
46
+ return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
47
+ end
48
+
49
+ # Step 2: push the branch.
26
50
  push_exit = push_branch!( branch: branch, remote: remote, result: result )
27
51
  return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
28
52
 
29
- # Step 2: find or create the PR.
53
+ # Step 3: find or create the PR.
30
54
  pr_number, pr_url = find_or_create_pr!(
31
55
  branch: branch, title: title, body_file: body_file, result: result
32
56
  )
@@ -41,43 +65,50 @@ module Carson
41
65
  return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
42
66
  end
43
67
 
44
- # Step 3: check CI status.
68
+ # Step 4: check CI status.
45
69
  ci_status = check_pr_ci( number: pr_number )
46
70
  result[ :ci ] = ci_status.to_s
47
71
 
48
72
  case ci_status
49
- when :pass
50
- # Continue to review gate.
73
+ when :pass, :none
74
+ # Continue to review gate. :none means no checks configured — nothing to wait for.
51
75
  when :pending
52
76
  result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
53
77
  return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
54
78
  when :fail
55
79
  result[ :recovery ] = "gh pr checks #{pr_number} — fix failures, push, then `carson deliver --merge`"
56
80
  return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
57
- else
58
- result[ :recovery ] = "gh pr checks #{pr_number}"
59
- return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
60
81
  end
61
82
 
62
- # Step 4: check review gate — block if changes are requested.
63
- review = check_pr_review( number: pr_number )
64
- result[ :review ] = review.to_s
65
- if review == :changes_requested
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
66
87
  result[ :error ] = "review changes requested on PR ##{pr_number}"
67
88
  result[ :recovery ] = "address review comments, push, then `carson deliver --merge`"
68
89
  return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
69
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
70
101
 
71
- # Step 5: merge.
102
+ # Step 6: merge.
72
103
  merge_exit = merge_pr!( number: pr_number, result: result )
73
104
  return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
74
105
 
75
106
  result[ :merged ] = true
76
107
 
77
- # Step 6: sync main in the main worktree.
108
+ # Step 7: sync main in the main worktree.
78
109
  sync_after_merge!( remote: remote, main: main, result: result )
79
110
 
80
- # Step 7: compute next-step guidance for the agent.
111
+ # Step 8: compute next-step guidance for the agent.
81
112
  compute_post_merge_next_step!( result: result )
82
113
 
83
114
  deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
@@ -103,8 +134,8 @@ module Carson
103
134
  exit_code = result.fetch( :exit_code )
104
135
 
105
136
  if result[ :error ]
106
- puts_line "ERROR: #{result[ :error ]}"
107
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
137
+ puts_line result[ :error ]
138
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
108
139
  return
109
140
  end
110
141
 
@@ -117,15 +148,14 @@ module Carson
117
148
  case ci
118
149
  when "pass"
119
150
  puts_line "CI: pass"
151
+ when "none"
152
+ puts_line "CI: none — no checks configured, proceeding."
120
153
  when "pending"
121
154
  puts_line "CI: pending — merge when checks complete."
122
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
155
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
123
156
  when "fail"
124
- puts_line "CI: failing — fix before merging."
125
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
126
- else
127
- puts_line "CI: #{ci} — check manually."
128
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
157
+ puts_line "CI: not passing yet — fix before merging."
158
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
129
159
  end
130
160
  end
131
161
 
@@ -136,22 +166,54 @@ module Carson
136
166
  end
137
167
 
138
168
  # Pushes the branch to the remote with tracking.
139
- # Sets CARSON_PUSH=1 so the pre-push hook knows this is a Carson-managed push.
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.
140
175
  def push_branch!( branch:, remote:, result: )
141
- _, push_stderr, push_success, = with_env_var( "CARSON_PUSH", "1" ) do
142
- git_run( "push", "-u", remote, branch )
176
+ _, push_stderr, push_success, = git_run( "push", "--no-verify", "-u", remote, branch )
177
+
178
+ if !push_success && push_stderr.to_s.include?( "non-fast-forward" )
179
+ return force_push_with_lease!( branch: branch, remote: remote, result: result )
143
180
  end
181
+
144
182
  unless push_success
145
183
  error_text = push_stderr.to_s.strip
146
184
  error_text = "push failed" if error_text.empty?
147
185
  result[ :error ] = error_text
148
- result[ :recovery ] = "git pull #{remote} #{branch} --rebase && git push -u #{remote} #{branch}"
149
186
  return EXIT_ERROR
150
187
  end
151
188
  puts_verbose "pushed #{branch} to #{remote}"
152
189
  EXIT_OK
153
190
  end
154
191
 
192
+ # 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
+ def force_push_with_lease!( branch:, remote:, result: )
197
+ puts_verbose "push rejected (non-fast-forward), retrying with --force-with-lease"
198
+ _, lease_stderr, lease_success, = git_run( "push", "--no-verify", "--force-with-lease", "-u", remote, branch )
199
+
200
+ if lease_success
201
+ puts_verbose "pushed #{branch} to #{remote} (force-with-lease)"
202
+ return EXIT_OK
203
+ end
204
+
205
+ # --force-with-lease rejected — another actor pushed to this branch.
206
+ if lease_stderr.to_s.include?( "stale info" )
207
+ result[ :error ] = "force-with-lease rejected — another push landed on #{branch} since your last fetch"
208
+ result[ :recovery ] = "git fetch #{remote} #{branch} && carson deliver"
209
+ else
210
+ error_text = lease_stderr.to_s.strip
211
+ error_text = "push failed (force-with-lease)" if error_text.empty?
212
+ result[ :error ] = error_text
213
+ end
214
+ EXIT_ERROR
215
+ end
216
+
155
217
  # Finds an existing PR for the branch, or creates a new one.
156
218
  # Returns [number, url] or [nil, nil] on failure.
157
219
  def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
@@ -165,14 +227,17 @@ module Carson
165
227
 
166
228
  # Queries gh for an open PR on this branch.
167
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.
168
233
  def find_existing_pr( branch: )
169
234
  stdout, _, success, = gh_run(
170
235
  "pr", "view", branch,
171
- "--json", "number,url"
236
+ "--json", "number,url,state"
172
237
  )
173
238
  if success
174
239
  data = JSON.parse( stdout ) rescue nil
175
- if data && data[ "number" ]
240
+ if data && data[ "number" ] && data[ "state" ] == "OPEN"
176
241
  return [ data[ "number" ], data[ "url" ].to_s ]
177
242
  end
178
243
  end
@@ -235,22 +300,24 @@ module Carson
235
300
  :pass
236
301
  end
237
302
 
238
- # Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
239
- def check_pr_review( number: )
240
- stdout, _, success, = gh_run(
241
- "pr", "view", number.to_s,
242
- "--json", "reviewDecision"
303
+ # Checks the full review gate on a PR. Returns a structured result hash.
304
+ def check_pr_review( number:, branch:, pr_url: nil )
305
+ owner, repo = repository_coordinates
306
+ report = review_gate_report_for_pr(
307
+ owner: owner,
308
+ repo: repo,
309
+ pr_number: number,
310
+ branch_name: branch,
311
+ pr_summary: {
312
+ number: number,
313
+ title: "",
314
+ url: pr_url.to_s,
315
+ state: "OPEN"
316
+ }
243
317
  )
244
- return :none unless success
245
-
246
- data = JSON.parse( stdout ) rescue {}
247
- decision = data[ "reviewDecision" ].to_s.strip.upcase
248
- case decision
249
- when "APPROVED" then :approved
250
- when "CHANGES_REQUESTED" then :changes_requested
251
- when "REVIEW_REQUIRED" then :review_required
252
- else :none
253
- end
318
+ review_gate_result( report: report )
319
+ rescue StandardError => exception
320
+ { status: :error, review: :error, detail: exception.message }
254
321
  end
255
322
 
256
323
  # Merges the PR using the configured merge method.
@@ -300,12 +367,12 @@ module Carson
300
367
  # Detects whether the agent is inside a worktree and suggests cleanup.
301
368
  def compute_post_merge_next_step!( result: )
302
369
  main_root = main_worktree_root
303
- cwd = realpath_safe( Dir.pwd )
304
- current_wt = worktree_list.select { it.fetch( :path ) != realpath_safe( main_root ) }
305
- .find { cwd == it.fetch( :path ) || cwd.start_with?( File.join( it.fetch( :path ), "" ) ) }
370
+ current_wt = worktree_list
371
+ .reject { it.path == realpath_safe( main_root ) }
372
+ .find { it.holds_cwd? }
306
373
 
307
374
  if current_wt
308
- wt_name = File.basename( current_wt.fetch( :path ) )
375
+ wt_name = File.basename( current_wt.path )
309
376
  result[ :next_step ] = "cd #{main_root} && carson worktree remove #{wt_name}"
310
377
  else
311
378
  result[ :next_step ] = "carson prune"
@@ -56,7 +56,7 @@ module Carson
56
56
 
57
57
  EXIT_OK
58
58
  rescue StandardError => exception
59
- puts_line "ERROR: govern failed #{exception.message}"
59
+ puts_line "Govern did not complete: #{exception.message}"
60
60
  EXIT_ERROR
61
61
  end
62
62
 
@@ -70,7 +70,7 @@ module Carson
70
70
  begin
71
71
  govern_cycle!( dry_run: dry_run, json_output: json_output )
72
72
  rescue StandardError => exception
73
- puts_line "ERROR: cycle #{cycle_count} failed #{exception.message}"
73
+ puts_line "Cycle #{cycle_count} did not complete: #{exception.message}"
74
74
  end
75
75
  puts_line "sleeping #{loop_seconds}s until next cycle…"
76
76
  sleep loop_seconds
@@ -88,7 +88,7 @@ module Carson
88
88
  config.govern_repos.map do |path|
89
89
  expanded = File.expand_path( path )
90
90
  unless Dir.exist?( expanded )
91
- puts_line "WARN: governed repo path does not exist: #{expanded}"
91
+ puts_line "Skipping #{expanded} path not found"
92
92
  next nil
93
93
  end
94
94
  expanded
@@ -107,14 +107,14 @@ module Carson
107
107
 
108
108
  unless Dir.exist?( repo_path )
109
109
  repo_report[ :error ] = "path does not exist"
110
- puts_line "ERROR: #{repo_path} does not exist"
110
+ puts_line "#{repo_path}: path not found, skipping"
111
111
  return repo_report
112
112
  end
113
113
 
114
114
  prs = list_open_prs( repo_path: repo_path )
115
115
  if prs.nil?
116
116
  repo_report[ :error ] = "failed to list open PRs"
117
- puts_line "ERROR: failed to list open PRs for #{repo_path}"
117
+ puts_line "#{File.basename(repo_path)}: unable to list open PRs"
118
118
  return repo_report
119
119
  end
120
120
 
@@ -196,8 +196,12 @@ module Carson
196
196
  if review_decision == "CHANGES_REQUESTED"
197
197
  return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
198
198
  end
199
+ if review_decision == "REVIEW_REQUIRED"
200
+ return [ TRIAGE_REVIEW_BLOCKED, "review required" ]
201
+ end
199
202
 
200
203
  review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
204
+ return [ TRIAGE_NEEDS_ATTENTION, review_detail ] if review_status == :error
201
205
  return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
202
206
 
203
207
  [ TRIAGE_READY, "all gates pass" ]
@@ -231,17 +235,25 @@ module Carson
231
235
 
232
236
  # Checks review gate status. Returns [:pass/:fail, detail].
233
237
  def check_review_gate_status( pr:, repo_path: )
234
- review_decision = pr[ "reviewDecision" ].to_s.upcase
235
- case review_decision
236
- when "APPROVED"
237
- [ :pass, "approved" ]
238
- when "CHANGES_REQUESTED"
239
- [ :fail, "changes requested" ]
240
- when "REVIEW_REQUIRED"
241
- [ :fail, "review required" ]
242
- else
243
- [ :pass, "no review policy or approved" ]
244
- end
238
+ repo_runtime = scoped_runtime( repo_path: repo_path )
239
+ owner, repo = repo_runtime.send( :repository_coordinates )
240
+ report = repo_runtime.send(
241
+ :review_gate_report_for_pr,
242
+ owner: owner,
243
+ repo: repo,
244
+ pr_number: pr.fetch( "number" ),
245
+ branch_name: pr.fetch( "headRefName" ).to_s,
246
+ pr_summary: {
247
+ number: pr.fetch( "number" ),
248
+ title: pr.fetch( "title" ).to_s,
249
+ url: pr.fetch( "url" ).to_s,
250
+ state: "OPEN"
251
+ }
252
+ )
253
+ result = repo_runtime.send( :review_gate_result, report: report )
254
+ [ result.fetch( :status ), result.fetch( :detail ) ]
255
+ rescue StandardError => exception
256
+ [ :error, "review gate check failed: #{exception.message}" ]
245
257
  end
246
258
 
247
259
  # Maps classification to action.
@@ -296,7 +308,7 @@ module Carson
296
308
  housekeep_repo!( repo_path: repo_path )
297
309
  else
298
310
  error_text = stderr_text.to_s.strip
299
- puts_line " merge failed: #{error_text}"
311
+ puts_line " merge did not succeed: #{error_text}"
300
312
  end
301
313
  end
302
314