carson 3.24.0 → 3.27.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,11 +1,10 @@
1
1
  # Passive repository record reconstructed from git state and Carson's ledger.
2
2
  module Carson
3
3
  class Repository
4
- attr_reader :path, :authority
4
+ attr_reader :path
5
5
 
6
- def initialize( path:, authority:, runtime: )
6
+ def initialize( path:, runtime: )
7
7
  @path = File.expand_path( path )
8
- @authority = authority
9
8
  @runtime = runtime
10
9
  end
11
10
 
@@ -35,7 +34,6 @@ module Carson
35
34
  {
36
35
  name: name,
37
36
  path: path,
38
- authority: authority,
39
37
  branches: runtime.ledger.active_deliveries( repo_path: path ).map { |delivery| delivery.branch }
40
38
  }
41
39
  end
@@ -1,11 +1,9 @@
1
1
  # Passive ledger record for one feedback-driven revision cycle.
2
2
  module Carson
3
3
  class Revision
4
- attr_reader :id, :delivery_id, :number, :cause, :provider, :status, :started_at, :finished_at, :summary
4
+ attr_reader :number, :cause, :provider, :status, :started_at, :finished_at, :summary
5
5
 
6
- def initialize( id:, delivery_id:, number:, cause:, provider:, status:, started_at:, finished_at:, summary: )
7
- @id = id
8
- @delivery_id = delivery_id
6
+ def initialize( number:, cause:, provider:, status:, started_at:, finished_at:, summary: )
9
7
  @number = number
10
8
  @cause = cause
11
9
  @provider = provider
@@ -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