carson 3.19.0 → 3.21.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/README.md +11 -3
- data/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/exe/carson +3 -3
- data/hooks/command-guard +56 -0
- data/hooks/pre-push +37 -1
- data/lib/carson/adapters/agent.rb +1 -0
- data/lib/carson/adapters/claude.rb +2 -0
- data/lib/carson/adapters/codex.rb +2 -0
- data/lib/carson/adapters/git.rb +2 -0
- data/lib/carson/adapters/github.rb +2 -0
- data/lib/carson/adapters/prompt.rb +2 -0
- data/lib/carson/cli.rb +415 -414
- data/lib/carson/config.rb +4 -3
- data/lib/carson/runtime/audit.rb +84 -84
- data/lib/carson/runtime/deliver.rb +27 -24
- data/lib/carson/runtime/govern.rb +29 -29
- data/lib/carson/runtime/housekeep.rb +15 -15
- data/lib/carson/runtime/local/hooks.rb +20 -0
- data/lib/carson/runtime/local/onboard.rb +17 -17
- data/lib/carson/runtime/local/prune.rb +13 -13
- data/lib/carson/runtime/local/sync.rb +6 -6
- data/lib/carson/runtime/local/template.rb +26 -25
- data/lib/carson/runtime/local/worktree.rb +76 -33
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/repos.rb +1 -1
- data/lib/carson/runtime/review/data_access.rb +1 -0
- data/lib/carson/runtime/review/gate_support.rb +15 -14
- data/lib/carson/runtime/review/query_text.rb +1 -0
- data/lib/carson/runtime/review/sweep_support.rb +5 -4
- data/lib/carson/runtime/review/utility.rb +2 -1
- data/lib/carson/runtime/review.rb +10 -8
- data/lib/carson/runtime/setup.rb +12 -10
- data/lib/carson/runtime/status.rb +20 -20
- data/lib/carson/runtime.rb +39 -25
- data/lib/carson/version.rb +1 -0
- data/lib/carson.rb +1 -0
- data/templates/.github/carson.md +7 -4
- metadata +2 -1
data/lib/carson/config.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Loads and validates Carson configuration from global config and environment overrides.
|
|
1
2
|
require "json"
|
|
2
3
|
|
|
3
4
|
module Carson
|
|
@@ -85,8 +86,8 @@ module Carson
|
|
|
85
86
|
parsed = JSON.parse( raw )
|
|
86
87
|
raise ConfigError, "global config must be a JSON object at #{path}" unless parsed.is_a?( Hash )
|
|
87
88
|
parsed
|
|
88
|
-
rescue JSON::ParserError =>
|
|
89
|
-
raise ConfigError, "invalid global config JSON at #{path} (#{
|
|
89
|
+
rescue JSON::ParserError => exception
|
|
90
|
+
raise ConfigError, "invalid global config JSON at #{path} (#{exception.message})"
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
def self.global_config_path( repo_root: )
|
|
@@ -212,7 +213,7 @@ module Carson
|
|
|
212
213
|
@audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
|
|
213
214
|
|
|
214
215
|
govern_hash = fetch_hash( hash: data, key: "govern" )
|
|
215
|
-
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |
|
|
216
|
+
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
|
|
216
217
|
@govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
|
|
217
218
|
@govern_merge_method = fetch_string( hash: govern_hash, key: "merge_method" ).downcase
|
|
218
219
|
govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Carson
|
|
|
11
11
|
return fingerprint_status unless fingerprint_status.nil?
|
|
12
12
|
unless head_exists?
|
|
13
13
|
if json_output
|
|
14
|
-
|
|
14
|
+
output.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
|
|
15
15
|
else
|
|
16
16
|
puts_line "No commits yet — audit skipped for initial commit."
|
|
17
17
|
end
|
|
@@ -69,24 +69,24 @@ module Carson
|
|
|
69
69
|
audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( monitor_report.fetch( :status ) )
|
|
70
70
|
if monitor_report.fetch( :status ) == "attention"
|
|
71
71
|
checks = monitor_report.fetch( :checks )
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
failing_count = checks.fetch( :failing_count )
|
|
73
|
+
pending_count = checks.fetch( :pending_count )
|
|
74
74
|
total = checks.fetch( :required_total )
|
|
75
75
|
fail_names = checks.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
|
|
76
|
-
if
|
|
77
|
-
audit_concise_problems << "Checks: #{
|
|
78
|
-
elsif
|
|
79
|
-
audit_concise_problems << "Checks: #{
|
|
80
|
-
elsif
|
|
81
|
-
audit_concise_problems << "Checks: pending (#{total -
|
|
76
|
+
if failing_count.positive? && pending_count.positive?
|
|
77
|
+
audit_concise_problems << "Checks: #{failing_count} failing (#{fail_names}), #{pending_count} pending of #{total} required."
|
|
78
|
+
elsif failing_count.positive?
|
|
79
|
+
audit_concise_problems << "Checks: #{failing_count} of #{total} failing (#{fail_names})."
|
|
80
|
+
elsif pending_count.positive?
|
|
81
|
+
audit_concise_problems << "Checks: pending (#{total - pending_count} of #{total} complete)."
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
puts_verbose ""
|
|
85
85
|
puts_verbose "[Default Branch CI Baseline (gh)]"
|
|
86
86
|
default_branch_baseline = default_branch_ci_baseline_report
|
|
87
87
|
audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( default_branch_baseline.fetch( :status ) )
|
|
88
|
-
|
|
89
|
-
if
|
|
88
|
+
baseline_status = default_branch_baseline.fetch( :status )
|
|
89
|
+
if baseline_status == "block"
|
|
90
90
|
parts = []
|
|
91
91
|
if default_branch_baseline.fetch( :failing_count ).positive?
|
|
92
92
|
names = default_branch_baseline.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
|
|
@@ -98,7 +98,7 @@ module Carson
|
|
|
98
98
|
end
|
|
99
99
|
parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
|
|
100
100
|
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
|
|
101
|
-
elsif
|
|
101
|
+
elsif baseline_status == "attention"
|
|
102
102
|
parts = []
|
|
103
103
|
if default_branch_baseline.fetch( :advisory_failing_count ).positive?
|
|
104
104
|
names = default_branch_baseline.fetch( :advisory_failing ).map { it.fetch( :name ) }.join( ", " )
|
|
@@ -143,7 +143,7 @@ module Carson
|
|
|
143
143
|
problems: audit_concise_problems,
|
|
144
144
|
exit_code: exit_code
|
|
145
145
|
}
|
|
146
|
-
|
|
146
|
+
output.puts JSON.pretty_generate( result )
|
|
147
147
|
else
|
|
148
148
|
puts_verbose ""
|
|
149
149
|
puts_verbose "[Audit Result]"
|
|
@@ -182,8 +182,8 @@ module Carson
|
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
begin
|
|
185
|
-
|
|
186
|
-
status =
|
|
185
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
186
|
+
status = scoped_runtime.audit!
|
|
187
187
|
case status
|
|
188
188
|
when EXIT_OK
|
|
189
189
|
puts_line "#{repo_name}: ok" unless verbose?
|
|
@@ -197,9 +197,9 @@ module Carson
|
|
|
197
197
|
record_batch_skip( command: "audit", repo_path: repo_path, reason: "audit failed" )
|
|
198
198
|
failed += 1
|
|
199
199
|
end
|
|
200
|
-
rescue StandardError =>
|
|
201
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
202
|
-
record_batch_skip( command: "audit", repo_path: repo_path, reason:
|
|
200
|
+
rescue StandardError => exception
|
|
201
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
202
|
+
record_batch_skip( command: "audit", repo_path: repo_path, reason: exception.message )
|
|
203
203
|
failed += 1
|
|
204
204
|
end
|
|
205
205
|
end
|
|
@@ -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 )}"
|
|
@@ -277,9 +277,9 @@ module Carson
|
|
|
277
277
|
report.dig( :checks, :pending ).each { |entry| puts_verbose "check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
|
|
278
278
|
report[ :status ] = "attention" if report.dig( :checks, :failing_count ).positive? || report.dig( :checks, :pending_count ).positive?
|
|
279
279
|
report
|
|
280
|
-
rescue JSON::ParserError =>
|
|
280
|
+
rescue JSON::ParserError => exception
|
|
281
281
|
report[ :status ] = "skipped"
|
|
282
|
-
report[ :skip_reason ] = "invalid gh JSON response (#{
|
|
282
|
+
report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
|
|
283
283
|
puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
|
|
284
284
|
report
|
|
285
285
|
end
|
|
@@ -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 )
|
|
@@ -374,14 +374,14 @@ module Carson
|
|
|
374
374
|
puts_verbose "ACTION: default branch has workflow files but no check-runs; align workflow triggers and branch protection check names."
|
|
375
375
|
end
|
|
376
376
|
report
|
|
377
|
-
rescue JSON::ParserError =>
|
|
377
|
+
rescue JSON::ParserError => exception
|
|
378
378
|
report[ :status ] = "skipped"
|
|
379
|
-
report[ :skip_reason ] = "invalid gh JSON response (#{
|
|
379
|
+
report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
|
|
380
380
|
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
381
381
|
report
|
|
382
|
-
rescue StandardError =>
|
|
382
|
+
rescue StandardError => exception
|
|
383
383
|
report[ :status ] = "skipped"
|
|
384
|
-
report[ :skip_reason ] =
|
|
384
|
+
report[ :skip_reason ] = exception.message
|
|
385
385
|
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
386
386
|
report
|
|
387
387
|
end
|
|
@@ -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
|
|
@@ -443,7 +443,7 @@ module Carson
|
|
|
443
443
|
end
|
|
444
444
|
|
|
445
445
|
# Returns true when a required-check entry is in a non-passing, non-pending state.
|
|
446
|
-
# Cancelled, errored, timed-
|
|
446
|
+
# Cancelled, errored, timed-output, and any unknown bucket all count as failing.
|
|
447
447
|
def check_entry_failing?( entry: )
|
|
448
448
|
!%w[pass pending].include?( entry[ "bucket" ].to_s )
|
|
449
449
|
end
|
|
@@ -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
|
|
@@ -487,8 +487,8 @@ module Carson
|
|
|
487
487
|
markdown_path, json_path = write_pr_monitor_report( report: report )
|
|
488
488
|
puts_verbose "report_markdown: #{markdown_path}"
|
|
489
489
|
puts_verbose "report_json: #{json_path}"
|
|
490
|
-
rescue StandardError =>
|
|
491
|
-
puts_verbose "report_write: SKIP (#{
|
|
490
|
+
rescue StandardError => exception
|
|
491
|
+
puts_verbose "report_write: SKIP (#{exception.message})"
|
|
492
492
|
end
|
|
493
493
|
|
|
494
494
|
# Persists report in both machine-readable JSON and human-readable Markdown.
|
|
@@ -90,7 +90,7 @@ module Carson
|
|
|
90
90
|
result[ :exit_code ] = exit_code
|
|
91
91
|
|
|
92
92
|
if json_output
|
|
93
|
-
|
|
93
|
+
output.puts JSON.pretty_generate( result )
|
|
94
94
|
else
|
|
95
95
|
print_deliver_human( result: result )
|
|
96
96
|
end
|
|
@@ -136,8 +136,11 @@ module Carson
|
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
# 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.
|
|
139
140
|
def push_branch!( branch:, remote:, result: )
|
|
140
|
-
_, push_stderr, push_success, =
|
|
141
|
+
_, push_stderr, push_success, = with_env_var( "CARSON_PUSH", "1" ) do
|
|
142
|
+
git_run( "push", "-u", remote, branch )
|
|
143
|
+
end
|
|
141
144
|
unless push_success
|
|
142
145
|
error_text = push_stderr.to_s.strip
|
|
143
146
|
error_text = "push failed" if error_text.empty?
|
|
@@ -210,27 +213,27 @@ module Carson
|
|
|
210
213
|
|
|
211
214
|
# Generates a default PR title from the branch name.
|
|
212
215
|
def default_pr_title( branch: )
|
|
213
|
-
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) {
|
|
216
|
+
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { it.upcase }
|
|
214
217
|
end
|
|
215
218
|
|
|
216
219
|
# 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
|
|
220
|
+
# Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
|
|
221
|
+
def check_pr_ci( number: )
|
|
222
|
+
stdout, _, success, = gh_run(
|
|
223
|
+
"pr", "checks", number.to_s,
|
|
224
|
+
"--json", "name,bucket"
|
|
225
|
+
)
|
|
226
|
+
return :none unless success
|
|
227
|
+
|
|
228
|
+
checks = JSON.parse( stdout ) rescue []
|
|
229
|
+
return :none if checks.empty?
|
|
230
|
+
|
|
231
|
+
buckets = checks.map { it[ "bucket" ].to_s.downcase }
|
|
232
|
+
return :fail if buckets.include?( "fail" )
|
|
233
|
+
return :pending if buckets.include?( "pending" )
|
|
234
|
+
|
|
235
|
+
:pass
|
|
236
|
+
end
|
|
234
237
|
|
|
235
238
|
# Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
|
|
236
239
|
def check_pr_review( number: )
|
|
@@ -253,7 +256,7 @@ end
|
|
|
253
256
|
# Merges the PR using the configured merge method.
|
|
254
257
|
# Deliberately omits --delete-branch: gh tries to switch the local
|
|
255
258
|
# checkout to main afterwards, which fails inside a worktree where
|
|
256
|
-
# main is already checked
|
|
259
|
+
# main is already checked output. Branch cleanup deferred to `carson prune`.
|
|
257
260
|
def merge_pr!( number:, result: )
|
|
258
261
|
method = config.govern_merge_method
|
|
259
262
|
result[ :merge_method ] = method
|
|
@@ -277,7 +280,7 @@ end
|
|
|
277
280
|
# Syncs main after a successful merge.
|
|
278
281
|
# Pulls into the main worktree directly — does not attempt checkout,
|
|
279
282
|
# because checkout would fail when running inside a feature worktree
|
|
280
|
-
# (main is already checked
|
|
283
|
+
# (main is already checked output in the main tree).
|
|
281
284
|
def sync_after_merge!( remote:, main:, result: )
|
|
282
285
|
main_root = main_worktree_root
|
|
283
286
|
_, pull_stderr, pull_success, = Open3.capture3(
|
|
@@ -298,8 +301,8 @@ end
|
|
|
298
301
|
def compute_post_merge_next_step!( result: )
|
|
299
302
|
main_root = main_worktree_root
|
|
300
303
|
cwd = realpath_safe( Dir.pwd )
|
|
301
|
-
current_wt = worktree_list.select {
|
|
302
|
-
.find {
|
|
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 ), "" ) ) }
|
|
303
306
|
|
|
304
307
|
if current_wt
|
|
305
308
|
wt_name = File.basename( current_wt.fetch( :path ) )
|
|
@@ -55,8 +55,8 @@ module Carson
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
EXIT_OK
|
|
58
|
-
rescue StandardError =>
|
|
59
|
-
puts_line "ERROR: govern failed — #{
|
|
58
|
+
rescue StandardError => exception
|
|
59
|
+
puts_line "ERROR: govern failed — #{exception.message}"
|
|
60
60
|
EXIT_ERROR
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -69,8 +69,8 @@ module Carson
|
|
|
69
69
|
puts_line "── cycle #{cycle_count} at #{Time.now.utc.strftime( "%Y-%m-%d %H:%M:%S UTC" )} ──"
|
|
70
70
|
begin
|
|
71
71
|
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
72
|
-
rescue StandardError =>
|
|
73
|
-
puts_line "ERROR: cycle #{cycle_count} failed — #{
|
|
72
|
+
rescue StandardError => exception
|
|
73
|
+
puts_line "ERROR: cycle #{cycle_count} failed — #{exception.message}"
|
|
74
74
|
end
|
|
75
75
|
puts_line "sleeping #{loop_seconds}s until next cycle…"
|
|
76
76
|
sleep loop_seconds
|
|
@@ -145,8 +145,8 @@ module Carson
|
|
|
145
145
|
return nil
|
|
146
146
|
end
|
|
147
147
|
JSON.parse( stdout_text )
|
|
148
|
-
rescue JSON::ParserError =>
|
|
149
|
-
puts_line "gh pr list returned invalid JSON: #{
|
|
148
|
+
rescue JSON::ParserError => exception
|
|
149
|
+
puts_line "gh pr list returned invalid JSON: #{exception.message}"
|
|
150
150
|
nil
|
|
151
151
|
end
|
|
152
152
|
|
|
@@ -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
|
|
@@ -345,13 +345,13 @@ module Carson
|
|
|
345
345
|
|
|
346
346
|
# Runs sync + prune in the given repo after a successful merge.
|
|
347
347
|
def housekeep_repo!( repo_path: )
|
|
348
|
-
|
|
348
|
+
scoped_runtime = if repo_path == self.repo_root
|
|
349
349
|
self
|
|
350
350
|
else
|
|
351
|
-
Runtime.new( repo_root: repo_path, tool_root: tool_root,
|
|
351
|
+
Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
|
|
352
352
|
end
|
|
353
|
-
sync_status =
|
|
354
|
-
|
|
353
|
+
sync_status = scoped_runtime.sync!
|
|
354
|
+
scoped_runtime.prune! if sync_status == EXIT_OK
|
|
355
355
|
end
|
|
356
356
|
|
|
357
357
|
# Selects which agent provider to use based on config and availability.
|
|
@@ -410,18 +410,18 @@ module Carson
|
|
|
410
410
|
|
|
411
411
|
# Evidence gathering — builds structured context Hash for agent work orders.
|
|
412
412
|
def evidence( pr:, repo_path:, objective: )
|
|
413
|
-
|
|
413
|
+
context = { title: pr.fetch( "title", "" ) }
|
|
414
414
|
case objective
|
|
415
415
|
when "fix_ci"
|
|
416
|
-
|
|
416
|
+
context.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
|
|
417
417
|
when "address_review"
|
|
418
|
-
|
|
418
|
+
context.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
|
|
419
419
|
end
|
|
420
420
|
prior = prior_attempt( pr: pr, repo_path: repo_path )
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
rescue StandardError =>
|
|
424
|
-
puts_line " evidence gathering failed: #{
|
|
421
|
+
context[ :prior_attempt ] = prior if prior
|
|
422
|
+
context
|
|
423
|
+
rescue StandardError => exception
|
|
424
|
+
puts_line " evidence gathering failed: #{exception.message}"
|
|
425
425
|
{ title: pr.fetch( "title", "" ) }
|
|
426
426
|
end
|
|
427
427
|
|
|
@@ -452,8 +452,8 @@ module Carson
|
|
|
452
452
|
return { ci_run_url: run_url } unless log_status.success?
|
|
453
453
|
|
|
454
454
|
{ ci_logs: truncate_log( text: log_stdout ), ci_run_url: run_url }
|
|
455
|
-
rescue StandardError =>
|
|
456
|
-
puts_line " ci_evidence failed: #{
|
|
455
|
+
rescue StandardError => exception
|
|
456
|
+
puts_line " ci_evidence failed: #{exception.message}"
|
|
457
457
|
{}
|
|
458
458
|
end
|
|
459
459
|
|
|
@@ -464,13 +464,13 @@ module Carson
|
|
|
464
464
|
end
|
|
465
465
|
|
|
466
466
|
def review_evidence( pr:, repo_path: )
|
|
467
|
-
|
|
468
|
-
owner, repo =
|
|
467
|
+
scoped_runtime = scoped_runtime( repo_path: repo_path )
|
|
468
|
+
owner, repo = scoped_runtime.send( :repository_coordinates )
|
|
469
469
|
pr_number = pr[ "number" ]
|
|
470
|
-
details =
|
|
470
|
+
details = scoped_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: pr_number )
|
|
471
471
|
pr_author = details.dig( :author, :login ).to_s
|
|
472
|
-
threads =
|
|
473
|
-
top_level =
|
|
472
|
+
threads = scoped_runtime.send( :unresolved_thread_entries, details: details )
|
|
473
|
+
top_level = scoped_runtime.send( :actionable_top_level_items, details: details, pr_author: pr_author )
|
|
474
474
|
|
|
475
475
|
findings = []
|
|
476
476
|
threads.each do |entry|
|
|
@@ -483,14 +483,14 @@ module Carson
|
|
|
483
483
|
end
|
|
484
484
|
|
|
485
485
|
{ review_findings: findings }
|
|
486
|
-
rescue StandardError =>
|
|
487
|
-
puts_line " review_evidence failed: #{
|
|
486
|
+
rescue StandardError => exception
|
|
487
|
+
puts_line " review_evidence failed: #{exception.message}"
|
|
488
488
|
{}
|
|
489
489
|
end
|
|
490
490
|
|
|
491
491
|
def scoped_runtime( repo_path: )
|
|
492
492
|
return self if repo_path == self.repo_root
|
|
493
|
-
Runtime.new( repo_root: repo_path, tool_root: tool_root,
|
|
493
|
+
Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
|
|
494
494
|
end
|
|
495
495
|
|
|
496
496
|
def prior_attempt( pr:, repo_path: )
|
|
@@ -64,9 +64,9 @@ module Carson
|
|
|
64
64
|
return unless gh_available?
|
|
65
65
|
|
|
66
66
|
main_root = main_worktree_root
|
|
67
|
-
worktree_list.each do |
|
|
68
|
-
path =
|
|
69
|
-
branch =
|
|
67
|
+
worktree_list.each do |worktree|
|
|
68
|
+
path = worktree.fetch( :path )
|
|
69
|
+
branch = worktree.fetch( :branch, nil )
|
|
70
70
|
next if path == main_root
|
|
71
71
|
next unless branch
|
|
72
72
|
next if cwd_inside_worktree?( worktree_path: path )
|
|
@@ -121,26 +121,26 @@ module Carson
|
|
|
121
121
|
return { name: repo_name, path: repo_path, status: "error", error: "path not found" }
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
buffer = verbose? ? output : StringIO.new
|
|
125
|
+
error_buffer = verbose? ? error : StringIO.new
|
|
126
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
|
|
127
127
|
|
|
128
|
-
sync_status =
|
|
128
|
+
sync_status = scoped_runtime.sync!
|
|
129
129
|
if sync_status == EXIT_OK
|
|
130
|
-
|
|
131
|
-
prune_status =
|
|
130
|
+
scoped_runtime.reap_dead_worktrees!
|
|
131
|
+
prune_status = scoped_runtime.prune!
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
ok = sync_status == EXIT_OK && prune_status == EXIT_OK
|
|
135
135
|
unless verbose? || silent
|
|
136
|
-
summary = strip_badge(
|
|
136
|
+
summary = strip_badge( buffer.string.lines.last.to_s.strip )
|
|
137
137
|
puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
{ name: repo_name, path: repo_path, status: ok ? "ok" : "error" }
|
|
141
|
-
rescue StandardError =>
|
|
142
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
143
|
-
{ name: repo_name, path: repo_path, status: "error", error:
|
|
141
|
+
rescue StandardError => exception
|
|
142
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})" unless silent
|
|
143
|
+
{ name: repo_name, path: repo_path, status: "error", error: exception.message }
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
# Strips the Carson badge prefix from a message to avoid double-badging.
|
|
@@ -156,7 +156,7 @@ module Carson
|
|
|
156
156
|
return expanded if repos.include?( expanded )
|
|
157
157
|
|
|
158
158
|
downcased = File.basename( target ).downcase
|
|
159
|
-
repos.find { |
|
|
159
|
+
repos.find { |repo_path| File.basename( repo_path ).downcase == downcased }
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
# Unified output — JSON or human-readable.
|
|
@@ -164,7 +164,7 @@ module Carson
|
|
|
164
164
|
result[ :exit_code ] = exit_code
|
|
165
165
|
|
|
166
166
|
if json_output
|
|
167
|
-
|
|
167
|
+
output.puts JSON.pretty_generate( result )
|
|
168
168
|
else
|
|
169
169
|
if results && ( succeeded || failed )
|
|
170
170
|
total = ( succeeded || 0 ) + ( failed || 0 )
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Installs, validates, and reports on managed git hooks for governed repositories.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Local
|
|
@@ -30,11 +31,30 @@ module Carson
|
|
|
30
31
|
end
|
|
31
32
|
git_system!( "config", "core.hooksPath", hooks_dir )
|
|
32
33
|
File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
|
|
34
|
+
install_command_guard!
|
|
33
35
|
puts_verbose "configured_hooks_path: #{hooks_dir}"
|
|
34
36
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
35
37
|
EXIT_OK
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# Installs the command guard hook to a stable (non-versioned) path.
|
|
41
|
+
# Claude Code's PreToolUse hook references this path — it must not change across upgrades.
|
|
42
|
+
def install_command_guard!
|
|
43
|
+
source = hook_template_path( hook_name: "command-guard" )
|
|
44
|
+
return unless File.file?( source )
|
|
45
|
+
|
|
46
|
+
target = command_guard_path
|
|
47
|
+
FileUtils.mkdir_p( File.dirname( target ) )
|
|
48
|
+
FileUtils.cp( source, target )
|
|
49
|
+
FileUtils.chmod( 0o755, target )
|
|
50
|
+
puts_verbose "command_guard: #{target}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stable path for the command guard script — not versioned so external references survive upgrades.
|
|
54
|
+
def command_guard_path
|
|
55
|
+
File.expand_path( File.join( config.hooks_path, "command-guard" ) )
|
|
56
|
+
end
|
|
57
|
+
|
|
38
58
|
# Canonical hook template location inside Carson repository.
|
|
39
59
|
def hook_template_path( hook_name: )
|
|
40
60
|
File.join( tool_root, "hooks", hook_name )
|