carson 3.19.0 → 3.20.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.
- checksums.yaml +4 -4
- data/RELEASE.md +11 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/audit.rb +57 -57
- data/lib/carson/runtime/deliver.rb +20 -20
- data/lib/carson/runtime/govern.rb +2 -2
- data/lib/carson/runtime/local/worktree.rb +50 -7
- data/lib/carson/runtime/review/data_access.rb +1 -0
- data/lib/carson/runtime/review/gate_support.rb +13 -12
- data/lib/carson/runtime/review/query_text.rb +1 -0
- data/lib/carson/runtime/review/sweep_support.rb +3 -2
- data/lib/carson/runtime/review/utility.rb +2 -1
- data/lib/carson/runtime/setup.rb +4 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e7fdc44d330fd415b8f6d9ee479d5256d6fc860e5d63c7c9287f28ebbdb2ba4
|
|
4
|
+
data.tar.gz: 19056bd376bcab3b7077e38ba4e42f2b9f5a3ffbf2c8027f0aa6626bb70587fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13f0d5ac83272711329d435a935dd940afc461c5c3b10e20d23842492e8e53f7032cf34a8b027d70db80563af148cda5f99c2c8e7ec4025b94f4d72609ea9d1d
|
|
7
|
+
data.tar.gz: b1903c6a8f8682adc7b942e49b553579d014d56c0bb65587465568119d41583a14e0cb4a7b00bc61b9165fac79094567395dbf2b39191e3c9d673dc0750dc4f5
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,17 @@ Release-note scope rule:
|
|
|
5
5
|
- `RELEASE.md` records only version deltas, breaking changes, and migration actions.
|
|
6
6
|
- Operational usage guides live in `MANUAL.md` and `API.md`.
|
|
7
7
|
|
|
8
|
+
## 3.20.0
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Cross-process CWD guard for worktree removal** — `worktree_remove!` and `sweep_stale_worktrees!` now detect when another process (e.g. an agent session) has its working directory inside a worktree before attempting removal. Uses `lsof -d cwd` to scan for cross-process CWD holds. Blocks removal with a clear recovery message instead of silently destroying the directory and crashing the other session's shell.
|
|
13
|
+
- **Code quality sweep** — fixed indentation errors in `deliver.rb` (`check_pr_ci` method). Converted single-parameter blocks to Ruby 3.4 `it` implicit parameter across `deliver.rb`, `govern.rb`, `setup.rb`, and all review submodules (`gate_support.rb`, `sweep_support.rb`, `utility.rb`). Added file-level purpose comments to review submodules (`gate_support.rb`, `sweep_support.rb`, `utility.rb`, `data_access.rb`, `query_text.rb`).
|
|
14
|
+
|
|
15
|
+
### UX improvement
|
|
16
|
+
|
|
17
|
+
- Worktree removal is now safe across process boundaries. Previously, Carson only checked if the *current* process's CWD was inside the worktree. A separate cleanup process (hook, batch sweep, or another agent) could still remove a worktree while another session's shell was inside it, permanently crashing that shell. The new guard detects this scenario and refuses removal with actionable recovery advice. Fails safe: if `lsof` is unavailable, removal proceeds as before.
|
|
18
|
+
|
|
8
19
|
## 3.19.0
|
|
9
20
|
|
|
10
21
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.20.0
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -212,20 +212,20 @@ module Carson
|
|
|
212
212
|
private
|
|
213
213
|
def pr_and_check_report
|
|
214
214
|
report = {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
215
|
+
generated_at: Time.now.utc.iso8601,
|
|
216
|
+
branch: current_branch,
|
|
217
|
+
status: "ok",
|
|
218
|
+
skip_reason: nil,
|
|
219
|
+
pr: nil,
|
|
220
|
+
checks: {
|
|
221
|
+
status: "unknown",
|
|
222
|
+
skip_reason: nil,
|
|
223
|
+
required_total: 0,
|
|
224
|
+
failing_count: 0,
|
|
225
|
+
pending_count: 0,
|
|
226
|
+
failing: [],
|
|
227
|
+
pending: []
|
|
228
|
+
}
|
|
229
229
|
}
|
|
230
230
|
unless gh_available?
|
|
231
231
|
report[ :status ] = "skipped"
|
|
@@ -243,11 +243,11 @@ module Carson
|
|
|
243
243
|
end
|
|
244
244
|
pr_data = JSON.parse( pr_stdout )
|
|
245
245
|
report[ :pr ] = {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
number: pr_data[ "number" ],
|
|
247
|
+
title: pr_data[ "title" ].to_s,
|
|
248
|
+
url: pr_data[ "url" ].to_s,
|
|
249
|
+
state: pr_data[ "state" ].to_s,
|
|
250
|
+
review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
|
|
251
251
|
}
|
|
252
252
|
puts_verbose "pr: ##{report.dig( :pr, :number )} #{report.dig( :pr, :title )}"
|
|
253
253
|
puts_verbose "url: #{report.dig( :pr, :url )}"
|
|
@@ -287,22 +287,22 @@ module Carson
|
|
|
287
287
|
# Evaluates default-branch CI health so stale workflow drift blocks before merge.
|
|
288
288
|
def default_branch_ci_baseline_report
|
|
289
289
|
report = {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
290
|
+
status: "ok",
|
|
291
|
+
skip_reason: nil,
|
|
292
|
+
repository: nil,
|
|
293
|
+
default_branch: nil,
|
|
294
|
+
head_sha: nil,
|
|
295
|
+
workflows_total: 0,
|
|
296
|
+
check_runs_total: 0,
|
|
297
|
+
failing_count: 0,
|
|
298
|
+
pending_count: 0,
|
|
299
|
+
advisory_failing_count: 0,
|
|
300
|
+
advisory_pending_count: 0,
|
|
301
|
+
no_check_evidence: false,
|
|
302
|
+
failing: [],
|
|
303
|
+
pending: [],
|
|
304
|
+
advisory_failing: [],
|
|
305
|
+
advisory_pending: []
|
|
306
306
|
}
|
|
307
307
|
unless gh_available?
|
|
308
308
|
report[ :status ] = "skipped"
|
|
@@ -313,30 +313,30 @@ module Carson
|
|
|
313
313
|
owner, repo = repository_coordinates
|
|
314
314
|
report[ :repository ] = "#{owner}/#{repo}"
|
|
315
315
|
repository_data = gh_json_payload!(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
"api", "repos/#{owner}/#{repo}",
|
|
317
|
+
"--method", "GET",
|
|
318
|
+
fallback: "unable to read repository metadata for #{owner}/#{repo}"
|
|
319
319
|
)
|
|
320
320
|
default_branch = blank_to( value: repository_data[ "default_branch" ], default: config.main_branch )
|
|
321
321
|
report[ :default_branch ] = default_branch
|
|
322
322
|
branch_data = gh_json_payload!(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
"api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
|
|
324
|
+
"--method", "GET",
|
|
325
|
+
fallback: "unable to read default branch #{default_branch}"
|
|
326
326
|
)
|
|
327
327
|
head_sha = branch_data.dig( "commit", "sha" ).to_s.strip
|
|
328
328
|
raise "default branch #{default_branch} has no commit SHA" if head_sha.empty?
|
|
329
329
|
report[ :head_sha ] = head_sha
|
|
330
330
|
workflow_entries = default_branch_workflow_entries(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
owner: owner,
|
|
332
|
+
repo: repo,
|
|
333
|
+
default_branch: default_branch
|
|
334
334
|
)
|
|
335
335
|
report[ :workflows_total ] = workflow_entries.count
|
|
336
336
|
check_runs_payload = gh_json_payload!(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
337
|
+
"api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
|
|
338
|
+
"--method", "GET",
|
|
339
|
+
fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
|
|
340
340
|
)
|
|
341
341
|
check_runs = Array( check_runs_payload[ "check_runs" ] )
|
|
342
342
|
failing, pending = partition_default_branch_check_runs( check_runs: check_runs )
|
|
@@ -399,15 +399,15 @@ module Carson
|
|
|
399
399
|
# Reads workflow files from default branch; missing workflow directory is valid and returns none.
|
|
400
400
|
def default_branch_workflow_entries( owner:, repo:, default_branch: )
|
|
401
401
|
stdout_text, stderr_text, success, = gh_run(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
"api", "repos/#{owner}/#{repo}/contents/.github/workflows",
|
|
403
|
+
"--method", "GET",
|
|
404
|
+
"-f", "ref=#{default_branch}"
|
|
405
405
|
)
|
|
406
406
|
unless success
|
|
407
407
|
error_text = gh_error_text(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
408
|
+
stdout_text: stdout_text,
|
|
409
|
+
stderr_text: stderr_text,
|
|
410
|
+
fallback: "unable to read workflow files for #{default_branch}"
|
|
411
411
|
)
|
|
412
412
|
return [] if error_text.match?( /\b404\b/ )
|
|
413
413
|
raise error_text
|
|
@@ -474,10 +474,10 @@ module Carson
|
|
|
474
474
|
blank_to( value: entry[ "status" ], default: "UNKNOWN" )
|
|
475
475
|
end
|
|
476
476
|
{
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
477
|
+
workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
|
|
478
|
+
name: blank_to( value: entry[ "name" ], default: "check" ),
|
|
479
|
+
state: state.upcase,
|
|
480
|
+
link: entry[ "html_url" ].to_s
|
|
481
481
|
}
|
|
482
482
|
end
|
|
483
483
|
end
|
|
@@ -210,27 +210,27 @@ module Carson
|
|
|
210
210
|
|
|
211
211
|
# Generates a default PR title from the branch name.
|
|
212
212
|
def default_pr_title( branch: )
|
|
213
|
-
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) {
|
|
213
|
+
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { it.upcase }
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
# Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
|
|
217
|
-
# Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
|
|
218
|
-
def check_pr_ci( number: )
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
end
|
|
217
|
+
# Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
|
|
218
|
+
def check_pr_ci( number: )
|
|
219
|
+
stdout, _, success, = gh_run(
|
|
220
|
+
"pr", "checks", number.to_s,
|
|
221
|
+
"--json", "name,bucket"
|
|
222
|
+
)
|
|
223
|
+
return :none unless success
|
|
224
|
+
|
|
225
|
+
checks = JSON.parse( stdout ) rescue []
|
|
226
|
+
return :none if checks.empty?
|
|
227
|
+
|
|
228
|
+
buckets = checks.map { it[ "bucket" ].to_s.downcase }
|
|
229
|
+
return :fail if buckets.include?( "fail" )
|
|
230
|
+
return :pending if buckets.include?( "pending" )
|
|
231
|
+
|
|
232
|
+
:pass
|
|
233
|
+
end
|
|
234
234
|
|
|
235
235
|
# Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
|
|
236
236
|
def check_pr_review( number: )
|
|
@@ -298,8 +298,8 @@ end
|
|
|
298
298
|
def compute_post_merge_next_step!( result: )
|
|
299
299
|
main_root = main_worktree_root
|
|
300
300
|
cwd = realpath_safe( Dir.pwd )
|
|
301
|
-
current_wt = worktree_list.select {
|
|
302
|
-
.find {
|
|
301
|
+
current_wt = worktree_list.select { it.fetch( :path ) != realpath_safe( main_root ) }
|
|
302
|
+
.find { cwd == it.fetch( :path ) || cwd.start_with?( File.join( it.fetch( :path ), "" ) ) }
|
|
303
303
|
|
|
304
304
|
if current_wt
|
|
305
305
|
wt_name = File.basename( current_wt.fetch( :path ) )
|
|
@@ -208,10 +208,10 @@ module Carson
|
|
|
208
208
|
checks = Array( pr[ "statusCheckRollup" ] )
|
|
209
209
|
return :green if checks.empty?
|
|
210
210
|
|
|
211
|
-
has_failure = checks.any? {
|
|
211
|
+
has_failure = checks.any? { check_state_failing?( state: it[ "state" ].to_s ) || check_conclusion_failing?( conclusion: it[ "conclusion" ].to_s ) }
|
|
212
212
|
return :red if has_failure
|
|
213
213
|
|
|
214
|
-
has_pending = checks.any? {
|
|
214
|
+
has_pending = checks.any? { check_state_pending?( state: it[ "state" ].to_s ) }
|
|
215
215
|
return :pending if has_pending
|
|
216
216
|
|
|
217
217
|
:green
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Safe worktree lifecycle management for coding agents.
|
|
2
2
|
# Two operations: create and remove. Create auto-syncs main before branching.
|
|
3
|
-
# Remove is safe by default — guards against CWD-inside-worktree
|
|
4
|
-
# commits. Content-aware: allows removal after
|
|
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.
|
|
5
6
|
# Supports --json for machine-readable structured output with recovery commands.
|
|
6
7
|
module Carson
|
|
7
8
|
class Runtime
|
|
@@ -78,12 +79,12 @@ module Carson
|
|
|
78
79
|
resolved_path = resolve_worktree_path( worktree_path: worktree_path )
|
|
79
80
|
|
|
80
81
|
# Missing directory: worktree was destroyed externally (e.g. gh pr merge
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
unless worktree_registered?( path: resolved_path )
|
|
87
88
|
return worktree_finish(
|
|
88
89
|
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
89
90
|
error: "#{resolved_path} is not a registered worktree",
|
|
@@ -104,6 +105,18 @@ module Carson
|
|
|
104
105
|
)
|
|
105
106
|
end
|
|
106
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
|
+
|
|
107
120
|
branch = worktree_branch( path: resolved_path )
|
|
108
121
|
puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
|
|
109
122
|
|
|
@@ -195,6 +208,7 @@ module Carson
|
|
|
195
208
|
next unless branch
|
|
196
209
|
next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
|
|
197
210
|
next if cwd_inside_worktree?( worktree_path: path )
|
|
211
|
+
next if worktree_held_by_other_process?( worktree_path: path )
|
|
198
212
|
next unless branch_absorbed_into_main?( branch: branch )
|
|
199
213
|
|
|
200
214
|
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
@@ -303,6 +317,35 @@ module Carson
|
|
|
303
317
|
false
|
|
304
318
|
end
|
|
305
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
|
|
347
|
+
end
|
|
348
|
+
|
|
306
349
|
# Checks whether a branch has unpushed commits that would be lost on removal.
|
|
307
350
|
# Content-aware: after squash/rebase merge, SHAs differ but tree content may match main. Compares content, not SHAs.
|
|
308
351
|
# Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review gate logic: snapshot convergence, disposition acknowledgements, and merge-readiness checks.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -30,7 +31,7 @@ module Carson
|
|
|
30
31
|
unresolved_threads = unresolved_thread_entries( details: details )
|
|
31
32
|
actionable_top_level = actionable_top_level_items( details: details, pr_author: pr_author )
|
|
32
33
|
acknowledgements = disposition_acknowledgements( details: details, pr_author: pr_author )
|
|
33
|
-
unacknowledged_actionable = actionable_top_level.reject {
|
|
34
|
+
unacknowledged_actionable = actionable_top_level.reject { acknowledged_by_disposition?( item: it, acknowledgements: acknowledgements ) }
|
|
34
35
|
{
|
|
35
36
|
latest_activity: latest_review_activity( details: details ),
|
|
36
37
|
unresolved_threads: unresolved_threads,
|
|
@@ -44,8 +45,8 @@ module Carson
|
|
|
44
45
|
def review_gate_signature( snapshot: )
|
|
45
46
|
{
|
|
46
47
|
latest_activity: snapshot.fetch( :latest_activity ).to_s,
|
|
47
|
-
unresolved_urls: snapshot.fetch( :unresolved_threads ).map {
|
|
48
|
-
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map {
|
|
48
|
+
unresolved_urls: snapshot.fetch( :unresolved_threads ).map { it.fetch( :url ) }.sort,
|
|
49
|
+
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { it.fetch( :url ) }.sort
|
|
49
50
|
}
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -67,7 +68,7 @@ module Carson
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def bot_username?( author: )
|
|
70
|
-
config.review_bot_usernames.any? {
|
|
71
|
+
config.review_bot_usernames.any? { it.downcase == author.to_s.downcase }
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def unresolved_thread_entries( details: )
|
|
@@ -78,7 +79,7 @@ module Carson
|
|
|
78
79
|
comments = thread.fetch( :comments )
|
|
79
80
|
first_comment = comments.first || {}
|
|
80
81
|
next if bot_username?( author: first_comment.fetch( :author, "" ) )
|
|
81
|
-
latest_time = comments.map {
|
|
82
|
+
latest_time = comments.map { it.fetch( :created_at ) }.max.to_s
|
|
82
83
|
{
|
|
83
84
|
url: blank_to( value: first_comment.fetch( :url, "" ), default: "#{details.fetch( :url )}#thread-#{index + 1}" ),
|
|
84
85
|
author: first_comment.fetch( :author, "" ),
|
|
@@ -130,7 +131,7 @@ module Carson
|
|
|
130
131
|
sources = []
|
|
131
132
|
sources.concat( Array( details.fetch( :comments ) ) )
|
|
132
133
|
sources.concat( Array( details.fetch( :reviews ) ) )
|
|
133
|
-
sources.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
134
|
+
sources.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) } )
|
|
134
135
|
sources.map do |entry|
|
|
135
136
|
next unless entry.fetch( :author, "" ) == pr_author
|
|
136
137
|
body = entry.fetch( :body, "" ).to_s
|
|
@@ -151,7 +152,7 @@ module Carson
|
|
|
151
152
|
# True when any disposition acknowledgement references the specific finding URL.
|
|
152
153
|
def acknowledged_by_disposition?( item:, acknowledgements: )
|
|
153
154
|
acknowledgements.any? do |ack|
|
|
154
|
-
Array( ack.fetch( :target_urls ) ).any? {
|
|
155
|
+
Array( ack.fetch( :target_urls ) ).any? { it == item.fetch( :url ) }
|
|
155
156
|
end
|
|
156
157
|
end
|
|
157
158
|
|
|
@@ -159,10 +160,10 @@ module Carson
|
|
|
159
160
|
def latest_review_activity( details: )
|
|
160
161
|
timestamps = []
|
|
161
162
|
timestamps << details.fetch( :updated_at )
|
|
162
|
-
timestamps.concat( Array( details.fetch( :comments ) ).map {
|
|
163
|
-
timestamps.concat( Array( details.fetch( :reviews ) ).map {
|
|
164
|
-
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
165
|
-
timestamps.map {
|
|
163
|
+
timestamps.concat( Array( details.fetch( :comments ) ).map { it.fetch( :created_at ) } )
|
|
164
|
+
timestamps.concat( Array( details.fetch( :reviews ) ).map { it.fetch( :created_at ) } )
|
|
165
|
+
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) }.map { it.fetch( :created_at ) } )
|
|
166
|
+
timestamps.map { parse_time_or_nil( text: it ) }.compact.max&.utc&.iso8601
|
|
166
167
|
end
|
|
167
168
|
|
|
168
169
|
# Writes review gate artefacts using fixed report names in global report output.
|
|
@@ -208,7 +209,7 @@ module Carson
|
|
|
208
209
|
if report.fetch( :block_reasons ).empty?
|
|
209
210
|
lines << "- none"
|
|
210
211
|
else
|
|
211
|
-
report.fetch( :block_reasons ).each {
|
|
212
|
+
report.fetch( :block_reasons ).each { lines << "- #{it}" }
|
|
212
213
|
end
|
|
213
214
|
lines << ""
|
|
214
215
|
lines << "## Unresolved Threads"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review sweep: late-event detection, tracking issue management, and sweep reporting.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -59,7 +60,7 @@ module Carson
|
|
|
59
60
|
)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
Array( details.fetch( :review_threads ) ).flat_map {
|
|
63
|
+
Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) }.each do |comment|
|
|
63
64
|
next if comment.fetch( :author ) == pr_author
|
|
64
65
|
next if bot_username?( author: comment.fetch( :author ) )
|
|
65
66
|
hits = matched_risk_keywords( text: comment.fetch( :body ) )
|
|
@@ -152,7 +153,7 @@ module Carson
|
|
|
152
153
|
stdout_text, stderr_text, success, = gh_run( "issue", "list", "--repo", repo_slug, "--state", "all", "--limit", "100", "--json", "number,title,state,url,labels" )
|
|
153
154
|
raise gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to list issues for review sweep" ) unless success
|
|
154
155
|
issues = Array( JSON.parse( stdout_text ) )
|
|
155
|
-
node = issues.find {
|
|
156
|
+
node = issues.find { it[ "title" ].to_s == config.review_tracking_issue_title }
|
|
156
157
|
return nil if node.nil?
|
|
157
158
|
{
|
|
158
159
|
number: node[ "number" ],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review utilities: risk keyword matching, disposition parsing, URL extraction, and deduplication.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -24,7 +25,7 @@ module Carson
|
|
|
24
25
|
|
|
25
26
|
# GitHub URL extraction for mapping disposition acknowledgements to finding URLs.
|
|
26
27
|
def extract_github_urls( text: )
|
|
27
|
-
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map {
|
|
28
|
+
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map { it.sub( /[.,;:]+$/, "" ) }.uniq
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
# Parse RFC3339 timestamps and return nil on blank/invalid values.
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -137,7 +137,7 @@ module Carson
|
|
|
137
137
|
{ label: "branch — enforce PR-only merges (default)", value: "branch" },
|
|
138
138
|
{ label: "trunk — commit directly to main", value: "trunk" }
|
|
139
139
|
]
|
|
140
|
-
default_index = options.index {
|
|
140
|
+
default_index = options.index { it.fetch( :value ) == current } || 0
|
|
141
141
|
prompt_choice( options: options, default: default_index )
|
|
142
142
|
end
|
|
143
143
|
|
|
@@ -151,7 +151,7 @@ module Carson
|
|
|
151
151
|
{ label: "rebase — linear history, individual commits", value: "rebase" },
|
|
152
152
|
{ label: "merge — merge commits", value: "merge" }
|
|
153
153
|
]
|
|
154
|
-
default_index = options.index {
|
|
154
|
+
default_index = options.index { it.fetch( :value ) == current } || 0
|
|
155
155
|
prompt_choice( options: options, default: default_index )
|
|
156
156
|
end
|
|
157
157
|
|
|
@@ -219,7 +219,7 @@ module Carson
|
|
|
219
219
|
others << entry
|
|
220
220
|
end
|
|
221
221
|
end
|
|
222
|
-
well_known.sort_by {
|
|
222
|
+
well_known.sort_by { WELL_KNOWN_REMOTES.index( it.fetch( :name ) ) || 999 } + others.sort_by { it.fetch( :name ) }
|
|
223
223
|
end
|
|
224
224
|
|
|
225
225
|
# Normalises a remote URL so SSH and HTTPS variants of the same host/path compare equal.
|
|
@@ -295,7 +295,7 @@ module Carson
|
|
|
295
295
|
|
|
296
296
|
def detect_git_remote
|
|
297
297
|
remotes = list_git_remotes
|
|
298
|
-
remote_names = remotes.map {
|
|
298
|
+
remote_names = remotes.map { it.fetch( :name ) }
|
|
299
299
|
return nil if remote_names.empty?
|
|
300
300
|
|
|
301
301
|
return config.git_remote if remote_names.include?( config.git_remote )
|