carson 3.22.1 → 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 +15 -11
- data/MANUAL.md +32 -38
- data/README.md +6 -9
- data/RELEASE.md +15 -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 +232 -368
- 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,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,31 +21,21 @@ 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
|
|
@@ -61,312 +45,259 @@ module Carson
|
|
|
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 "Cycle #{cycle_count} did not complete: #{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 "Skipping #{expanded} — path not found"
|
|
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 "#{repo_path}: path not found, skipping"
|
|
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 "#{File.basename(repo_path)}: unable to list open PRs"
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
)
|
|
201
134
|
end
|
|
202
135
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
206
144
|
|
|
207
|
-
|
|
145
|
+
assess_delivery!( delivery: delivery, branch_name: delivery.branch )
|
|
208
146
|
end
|
|
209
147
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return :pending if has_pending
|
|
220
|
-
|
|
221
|
-
:green
|
|
222
|
-
end
|
|
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
|
+
}
|
|
223
157
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
158
|
+
if delivery.superseded? || delivery.integrated? || delivery.failed?
|
|
159
|
+
return report
|
|
160
|
+
end
|
|
227
161
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
166
|
+
end
|
|
231
167
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
176
|
+
end
|
|
235
177
|
|
|
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}" ]
|
|
178
|
+
report
|
|
257
179
|
end
|
|
258
180
|
|
|
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
|
|
181
|
+
def execute_delivery_action!( action:, delivery:, repo_path:, dry_run: )
|
|
182
|
+
return delivery if dry_run
|
|
276
183
|
|
|
277
|
-
# Executes the decided action on a PR.
|
|
278
|
-
def execute_action!( action:, pr:, repo_path:, dry_run: )
|
|
279
184
|
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" )
|
|
185
|
+
when "integrate"
|
|
186
|
+
integrate_delivery!( delivery: delivery, repo_path: repo_path )
|
|
187
|
+
when "revise"
|
|
188
|
+
revise_delivery!( delivery: delivery, repo_path: repo_path )
|
|
286
189
|
when "escalate"
|
|
287
|
-
|
|
190
|
+
escalate_delivery!( delivery: delivery, reason: "revision limit reached" )
|
|
191
|
+
else
|
|
192
|
+
delivery
|
|
288
193
|
end
|
|
289
194
|
end
|
|
290
195
|
|
|
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
|
|
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}"
|
|
305
202
|
)
|
|
306
|
-
|
|
307
|
-
|
|
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
|
+
)
|
|
308
211
|
housekeep_repo!( repo_path: repo_path )
|
|
212
|
+
integrated
|
|
309
213
|
else
|
|
310
|
-
|
|
311
|
-
|
|
214
|
+
ledger.update_delivery(
|
|
215
|
+
delivery: prepared,
|
|
216
|
+
status: "gated",
|
|
217
|
+
cause: "policy",
|
|
218
|
+
summary: result.fetch( :error, "merge failed" )
|
|
219
|
+
)
|
|
312
220
|
end
|
|
313
221
|
end
|
|
314
222
|
|
|
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
|
-
|
|
223
|
+
def revise_delivery!( delivery:, repo_path: )
|
|
326
224
|
provider = select_agent_provider
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return
|
|
330
|
-
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 )
|
|
331
227
|
|
|
332
|
-
|
|
228
|
+
objective = revision_objective( cause: delivery.cause )
|
|
229
|
+
context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
|
|
333
230
|
work_order = Adapters::Agent::WorkOrder.new(
|
|
334
231
|
repo: repo_path,
|
|
335
|
-
branch:
|
|
336
|
-
pr_number:
|
|
232
|
+
branch: delivery.branch,
|
|
233
|
+
pr_number: delivery.pull_request_number,
|
|
337
234
|
objective: objective,
|
|
338
235
|
context: context,
|
|
339
236
|
acceptance_checks: nil
|
|
340
237
|
)
|
|
341
238
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
)
|
|
345
247
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
354
257
|
|
|
355
|
-
|
|
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
|
|
356
268
|
end
|
|
357
269
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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"
|
|
364
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 )
|
|
365
297
|
sync_status = scoped_runtime.sync!
|
|
366
298
|
scoped_runtime.prune! if sync_status == EXIT_OK
|
|
367
299
|
end
|
|
368
300
|
|
|
369
|
-
# Selects which agent provider to use based on config and availability.
|
|
370
301
|
def select_agent_provider
|
|
371
302
|
provider = config.govern_agent_provider
|
|
372
303
|
case provider
|
|
@@ -399,48 +330,26 @@ module Carson
|
|
|
399
330
|
end
|
|
400
331
|
end
|
|
401
332
|
|
|
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", "" ) }
|
|
333
|
+
def evidence( delivery:, repo_path:, objective: )
|
|
334
|
+
context = { title: delivery.summary.to_s }
|
|
426
335
|
case objective
|
|
427
336
|
when "fix_ci"
|
|
428
|
-
context.merge!( ci_evidence(
|
|
337
|
+
context.merge!( ci_evidence( delivery: delivery, repo_path: repo_path ) )
|
|
429
338
|
when "address_review"
|
|
430
|
-
context.merge!( review_evidence(
|
|
339
|
+
context.merge!( review_evidence( delivery: delivery, repo_path: repo_path ) )
|
|
431
340
|
end
|
|
432
|
-
prior = prior_attempt(
|
|
341
|
+
prior = prior_attempt( delivery: delivery )
|
|
433
342
|
context[ :prior_attempt ] = prior if prior
|
|
434
343
|
context
|
|
435
344
|
rescue StandardError => exception
|
|
436
|
-
puts_line "
|
|
437
|
-
{ title:
|
|
345
|
+
puts_line "evidence gathering failed for #{delivery.branch}: #{exception.message}"
|
|
346
|
+
{ title: delivery.summary.to_s }
|
|
438
347
|
end
|
|
439
348
|
|
|
440
349
|
CI_LOG_LIMIT = 8_000
|
|
441
350
|
|
|
442
|
-
def ci_evidence(
|
|
443
|
-
branch =
|
|
351
|
+
def ci_evidence( delivery:, repo_path: )
|
|
352
|
+
branch = delivery.branch
|
|
444
353
|
stdout_text, _, status = Open3.capture3(
|
|
445
354
|
"gh", "run", "list",
|
|
446
355
|
"--branch", branch,
|
|
@@ -456,17 +365,10 @@ module Carson
|
|
|
456
365
|
|
|
457
366
|
run_id = runs.first[ "databaseId" ].to_s
|
|
458
367
|
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
|
-
)
|
|
368
|
+
log_stdout, _, log_status = Open3.capture3( "gh", "run", "view", run_id, "--log-failed", chdir: repo_path )
|
|
464
369
|
return { ci_run_url: run_url } unless log_status.success?
|
|
465
370
|
|
|
466
371
|
{ 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
372
|
end
|
|
471
373
|
|
|
472
374
|
def truncate_log( text:, limit: CI_LOG_LIMIT )
|
|
@@ -475,14 +377,13 @@ module Carson
|
|
|
475
377
|
text[ -limit.. ]
|
|
476
378
|
end
|
|
477
379
|
|
|
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 )
|
|
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 )
|
|
483
384
|
pr_author = details.dig( :author, :login ).to_s
|
|
484
|
-
threads =
|
|
485
|
-
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 )
|
|
486
387
|
|
|
487
388
|
findings = []
|
|
488
389
|
threads.each do |entry|
|
|
@@ -495,23 +396,12 @@ module Carson
|
|
|
495
396
|
end
|
|
496
397
|
|
|
497
398
|
{ 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
399
|
end
|
|
507
400
|
|
|
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 }
|
|
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 }
|
|
515
405
|
end
|
|
516
406
|
|
|
517
407
|
def thread_body( details:, url: )
|
|
@@ -533,30 +423,11 @@ module Carson
|
|
|
533
423
|
""
|
|
534
424
|
end
|
|
535
425
|
|
|
536
|
-
# Check wait: returns true if the PR was updated within the configured wait window.
|
|
537
|
-
def within_check_wait?( pr: )
|
|
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
426
|
def write_govern_report( report: )
|
|
552
427
|
report_dir = report_dir_path
|
|
553
428
|
FileUtils.mkdir_p( report_dir )
|
|
554
|
-
|
|
555
|
-
|
|
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}"
|
|
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 ) )
|
|
560
431
|
end
|
|
561
432
|
|
|
562
433
|
def render_govern_markdown( report: )
|
|
@@ -567,8 +438,8 @@ module Carson
|
|
|
567
438
|
lines << "**Dry run**: #{report[ :dry_run ]}"
|
|
568
439
|
lines << ""
|
|
569
440
|
|
|
570
|
-
Array( report[ :
|
|
571
|
-
lines << "## #{repo_report[ :
|
|
441
|
+
Array( report[ :repositories ] ).each do |repo_report|
|
|
442
|
+
lines << "## #{repo_report[ :path ]}"
|
|
572
443
|
lines << ""
|
|
573
444
|
if repo_report[ :error ]
|
|
574
445
|
lines << "**Error**: #{repo_report[ :error ]}"
|
|
@@ -576,20 +447,20 @@ module Carson
|
|
|
576
447
|
next
|
|
577
448
|
end
|
|
578
449
|
|
|
579
|
-
|
|
580
|
-
if
|
|
581
|
-
lines << "No
|
|
450
|
+
deliveries = Array( repo_report[ :deliveries ] )
|
|
451
|
+
if deliveries.empty?
|
|
452
|
+
lines << "No active deliveries."
|
|
582
453
|
lines << ""
|
|
583
454
|
next
|
|
584
455
|
end
|
|
585
456
|
|
|
586
|
-
|
|
587
|
-
lines << "###
|
|
457
|
+
deliveries.each do |delivery|
|
|
458
|
+
lines << "### #{delivery[ :branch ]}"
|
|
588
459
|
lines << ""
|
|
589
|
-
lines << "- **
|
|
590
|
-
lines << "- **
|
|
591
|
-
lines << "- **
|
|
592
|
-
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 ]}"
|
|
593
464
|
lines << ""
|
|
594
465
|
end
|
|
595
466
|
end
|
|
@@ -598,28 +469,21 @@ module Carson
|
|
|
598
469
|
end
|
|
599
470
|
|
|
600
471
|
def print_govern_summary( report: )
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
472
|
+
Array( report[ :repositories ] ).each do |repo_report|
|
|
473
|
+
if repo_report[ :error ]
|
|
474
|
+
puts_line "#{repo_report[ :repository ]}: #{repo_report[ :error ]}"
|
|
475
|
+
next
|
|
615
476
|
end
|
|
616
|
-
end
|
|
617
477
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
623
487
|
end
|
|
624
488
|
end
|
|
625
489
|
end
|