carson 4.3.1 → 4.3.3
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/.github/workflows/carson_policy.yml +0 -4
- data/API.md +2 -3
- data/MANUAL.md +8 -8
- data/README.md +1 -1
- data/RELEASE.md +28 -0
- data/VERSION +1 -1
- data/config/hooks/pre-commit +2 -17
- data/lib/carson/adapters/agent.rb +1 -1
- data/lib/carson/config.rb +0 -10
- data/lib/carson/courier.rb +10 -10
- data/lib/carson/parcel.rb +11 -2
- data/lib/carson/runtime/local/onboard.rb +9 -43
- data/lib/carson/runtime/receive.rb +2 -2
- data/lib/carson/runtime/recover.rb +3 -3
- data/lib/carson/runtime/review/sweep_support.rb +1 -1
- data/lib/carson/runtime.rb +2 -5
- data/lib/carson/warehouse/bureau.rb +8 -11
- data/lib/carson/warehouse/seal.rb +17 -16
- data/lib/carson/warehouse/vault.rb +51 -36
- data/lib/carson/warehouse/workbench.rb +21 -94
- data/lib/carson/warehouse.rb +68 -77
- data/lib/carson/worktree.rb +1 -1
- data/lib/cli.rb +4 -38
- metadata +1 -2
- data/lib/carson/runtime/audit.rb +0 -603
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: carson
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.3.
|
|
4
|
+
version: 4.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -57,7 +57,6 @@ files:
|
|
|
57
57
|
- lib/carson/revision.rb
|
|
58
58
|
- lib/carson/runtime.rb
|
|
59
59
|
- lib/carson/runtime/abandon.rb
|
|
60
|
-
- lib/carson/runtime/audit.rb
|
|
61
60
|
- lib/carson/runtime/deliver.rb
|
|
62
61
|
- lib/carson/runtime/housekeep.rb
|
|
63
62
|
- lib/carson/runtime/list.rb
|
data/lib/carson/runtime/audit.rb
DELETED
|
@@ -1,603 +0,0 @@
|
|
|
1
|
-
# Pre-commit audit — checks hooks, main sync, PR checks, and CI baseline.
|
|
2
|
-
# Exits with EXIT_BLOCK when policy violations are found.
|
|
3
|
-
# Supports --json for machine-readable structured output.
|
|
4
|
-
require "cgi"
|
|
5
|
-
|
|
6
|
-
module Carson
|
|
7
|
-
class Runtime
|
|
8
|
-
module Audit
|
|
9
|
-
def audit!( json_output: false )
|
|
10
|
-
# Sealed workbench guard — hard block before anything else.
|
|
11
|
-
# The warehouse seals the workbench when a parcel ships.
|
|
12
|
-
# No commits allowed until the delivery outcome is confirmed.
|
|
13
|
-
sealed_result = audit_sealed_workbench( json_output: json_output )
|
|
14
|
-
return sealed_result unless sealed_result.nil?
|
|
15
|
-
|
|
16
|
-
fingerprint_status = block_if_outsider_fingerprints!
|
|
17
|
-
return fingerprint_status unless fingerprint_status.nil?
|
|
18
|
-
unless head_exists?
|
|
19
|
-
if json_output
|
|
20
|
-
output.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
|
|
21
|
-
else
|
|
22
|
-
puts_line "No commits yet — audit skipped for initial commit."
|
|
23
|
-
end
|
|
24
|
-
return EXIT_OK
|
|
25
|
-
end
|
|
26
|
-
audit_state = "ok"
|
|
27
|
-
audit_concise_problems = []
|
|
28
|
-
puts_verbose ""
|
|
29
|
-
puts_verbose "[Repository]"
|
|
30
|
-
puts_verbose "root: #{repo_root}"
|
|
31
|
-
puts_verbose "current_branch: #{current_branch}"
|
|
32
|
-
puts_verbose ""
|
|
33
|
-
puts_verbose "[Working Tree]"
|
|
34
|
-
puts_verbose git_capture!( "status", "--short", "--branch" ).strip
|
|
35
|
-
working_tree = audit_working_tree_report
|
|
36
|
-
if working_tree.fetch( :status ) == "block"
|
|
37
|
-
puts_verbose "ACTION: #{working_tree.fetch( :error )}; #{working_tree.fetch( :recovery )}."
|
|
38
|
-
audit_state = "block"
|
|
39
|
-
audit_concise_problems << "Working tree: #{working_tree.fetch( :error )} — #{working_tree.fetch( :recovery )}."
|
|
40
|
-
end
|
|
41
|
-
puts_verbose ""
|
|
42
|
-
puts_verbose "[Hooks]"
|
|
43
|
-
hooks_ok = hooks_health_report
|
|
44
|
-
hooks_status = hooks_ok ? "ok" : "mismatch"
|
|
45
|
-
unless hooks_ok
|
|
46
|
-
audit_state = "block"
|
|
47
|
-
audit_concise_problems << "Hooks don't match — run carson refresh."
|
|
48
|
-
end
|
|
49
|
-
puts_verbose ""
|
|
50
|
-
puts_verbose "[Main Sync Status]"
|
|
51
|
-
ahead_count, behind_count, main_error = main_sync_counts
|
|
52
|
-
main_sync = { ahead: 0, behind: 0, status: "ok" }
|
|
53
|
-
if main_error
|
|
54
|
-
puts_verbose "main_vs_remote_main: unknown"
|
|
55
|
-
puts_verbose "WARN: unable to calculate main sync status (#{main_error})."
|
|
56
|
-
audit_state = "attention" if audit_state == "ok"
|
|
57
|
-
audit_concise_problems << "Main sync: unable to determine — check remote connectivity."
|
|
58
|
-
main_sync = { ahead: 0, behind: 0, status: "unknown", error: main_error }
|
|
59
|
-
elsif ahead_count.positive?
|
|
60
|
-
puts_verbose "main_vs_remote_main_ahead: #{ahead_count}"
|
|
61
|
-
puts_verbose "main_vs_remote_main_behind: #{behind_count}"
|
|
62
|
-
puts_verbose "ACTION: local #{config.main_branch} is ahead of #{config.git_remote}/#{config.main_branch} by #{ahead_count} commit#{plural_suffix( count: ahead_count )}; reset local drift before commit/push workflows."
|
|
63
|
-
audit_state = "block"
|
|
64
|
-
audit_concise_problems << "Main sync (#{config.git_remote}): ahead by #{ahead_count} — git fetch #{config.git_remote}, or carson setup to switch remote."
|
|
65
|
-
main_sync = { ahead: ahead_count, behind: behind_count, status: "ahead" }
|
|
66
|
-
elsif behind_count.positive?
|
|
67
|
-
puts_verbose "main_vs_remote_main_ahead: #{ahead_count}"
|
|
68
|
-
puts_verbose "main_vs_remote_main_behind: #{behind_count}"
|
|
69
|
-
puts_verbose "ACTION: local #{config.main_branch} is behind #{config.git_remote}/#{config.main_branch} by #{behind_count} commit#{plural_suffix( count: behind_count )}; run carson sync."
|
|
70
|
-
audit_state = "attention" if audit_state == "ok"
|
|
71
|
-
audit_concise_problems << "Main sync (#{config.git_remote}): behind by #{behind_count} — run carson sync."
|
|
72
|
-
main_sync = { ahead: ahead_count, behind: behind_count, status: "behind" }
|
|
73
|
-
else
|
|
74
|
-
puts_verbose "main_vs_remote_main_ahead: 0"
|
|
75
|
-
puts_verbose "main_vs_remote_main_behind: 0"
|
|
76
|
-
puts_verbose "ACTION: local #{config.main_branch} is in sync with #{config.git_remote}/#{config.main_branch}."
|
|
77
|
-
end
|
|
78
|
-
puts_verbose ""
|
|
79
|
-
puts_verbose "[PR and Required Checks (gh)]"
|
|
80
|
-
monitor_report = pr_and_check_report
|
|
81
|
-
audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( monitor_report.fetch( :status ) )
|
|
82
|
-
if monitor_report.fetch( :status ) == "attention"
|
|
83
|
-
checks = monitor_report.fetch( :checks )
|
|
84
|
-
failing_count = checks.fetch( :failing_count )
|
|
85
|
-
pending_count = checks.fetch( :pending_count )
|
|
86
|
-
total = checks.fetch( :required_total )
|
|
87
|
-
fail_names = checks.fetch( :failing ).map { |entry| entry.fetch( :name ) }.join( ", " )
|
|
88
|
-
if failing_count.positive? && pending_count.positive?
|
|
89
|
-
audit_concise_problems << "Checks: #{failing_count} failing (#{fail_names}), #{pending_count} pending of #{total} required."
|
|
90
|
-
elsif failing_count.positive?
|
|
91
|
-
audit_concise_problems << "Checks: #{failing_count} of #{total} failing (#{fail_names})."
|
|
92
|
-
elsif pending_count.positive?
|
|
93
|
-
audit_concise_problems << "Checks: pending (#{total - pending_count} of #{total} complete)."
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
puts_verbose ""
|
|
97
|
-
puts_verbose "[Default Branch CI Baseline (gh)]"
|
|
98
|
-
default_branch_baseline = default_branch_ci_baseline_report
|
|
99
|
-
audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( default_branch_baseline.fetch( :status ) )
|
|
100
|
-
baseline_status = default_branch_baseline.fetch( :status )
|
|
101
|
-
if baseline_status == "block"
|
|
102
|
-
parts = []
|
|
103
|
-
if default_branch_baseline.fetch( :failing_count ).positive?
|
|
104
|
-
names = default_branch_baseline.fetch( :failing ).map { |entry| entry.fetch( :name ) }.join( ", " )
|
|
105
|
-
parts << "#{default_branch_baseline.fetch( :failing_count )} failing (#{names})"
|
|
106
|
-
end
|
|
107
|
-
if default_branch_baseline.fetch( :pending_count ).positive?
|
|
108
|
-
names = default_branch_baseline.fetch( :pending ).map { |entry| entry.fetch( :name ) }.join( ", " )
|
|
109
|
-
parts << "#{default_branch_baseline.fetch( :pending_count )} pending (#{names})"
|
|
110
|
-
end
|
|
111
|
-
parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
|
|
112
|
-
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
|
|
113
|
-
elsif baseline_status == "attention"
|
|
114
|
-
parts = []
|
|
115
|
-
if default_branch_baseline.fetch( :advisory_failing_count ).positive?
|
|
116
|
-
names = default_branch_baseline.fetch( :advisory_failing ).map { |entry| entry.fetch( :name ) }.join( ", " )
|
|
117
|
-
parts << "#{default_branch_baseline.fetch( :advisory_failing_count )} advisory failing (#{names})"
|
|
118
|
-
end
|
|
119
|
-
if default_branch_baseline.fetch( :advisory_pending_count ).positive?
|
|
120
|
-
names = default_branch_baseline.fetch( :advisory_pending ).map { |entry| entry.fetch( :name ) }.join( ", " )
|
|
121
|
-
parts << "#{default_branch_baseline.fetch( :advisory_pending_count )} advisory pending (#{names})"
|
|
122
|
-
end
|
|
123
|
-
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )}."
|
|
124
|
-
end
|
|
125
|
-
if config.lint_canonical.nil? || config.lint_canonical.to_s.empty?
|
|
126
|
-
puts_verbose ""
|
|
127
|
-
puts_verbose "[Canonical Lint Policy]"
|
|
128
|
-
puts_verbose "HINT: lint.canonical not configured — run carson setup to enable."
|
|
129
|
-
end
|
|
130
|
-
write_and_print_pr_monitor_report(
|
|
131
|
-
report: monitor_report.merge(
|
|
132
|
-
default_branch_baseline: default_branch_baseline,
|
|
133
|
-
audit_status: audit_state
|
|
134
|
-
)
|
|
135
|
-
)
|
|
136
|
-
exit_code = audit_state == "block" ? EXIT_BLOCK : EXIT_OK
|
|
137
|
-
|
|
138
|
-
if json_output
|
|
139
|
-
result = {
|
|
140
|
-
command: "audit",
|
|
141
|
-
status: audit_state,
|
|
142
|
-
branch: current_branch,
|
|
143
|
-
working_tree: working_tree,
|
|
144
|
-
hooks: { status: hooks_status },
|
|
145
|
-
main_sync: main_sync,
|
|
146
|
-
pr: monitor_report[ :pr ],
|
|
147
|
-
checks: monitor_report.fetch( :checks ),
|
|
148
|
-
baseline: {
|
|
149
|
-
status: default_branch_baseline.fetch( :status ),
|
|
150
|
-
repository: default_branch_baseline[ :repository ],
|
|
151
|
-
failing_count: default_branch_baseline.fetch( :failing_count ),
|
|
152
|
-
pending_count: default_branch_baseline.fetch( :pending_count ),
|
|
153
|
-
advisory_failing_count: default_branch_baseline.fetch( :advisory_failing_count ),
|
|
154
|
-
advisory_pending_count: default_branch_baseline.fetch( :advisory_pending_count )
|
|
155
|
-
},
|
|
156
|
-
problems: audit_concise_problems,
|
|
157
|
-
exit_code: exit_code
|
|
158
|
-
}
|
|
159
|
-
output.puts JSON.pretty_generate( result )
|
|
160
|
-
else
|
|
161
|
-
puts_verbose ""
|
|
162
|
-
puts_verbose "[Audit Result]"
|
|
163
|
-
puts_verbose "status: #{audit_state}"
|
|
164
|
-
puts_verbose( audit_state == "block" ? "ACTION: local policy block must be resolved before commit/push." : "ACTION: no local hard block detected." )
|
|
165
|
-
unless verbose?
|
|
166
|
-
audit_concise_problems.each { |problem| puts_line problem }
|
|
167
|
-
puts_line format_audit_state( audit_state )
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
exit_code
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
|
|
174
|
-
private
|
|
175
|
-
# rubocop:enable Layout/AccessModifierIndentation
|
|
176
|
-
|
|
177
|
-
def format_audit_state( state )
|
|
178
|
-
case state
|
|
179
|
-
when "ok" then "Audit passed."
|
|
180
|
-
when "block" then "Audit blocked."
|
|
181
|
-
when "attention" then "Audit: needs attention."
|
|
182
|
-
else "Audit: #{state}"
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Check if the workbench is sealed (parcel in flight).
|
|
187
|
-
# Returns EXIT_BLOCK if sealed, nil otherwise.
|
|
188
|
-
# Delegates to Warehouse so the seal path is resolved in one place.
|
|
189
|
-
def audit_sealed_workbench( json_output: )
|
|
190
|
-
warehouse = Warehouse.new( path: work_dir )
|
|
191
|
-
return nil unless warehouse.sealed?
|
|
192
|
-
|
|
193
|
-
tracking_number = warehouse.sealed_tracking_number || "unknown"
|
|
194
|
-
if json_output
|
|
195
|
-
require "json"
|
|
196
|
-
output.puts JSON.pretty_generate( {
|
|
197
|
-
command: "audit",
|
|
198
|
-
status: "block",
|
|
199
|
-
reason: "workbench_sealed",
|
|
200
|
-
tracking_number: tracking_number,
|
|
201
|
-
recovery: "carson worktree create <name>",
|
|
202
|
-
exit_code: EXIT_BLOCK
|
|
203
|
-
} )
|
|
204
|
-
else
|
|
205
|
-
puts_line "Branch is locked — PR ##{tracking_number} in flight."
|
|
206
|
-
puts_line " \u2192 carson worktree create <name>"
|
|
207
|
-
end
|
|
208
|
-
EXIT_BLOCK
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def audit_working_tree_report
|
|
212
|
-
dirty_reason = dirty_worktree_reason
|
|
213
|
-
return { dirty: false, context: nil, status: "ok" } if dirty_reason.nil?
|
|
214
|
-
|
|
215
|
-
if dirty_reason == "main_worktree"
|
|
216
|
-
{
|
|
217
|
-
dirty: true,
|
|
218
|
-
context: dirty_reason,
|
|
219
|
-
status: "block",
|
|
220
|
-
error: "main working tree has uncommitted changes",
|
|
221
|
-
recovery: "create a worktree with carson worktree create <name>"
|
|
222
|
-
}
|
|
223
|
-
else
|
|
224
|
-
{ dirty: true, context: dirty_reason, status: "ok" }
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def pr_and_check_report
|
|
229
|
-
report = {
|
|
230
|
-
generated_at: Time.now.utc.iso8601,
|
|
231
|
-
branch: current_branch,
|
|
232
|
-
status: "ok",
|
|
233
|
-
skip_reason: nil,
|
|
234
|
-
pr: nil,
|
|
235
|
-
checks: {
|
|
236
|
-
status: "unknown",
|
|
237
|
-
skip_reason: nil,
|
|
238
|
-
required_total: 0,
|
|
239
|
-
failing_count: 0,
|
|
240
|
-
pending_count: 0,
|
|
241
|
-
failing: [],
|
|
242
|
-
pending: []
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
unless gh_available?
|
|
246
|
-
report[ :status ] = "skipped"
|
|
247
|
-
report[ :skip_reason ] = "gh CLI not available in PATH"
|
|
248
|
-
puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
|
|
249
|
-
return report
|
|
250
|
-
end
|
|
251
|
-
pr_stdout, pr_stderr, pr_success, = gh_run( "pr", "view", current_branch, "--json", "number,title,url,state,reviewDecision" )
|
|
252
|
-
unless pr_success
|
|
253
|
-
error_text = gh_error_text( stdout_text: pr_stdout, stderr_text: pr_stderr, fallback: "unable to read PR for branch #{current_branch}" )
|
|
254
|
-
report[ :status ] = "skipped"
|
|
255
|
-
report[ :skip_reason ] = error_text
|
|
256
|
-
puts_verbose "SKIP: #{error_text}"
|
|
257
|
-
return report
|
|
258
|
-
end
|
|
259
|
-
pr_data = JSON.parse( pr_stdout )
|
|
260
|
-
report[ :pr ] = {
|
|
261
|
-
number: pr_data[ "number" ],
|
|
262
|
-
title: pr_data[ "title" ].to_s,
|
|
263
|
-
url: pr_data[ "url" ].to_s,
|
|
264
|
-
state: pr_data[ "state" ].to_s,
|
|
265
|
-
review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
|
|
266
|
-
}
|
|
267
|
-
puts_verbose "pr: ##{report.dig( :pr, :number )} #{report.dig( :pr, :title )}"
|
|
268
|
-
puts_verbose "url: #{report.dig( :pr, :url )}"
|
|
269
|
-
puts_verbose "review_decision: #{report.dig( :pr, :review_decision )}"
|
|
270
|
-
checks_stdout, checks_stderr, checks_success, checks_exit = gh_run( "pr", "checks", report.dig( :pr, :number ).to_s, "--required", "--json", "name,state,bucket,workflow,link" )
|
|
271
|
-
if checks_stdout.to_s.strip.empty?
|
|
272
|
-
error_text = gh_error_text( stdout_text: checks_stdout, stderr_text: checks_stderr, fallback: "required checks unavailable" )
|
|
273
|
-
report[ :checks ][ :status ] = "skipped"
|
|
274
|
-
report[ :checks ][ :skip_reason ] = error_text
|
|
275
|
-
report[ :status ] = "attention"
|
|
276
|
-
puts_verbose "checks: SKIP (#{error_text})"
|
|
277
|
-
return report
|
|
278
|
-
end
|
|
279
|
-
checks_data = JSON.parse( checks_stdout )
|
|
280
|
-
pending = checks_data.select { |entry| entry[ "bucket" ].to_s == "pending" }
|
|
281
|
-
failing = checks_data.select { |entry| check_entry_failing?( entry: entry ) }
|
|
282
|
-
report[ :checks ][ :status ] = checks_success ? "ok" : ( checks_exit == 8 ? "pending" : "attention" )
|
|
283
|
-
report[ :checks ][ :required_total ] = checks_data.count
|
|
284
|
-
report[ :checks ][ :failing_count ] = failing.count
|
|
285
|
-
report[ :checks ][ :pending_count ] = pending.count
|
|
286
|
-
report[ :checks ][ :failing ] = normalise_check_entries( entries: failing )
|
|
287
|
-
report[ :checks ][ :pending ] = normalise_check_entries( entries: pending )
|
|
288
|
-
puts_verbose "required_checks_total: #{report.dig( :checks, :required_total )}"
|
|
289
|
-
puts_verbose "required_checks_failing: #{report.dig( :checks, :failing_count )}"
|
|
290
|
-
puts_verbose "required_checks_pending: #{report.dig( :checks, :pending_count )}"
|
|
291
|
-
report.dig( :checks, :failing ).each { |entry| puts_verbose "check_fail: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
|
|
292
|
-
report.dig( :checks, :pending ).each { |entry| puts_verbose "check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
|
|
293
|
-
report[ :status ] = "attention" if report.dig( :checks, :failing_count ).positive? || report.dig( :checks, :pending_count ).positive?
|
|
294
|
-
report
|
|
295
|
-
rescue JSON::ParserError => exception
|
|
296
|
-
report[ :status ] = "skipped"
|
|
297
|
-
report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
|
|
298
|
-
puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
|
|
299
|
-
report
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
# Evaluates default-branch CI health so stale workflow drift blocks before merge.
|
|
303
|
-
def default_branch_ci_baseline_report
|
|
304
|
-
report = {
|
|
305
|
-
status: "ok",
|
|
306
|
-
skip_reason: nil,
|
|
307
|
-
repository: nil,
|
|
308
|
-
default_branch: nil,
|
|
309
|
-
head_sha: nil,
|
|
310
|
-
workflows_total: 0,
|
|
311
|
-
check_runs_total: 0,
|
|
312
|
-
failing_count: 0,
|
|
313
|
-
pending_count: 0,
|
|
314
|
-
advisory_failing_count: 0,
|
|
315
|
-
advisory_pending_count: 0,
|
|
316
|
-
no_check_evidence: false,
|
|
317
|
-
failing: [],
|
|
318
|
-
pending: [],
|
|
319
|
-
advisory_failing: [],
|
|
320
|
-
advisory_pending: []
|
|
321
|
-
}
|
|
322
|
-
unless gh_available?
|
|
323
|
-
report[ :status ] = "skipped"
|
|
324
|
-
report[ :skip_reason ] = "gh CLI not available in PATH"
|
|
325
|
-
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
326
|
-
return report
|
|
327
|
-
end
|
|
328
|
-
owner, repo = repository_coordinates
|
|
329
|
-
report[ :repository ] = "#{owner}/#{repo}"
|
|
330
|
-
repository_data = gh_json_payload!(
|
|
331
|
-
"api", "repos/#{owner}/#{repo}",
|
|
332
|
-
"--method", "GET",
|
|
333
|
-
fallback: "unable to read repository metadata for #{owner}/#{repo}"
|
|
334
|
-
)
|
|
335
|
-
default_branch = blank_to( value: repository_data[ "default_branch" ], default: config.main_branch )
|
|
336
|
-
report[ :default_branch ] = default_branch
|
|
337
|
-
branch_data = gh_json_payload!(
|
|
338
|
-
"api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
|
|
339
|
-
"--method", "GET",
|
|
340
|
-
fallback: "unable to read default branch #{default_branch}"
|
|
341
|
-
)
|
|
342
|
-
head_sha = branch_data.dig( "commit", "sha" ).to_s.strip
|
|
343
|
-
raise "default branch #{default_branch} has no commit SHA" if head_sha.empty?
|
|
344
|
-
report[ :head_sha ] = head_sha
|
|
345
|
-
workflow_entries = default_branch_workflow_entries(
|
|
346
|
-
owner: owner,
|
|
347
|
-
repo: repo,
|
|
348
|
-
default_branch: default_branch
|
|
349
|
-
)
|
|
350
|
-
report[ :workflows_total ] = workflow_entries.count
|
|
351
|
-
check_runs_payload = gh_json_payload!(
|
|
352
|
-
"api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
|
|
353
|
-
"--method", "GET",
|
|
354
|
-
fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
|
|
355
|
-
)
|
|
356
|
-
check_runs = Array( check_runs_payload[ "check_runs" ] )
|
|
357
|
-
failing, pending = partition_default_branch_check_runs( check_runs: check_runs )
|
|
358
|
-
advisory_names = config.audit_advisory_check_names
|
|
359
|
-
critical_failing, advisory_failing = separate_advisory_check_entries( entries: failing, advisory_names: advisory_names )
|
|
360
|
-
critical_pending, advisory_pending = separate_advisory_check_entries( entries: pending, advisory_names: advisory_names )
|
|
361
|
-
report[ :check_runs_total ] = check_runs.count
|
|
362
|
-
report[ :failing ] = normalise_default_branch_check_entries( entries: critical_failing )
|
|
363
|
-
report[ :pending ] = normalise_default_branch_check_entries( entries: critical_pending )
|
|
364
|
-
report[ :advisory_failing ] = normalise_default_branch_check_entries( entries: advisory_failing )
|
|
365
|
-
report[ :advisory_pending ] = normalise_default_branch_check_entries( entries: advisory_pending )
|
|
366
|
-
report[ :failing_count ] = report.fetch( :failing ).count
|
|
367
|
-
report[ :pending_count ] = report.fetch( :pending ).count
|
|
368
|
-
report[ :advisory_failing_count ] = report.fetch( :advisory_failing ).count
|
|
369
|
-
report[ :advisory_pending_count ] = report.fetch( :advisory_pending ).count
|
|
370
|
-
report[ :no_check_evidence ] = report.fetch( :workflows_total ).positive? && report.fetch( :check_runs_total ).zero?
|
|
371
|
-
report[ :status ] = "block" if report.fetch( :failing_count ).positive?
|
|
372
|
-
report[ :status ] = "block" if report.fetch( :pending_count ).positive?
|
|
373
|
-
report[ :status ] = "block" if report.fetch( :no_check_evidence )
|
|
374
|
-
report[ :status ] = "attention" if report.fetch( :status ) == "ok" && ( report.fetch( :advisory_failing_count ).positive? || report.fetch( :advisory_pending_count ).positive? )
|
|
375
|
-
puts_verbose "default_branch_repository: #{report.fetch( :repository )}"
|
|
376
|
-
puts_verbose "default_branch_name: #{report.fetch( :default_branch )}"
|
|
377
|
-
puts_verbose "default_branch_head_sha: #{report.fetch( :head_sha )}"
|
|
378
|
-
puts_verbose "default_branch_workflows_total: #{report.fetch( :workflows_total )}"
|
|
379
|
-
puts_verbose "default_branch_check_runs_total: #{report.fetch( :check_runs_total )}"
|
|
380
|
-
puts_verbose "default_branch_failing: #{report.fetch( :failing_count )}"
|
|
381
|
-
puts_verbose "default_branch_pending: #{report.fetch( :pending_count )}"
|
|
382
|
-
puts_verbose "default_branch_advisory_failing: #{report.fetch( :advisory_failing_count )}"
|
|
383
|
-
puts_verbose "default_branch_advisory_pending: #{report.fetch( :advisory_pending_count )}"
|
|
384
|
-
report.fetch( :failing ).each { |entry| puts_verbose "default_branch_check_fail: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
|
|
385
|
-
report.fetch( :pending ).each { |entry| puts_verbose "default_branch_check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
|
|
386
|
-
report.fetch( :advisory_failing ).each { |entry| puts_verbose "default_branch_check_advisory_fail: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (advisory) #{entry.fetch( :link )}".strip }
|
|
387
|
-
report.fetch( :advisory_pending ).each { |entry| puts_verbose "default_branch_check_advisory_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (advisory) #{entry.fetch( :link )}".strip }
|
|
388
|
-
if report.fetch( :no_check_evidence )
|
|
389
|
-
puts_verbose "ACTION: default branch has workflow files but no check-runs; align workflow triggers and branch protection check names."
|
|
390
|
-
end
|
|
391
|
-
report
|
|
392
|
-
rescue JSON::ParserError => exception
|
|
393
|
-
report[ :status ] = "skipped"
|
|
394
|
-
report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
|
|
395
|
-
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
396
|
-
report
|
|
397
|
-
rescue StandardError => exception
|
|
398
|
-
report[ :status ] = "skipped"
|
|
399
|
-
report[ :skip_reason ] = exception.message
|
|
400
|
-
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
401
|
-
report
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
# Reads JSON API payloads and raises a detailed error when gh reports non-success.
|
|
405
|
-
def gh_json_payload!( *args, fallback: )
|
|
406
|
-
stdout_text, stderr_text, success, = gh_run( *args )
|
|
407
|
-
unless success
|
|
408
|
-
error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: fallback )
|
|
409
|
-
raise error_text
|
|
410
|
-
end
|
|
411
|
-
JSON.parse( stdout_text )
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
# Reads workflow files from default branch; missing workflow directory is valid and returns none.
|
|
415
|
-
def default_branch_workflow_entries( owner:, repo:, default_branch: )
|
|
416
|
-
stdout_text, stderr_text, success, = gh_run(
|
|
417
|
-
"api", "repos/#{owner}/#{repo}/contents/.github/workflows",
|
|
418
|
-
"--method", "GET",
|
|
419
|
-
"-f", "ref=#{default_branch}"
|
|
420
|
-
)
|
|
421
|
-
unless success
|
|
422
|
-
error_text = gh_error_text(
|
|
423
|
-
stdout_text: stdout_text,
|
|
424
|
-
stderr_text: stderr_text,
|
|
425
|
-
fallback: "unable to read workflow files for #{default_branch}"
|
|
426
|
-
)
|
|
427
|
-
return [] if error_text.match?( /\b404\b/ )
|
|
428
|
-
raise error_text
|
|
429
|
-
end
|
|
430
|
-
payload = JSON.parse( stdout_text )
|
|
431
|
-
Array( payload ).select do |entry|
|
|
432
|
-
entry.is_a?( Hash ) &&
|
|
433
|
-
entry[ "type" ].to_s == "file" &&
|
|
434
|
-
entry[ "name" ].to_s.match?( /\.ya?ml\z/i )
|
|
435
|
-
end
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
# Splits default-branch check-runs into failing and pending policy buckets.
|
|
439
|
-
def partition_default_branch_check_runs( check_runs: )
|
|
440
|
-
failing = []
|
|
441
|
-
pending = []
|
|
442
|
-
Array( check_runs ).each do |entry|
|
|
443
|
-
if default_branch_check_run_failing?( entry: entry )
|
|
444
|
-
failing << entry
|
|
445
|
-
elsif default_branch_check_run_pending?( entry: entry )
|
|
446
|
-
pending << entry
|
|
447
|
-
end
|
|
448
|
-
end
|
|
449
|
-
[ failing, pending ]
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
# Separates check-run entries into critical and advisory buckets based on configured advisory names.
|
|
453
|
-
def separate_advisory_check_entries( entries:, advisory_names: )
|
|
454
|
-
advisory, critical = Array( entries ).partition do |entry|
|
|
455
|
-
advisory_names.include?( entry[ "name" ].to_s.strip )
|
|
456
|
-
end
|
|
457
|
-
[ critical, advisory ]
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# Returns true when a required-check entry is in a non-passing, non-pending state.
|
|
461
|
-
# Cancelled, errored, timed-output, and any unknown bucket all count as failing.
|
|
462
|
-
def check_entry_failing?( entry: )
|
|
463
|
-
!%w[pass pending].include?( entry[ "bucket" ].to_s )
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
# Failing means completed with a non-successful conclusion.
|
|
467
|
-
def default_branch_check_run_failing?( entry: )
|
|
468
|
-
status = entry[ "status" ].to_s.strip.downcase
|
|
469
|
-
conclusion = entry[ "conclusion" ].to_s.strip.downcase
|
|
470
|
-
status == "completed" && !conclusion.empty? && !%w[success neutral skipped].include?( conclusion )
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# Pending includes non-completed checks and completed checks missing conclusion.
|
|
474
|
-
def default_branch_check_run_pending?( entry: )
|
|
475
|
-
status = entry[ "status" ].to_s.strip.downcase
|
|
476
|
-
conclusion = entry[ "conclusion" ].to_s.strip.downcase
|
|
477
|
-
return true if status.empty?
|
|
478
|
-
return true unless status == "completed"
|
|
479
|
-
|
|
480
|
-
conclusion.empty?
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
# Normalises default-branch check-runs to report layout used by markdown output.
|
|
484
|
-
def normalise_default_branch_check_entries( entries: )
|
|
485
|
-
Array( entries ).map do |entry|
|
|
486
|
-
state = if entry[ "status" ].to_s.strip.downcase == "completed"
|
|
487
|
-
blank_to( value: entry[ "conclusion" ], default: "UNKNOWN" )
|
|
488
|
-
else
|
|
489
|
-
blank_to( value: entry[ "status" ], default: "UNKNOWN" )
|
|
490
|
-
end
|
|
491
|
-
{
|
|
492
|
-
workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
|
|
493
|
-
name: blank_to( value: entry[ "name" ], default: "check" ),
|
|
494
|
-
state: state.upcase,
|
|
495
|
-
link: entry[ "html_url" ].to_s
|
|
496
|
-
}
|
|
497
|
-
end
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
# Writes monitor report artefacts and prints their locations.
|
|
501
|
-
def write_and_print_pr_monitor_report( report: )
|
|
502
|
-
markdown_path, json_path = write_pr_monitor_report( report: report )
|
|
503
|
-
puts_verbose "report_markdown: #{markdown_path}"
|
|
504
|
-
puts_verbose "report_json: #{json_path}"
|
|
505
|
-
rescue StandardError => exception
|
|
506
|
-
puts_verbose "report_write: SKIP (#{exception.message})"
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
# Persists report in both machine-readable JSON and human-readable Markdown.
|
|
510
|
-
def write_pr_monitor_report( report: )
|
|
511
|
-
report_dir = report_dir_path
|
|
512
|
-
FileUtils.mkdir_p( report_dir )
|
|
513
|
-
markdown_path = File.join( report_dir, REPORT_MD )
|
|
514
|
-
json_path = File.join( report_dir, REPORT_JSON )
|
|
515
|
-
File.write( json_path, JSON.pretty_generate( report ) )
|
|
516
|
-
File.write( markdown_path, render_pr_monitor_markdown( report: report ) )
|
|
517
|
-
[ markdown_path, json_path ]
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
# Renders Markdown summary used by humans during merge-readiness reviews.
|
|
521
|
-
def render_pr_monitor_markdown( report: )
|
|
522
|
-
lines = []
|
|
523
|
-
lines << "# Carson PR Monitor Report"
|
|
524
|
-
lines << ""
|
|
525
|
-
lines << "- Generated at: #{report.fetch( :generated_at )}"
|
|
526
|
-
lines << "- Branch: #{report.fetch( :branch )}"
|
|
527
|
-
lines << "- Audit status: #{report.fetch( :audit_status, 'unknown' )}"
|
|
528
|
-
lines << "- Monitor status: #{report.fetch( :status )}"
|
|
529
|
-
lines << "- Skip reason: #{report.fetch( :skip_reason )}" unless report.fetch( :skip_reason ).nil?
|
|
530
|
-
lines << ""
|
|
531
|
-
lines << "## PR"
|
|
532
|
-
pr = report[ :pr ]
|
|
533
|
-
if pr.nil?
|
|
534
|
-
lines << "- not available"
|
|
535
|
-
else
|
|
536
|
-
lines << "- Number: ##{pr.fetch( :number )}"
|
|
537
|
-
lines << "- Title: #{pr.fetch( :title )}"
|
|
538
|
-
lines << "- URL: #{pr.fetch( :url )}"
|
|
539
|
-
lines << "- State: #{pr.fetch( :state )}"
|
|
540
|
-
lines << "- Review decision: #{pr.fetch( :review_decision )}"
|
|
541
|
-
end
|
|
542
|
-
lines << ""
|
|
543
|
-
lines << "## Required Checks"
|
|
544
|
-
checks = report.fetch( :checks )
|
|
545
|
-
lines << "- Status: #{checks.fetch( :status )}"
|
|
546
|
-
lines << "- Skip reason: #{checks.fetch( :skip_reason )}" unless checks.fetch( :skip_reason ).nil?
|
|
547
|
-
lines << "- Total: #{checks.fetch( :required_total )}"
|
|
548
|
-
lines << "- Failing: #{checks.fetch( :failing_count )}"
|
|
549
|
-
lines << "- Pending: #{checks.fetch( :pending_count )}"
|
|
550
|
-
lines << ""
|
|
551
|
-
lines << "### Failing"
|
|
552
|
-
if checks.fetch( :failing ).empty?
|
|
553
|
-
lines << "- none"
|
|
554
|
-
else
|
|
555
|
-
checks.fetch( :failing ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
|
|
556
|
-
end
|
|
557
|
-
lines << ""
|
|
558
|
-
lines << "### Pending"
|
|
559
|
-
if checks.fetch( :pending ).empty?
|
|
560
|
-
lines << "- none"
|
|
561
|
-
else
|
|
562
|
-
checks.fetch( :pending ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
|
|
563
|
-
end
|
|
564
|
-
lines << ""
|
|
565
|
-
lines << "## Default Branch CI Baseline"
|
|
566
|
-
baseline = report[ :default_branch_baseline ]
|
|
567
|
-
if baseline.nil?
|
|
568
|
-
lines << "- not available"
|
|
569
|
-
else
|
|
570
|
-
lines << "- Status: #{baseline.fetch( :status )}"
|
|
571
|
-
lines << "- Skip reason: #{baseline.fetch( :skip_reason )}" unless baseline.fetch( :skip_reason ).nil?
|
|
572
|
-
lines << "- Repository: #{baseline.fetch( :repository )}" unless baseline.fetch( :repository ).nil?
|
|
573
|
-
lines << "- Branch: #{baseline.fetch( :default_branch )}" unless baseline.fetch( :default_branch ).nil?
|
|
574
|
-
lines << "- Head SHA: #{baseline.fetch( :head_sha )}" unless baseline.fetch( :head_sha ).nil?
|
|
575
|
-
lines << "- Workflow files: #{baseline.fetch( :workflows_total )}"
|
|
576
|
-
lines << "- Check-runs: #{baseline.fetch( :check_runs_total )}"
|
|
577
|
-
lines << "- Failing: #{baseline.fetch( :failing_count )}"
|
|
578
|
-
lines << "- Pending: #{baseline.fetch( :pending_count )}"
|
|
579
|
-
lines << "- No check evidence: #{baseline.fetch( :no_check_evidence )}"
|
|
580
|
-
lines << ""
|
|
581
|
-
lines << "### Baseline Failing"
|
|
582
|
-
if baseline.fetch( :failing ).empty?
|
|
583
|
-
lines << "- none"
|
|
584
|
-
else
|
|
585
|
-
baseline.fetch( :failing ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
|
|
586
|
-
end
|
|
587
|
-
lines << ""
|
|
588
|
-
lines << "### Baseline Pending"
|
|
589
|
-
if baseline.fetch( :pending ).empty?
|
|
590
|
-
lines << "- none"
|
|
591
|
-
else
|
|
592
|
-
baseline.fetch( :pending ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
|
|
593
|
-
end
|
|
594
|
-
end
|
|
595
|
-
lines << ""
|
|
596
|
-
lines.join( "\n" )
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
end
|
|
600
|
-
|
|
601
|
-
include Audit
|
|
602
|
-
end
|
|
603
|
-
end
|