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.
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.1
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
@@ -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