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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# Carson govern — portfolio-
|
|
2
|
-
#
|
|
1
|
+
# Carson govern — portfolio-wide oversight over branch deliveries.
|
|
2
|
+
# Govern reassesses queued/gated deliveries, records revision cycles, and integrates one ready delivery at a time.
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
5
|
require "fileutils"
|
|
@@ -10,13 +10,7 @@ module Carson
|
|
|
10
10
|
GOVERN_REPORT_MD = "govern_latest.md".freeze
|
|
11
11
|
GOVERN_REPORT_JSON = "govern_latest.json".freeze
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
TRIAGE_CI_FAILING = "ci_failing".freeze
|
|
15
|
-
TRIAGE_REVIEW_BLOCKED = "review_blocked".freeze
|
|
16
|
-
TRIAGE_NEEDS_ATTENTION = "needs_attention".freeze
|
|
17
|
-
|
|
18
|
-
# Portfolio-level entry point. Scans configured repos (or current repo)
|
|
19
|
-
# and triages all open PRs. Returns EXIT_OK/EXIT_ERROR.
|
|
13
|
+
# Portfolio-level entry point. Scans governed repos (or the current repo) and advances deliveries.
|
|
20
14
|
def govern!( dry_run: false, json_output: false, loop_seconds: nil )
|
|
21
15
|
if loop_seconds
|
|
22
16
|
govern_loop!( dry_run: dry_run, json_output: json_output, loop_seconds: loop_seconds )
|
|
@@ -27,334 +21,283 @@ module Carson
|
|
|
27
21
|
|
|
28
22
|
def govern_cycle!( dry_run:, json_output: )
|
|
29
23
|
print_header "Carson Govern"
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
repos = [ repo_root ]
|
|
34
|
-
else
|
|
35
|
-
puts_line "governing #{repos.length} repo#{plural_suffix( count: repos.length )}"
|
|
36
|
-
end
|
|
24
|
+
repositories = governed_repo_paths
|
|
25
|
+
repositories = [ repo_root ] if repositories.empty?
|
|
26
|
+
puts_line "governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
|
|
37
27
|
|
|
38
|
-
|
|
28
|
+
report = {
|
|
39
29
|
cycle_at: Time.now.utc.iso8601,
|
|
40
30
|
dry_run: dry_run,
|
|
41
|
-
|
|
31
|
+
repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run ) }
|
|
42
32
|
}
|
|
43
33
|
|
|
44
|
-
|
|
45
|
-
repo_report = govern_repo!( repo_path: repo_path, dry_run: dry_run )
|
|
46
|
-
portfolio_report[ :repos ] << repo_report
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
write_govern_report( report: portfolio_report )
|
|
50
|
-
|
|
34
|
+
write_govern_report( report: report )
|
|
51
35
|
if json_output
|
|
52
|
-
|
|
36
|
+
output.puts JSON.pretty_generate( report )
|
|
53
37
|
else
|
|
54
|
-
print_govern_summary( report:
|
|
38
|
+
print_govern_summary( report: report )
|
|
55
39
|
end
|
|
56
40
|
|
|
57
41
|
EXIT_OK
|
|
58
42
|
rescue StandardError => exception
|
|
59
|
-
puts_line "
|
|
43
|
+
puts_line "Govern did not complete: #{exception.message}"
|
|
60
44
|
EXIT_ERROR
|
|
61
45
|
end
|
|
62
46
|
|
|
63
47
|
def govern_loop!( dry_run:, json_output:, loop_seconds: )
|
|
64
|
-
print_header "⧓ Carson Govern — loop mode (every #{loop_seconds}s)"
|
|
65
48
|
cycle_count = 0
|
|
66
49
|
loop do
|
|
67
50
|
cycle_count += 1
|
|
68
51
|
puts_line ""
|
|
69
|
-
puts_line "
|
|
70
|
-
|
|
71
|
-
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
72
|
-
rescue StandardError => exception
|
|
73
|
-
puts_line "ERROR: cycle #{cycle_count} failed — #{exception.message}"
|
|
74
|
-
end
|
|
75
|
-
puts_line "sleeping #{loop_seconds}s until next cycle…"
|
|
52
|
+
puts_line "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
|
|
53
|
+
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
76
54
|
sleep loop_seconds
|
|
77
55
|
end
|
|
78
56
|
rescue Interrupt
|
|
79
|
-
puts_line ""
|
|
80
|
-
puts_line "⧓ govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}."
|
|
57
|
+
puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
81
58
|
EXIT_OK
|
|
82
59
|
end
|
|
83
60
|
|
|
84
61
|
private
|
|
85
62
|
|
|
86
|
-
# Resolves the list of repo paths to govern from config.
|
|
87
63
|
def governed_repo_paths
|
|
88
64
|
config.govern_repos.map do |path|
|
|
89
65
|
expanded = File.expand_path( path )
|
|
90
|
-
unless Dir.exist?( expanded )
|
|
91
|
-
puts_line "WARN: governed repo path does not exist: #{expanded}"
|
|
92
|
-
next nil
|
|
93
|
-
end
|
|
66
|
+
next nil unless Dir.exist?( expanded )
|
|
94
67
|
expanded
|
|
95
68
|
end.compact
|
|
96
69
|
end
|
|
97
70
|
|
|
98
|
-
# Governs a single repository: list open PRs, triage each.
|
|
99
71
|
def govern_repo!( repo_path:, dry_run: )
|
|
100
|
-
|
|
101
|
-
|
|
72
|
+
scoped_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
|
|
73
|
+
repository = Repository.new( path: repo_path, authority: scoped_runtime.config.govern_authority, runtime: scoped_runtime )
|
|
74
|
+
deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
|
|
75
|
+
|
|
102
76
|
repo_report = {
|
|
103
|
-
|
|
104
|
-
|
|
77
|
+
repository: repository.name,
|
|
78
|
+
path: repo_path,
|
|
79
|
+
authority: repository.authority,
|
|
80
|
+
deliveries: [],
|
|
105
81
|
error: nil
|
|
106
82
|
}
|
|
107
83
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
puts_line "ERROR: #{repo_path} does not exist"
|
|
84
|
+
if deliveries.empty?
|
|
85
|
+
puts_line "#{repository.name}: no active deliveries"
|
|
111
86
|
return repo_report
|
|
112
87
|
end
|
|
113
88
|
|
|
114
|
-
|
|
115
|
-
if prs.nil?
|
|
116
|
-
repo_report[ :error ] = "failed to list open PRs"
|
|
117
|
-
puts_line "ERROR: failed to list open PRs for #{repo_path}"
|
|
118
|
-
return repo_report
|
|
119
|
-
end
|
|
89
|
+
puts_line "#{repository.name}: #{deliveries.length} active deliver#{plural_suffix( count: deliveries.length )}"
|
|
120
90
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return repo_report
|
|
124
|
-
end
|
|
91
|
+
reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
|
|
92
|
+
next_integration_id = reconciled.find( &:ready? )&.id
|
|
125
93
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
94
|
+
reconciled.each do |delivery|
|
|
95
|
+
delivery_report = scoped_runtime.send(
|
|
96
|
+
:decide_delivery_action,
|
|
97
|
+
delivery: delivery,
|
|
98
|
+
repo_path: repo_path,
|
|
99
|
+
dry_run: dry_run,
|
|
100
|
+
next_integration_id: next_integration_id
|
|
101
|
+
)
|
|
102
|
+
repo_report[ :deliveries ] << delivery_report
|
|
130
103
|
end
|
|
131
104
|
|
|
132
105
|
repo_report
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"--json", "number,title,headRefName,statusCheckRollup,reviewDecision,url,updatedAt",
|
|
140
|
-
chdir: repo_path
|
|
141
|
-
)
|
|
142
|
-
unless status.success?
|
|
143
|
-
error_text = stderr_text.to_s.strip
|
|
144
|
-
puts_line "gh pr list failed: #{error_text}" unless error_text.empty?
|
|
145
|
-
return nil
|
|
106
|
+
rescue StandardError => exception
|
|
107
|
+
if defined?( repo_report ) && repo_report.is_a?( Hash )
|
|
108
|
+
repo_report[ :error ] = exception.message
|
|
109
|
+
repo_report
|
|
110
|
+
else
|
|
111
|
+
{ repository: File.basename( repo_path ), path: repo_path, deliveries: [], error: exception.message }
|
|
146
112
|
end
|
|
147
|
-
JSON.parse( stdout_text )
|
|
148
|
-
rescue JSON::ParserError => exception
|
|
149
|
-
puts_line "gh pr list returned invalid JSON: #{exception.message}"
|
|
150
|
-
nil
|
|
151
113
|
end
|
|
152
114
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
title: title,
|
|
163
|
-
branch: branch,
|
|
164
|
-
url: url,
|
|
165
|
-
classification: nil,
|
|
166
|
-
action: nil,
|
|
167
|
-
detail: nil
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
classification, detail = classify_pr( pr: pr, repo_path: repo_path )
|
|
171
|
-
pr_report[ :classification ] = classification
|
|
172
|
-
pr_report[ :detail ] = detail
|
|
173
|
-
|
|
174
|
-
action = decide_action( classification: classification, dry_run: dry_run )
|
|
175
|
-
pr_report[ :action ] = action
|
|
176
|
-
|
|
177
|
-
puts_line " PR ##{number} (#{branch}): #{classification} → #{action}"
|
|
178
|
-
puts_line " #{detail}" unless detail.to_s.empty?
|
|
179
|
-
|
|
180
|
-
execute_action!( action: action, pr: pr, repo_path: repo_path, dry_run: dry_run ) unless dry_run
|
|
181
|
-
|
|
182
|
-
pr_report
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
TRIAGE_PENDING = "pending".freeze
|
|
186
|
-
|
|
187
|
-
# Classifies PR state by checking CI, review status, and audit readiness.
|
|
188
|
-
def classify_pr( pr:, repo_path: )
|
|
189
|
-
ci_status = check_ci_status( pr: pr )
|
|
190
|
-
if ci_status == :pending && within_check_wait?( pr: pr )
|
|
191
|
-
return [ TRIAGE_PENDING, "checks still settling (within check_wait window)" ]
|
|
115
|
+
def reconcile_delivery!( delivery: )
|
|
116
|
+
branch = Repository.new( path: repo_root, authority: config.govern_authority, runtime: self ).branch( delivery.branch ).reload
|
|
117
|
+
if branch.head && branch.head != delivery.head
|
|
118
|
+
return ledger.update_delivery(
|
|
119
|
+
delivery: delivery,
|
|
120
|
+
status: "superseded",
|
|
121
|
+
superseded_at: Time.now.utc.iso8601,
|
|
122
|
+
summary: "branch head advanced to #{branch.head}; run carson deliver again"
|
|
123
|
+
)
|
|
192
124
|
end
|
|
193
|
-
return [ TRIAGE_CI_FAILING, "CI checks failing or pending" ] unless ci_status == :green
|
|
194
125
|
|
|
195
|
-
|
|
196
|
-
if
|
|
197
|
-
return
|
|
126
|
+
pr_state = pull_request_state( number: delivery.pull_request_number )
|
|
127
|
+
if pr_state && pr_state[ "state" ] == "MERGED"
|
|
128
|
+
return ledger.update_delivery(
|
|
129
|
+
delivery: delivery,
|
|
130
|
+
status: "integrated",
|
|
131
|
+
integrated_at: Time.now.utc.iso8601,
|
|
132
|
+
summary: "integrated into #{config.main_branch}"
|
|
133
|
+
)
|
|
198
134
|
end
|
|
199
135
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
checks = Array( pr[ "statusCheckRollup" ] )
|
|
209
|
-
return :green if checks.empty?
|
|
210
|
-
|
|
211
|
-
has_failure = checks.any? { check_state_failing?( state: it[ "state" ].to_s ) || check_conclusion_failing?( conclusion: it[ "conclusion" ].to_s ) }
|
|
212
|
-
return :red if has_failure
|
|
213
|
-
|
|
214
|
-
has_pending = checks.any? { check_state_pending?( state: it[ "state" ].to_s ) }
|
|
215
|
-
return :pending if has_pending
|
|
216
|
-
|
|
217
|
-
:green
|
|
218
|
-
end
|
|
136
|
+
if pr_state && pr_state[ "state" ] == "CLOSED"
|
|
137
|
+
return ledger.update_delivery(
|
|
138
|
+
delivery: delivery,
|
|
139
|
+
status: "failed",
|
|
140
|
+
cause: "policy",
|
|
141
|
+
summary: "pull request closed without integration"
|
|
142
|
+
)
|
|
143
|
+
end
|
|
219
144
|
|
|
220
|
-
|
|
221
|
-
[ "FAILURE", "ERROR" ].include?( state.upcase )
|
|
145
|
+
assess_delivery!( delivery: delivery, branch_name: delivery.branch )
|
|
222
146
|
end
|
|
223
147
|
|
|
224
|
-
def
|
|
225
|
-
|
|
226
|
-
|
|
148
|
+
def decide_delivery_action( delivery:, repo_path:, dry_run:, next_integration_id: )
|
|
149
|
+
report = {
|
|
150
|
+
id: delivery.id,
|
|
151
|
+
branch: delivery.branch,
|
|
152
|
+
status: delivery.status,
|
|
153
|
+
summary: delivery.summary,
|
|
154
|
+
revision_count: delivery.revision_count,
|
|
155
|
+
action: "none"
|
|
156
|
+
}
|
|
227
157
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
158
|
+
if delivery.superseded? || delivery.integrated? || delivery.failed?
|
|
159
|
+
return report
|
|
160
|
+
end
|
|
231
161
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
when "APPROVED"
|
|
237
|
-
[ :pass, "approved" ]
|
|
238
|
-
when "CHANGES_REQUESTED"
|
|
239
|
-
[ :fail, "changes requested" ]
|
|
240
|
-
when "REVIEW_REQUIRED"
|
|
241
|
-
[ :fail, "review required" ]
|
|
242
|
-
else
|
|
243
|
-
[ :pass, "no review policy or approved" ]
|
|
162
|
+
if delivery.ready? && delivery.id == next_integration_id
|
|
163
|
+
report[ :action ] = dry_run ? "would_integrate" : "integrate"
|
|
164
|
+
report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
|
|
165
|
+
return report
|
|
244
166
|
end
|
|
245
|
-
end
|
|
246
167
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
dry_run ? "would_dispatch_review_fix" : "dispatch_review_fix"
|
|
256
|
-
when TRIAGE_PENDING
|
|
257
|
-
"skip"
|
|
258
|
-
when TRIAGE_NEEDS_ATTENTION
|
|
259
|
-
"escalate"
|
|
260
|
-
else
|
|
261
|
-
"skip"
|
|
168
|
+
if delivery.blocked?
|
|
169
|
+
if delivery.revision_count >= 3
|
|
170
|
+
report[ :action ] = dry_run ? "would_escalate" : "escalate"
|
|
171
|
+
report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
|
|
172
|
+
else
|
|
173
|
+
report[ :action ] = dry_run ? "would_revise" : "revise"
|
|
174
|
+
report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
|
|
175
|
+
end
|
|
262
176
|
end
|
|
177
|
+
|
|
178
|
+
report
|
|
263
179
|
end
|
|
264
180
|
|
|
265
|
-
|
|
266
|
-
|
|
181
|
+
def execute_delivery_action!( action:, delivery:, repo_path:, dry_run: )
|
|
182
|
+
return delivery if dry_run
|
|
183
|
+
|
|
267
184
|
case action
|
|
268
|
-
when "
|
|
269
|
-
|
|
270
|
-
when "
|
|
271
|
-
|
|
272
|
-
when "dispatch_review_fix"
|
|
273
|
-
dispatch_agent!( pr: pr, repo_path: repo_path, objective: "address_review" )
|
|
185
|
+
when "integrate"
|
|
186
|
+
integrate_delivery!( delivery: delivery, repo_path: repo_path )
|
|
187
|
+
when "revise"
|
|
188
|
+
revise_delivery!( delivery: delivery, repo_path: repo_path )
|
|
274
189
|
when "escalate"
|
|
275
|
-
|
|
190
|
+
escalate_delivery!( delivery: delivery, reason: "revision limit reached" )
|
|
191
|
+
else
|
|
192
|
+
delivery
|
|
276
193
|
end
|
|
277
194
|
end
|
|
278
195
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
method = config.govern_merge_method
|
|
288
|
-
number = pr[ "number" ]
|
|
289
|
-
stdout_text, stderr_text, status = Open3.capture3(
|
|
290
|
-
"gh", "pr", "merge", number.to_s,
|
|
291
|
-
"--#{method}",
|
|
292
|
-
chdir: repo_path
|
|
196
|
+
def integrate_delivery!( delivery:, repo_path: )
|
|
197
|
+
result = {}
|
|
198
|
+
prepared = ledger.update_delivery(
|
|
199
|
+
delivery: delivery,
|
|
200
|
+
status: "integrating",
|
|
201
|
+
summary: "integrating into #{config.main_branch}"
|
|
293
202
|
)
|
|
294
|
-
|
|
295
|
-
|
|
203
|
+
merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
|
|
204
|
+
if merge_exit == EXIT_OK
|
|
205
|
+
integrated = ledger.update_delivery(
|
|
206
|
+
delivery: prepared,
|
|
207
|
+
status: "integrated",
|
|
208
|
+
integrated_at: Time.now.utc.iso8601,
|
|
209
|
+
summary: "integrated into #{config.main_branch}"
|
|
210
|
+
)
|
|
296
211
|
housekeep_repo!( repo_path: repo_path )
|
|
212
|
+
integrated
|
|
297
213
|
else
|
|
298
|
-
|
|
299
|
-
|
|
214
|
+
ledger.update_delivery(
|
|
215
|
+
delivery: prepared,
|
|
216
|
+
status: "gated",
|
|
217
|
+
cause: "policy",
|
|
218
|
+
summary: result.fetch( :error, "merge failed" )
|
|
219
|
+
)
|
|
300
220
|
end
|
|
301
221
|
end
|
|
302
222
|
|
|
303
|
-
|
|
304
|
-
def dispatch_agent!( pr:, repo_path:, objective: )
|
|
305
|
-
state = load_dispatch_state
|
|
306
|
-
state_key = dispatch_state_key( pr: pr, repo_path: repo_path )
|
|
307
|
-
|
|
308
|
-
existing = state[ state_key ]
|
|
309
|
-
if existing && existing[ "status" ] == "running"
|
|
310
|
-
puts_line " agent already dispatched for #{objective}; skipping"
|
|
311
|
-
return
|
|
312
|
-
end
|
|
313
|
-
|
|
223
|
+
def revise_delivery!( delivery:, repo_path: )
|
|
314
224
|
provider = select_agent_provider
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return
|
|
318
|
-
end
|
|
225
|
+
return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
|
|
226
|
+
return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
|
|
319
227
|
|
|
320
|
-
|
|
228
|
+
objective = revision_objective( cause: delivery.cause )
|
|
229
|
+
context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
|
|
321
230
|
work_order = Adapters::Agent::WorkOrder.new(
|
|
322
231
|
repo: repo_path,
|
|
323
|
-
branch:
|
|
324
|
-
pr_number:
|
|
232
|
+
branch: delivery.branch,
|
|
233
|
+
pr_number: delivery.pull_request_number,
|
|
325
234
|
objective: objective,
|
|
326
235
|
context: context,
|
|
327
236
|
acceptance_checks: nil
|
|
328
237
|
)
|
|
329
238
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
239
|
+
result = build_agent_adapter( provider: provider, repo_path: delivery.worktree_path ).dispatch( work_order: work_order )
|
|
240
|
+
revision = ledger.record_revision(
|
|
241
|
+
delivery: delivery,
|
|
242
|
+
cause: delivery.cause || "policy",
|
|
243
|
+
provider: provider,
|
|
244
|
+
status: revision_status_for( result: result ),
|
|
245
|
+
summary: result.summary
|
|
246
|
+
)
|
|
333
247
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
248
|
+
if revision.completed?
|
|
249
|
+
updated = ledger.update_delivery(
|
|
250
|
+
delivery: delivery,
|
|
251
|
+
status: "gated",
|
|
252
|
+
summary: "revision #{revision.number} completed — waiting for reassessment",
|
|
253
|
+
revision_count: revision.number
|
|
254
|
+
)
|
|
255
|
+
return reconcile_delivery!( delivery: updated )
|
|
256
|
+
end
|
|
342
257
|
|
|
343
|
-
|
|
258
|
+
if revision.number >= 3
|
|
259
|
+
escalate_delivery!( delivery: delivery, reason: "revision #{revision.number} failed: #{result.summary}" )
|
|
260
|
+
else
|
|
261
|
+
ledger.update_delivery(
|
|
262
|
+
delivery: delivery,
|
|
263
|
+
status: "gated",
|
|
264
|
+
summary: "revision #{revision.number} failed: #{result.summary}",
|
|
265
|
+
revision_count: revision.number
|
|
266
|
+
)
|
|
267
|
+
end
|
|
344
268
|
end
|
|
345
269
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
270
|
+
def escalate_delivery!( delivery:, reason: )
|
|
271
|
+
ledger.update_delivery(
|
|
272
|
+
delivery: delivery,
|
|
273
|
+
status: "escalated",
|
|
274
|
+
cause: delivery.cause || "policy",
|
|
275
|
+
summary: reason
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def revision_objective( cause: )
|
|
280
|
+
case cause
|
|
281
|
+
when "ci" then "fix_ci"
|
|
282
|
+
when "review" then "address_review"
|
|
283
|
+
else "fix_audit"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def revision_status_for( result: )
|
|
288
|
+
case result.status
|
|
289
|
+
when "done" then "completed"
|
|
290
|
+
when "timeout" then "stalled"
|
|
291
|
+
else "failed"
|
|
352
292
|
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def housekeep_repo!( repo_path: )
|
|
296
|
+
scoped_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
|
|
353
297
|
sync_status = scoped_runtime.sync!
|
|
354
298
|
scoped_runtime.prune! if sync_status == EXIT_OK
|
|
355
299
|
end
|
|
356
300
|
|
|
357
|
-
# Selects which agent provider to use based on config and availability.
|
|
358
301
|
def select_agent_provider
|
|
359
302
|
provider = config.govern_agent_provider
|
|
360
303
|
case provider
|
|
@@ -387,48 +330,26 @@ module Carson
|
|
|
387
330
|
end
|
|
388
331
|
end
|
|
389
332
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
path = config.govern_dispatch_state_path
|
|
393
|
-
return {} unless File.file?( path )
|
|
394
|
-
|
|
395
|
-
JSON.parse( File.read( path ) )
|
|
396
|
-
rescue JSON::ParserError
|
|
397
|
-
{}
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
def save_dispatch_state( state: )
|
|
401
|
-
path = config.govern_dispatch_state_path
|
|
402
|
-
FileUtils.mkdir_p( File.dirname( path ) )
|
|
403
|
-
File.write( path, JSON.pretty_generate( state ) )
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
def dispatch_state_key( pr:, repo_path: )
|
|
407
|
-
dir_name = File.basename( repo_path )
|
|
408
|
-
"#{dir_name}##{pr[ 'number' ]}"
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# Evidence gathering — builds structured context Hash for agent work orders.
|
|
412
|
-
def evidence( pr:, repo_path:, objective: )
|
|
413
|
-
context = { title: pr.fetch( "title", "" ) }
|
|
333
|
+
def evidence( delivery:, repo_path:, objective: )
|
|
334
|
+
context = { title: delivery.summary.to_s }
|
|
414
335
|
case objective
|
|
415
336
|
when "fix_ci"
|
|
416
|
-
context.merge!( ci_evidence(
|
|
337
|
+
context.merge!( ci_evidence( delivery: delivery, repo_path: repo_path ) )
|
|
417
338
|
when "address_review"
|
|
418
|
-
context.merge!( review_evidence(
|
|
339
|
+
context.merge!( review_evidence( delivery: delivery, repo_path: repo_path ) )
|
|
419
340
|
end
|
|
420
|
-
prior = prior_attempt(
|
|
341
|
+
prior = prior_attempt( delivery: delivery )
|
|
421
342
|
context[ :prior_attempt ] = prior if prior
|
|
422
343
|
context
|
|
423
344
|
rescue StandardError => exception
|
|
424
|
-
puts_line "
|
|
425
|
-
{ title:
|
|
345
|
+
puts_line "evidence gathering failed for #{delivery.branch}: #{exception.message}"
|
|
346
|
+
{ title: delivery.summary.to_s }
|
|
426
347
|
end
|
|
427
348
|
|
|
428
349
|
CI_LOG_LIMIT = 8_000
|
|
429
350
|
|
|
430
|
-
def ci_evidence(
|
|
431
|
-
branch =
|
|
351
|
+
def ci_evidence( delivery:, repo_path: )
|
|
352
|
+
branch = delivery.branch
|
|
432
353
|
stdout_text, _, status = Open3.capture3(
|
|
433
354
|
"gh", "run", "list",
|
|
434
355
|
"--branch", branch,
|
|
@@ -444,17 +365,10 @@ module Carson
|
|
|
444
365
|
|
|
445
366
|
run_id = runs.first[ "databaseId" ].to_s
|
|
446
367
|
run_url = runs.first[ "url" ].to_s
|
|
447
|
-
|
|
448
|
-
log_stdout, _, log_status = Open3.capture3(
|
|
449
|
-
"gh", "run", "view", run_id, "--log-failed",
|
|
450
|
-
chdir: repo_path
|
|
451
|
-
)
|
|
368
|
+
log_stdout, _, log_status = Open3.capture3( "gh", "run", "view", run_id, "--log-failed", chdir: repo_path )
|
|
452
369
|
return { ci_run_url: run_url } unless log_status.success?
|
|
453
370
|
|
|
454
371
|
{ ci_logs: truncate_log( text: log_stdout ), ci_run_url: run_url }
|
|
455
|
-
rescue StandardError => exception
|
|
456
|
-
puts_line " ci_evidence failed: #{exception.message}"
|
|
457
|
-
{}
|
|
458
372
|
end
|
|
459
373
|
|
|
460
374
|
def truncate_log( text:, limit: CI_LOG_LIMIT )
|
|
@@ -463,14 +377,13 @@ module Carson
|
|
|
463
377
|
text[ -limit.. ]
|
|
464
378
|
end
|
|
465
379
|
|
|
466
|
-
def review_evidence(
|
|
467
|
-
|
|
468
|
-
owner, repo =
|
|
469
|
-
|
|
470
|
-
details = scoped_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: pr_number )
|
|
380
|
+
def review_evidence( delivery:, repo_path: )
|
|
381
|
+
repo_runtime = repo_path == repo_root ? self : build_scoped_runtime( repo_path: repo_path )
|
|
382
|
+
owner, repo = repo_runtime.send( :repository_coordinates )
|
|
383
|
+
details = repo_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: delivery.pull_request_number )
|
|
471
384
|
pr_author = details.dig( :author, :login ).to_s
|
|
472
|
-
threads =
|
|
473
|
-
top_level =
|
|
385
|
+
threads = repo_runtime.send( :unresolved_thread_entries, details: details )
|
|
386
|
+
top_level = repo_runtime.send( :actionable_top_level_items, details: details, pr_author: pr_author )
|
|
474
387
|
|
|
475
388
|
findings = []
|
|
476
389
|
threads.each do |entry|
|
|
@@ -483,23 +396,12 @@ module Carson
|
|
|
483
396
|
end
|
|
484
397
|
|
|
485
398
|
{ review_findings: findings }
|
|
486
|
-
rescue StandardError => exception
|
|
487
|
-
puts_line " review_evidence failed: #{exception.message}"
|
|
488
|
-
{}
|
|
489
|
-
end
|
|
490
|
-
|
|
491
|
-
def scoped_runtime( repo_path: )
|
|
492
|
-
return self if repo_path == self.repo_root
|
|
493
|
-
Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
|
|
494
399
|
end
|
|
495
400
|
|
|
496
|
-
def prior_attempt(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
return nil unless existing
|
|
501
|
-
return nil unless existing[ "status" ] == "failed"
|
|
502
|
-
{ summary: existing[ "summary" ].to_s, dispatched_at: existing[ "dispatched_at" ].to_s }
|
|
401
|
+
def prior_attempt( delivery: )
|
|
402
|
+
revision = ledger.revisions_for_delivery( delivery_id: delivery.id ).last
|
|
403
|
+
return nil unless revision&.failed?
|
|
404
|
+
{ summary: revision.summary.to_s, dispatched_at: revision.started_at.to_s }
|
|
503
405
|
end
|
|
504
406
|
|
|
505
407
|
def thread_body( details:, url: )
|
|
@@ -521,30 +423,11 @@ module Carson
|
|
|
521
423
|
""
|
|
522
424
|
end
|
|
523
425
|
|
|
524
|
-
# Check wait: returns true if the PR was updated within the configured wait window.
|
|
525
|
-
def within_check_wait?( pr: )
|
|
526
|
-
wait = config.govern_check_wait
|
|
527
|
-
return false if wait <= 0
|
|
528
|
-
|
|
529
|
-
updated_at_text = pr[ "updatedAt" ].to_s.strip
|
|
530
|
-
return false if updated_at_text.empty?
|
|
531
|
-
|
|
532
|
-
updated_at = Time.parse( updated_at_text )
|
|
533
|
-
( Time.now.utc - updated_at.utc ) < wait
|
|
534
|
-
rescue ArgumentError
|
|
535
|
-
false
|
|
536
|
-
end
|
|
537
|
-
|
|
538
|
-
# Report writing.
|
|
539
426
|
def write_govern_report( report: )
|
|
540
427
|
report_dir = report_dir_path
|
|
541
428
|
FileUtils.mkdir_p( report_dir )
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
File.write( json_path, JSON.pretty_generate( report ) )
|
|
545
|
-
File.write( md_path, render_govern_markdown( report: report ) )
|
|
546
|
-
puts_verbose "report_json: #{json_path}"
|
|
547
|
-
puts_verbose "report_markdown: #{md_path}"
|
|
429
|
+
File.write( File.join( report_dir, GOVERN_REPORT_JSON ), JSON.pretty_generate( report ) )
|
|
430
|
+
File.write( File.join( report_dir, GOVERN_REPORT_MD ), render_govern_markdown( report: report ) )
|
|
548
431
|
end
|
|
549
432
|
|
|
550
433
|
def render_govern_markdown( report: )
|
|
@@ -555,8 +438,8 @@ module Carson
|
|
|
555
438
|
lines << "**Dry run**: #{report[ :dry_run ]}"
|
|
556
439
|
lines << ""
|
|
557
440
|
|
|
558
|
-
Array( report[ :
|
|
559
|
-
lines << "## #{repo_report[ :
|
|
441
|
+
Array( report[ :repositories ] ).each do |repo_report|
|
|
442
|
+
lines << "## #{repo_report[ :path ]}"
|
|
560
443
|
lines << ""
|
|
561
444
|
if repo_report[ :error ]
|
|
562
445
|
lines << "**Error**: #{repo_report[ :error ]}"
|
|
@@ -564,20 +447,20 @@ module Carson
|
|
|
564
447
|
next
|
|
565
448
|
end
|
|
566
449
|
|
|
567
|
-
|
|
568
|
-
if
|
|
569
|
-
lines << "No
|
|
450
|
+
deliveries = Array( repo_report[ :deliveries ] )
|
|
451
|
+
if deliveries.empty?
|
|
452
|
+
lines << "No active deliveries."
|
|
570
453
|
lines << ""
|
|
571
454
|
next
|
|
572
455
|
end
|
|
573
456
|
|
|
574
|
-
|
|
575
|
-
lines << "###
|
|
457
|
+
deliveries.each do |delivery|
|
|
458
|
+
lines << "### #{delivery[ :branch ]}"
|
|
576
459
|
lines << ""
|
|
577
|
-
lines << "- **
|
|
578
|
-
lines << "- **
|
|
579
|
-
lines << "- **
|
|
580
|
-
lines << "- **
|
|
460
|
+
lines << "- **Status**: #{delivery[ :status ]}"
|
|
461
|
+
lines << "- **Action**: #{delivery[ :action ]}"
|
|
462
|
+
lines << "- **Summary**: #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
|
|
463
|
+
lines << "- **Revision count**: #{delivery[ :revision_count ]}"
|
|
581
464
|
lines << ""
|
|
582
465
|
end
|
|
583
466
|
end
|
|
@@ -586,28 +469,21 @@ module Carson
|
|
|
586
469
|
end
|
|
587
470
|
|
|
588
471
|
def print_govern_summary( report: )
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
Array( report[ :repos ] ).each do |repo_report|
|
|
595
|
-
Array( repo_report[ :prs ] ).each do |pr|
|
|
596
|
-
total_prs += 1
|
|
597
|
-
case pr[ :classification ]
|
|
598
|
-
when TRIAGE_READY
|
|
599
|
-
ready_count += 1
|
|
600
|
-
else
|
|
601
|
-
blocked_count += 1
|
|
602
|
-
end
|
|
472
|
+
Array( report[ :repositories ] ).each do |repo_report|
|
|
473
|
+
if repo_report[ :error ]
|
|
474
|
+
puts_line "#{repo_report[ :repository ]}: #{repo_report[ :error ]}"
|
|
475
|
+
next
|
|
603
476
|
end
|
|
604
|
-
end
|
|
605
477
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
478
|
+
if repo_report[ :deliveries ].empty?
|
|
479
|
+
puts_line "#{repo_report[ :repository ]}: no active deliveries"
|
|
480
|
+
next
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
repo_report[ :deliveries ].each do |delivery|
|
|
484
|
+
puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]}: #{delivery[ :status ]} -> #{delivery[ :action ]}"
|
|
485
|
+
puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
|
|
486
|
+
end
|
|
611
487
|
end
|
|
612
488
|
end
|
|
613
489
|
end
|