carson 3.22.0 → 3.23.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/API.md +19 -20
- data/MANUAL.md +76 -65
- data/README.md +42 -50
- data/RELEASE.md +24 -1
- data/SKILL.md +1 -1
- data/VERSION +1 -1
- data/carson.gemspec +3 -4
- data/hooks/command-guard +1 -1
- data/hooks/pre-push +17 -20
- data/lib/carson/adapters/agent.rb +2 -2
- data/lib/carson/branch.rb +38 -0
- data/lib/carson/cli.rb +45 -30
- data/lib/carson/config.rb +80 -29
- data/lib/carson/delivery.rb +64 -0
- data/lib/carson/ledger.rb +305 -0
- data/lib/carson/repository.rb +47 -0
- data/lib/carson/revision.rb +30 -0
- data/lib/carson/runtime/audit.rb +43 -17
- data/lib/carson/runtime/deliver.rb +163 -149
- data/lib/carson/runtime/govern.rb +233 -357
- data/lib/carson/runtime/housekeep.rb +233 -27
- data/lib/carson/runtime/local/onboard.rb +29 -29
- data/lib/carson/runtime/local/prune.rb +120 -35
- data/lib/carson/runtime/local/sync.rb +29 -7
- data/lib/carson/runtime/local/template.rb +30 -12
- data/lib/carson/runtime/local/worktree.rb +37 -442
- data/lib/carson/runtime/review/gate_support.rb +144 -12
- data/lib/carson/runtime/review/sweep_support.rb +2 -2
- data/lib/carson/runtime/review/utility.rb +1 -1
- data/lib/carson/runtime/review.rb +21 -77
- data/lib/carson/runtime/setup.rb +25 -33
- data/lib/carson/runtime/status.rb +96 -212
- data/lib/carson/runtime.rb +39 -4
- data/lib/carson/worktree.rb +497 -0
- data/lib/carson.rb +6 -0
- metadata +37 -17
- data/.github/copilot-instructions.md +0 -1
- data/.github/pull_request_template.md +0 -12
- data/templates/.github/AGENTS.md +0 -1
- data/templates/.github/CLAUDE.md +0 -1
- data/templates/.github/carson.md +0 -47
- data/templates/.github/copilot-instructions.md +0 -1
- data/templates/.github/pull_request_template.md +0 -12
|
@@ -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 )
|
|
@@ -31,7 +98,9 @@ module Carson
|
|
|
31
98
|
unresolved_threads = unresolved_thread_entries( details: details )
|
|
32
99
|
actionable_top_level = actionable_top_level_items( details: details, pr_author: pr_author )
|
|
33
100
|
acknowledgements = disposition_acknowledgements( details: details, pr_author: pr_author )
|
|
34
|
-
unacknowledged_actionable = actionable_top_level.reject
|
|
101
|
+
unacknowledged_actionable = actionable_top_level.reject do |item|
|
|
102
|
+
acknowledged_by_disposition?( item: item, acknowledgements: acknowledgements )
|
|
103
|
+
end
|
|
35
104
|
{
|
|
36
105
|
latest_activity: latest_review_activity( details: details ),
|
|
37
106
|
unresolved_threads: unresolved_threads,
|
|
@@ -45,8 +114,8 @@ module Carson
|
|
|
45
114
|
def review_gate_signature( snapshot: )
|
|
46
115
|
{
|
|
47
116
|
latest_activity: snapshot.fetch( :latest_activity ).to_s,
|
|
48
|
-
unresolved_urls: snapshot.fetch( :unresolved_threads ).map {
|
|
49
|
-
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map {
|
|
117
|
+
unresolved_urls: snapshot.fetch( :unresolved_threads ).map { |entry| entry.fetch( :url ) }.sort,
|
|
118
|
+
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { |entry| entry.fetch( :url ) }.sort
|
|
50
119
|
}
|
|
51
120
|
end
|
|
52
121
|
|
|
@@ -67,8 +136,11 @@ module Carson
|
|
|
67
136
|
}
|
|
68
137
|
end
|
|
69
138
|
|
|
139
|
+
# GraphQL returns "gemini-code-assist"; REST returns "gemini-code-assist[bot]".
|
|
140
|
+
# Normalise both sides by stripping the [bot] suffix for a consistent match.
|
|
70
141
|
def bot_username?( author: )
|
|
71
|
-
|
|
142
|
+
normalised = author.to_s.downcase.delete_suffix( "[bot]" )
|
|
143
|
+
config.review_bot_usernames.any? { |username| username.downcase.delete_suffix( "[bot]" ) == normalised }
|
|
72
144
|
end
|
|
73
145
|
|
|
74
146
|
def unresolved_thread_entries( details: )
|
|
@@ -79,7 +151,7 @@ module Carson
|
|
|
79
151
|
comments = thread.fetch( :comments )
|
|
80
152
|
first_comment = comments.first || {}
|
|
81
153
|
next if bot_username?( author: first_comment.fetch( :author, "" ) )
|
|
82
|
-
latest_time = comments.map {
|
|
154
|
+
latest_time = comments.map { |comment| comment.fetch( :created_at ) }.max.to_s
|
|
83
155
|
{
|
|
84
156
|
url: blank_to( value: first_comment.fetch( :url, "" ), default: "#{details.fetch( :url )}#thread-#{index + 1}" ),
|
|
85
157
|
author: first_comment.fetch( :author, "" ),
|
|
@@ -131,7 +203,7 @@ module Carson
|
|
|
131
203
|
sources = []
|
|
132
204
|
sources.concat( Array( details.fetch( :comments ) ) )
|
|
133
205
|
sources.concat( Array( details.fetch( :reviews ) ) )
|
|
134
|
-
sources.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
206
|
+
sources.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) } )
|
|
135
207
|
sources.map do |entry|
|
|
136
208
|
next unless entry.fetch( :author, "" ) == pr_author
|
|
137
209
|
body = entry.fetch( :body, "" ).to_s
|
|
@@ -152,7 +224,7 @@ module Carson
|
|
|
152
224
|
# True when any disposition acknowledgement references the specific finding URL.
|
|
153
225
|
def acknowledged_by_disposition?( item:, acknowledgements: )
|
|
154
226
|
acknowledgements.any? do |ack|
|
|
155
|
-
Array( ack.fetch( :target_urls ) ).any? {
|
|
227
|
+
Array( ack.fetch( :target_urls ) ).any? { |target_url| target_url == item.fetch( :url ) }
|
|
156
228
|
end
|
|
157
229
|
end
|
|
158
230
|
|
|
@@ -160,10 +232,70 @@ module Carson
|
|
|
160
232
|
def latest_review_activity( details: )
|
|
161
233
|
timestamps = []
|
|
162
234
|
timestamps << details.fetch( :updated_at )
|
|
163
|
-
timestamps.concat( Array( details.fetch( :comments ) ).map {
|
|
164
|
-
timestamps.concat( Array( details.fetch( :reviews ) ).map {
|
|
165
|
-
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
166
|
-
timestamps.map { parse_time_or_nil( text:
|
|
235
|
+
timestamps.concat( Array( details.fetch( :comments ) ).map { |comment| comment.fetch( :created_at ) } )
|
|
236
|
+
timestamps.concat( Array( details.fetch( :reviews ) ).map { |review| review.fetch( :created_at ) } )
|
|
237
|
+
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) }.map { |comment| comment.fetch( :created_at ) } )
|
|
238
|
+
timestamps.map { |timestamp| parse_time_or_nil( text: timestamp ) }.compact.max&.utc&.iso8601
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def resolved_review_gate_pr_summary( owner:, repo:, pr_number:, pr_summary: )
|
|
242
|
+
required_keys = %i[number title url state]
|
|
243
|
+
if !pr_summary.nil? && required_keys.all? { |key| pr_summary.key?( key ) && !pr_summary.fetch( key ).to_s.empty? }
|
|
244
|
+
return pr_summary
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
pull_request_summary( owner: owner, repo: repo, pr_number: pr_number )
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def pull_request_summary( owner:, repo:, pr_number: )
|
|
251
|
+
details = pull_request_details( owner: owner, repo: repo, pr_number: pr_number )
|
|
252
|
+
{
|
|
253
|
+
number: details.fetch( :number ),
|
|
254
|
+
title: details.fetch( :title ),
|
|
255
|
+
url: details.fetch( :url ),
|
|
256
|
+
state: details.fetch( :state )
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def build_review_gate_report( branch_name:, pr_summary:, snapshot:, converged:, poll_attempts: )
|
|
261
|
+
{
|
|
262
|
+
generated_at: Time.now.utc.iso8601,
|
|
263
|
+
branch: branch_name,
|
|
264
|
+
status: review_gate_block_reasons( snapshot: snapshot, converged: converged ).empty? ? "ok" : "block",
|
|
265
|
+
converged: converged,
|
|
266
|
+
wait_seconds: config.review_wait_seconds,
|
|
267
|
+
poll_seconds: config.review_poll_seconds,
|
|
268
|
+
max_polls: config.review_max_polls,
|
|
269
|
+
poll_attempts: poll_attempts,
|
|
270
|
+
block_reasons: review_gate_block_reasons( snapshot: snapshot, converged: converged ),
|
|
271
|
+
pr: {
|
|
272
|
+
number: pr_summary.fetch( :number ),
|
|
273
|
+
title: pr_summary.fetch( :title ),
|
|
274
|
+
url: pr_summary.fetch( :url ),
|
|
275
|
+
state: pr_summary.fetch( :state )
|
|
276
|
+
},
|
|
277
|
+
unresolved_threads: snapshot.fetch( :unresolved_threads ),
|
|
278
|
+
actionable_top_level: snapshot.fetch( :actionable_top_level ),
|
|
279
|
+
unacknowledged_actionable: snapshot.fetch( :unacknowledged_actionable )
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def review_gate_block_reasons( snapshot:, converged: )
|
|
284
|
+
reasons = []
|
|
285
|
+
reasons << "review snapshot did not converge within #{config.review_max_polls} polls" unless converged
|
|
286
|
+
if snapshot.fetch( :unresolved_threads ).any?
|
|
287
|
+
reasons << "unresolved review threads remain (#{snapshot.fetch( :unresolved_threads ).count})"
|
|
288
|
+
end
|
|
289
|
+
if snapshot.fetch( :unacknowledged_actionable ).any?
|
|
290
|
+
reasons << "actionable top-level comments/reviews without required disposition (#{snapshot.fetch( :unacknowledged_actionable ).count})"
|
|
291
|
+
end
|
|
292
|
+
reasons
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def review_gate_changes_requested?( report: )
|
|
296
|
+
Array( report.fetch( :unacknowledged_actionable ) ).any? do |entry|
|
|
297
|
+
entry.fetch( :reason ) == "changes_requested_review"
|
|
298
|
+
end
|
|
167
299
|
end
|
|
168
300
|
|
|
169
301
|
# Writes review gate artefacts using fixed report names in global report output.
|
|
@@ -209,7 +341,7 @@ module Carson
|
|
|
209
341
|
if report.fetch( :block_reasons ).empty?
|
|
210
342
|
lines << "- none"
|
|
211
343
|
else
|
|
212
|
-
report.fetch( :block_reasons ).each { lines << "- #{
|
|
344
|
+
report.fetch( :block_reasons ).each { |reason| lines << "- #{reason}" }
|
|
213
345
|
end
|
|
214
346
|
lines << ""
|
|
215
347
|
lines << "## Unresolved Threads"
|
|
@@ -60,7 +60,7 @@ module Carson
|
|
|
60
60
|
)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
Array( details.fetch( :review_threads ) ).flat_map {
|
|
63
|
+
Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) }.each do |comment|
|
|
64
64
|
next if comment.fetch( :author ) == pr_author
|
|
65
65
|
next if bot_username?( author: comment.fetch( :author ) )
|
|
66
66
|
hits = matched_risk_keywords( text: comment.fetch( :body ) )
|
|
@@ -153,7 +153,7 @@ module Carson
|
|
|
153
153
|
stdout_text, stderr_text, success, = gh_run( "issue", "list", "--repo", repo_slug, "--state", "all", "--limit", "100", "--json", "number,title,state,url,labels" )
|
|
154
154
|
raise gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to list issues for review sweep" ) unless success
|
|
155
155
|
issues = Array( JSON.parse( stdout_text ) )
|
|
156
|
-
node = issues.find {
|
|
156
|
+
node = issues.find { |issue| issue[ "title" ].to_s == config.review_tracking_issue_title }
|
|
157
157
|
return nil if node.nil?
|
|
158
158
|
{
|
|
159
159
|
number: node[ "number" ],
|
|
@@ -25,7 +25,7 @@ module Carson
|
|
|
25
25
|
|
|
26
26
|
# GitHub URL extraction for mapping disposition acknowledgements to finding URLs.
|
|
27
27
|
def extract_github_urls( text: )
|
|
28
|
-
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map {
|
|
28
|
+
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map { |url| url.sub( /[.,;:]+$/, "" ) }.uniq
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Parse RFC3339 timestamps and return nil on blank/invalid values.
|
|
@@ -24,15 +24,16 @@ module Carson
|
|
|
24
24
|
puts_line "Review Gate"
|
|
25
25
|
end
|
|
26
26
|
unless gh_available?
|
|
27
|
-
puts_line "
|
|
27
|
+
puts_line "gh CLI not found in PATH — install it to use review commands."
|
|
28
28
|
return EXIT_ERROR
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
owner, repo = repository_coordinates
|
|
32
|
+
branch = current_branch
|
|
32
33
|
pr_number_override = carson_pr_number_override
|
|
33
34
|
pr_summary =
|
|
34
35
|
if pr_number_override.nil?
|
|
35
|
-
current_pull_request_for_branch( branch_name:
|
|
36
|
+
current_pull_request_for_branch( branch_name: branch )
|
|
36
37
|
else
|
|
37
38
|
details = pull_request_details( owner: owner, repo: repo, pr_number: pr_number_override )
|
|
38
39
|
{
|
|
@@ -43,93 +44,36 @@ module Carson
|
|
|
43
44
|
}
|
|
44
45
|
end
|
|
45
46
|
if pr_summary.nil?
|
|
46
|
-
puts_line "
|
|
47
|
-
report =
|
|
48
|
-
generated_at: Time.now.utc.iso8601,
|
|
49
|
-
branch: current_branch,
|
|
50
|
-
status: "block",
|
|
51
|
-
converged: false,
|
|
52
|
-
wait_seconds: config.review_wait_seconds,
|
|
53
|
-
poll_seconds: config.review_poll_seconds,
|
|
54
|
-
max_polls: config.review_max_polls,
|
|
55
|
-
block_reasons: [ "no pull request found for current branch" ],
|
|
56
|
-
pr: nil,
|
|
57
|
-
unresolved_threads: [],
|
|
58
|
-
actionable_top_level: [],
|
|
59
|
-
unacknowledged_actionable: []
|
|
60
|
-
}
|
|
47
|
+
puts_line "No pull request found for branch #{branch}."
|
|
48
|
+
report = review_gate_report_for_missing_pr( branch_name: branch )
|
|
61
49
|
write_review_gate_report( report: report )
|
|
62
50
|
return EXIT_BLOCK
|
|
63
51
|
end
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
poll_attempts = index + 1
|
|
73
|
-
snapshot = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_summary.fetch( :number ) )
|
|
74
|
-
last_snapshot = snapshot
|
|
75
|
-
signature = review_gate_signature( snapshot: snapshot )
|
|
76
|
-
puts_verbose "poll_attempt: #{poll_attempts}/#{config.review_max_polls}"
|
|
77
|
-
puts_verbose "latest_activity: #{snapshot.fetch( :latest_activity ) || 'unknown'}"
|
|
78
|
-
puts_verbose "unresolved_threads: #{snapshot.fetch( :unresolved_threads ).count}"
|
|
79
|
-
puts_verbose "unacknowledged_actionable: #{snapshot.fetch( :unacknowledged_actionable ).count}"
|
|
80
|
-
if !last_signature.nil? && signature == last_signature
|
|
81
|
-
converged = true
|
|
82
|
-
puts_verbose "convergence: stable"
|
|
83
|
-
break
|
|
84
|
-
end
|
|
85
|
-
last_signature = signature
|
|
86
|
-
wait_for_review_poll if index < config.review_max_polls - 1
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
block_reasons = []
|
|
90
|
-
block_reasons << "review snapshot did not converge within #{config.review_max_polls} polls" unless converged
|
|
91
|
-
if last_snapshot.fetch( :unresolved_threads ).any?
|
|
92
|
-
block_reasons << "unresolved review threads remain (#{last_snapshot.fetch( :unresolved_threads ).count})"
|
|
93
|
-
end
|
|
94
|
-
if last_snapshot.fetch( :unacknowledged_actionable ).any?
|
|
95
|
-
block_reasons << "actionable top-level comments/reviews without required disposition (#{last_snapshot.fetch( :unacknowledged_actionable ).count})"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
report = {
|
|
99
|
-
generated_at: Time.now.utc.iso8601,
|
|
100
|
-
branch: current_branch,
|
|
101
|
-
status: block_reasons.empty? ? "ok" : "block",
|
|
102
|
-
converged: converged,
|
|
103
|
-
wait_seconds: config.review_wait_seconds,
|
|
104
|
-
poll_seconds: config.review_poll_seconds,
|
|
105
|
-
max_polls: config.review_max_polls,
|
|
106
|
-
poll_attempts: poll_attempts,
|
|
107
|
-
block_reasons: block_reasons,
|
|
108
|
-
pr: {
|
|
109
|
-
number: pr_summary.fetch( :number ),
|
|
110
|
-
title: pr_summary.fetch( :title ),
|
|
111
|
-
url: pr_summary.fetch( :url ),
|
|
112
|
-
state: pr_summary.fetch( :state )
|
|
113
|
-
},
|
|
114
|
-
unresolved_threads: last_snapshot.fetch( :unresolved_threads ),
|
|
115
|
-
actionable_top_level: last_snapshot.fetch( :actionable_top_level ),
|
|
116
|
-
unacknowledged_actionable: last_snapshot.fetch( :unacknowledged_actionable )
|
|
117
|
-
}
|
|
53
|
+
report = review_gate_report_for_pr(
|
|
54
|
+
owner: owner,
|
|
55
|
+
repo: repo,
|
|
56
|
+
pr_number: pr_summary.fetch( :number ),
|
|
57
|
+
branch_name: branch,
|
|
58
|
+
pr_summary: pr_summary
|
|
59
|
+
)
|
|
118
60
|
write_review_gate_report( report: report )
|
|
119
61
|
unless verbose?
|
|
62
|
+
poll_attempts = report.fetch( :poll_attempts, 0 )
|
|
120
63
|
puts_line "Polling... (converged after #{poll_attempts} attempt#{plural_suffix( count: poll_attempts )})"
|
|
121
64
|
end
|
|
65
|
+
block_reasons = report.fetch( :block_reasons )
|
|
122
66
|
if block_reasons.empty?
|
|
123
67
|
puts_line "OK: review gate passed."
|
|
124
68
|
return EXIT_OK
|
|
125
69
|
end
|
|
126
|
-
block_reasons.each { |reason| puts_line
|
|
70
|
+
block_reasons.each { |reason| puts_line reason }
|
|
127
71
|
EXIT_BLOCK
|
|
128
72
|
rescue JSON::ParserError => exception
|
|
129
|
-
puts_line "
|
|
73
|
+
puts_line "Unexpected response from gh (#{exception.message})."
|
|
130
74
|
EXIT_ERROR
|
|
131
75
|
rescue StandardError => exception
|
|
132
|
-
puts_line
|
|
76
|
+
puts_line exception.message
|
|
133
77
|
EXIT_ERROR
|
|
134
78
|
end
|
|
135
79
|
|
|
@@ -140,7 +84,7 @@ module Carson
|
|
|
140
84
|
puts_verbose ""
|
|
141
85
|
puts_verbose "[Review Sweep]"
|
|
142
86
|
unless gh_available?
|
|
143
|
-
puts_line "
|
|
87
|
+
puts_line "gh CLI not found in PATH — install it to use review commands."
|
|
144
88
|
return EXIT_ERROR
|
|
145
89
|
end
|
|
146
90
|
|
|
@@ -176,13 +120,13 @@ module Carson
|
|
|
176
120
|
puts_line "OK: no actionable late review activity detected."
|
|
177
121
|
return EXIT_OK
|
|
178
122
|
end
|
|
179
|
-
puts_line "
|
|
123
|
+
puts_line "Late review activity needs attention."
|
|
180
124
|
EXIT_BLOCK
|
|
181
125
|
rescue JSON::ParserError => exception
|
|
182
|
-
puts_line "
|
|
126
|
+
puts_line "Unexpected response from gh (#{exception.message})."
|
|
183
127
|
EXIT_ERROR
|
|
184
128
|
rescue StandardError => exception
|
|
185
|
-
puts_line
|
|
129
|
+
puts_line exception.message
|
|
186
130
|
EXIT_ERROR
|
|
187
131
|
end
|
|
188
132
|
end
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Carson
|
|
|
21
21
|
return write_setup_config( choices: cli_choices )
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
if
|
|
24
|
+
if input_stream.respond_to?( :tty? ) && input_stream.tty?
|
|
25
25
|
interactive_setup!
|
|
26
26
|
else
|
|
27
27
|
silent_setup!
|
|
@@ -42,11 +42,8 @@ module Carson
|
|
|
42
42
|
workflow_choice = prompt_workflow_style
|
|
43
43
|
choices[ "workflow.style" ] = workflow_choice unless workflow_choice.nil?
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
choices[ "
|
|
47
|
-
|
|
48
|
-
canonical_choice = prompt_canonical_template
|
|
49
|
-
choices[ "template.canonical" ] = canonical_choice unless canonical_choice.nil?
|
|
45
|
+
canonical_choice = prompt_canonical_lint_policy
|
|
46
|
+
choices[ "lint.canonical" ] = canonical_choice unless canonical_choice.nil?
|
|
50
47
|
|
|
51
48
|
write_setup_config( choices: choices )
|
|
52
49
|
end
|
|
@@ -67,7 +64,7 @@ module Carson
|
|
|
67
64
|
duplicates = duplicate_remote_groups( remotes: remotes )
|
|
68
65
|
unless duplicates.empty?
|
|
69
66
|
duplicates.each_value do |group|
|
|
70
|
-
names = group.map {
|
|
67
|
+
names = group.map { |entry| entry.fetch( :name ) }.join( " and " )
|
|
71
68
|
puts_verbose "duplicate_remotes: #{names} share the same URL"
|
|
72
69
|
end
|
|
73
70
|
end
|
|
@@ -91,10 +88,10 @@ module Carson
|
|
|
91
88
|
end
|
|
92
89
|
|
|
93
90
|
duplicates = duplicate_remote_groups( remotes: remotes )
|
|
94
|
-
duplicate_names = duplicates.values.flatten.map {
|
|
91
|
+
duplicate_names = duplicates.values.flatten.map { |entry| entry.fetch( :name ) }.to_set
|
|
95
92
|
unless duplicates.empty?
|
|
96
93
|
duplicates.each_value do |group|
|
|
97
|
-
names = group.map {
|
|
94
|
+
names = group.map { |entry| entry.fetch( :name ) }.join( " and " )
|
|
98
95
|
puts_line "Remotes #{names} share the same URL. Consider removing the duplicate."
|
|
99
96
|
end
|
|
100
97
|
end
|
|
@@ -139,33 +136,19 @@ module Carson
|
|
|
139
136
|
{ label: "branch — enforce PR-only merges (default)", value: "branch" },
|
|
140
137
|
{ label: "trunk — commit directly to main", value: "trunk" }
|
|
141
138
|
]
|
|
142
|
-
default_index = options.index {
|
|
139
|
+
default_index = options.index { |option| option.fetch( :value ) == current } || 0
|
|
143
140
|
prompt_choice( options: options, default: default_index )
|
|
144
141
|
end
|
|
145
142
|
|
|
146
|
-
def
|
|
143
|
+
def prompt_canonical_lint_policy
|
|
147
144
|
puts_line ""
|
|
148
|
-
puts_line "
|
|
149
|
-
current = config.
|
|
150
|
-
puts_line " Currently: #{current}" unless current.nil? || current.empty?
|
|
151
|
-
options = [
|
|
152
|
-
{ label: "squash — one commit per PR (recommended)", value: "squash" },
|
|
153
|
-
{ label: "rebase — linear history, individual commits", value: "rebase" },
|
|
154
|
-
{ label: "merge — merge commits", value: "merge" }
|
|
155
|
-
]
|
|
156
|
-
default_index = options.index { it.fetch( :value ) == current } || 0
|
|
157
|
-
prompt_choice( options: options, default: default_index )
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def prompt_canonical_template
|
|
161
|
-
puts_line ""
|
|
162
|
-
puts_line "Canonical template directory"
|
|
163
|
-
current = config.template_canonical
|
|
145
|
+
puts_line "Canonical lint policy directory"
|
|
146
|
+
current = config.lint_canonical
|
|
164
147
|
if current && !current.empty?
|
|
165
148
|
puts_line " Currently set to: #{current}"
|
|
166
149
|
puts_line " Leave blank to keep current value."
|
|
167
150
|
else
|
|
168
|
-
puts_line " A directory of
|
|
151
|
+
puts_line " A directory of canonical lint-policy files to sync across governed repos."
|
|
169
152
|
puts_line " Leave blank to skip for now."
|
|
170
153
|
end
|
|
171
154
|
prompt_custom_value( label: "Path" )
|
|
@@ -177,7 +160,7 @@ module Carson
|
|
|
177
160
|
end
|
|
178
161
|
output.print "#{BADGE} Choice [#{default + 1}]: "
|
|
179
162
|
output.flush
|
|
180
|
-
raw =
|
|
163
|
+
raw = input_stream.gets
|
|
181
164
|
return options[ default ].fetch( :value ) if raw.nil?
|
|
182
165
|
|
|
183
166
|
input = raw.to_s.strip
|
|
@@ -194,7 +177,7 @@ module Carson
|
|
|
194
177
|
def prompt_custom_value( label: )
|
|
195
178
|
output.print "#{BADGE} #{label}: "
|
|
196
179
|
output.flush
|
|
197
|
-
raw =
|
|
180
|
+
raw = input_stream.gets
|
|
198
181
|
return nil if raw.nil?
|
|
199
182
|
|
|
200
183
|
value = raw.to_s.strip
|
|
@@ -221,7 +204,7 @@ module Carson
|
|
|
221
204
|
others << entry
|
|
222
205
|
end
|
|
223
206
|
end
|
|
224
|
-
well_known.sort_by { WELL_KNOWN_REMOTES.index(
|
|
207
|
+
well_known.sort_by { |entry| WELL_KNOWN_REMOTES.index( entry.fetch( :name ) ) || 999 } + others.sort_by { |entry| entry.fetch( :name ) }
|
|
225
208
|
end
|
|
226
209
|
|
|
227
210
|
# Normalises a remote URL so SSH and HTTPS variants of the same host/path compare equal.
|
|
@@ -297,7 +280,7 @@ module Carson
|
|
|
297
280
|
|
|
298
281
|
def detect_git_remote
|
|
299
282
|
remotes = list_git_remotes
|
|
300
|
-
remote_names = remotes.map {
|
|
283
|
+
remote_names = remotes.map { |entry| entry.fetch( :name ) }
|
|
301
284
|
return nil if remote_names.empty?
|
|
302
285
|
|
|
303
286
|
return config.git_remote if remote_names.include?( config.git_remote )
|
|
@@ -327,6 +310,7 @@ module Carson
|
|
|
327
310
|
|
|
328
311
|
existing_data = load_existing_config( path: config_path )
|
|
329
312
|
merged = Config.deep_merge( base: existing_data, overlay: config_data )
|
|
313
|
+
remove_deprecated_template_canonical!( data: merged ) if config_data.dig( "lint", "canonical" )
|
|
330
314
|
|
|
331
315
|
FileUtils.mkdir_p( File.dirname( config_path ) )
|
|
332
316
|
File.write( config_path, JSON.pretty_generate( merged ) )
|
|
@@ -361,6 +345,14 @@ module Carson
|
|
|
361
345
|
{}
|
|
362
346
|
end
|
|
363
347
|
|
|
348
|
+
def remove_deprecated_template_canonical!( data: )
|
|
349
|
+
template_hash = data[ "template" ]
|
|
350
|
+
return unless template_hash.is_a?( Hash )
|
|
351
|
+
|
|
352
|
+
template_hash.delete( "canonical" )
|
|
353
|
+
data.delete( "template" ) if template_hash.empty?
|
|
354
|
+
end
|
|
355
|
+
|
|
364
356
|
def reload_config_after_setup!
|
|
365
357
|
@config = Config.load( repo_root: repo_root )
|
|
366
358
|
end
|
|
@@ -387,7 +379,7 @@ module Carson
|
|
|
387
379
|
hint = default ? "Y/n" : "y/N"
|
|
388
380
|
output.print "#{BADGE} [#{hint}]: "
|
|
389
381
|
output.flush
|
|
390
|
-
raw =
|
|
382
|
+
raw = input_stream.gets
|
|
391
383
|
return default if raw.nil?
|
|
392
384
|
|
|
393
385
|
input = raw.to_s.strip.downcase
|