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