carson 3.22.0 → 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,384 +1,36 @@
1
- # Safe worktree lifecycle management for coding agents.
2
- # Two operations: create and remove. Create auto-syncs main before branching.
3
- # Remove is safe by default guards against CWD-inside-worktree, cross-process
4
- # CWD holds, and unpushed commits. Content-aware: allows removal after
5
- # squash/rebase merge without --force.
6
- # Supports --json for machine-readable structured output with recovery commands.
1
+ # Thin worktree delegate layer on Runtime.
2
+ # Lifecycle operations live on Carson::Worktree; this module delegates
3
+ # and keeps only methods that genuinely belong on Runtime (path resolution,
4
+ # CWD branch detection).
7
5
  module Carson
8
6
  class Runtime
9
7
  module Local
10
8
 
11
- # Agent directory names whose worktrees Carson may sweep.
12
- AGENT_WORKTREE_DIRS = %w[ .claude .codex ].freeze
9
+ # --- Delegates to Carson::Worktree ---
13
10
 
14
- # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
15
- # Uses main_worktree_root so this works even when called from inside a worktree.
11
+ # Creates a new worktree under .claude/worktrees/<name>.
16
12
  def worktree_create!( name:, json_output: false )
17
- worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
18
- worktree_path = File.join( worktrees_dir, name )
19
-
20
- if Dir.exist?( worktree_path )
21
- return worktree_finish(
22
- result: { command: "worktree create", status: "error", name: name, path: worktree_path,
23
- error: "worktree already exists: #{name}",
24
- recovery: "carson worktree remove #{name}, then retry" },
25
- exit_code: EXIT_ERROR, json_output: json_output
26
- )
27
- end
28
-
29
- # Determine the base branch (main branch from config).
30
- base = config.main_branch
31
-
32
- # Sync main from remote before branching so the worktree starts
33
- # from the latest code. Prevents stale-base merge conflicts later.
34
- # Best-effort — if pull fails (non-ff, offline), continue anyway.
35
- main_root = main_worktree_root
36
- _, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", config.git_remote, base )
37
- puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}"
38
-
39
- # Ensure .claude/ is excluded from git status in the host repository.
40
- # Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
41
- ensure_claude_dir_excluded!
42
-
43
- # Create the worktree with a new branch based on the main branch.
44
- FileUtils.mkdir_p( worktrees_dir )
45
- _, worktree_stderr, worktree_success, = git_run( "worktree", "add", worktree_path, "-b", name, base )
46
- unless worktree_success
47
- error_text = worktree_stderr.to_s.strip
48
- error_text = "unable to create worktree" if error_text.empty?
49
- return worktree_finish(
50
- result: { command: "worktree create", status: "error", name: name,
51
- error: error_text },
52
- exit_code: EXIT_ERROR, json_output: json_output
53
- )
54
- end
55
-
56
- worktree_finish(
57
- result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
58
- exit_code: EXIT_OK, json_output: json_output
59
- )
13
+ Worktree.create!( name: name, runtime: self, json_output: json_output )
60
14
  end
61
15
 
62
16
  # Removes a worktree: directory, git registration, and branch.
63
- # Never forces removal — if the worktree has uncommitted changes, refuses unless
64
- # the user explicitly passes force: true via CLI --force flag.
65
17
  def worktree_remove!( worktree_path:, force: false, json_output: false )
66
- fingerprint_status = block_if_outsider_fingerprints!
67
- unless fingerprint_status.nil?
68
- if json_output
69
- output.puts JSON.pretty_generate( {
70
- command: "worktree remove", status: "block",
71
- error: "Carson-owned artefacts detected in host repository",
72
- recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
73
- exit_code: EXIT_BLOCK
74
- } )
75
- end
76
- return fingerprint_status
77
- end
78
-
79
- resolved_path = resolve_worktree_path( worktree_path: worktree_path )
80
-
81
- # Missing directory: worktree was destroyed externally (e.g. gh pr merge
82
- # --delete-branch). Clean up the stale git registration and delete the branch.
83
- if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
84
- return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
85
- end
86
-
87
- unless worktree_registered?( path: resolved_path )
88
- return worktree_finish(
89
- result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
90
- error: "#{resolved_path} is not a registered worktree",
91
- recovery: "git worktree list" },
92
- exit_code: EXIT_ERROR, json_output: json_output
93
- )
94
- end
95
-
96
- # Safety: refuse if the caller's shell CWD is inside the worktree.
97
- # Removing a directory while a shell is inside it kills the shell permanently.
98
- if cwd_inside_worktree?( worktree_path: resolved_path )
99
- safe_root = main_worktree_root
100
- return worktree_finish(
101
- result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
102
- error: "current working directory is inside this worktree",
103
- recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
104
- exit_code: EXIT_BLOCK, json_output: json_output
105
- )
106
- end
107
-
108
- # Safety: refuse if another process has its CWD inside the worktree.
109
- # Protects against cross-process CWD crashes (e.g. an agent session
110
- # removed by a separate cleanup process while the agent's shell is inside).
111
- if worktree_held_by_other_process?( worktree_path: resolved_path )
112
- return worktree_finish(
113
- result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
114
- error: "another process has its working directory inside this worktree",
115
- recovery: "wait for the other session to finish, then retry" },
116
- exit_code: EXIT_BLOCK, json_output: json_output
117
- )
118
- end
119
-
120
- branch = worktree_branch( path: resolved_path )
121
- puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
122
-
123
- # Safety: refuse if the branch has unpushed commits (unless --force).
124
- # Prevents accidental destruction of work that exists only locally.
125
- unless force
126
- unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
127
- if unpushed
128
- return worktree_finish(
129
- result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
130
- branch: branch,
131
- error: unpushed[ :error ],
132
- recovery: unpushed[ :recovery ] },
133
- exit_code: EXIT_BLOCK, json_output: json_output
134
- )
135
- end
136
- end
137
-
138
- # Step 1: remove the worktree (directory + git registration).
139
- rm_args = [ "worktree", "remove" ]
140
- rm_args << "--force" if force
141
- rm_args << resolved_path
142
- rm_stdout, rm_stderr, rm_success, = git_run( *rm_args )
143
- unless rm_success
144
- error_text = rm_stderr.to_s.strip
145
- error_text = "unable to remove worktree" if error_text.empty?
146
- if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
147
- return worktree_finish(
148
- result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
149
- error: "worktree has uncommitted changes",
150
- recovery: "commit or discard changes first, or use --force to override" },
151
- exit_code: EXIT_ERROR, json_output: json_output
152
- )
153
- end
154
- return worktree_finish(
155
- result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
156
- error: error_text },
157
- exit_code: EXIT_ERROR, json_output: json_output
158
- )
159
- end
160
- puts_verbose "worktree_removed: #{resolved_path}"
161
-
162
- # Step 2: delete the local branch.
163
- branch_deleted = false
164
- if branch && !config.protected_branches.include?( branch )
165
- _, del_stderr, del_success, = git_run( "branch", "-D", branch )
166
- if del_success
167
- puts_verbose "branch_deleted: #{branch}"
168
- branch_deleted = true
169
- else
170
- puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
171
- end
172
- end
173
-
174
- # Step 3: delete the remote branch (best-effort).
175
- remote_deleted = false
176
- if branch && !config.protected_branches.include?( branch )
177
- remote_branch = branch
178
- _, _, rd_success, = git_run( "push", config.git_remote, "--delete", remote_branch )
179
- if rd_success
180
- puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
181
- remote_deleted = true
182
- end
183
- end
184
- worktree_finish(
185
- result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
186
- branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
187
- exit_code: EXIT_OK, json_output: json_output
188
- )
18
+ Worktree.remove!( path: worktree_path, runtime: self, force: force, json_output: json_output )
189
19
  end
190
20
 
191
21
  # Removes agent-owned worktrees whose branch content is already on main.
192
- # Scans AGENT_WORKTREE_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
193
- # under the main repo root. Safe: skips detached HEADs, the caller's CWD,
194
- # and dirty working trees (git worktree remove refuses without --force).
195
22
  def sweep_stale_worktrees!
196
- main_root = main_worktree_root
197
- worktrees = worktree_list
198
-
199
- agent_prefixes = AGENT_WORKTREE_DIRS.filter_map do |dir|
200
- full = File.join( main_root, dir, "worktrees" )
201
- File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
202
- end
203
- return if agent_prefixes.empty?
204
-
205
- worktrees.each do |worktree|
206
- path = worktree.fetch( :path )
207
- branch = worktree.fetch( :branch, nil )
208
- next unless branch
209
- next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
210
- next if cwd_inside_worktree?( worktree_path: path )
211
- next if worktree_held_by_other_process?( worktree_path: path )
212
- next unless branch_absorbed_into_main?( branch: branch )
213
-
214
- # Remove the worktree (no --force: refuses if dirty working tree).
215
- _, _, rm_success, = git_run( "worktree", "remove", path )
216
- next unless rm_success
217
-
218
- puts_verbose "swept stale worktree: #{File.basename( path )} (branch: #{branch})"
219
-
220
- # Delete the local branch now that no worktree holds it.
221
- if !config.protected_branches.include?( branch )
222
- git_run( "branch", "-D", branch )
223
- puts_verbose "deleted branch: #{branch}"
224
- end
225
- end
226
- end
227
-
228
- private
229
-
230
- # Handles removal when the worktree directory is already gone (destroyed
231
- # externally by gh pr merge --delete-branch or manual deletion).
232
- # Prunes the stale git worktree entry and cleans up the branch.
233
- def worktree_remove_missing!( resolved_path:, json_output: )
234
- branch = worktree_branch( path: resolved_path )
235
- puts_verbose "worktree_remove_missing: path=#{resolved_path} branch=#{branch}"
236
-
237
- # Prune the stale worktree entry from git's registry.
238
- git_run( "worktree", "prune" )
239
- puts_verbose "pruned stale worktree entry: #{resolved_path}"
240
-
241
- # Delete the local branch.
242
- branch_deleted = false
243
- if branch && !config.protected_branches.include?( branch )
244
- _, _, del_success, = git_run( "branch", "-D", branch )
245
- if del_success
246
- puts_verbose "branch_deleted: #{branch}"
247
- branch_deleted = true
248
- end
249
- end
250
-
251
- # Delete the remote branch (best-effort).
252
- remote_deleted = false
253
- if branch && !config.protected_branches.include?( branch )
254
- _, _, rd_success, = git_run( "push", config.git_remote, "--delete", branch )
255
- if rd_success
256
- puts_verbose "remote_branch_deleted: #{config.git_remote}/#{branch}"
257
- remote_deleted = true
258
- end
259
- end
260
-
261
- worktree_finish(
262
- result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
263
- branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
264
- exit_code: EXIT_OK, json_output: json_output
265
- )
266
- end
267
-
268
- # Unified output for worktree results — JSON or human-readable.
269
- def worktree_finish( result:, exit_code:, json_output: )
270
- result[ :exit_code ] = exit_code
271
-
272
- if json_output
273
- output.puts JSON.pretty_generate( result )
274
- else
275
- print_worktree_human( result: result )
276
- end
277
-
278
- exit_code
23
+ Worktree.sweep_stale!( runtime: self )
279
24
  end
280
25
 
281
- # Human-readable output for worktree results.
282
- def print_worktree_human( result: )
283
- command = result[ :command ]
284
- status = result[ :status ]
285
-
286
- case status
287
- when "ok"
288
- case command
289
- when "worktree create"
290
- puts_line "Worktree created: #{result[ :name ]}"
291
- puts_line " Path: #{result[ :path ]}"
292
- puts_line " Branch: #{result[ :branch ]}"
293
- when "worktree remove"
294
- unless verbose?
295
- puts_line "Worktree removed: #{result[ :name ]}"
296
- end
297
- end
298
- when "error"
299
- puts_line "ERROR: #{result[ :error ]}"
300
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
301
- when "block"
302
- puts_line "#{result[ :error ]&.capitalize || 'Blocked'}: #{result[ :name ]}"
303
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
304
- end
305
- end
306
-
307
- # Returns true when the process CWD is inside the given worktree path.
308
- # This detects the most common session-crash scenario: removing a worktree
309
- # while the caller's shell is inside it.
310
- # Uses realpath on both sides to handle symlink differences (e.g. /tmp vs /private/tmp).
311
- def cwd_inside_worktree?( worktree_path: )
312
- cwd = realpath_safe( Dir.pwd )
313
- worktree = realpath_safe( worktree_path )
314
- normalised_wt = File.join( worktree, "" )
315
- cwd == worktree || cwd.start_with?( normalised_wt )
316
- rescue StandardError
317
- false
318
- end
319
-
320
- # Checks whether any other process has its working directory inside the worktree.
321
- # Uses lsof to query CWD file descriptors system-wide, then matches against
322
- # the worktree path. Catches the cross-process CWD crash scenario: a cleanup
323
- # process removing a worktree while another session's shell is still inside it.
324
- # Fails safe: returns false if lsof is unavailable or any error occurs.
325
- def worktree_held_by_other_process?( worktree_path: )
326
- canonical = realpath_safe( worktree_path )
327
- return false if canonical.nil? || canonical.empty?
328
- return false unless Dir.exist?( canonical )
329
-
330
- stdout, _, status = Open3.capture3( "lsof", "-d", "cwd" )
331
- return false unless status.success?
332
-
333
- normalised = File.join( canonical, "" )
334
- my_pid = Process.pid
335
- stdout.lines.drop( 1 ).any? do |line|
336
- fields = line.strip.split( /\s+/ )
337
- next false unless fields.length >= 9
338
- next false if fields[ 1 ].to_i == my_pid
339
- name = fields[ 8.. ].join( " " )
340
- name == canonical || name.start_with?( normalised )
341
- end
342
- rescue Errno::ENOENT
343
- # lsof not installed.
344
- false
345
- rescue StandardError
346
- false
26
+ # Returns all registered worktrees as Carson::Worktree instances.
27
+ def worktree_list
28
+ Worktree.list( runtime: self )
347
29
  end
348
30
 
349
- # Checks whether a branch has unpushed commits that would be lost on removal.
350
- # Content-aware: after squash/rebase merge, SHAs differ but tree content may match main. Compares content, not SHAs.
351
- # Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
352
- def check_unpushed_commits( branch:, worktree_path: )
353
- return nil unless branch
354
-
355
- remote = config.git_remote
356
- remote_ref = "#{remote}/#{branch}"
357
- ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
358
- if !ahead_status.success?
359
- # Remote ref does not exist. Only block if the branch has unique commits vs main.
360
- unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{config.main_branch}..#{branch}", chdir: worktree_path )
361
- if unique_status.success? && unique.strip.to_i > 0
362
- # Content-aware check: after squash/rebase merge, commit SHAs differ
363
- # but the tree content may be identical to main. Compare content,
364
- # not SHAs — if the diff is empty, the work is already on main.
365
- _, _, diff_ok, = Open3.capture3( "git", "diff", "--quiet", config.main_branch, branch, chdir: worktree_path )
366
- unless diff_ok.success?
367
- return { error: "branch has not been pushed to #{remote}",
368
- recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
369
- end
370
- # Diff is empty — content is on main (squash/rebase merged). Safe.
371
- puts_verbose "branch #{branch} content matches main — squash/rebase merged, safe to remove"
372
- end
373
- elsif ahead.strip.to_i > 0
374
- return { error: "worktree has unpushed commits",
375
- recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
376
- end
377
-
378
- nil
379
- end
31
+ # --- Methods that stay on Runtime ---
380
32
 
381
- # Returns the branch checked output in the worktree that contains the process CWD,
33
+ # Returns the branch checked out in the worktree that contains the process CWD,
382
34
  # or nil if CWD is not inside any worktree. Used by prune to proactively
383
35
  # protect the CWD worktree's branch from deletion.
384
36
  # Matches the longest (most specific) path because worktree directories
@@ -388,11 +40,10 @@ module Carson
388
40
  best_branch = nil
389
41
  best_length = -1
390
42
  worktree_list.each do |worktree|
391
- worktree_path = worktree.fetch( :path )
392
- normalised = File.join( worktree_path, "" )
393
- if ( cwd == worktree_path || cwd.start_with?( normalised ) ) && worktree_path.length > best_length
394
- best_branch = worktree.fetch( :branch, nil )
395
- best_length = worktree_path.length
43
+ normalised = File.join( worktree.path, "" )
44
+ if ( cwd == worktree.path || cwd.start_with?( normalised ) ) && worktree.path.length > best_length
45
+ best_branch = worktree.branch
46
+ best_length = worktree.path.length
396
47
  end
397
48
  end
398
49
  best_branch
@@ -410,87 +61,31 @@ module Carson
410
61
  repo_root
411
62
  end
412
63
 
413
- # Adds .claude/ to .git/info/exclude if not already present.
414
- # This prevents worktree directories from appearing as untracked files
415
- # in the host repository. Uses the local exclude file (never committed)
416
- # so the host repo's .gitignore is never touched.
417
- # Uses main_worktree_root — worktrees have .git as a file, not a directory.
418
- def ensure_claude_dir_excluded!
419
- git_dir = File.join( main_worktree_root, ".git" )
420
- return unless File.directory?( git_dir )
421
-
422
- info_dir = File.join( git_dir, "info" )
423
- exclude_path = File.join( info_dir, "exclude" )
424
-
425
- FileUtils.mkdir_p( info_dir )
426
- existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
427
- return if existing.lines.any? { |line| line.strip == ".claude/" }
64
+ # Resolves a path to its canonical form, tolerating non-existent paths.
65
+ # Preserves canonical parents for missing paths so deleted worktrees still
66
+ # compare equal to git's recorded path (for example /tmp vs /private/tmp).
67
+ def realpath_safe( path )
68
+ File.realpath( path )
69
+ rescue Errno::ENOENT
70
+ expanded = File.expand_path( path )
71
+ missing_segments = []
72
+ candidate = expanded
428
73
 
429
- File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
430
- rescue StandardError
431
- # Best-effort do not block worktree creation if exclude fails.
432
- end
74
+ until File.exist?( candidate ) || Dir.exist?( candidate )
75
+ parent = File.dirname( candidate )
76
+ break if parent == candidate
433
77
 
434
- # Resolves a worktree path: if it's a bare name, always resolve under
435
- # .claude/worktrees/ — even when the directory no longer exists (e.g. after
436
- # gh pr merge --delete-branch deleted it externally).
437
- # Returns the canonical (realpath) form so comparisons against git worktree list
438
- # succeed, even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp).
439
- # Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
440
- def resolve_worktree_path( worktree_path: )
441
- if worktree_path.include?( "/" )
442
- return realpath_safe( worktree_path )
78
+ missing_segments.unshift( File.basename( candidate ) )
79
+ candidate = parent
443
80
  end
444
81
 
445
- root = main_worktree_root
446
- candidate = File.join( root, ".claude", "worktrees", worktree_path )
447
- realpath_safe( candidate )
448
- end
449
-
450
- # Returns true if the path is a registered git worktree.
451
- # Compares using realpath to handle symlink differences.
452
- def worktree_registered?( path: )
453
- canonical = realpath_safe( path )
454
- worktree_list.any? { |worktree| worktree.fetch( :path ) == canonical }
455
- end
456
-
457
- # Returns the branch name checked output in a worktree, or nil.
458
- # Compares using realpath to handle symlink differences.
459
- def worktree_branch( path: )
460
- canonical = realpath_safe( path )
461
- entry = worktree_list.find { |worktree| worktree.fetch( :path ) == canonical }
462
- entry&.fetch( :branch, nil )
463
- end
464
-
465
- # Parses `git worktree list --porcelain` into structured entries.
466
- # Normalises paths with realpath so comparisons work across symlink differences.
467
- def worktree_list
468
- output = git_capture!( "worktree", "list", "--porcelain" )
469
- entries = []
470
- current = {}
471
- output.lines.each do |line|
472
- line = line.strip
473
- if line.empty?
474
- entries << current unless current.empty?
475
- current = {}
476
- elsif line.start_with?( "worktree " )
477
- current[ :path ] = realpath_safe( line.sub( "worktree ", "" ) )
478
- elsif line.start_with?( "branch " )
479
- current[ :branch ] = line.sub( "branch refs/heads/", "" )
480
- elsif line == "detached"
481
- current[ :branch ] = nil
482
- end
82
+ base = if File.exist?( candidate ) || Dir.exist?( candidate )
83
+ File.realpath( candidate )
84
+ else
85
+ candidate
483
86
  end
484
- entries << current unless current.empty?
485
- entries
486
- end
487
87
 
488
- # Resolves a path to its canonical form, tolerating non-existent paths.
489
- # Falls back to File.expand_path when the path does not exist yet.
490
- def realpath_safe( path )
491
- File.realpath( path )
492
- rescue Errno::ENOENT
493
- File.expand_path( path )
88
+ missing_segments.empty? ? base : File.join( base, *missing_segments )
494
89
  end
495
90
  end
496
91
 
@@ -5,6 +5,73 @@ module Carson
5
5
  module GateSupport
6
6
  private
7
7
 
8
+ def review_gate_report_for_missing_pr( branch_name: )
9
+ {
10
+ generated_at: Time.now.utc.iso8601,
11
+ branch: branch_name,
12
+ status: "block",
13
+ converged: false,
14
+ wait_seconds: config.review_wait_seconds,
15
+ poll_seconds: config.review_poll_seconds,
16
+ max_polls: config.review_max_polls,
17
+ block_reasons: [ "no pull request found for current branch" ],
18
+ pr: nil,
19
+ unresolved_threads: [],
20
+ actionable_top_level: [],
21
+ unacknowledged_actionable: []
22
+ }
23
+ end
24
+
25
+ def review_gate_report_for_pr( owner:, repo:, pr_number:, branch_name:, pr_summary: nil )
26
+ resolved_pr_summary = resolved_review_gate_pr_summary(
27
+ owner: owner,
28
+ repo: repo,
29
+ pr_number: pr_number,
30
+ pr_summary: pr_summary
31
+ )
32
+ pre_snapshot = wait_for_review_warmup( owner: owner, repo: repo, pr_number: pr_number )
33
+ converged = false
34
+ last_snapshot = pre_snapshot
35
+ last_signature = pre_snapshot.nil? ? nil : review_gate_signature( snapshot: pre_snapshot )
36
+ poll_attempts = 0
37
+
38
+ config.review_max_polls.times do |index|
39
+ poll_attempts = index + 1
40
+ snapshot = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_number )
41
+ last_snapshot = snapshot
42
+ signature = review_gate_signature( snapshot: snapshot )
43
+ puts_verbose "poll_attempt: #{poll_attempts}/#{config.review_max_polls}"
44
+ puts_verbose "latest_activity: #{snapshot.fetch( :latest_activity ) || 'unknown'}"
45
+ puts_verbose "unresolved_threads: #{snapshot.fetch( :unresolved_threads ).count}"
46
+ puts_verbose "unacknowledged_actionable: #{snapshot.fetch( :unacknowledged_actionable ).count}"
47
+ if !last_signature.nil? && signature == last_signature
48
+ converged = true
49
+ puts_verbose "convergence: stable"
50
+ break
51
+ end
52
+ last_signature = signature
53
+ wait_for_review_poll if index < config.review_max_polls - 1
54
+ end
55
+
56
+ build_review_gate_report(
57
+ branch_name: branch_name,
58
+ pr_summary: resolved_pr_summary,
59
+ snapshot: last_snapshot,
60
+ converged: converged,
61
+ poll_attempts: poll_attempts
62
+ )
63
+ end
64
+
65
+ def review_gate_result( report: )
66
+ return { status: :pass, review: :approved, detail: "review gate passed" } if report.fetch( :status ) == "ok"
67
+
68
+ {
69
+ status: :fail,
70
+ review: review_gate_changes_requested?( report: report ) ? :changes_requested : :blocked,
71
+ detail: report.fetch( :block_reasons ).join( "; " )
72
+ }
73
+ end
74
+
8
75
  def wait_for_review_warmup( owner:, repo:, pr_number: )
9
76
  return unless config.review_wait_seconds.positive?
10
77
  quick = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_number )
@@ -67,8 +134,11 @@ module Carson
67
134
  }
68
135
  end
69
136
 
137
+ # GraphQL returns "gemini-code-assist"; REST returns "gemini-code-assist[bot]".
138
+ # Normalise both sides by stripping the [bot] suffix for a consistent match.
70
139
  def bot_username?( author: )
71
- config.review_bot_usernames.any? { it.downcase == author.to_s.downcase }
140
+ normalised = author.to_s.downcase.delete_suffix( "[bot]" )
141
+ config.review_bot_usernames.any? { it.downcase.delete_suffix( "[bot]" ) == normalised }
72
142
  end
73
143
 
74
144
  def unresolved_thread_entries( details: )
@@ -166,6 +236,66 @@ module Carson
166
236
  timestamps.map { parse_time_or_nil( text: it ) }.compact.max&.utc&.iso8601
167
237
  end
168
238
 
239
+ def resolved_review_gate_pr_summary( owner:, repo:, pr_number:, pr_summary: )
240
+ required_keys = %i[number title url state]
241
+ if !pr_summary.nil? && required_keys.all? { |key| pr_summary.key?( key ) && !pr_summary.fetch( key ).to_s.empty? }
242
+ return pr_summary
243
+ end
244
+
245
+ pull_request_summary( owner: owner, repo: repo, pr_number: pr_number )
246
+ end
247
+
248
+ def pull_request_summary( owner:, repo:, pr_number: )
249
+ details = pull_request_details( owner: owner, repo: repo, pr_number: pr_number )
250
+ {
251
+ number: details.fetch( :number ),
252
+ title: details.fetch( :title ),
253
+ url: details.fetch( :url ),
254
+ state: details.fetch( :state )
255
+ }
256
+ end
257
+
258
+ def build_review_gate_report( branch_name:, pr_summary:, snapshot:, converged:, poll_attempts: )
259
+ {
260
+ generated_at: Time.now.utc.iso8601,
261
+ branch: branch_name,
262
+ status: review_gate_block_reasons( snapshot: snapshot, converged: converged ).empty? ? "ok" : "block",
263
+ converged: converged,
264
+ wait_seconds: config.review_wait_seconds,
265
+ poll_seconds: config.review_poll_seconds,
266
+ max_polls: config.review_max_polls,
267
+ poll_attempts: poll_attempts,
268
+ block_reasons: review_gate_block_reasons( snapshot: snapshot, converged: converged ),
269
+ pr: {
270
+ number: pr_summary.fetch( :number ),
271
+ title: pr_summary.fetch( :title ),
272
+ url: pr_summary.fetch( :url ),
273
+ state: pr_summary.fetch( :state )
274
+ },
275
+ unresolved_threads: snapshot.fetch( :unresolved_threads ),
276
+ actionable_top_level: snapshot.fetch( :actionable_top_level ),
277
+ unacknowledged_actionable: snapshot.fetch( :unacknowledged_actionable )
278
+ }
279
+ end
280
+
281
+ def review_gate_block_reasons( snapshot:, converged: )
282
+ reasons = []
283
+ reasons << "review snapshot did not converge within #{config.review_max_polls} polls" unless converged
284
+ if snapshot.fetch( :unresolved_threads ).any?
285
+ reasons << "unresolved review threads remain (#{snapshot.fetch( :unresolved_threads ).count})"
286
+ end
287
+ if snapshot.fetch( :unacknowledged_actionable ).any?
288
+ reasons << "actionable top-level comments/reviews without required disposition (#{snapshot.fetch( :unacknowledged_actionable ).count})"
289
+ end
290
+ reasons
291
+ end
292
+
293
+ def review_gate_changes_requested?( report: )
294
+ Array( report.fetch( :unacknowledged_actionable ) ).any? do |entry|
295
+ entry.fetch( :reason ) == "changes_requested_review"
296
+ end
297
+ end
298
+
169
299
  # Writes review gate artefacts using fixed report names in global report output.
170
300
  def write_review_gate_report( report: )
171
301
  markdown_path, json_path = report(