carson 3.24.0 → 3.27.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 +26 -8
- data/MANUAL.md +54 -25
- data/README.md +9 -16
- data/RELEASE.md +31 -2
- data/VERSION +1 -1
- data/hooks/command-guard +60 -16
- data/lib/carson/cli.rb +116 -5
- data/lib/carson/config.rb +3 -8
- data/lib/carson/delivery.rb +17 -9
- data/lib/carson/ledger.rb +462 -224
- data/lib/carson/repository.rb +2 -4
- data/lib/carson/revision.rb +2 -4
- data/lib/carson/runtime/abandon.rb +238 -0
- data/lib/carson/runtime/audit.rb +12 -2
- data/lib/carson/runtime/deliver.rb +162 -15
- data/lib/carson/runtime/govern.rb +48 -21
- data/lib/carson/runtime/housekeep.rb +189 -153
- data/lib/carson/runtime/local/onboard.rb +4 -3
- data/lib/carson/runtime/local/prune.rb +6 -11
- data/lib/carson/runtime/local/sync.rb +9 -0
- data/lib/carson/runtime/local/worktree.rb +166 -0
- data/lib/carson/runtime/recover.rb +418 -0
- data/lib/carson/runtime/setup.rb +11 -7
- data/lib/carson/runtime/status.rb +39 -28
- data/lib/carson/runtime.rb +3 -1
- data/lib/carson/worktree.rb +128 -53
- metadata +3 -1
|
@@ -28,6 +28,25 @@ module Carson
|
|
|
28
28
|
Worktree.list( runtime: self )
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Human and JSON status surface for all registered worktrees.
|
|
32
|
+
def worktree_list!( json_output: false )
|
|
33
|
+
entries = worktree_inventory
|
|
34
|
+
result = {
|
|
35
|
+
command: "worktree list",
|
|
36
|
+
status: "ok",
|
|
37
|
+
worktrees: entries,
|
|
38
|
+
exit_code: EXIT_OK
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if json_output
|
|
42
|
+
output.puts JSON.pretty_generate( result )
|
|
43
|
+
else
|
|
44
|
+
print_worktree_list( entries: entries )
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
EXIT_OK
|
|
48
|
+
end
|
|
49
|
+
|
|
31
50
|
# --- Methods that stay on Runtime ---
|
|
32
51
|
|
|
33
52
|
# Returns the branch checked out in the worktree that contains the process CWD,
|
|
@@ -87,6 +106,153 @@ module Carson
|
|
|
87
106
|
|
|
88
107
|
missing_segments.empty? ? base : File.join( base, *missing_segments )
|
|
89
108
|
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def worktree_inventory
|
|
113
|
+
worktree_list.map { |worktree| worktree_inventory_entry( worktree: worktree ) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def worktree_inventory_entry( worktree: )
|
|
117
|
+
cleanup = classify_worktree_cleanup( worktree: worktree )
|
|
118
|
+
pull_request = worktree_pull_request( branch: worktree.branch )
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
name: File.basename( worktree.path ),
|
|
122
|
+
branch: worktree.branch,
|
|
123
|
+
path: worktree.path,
|
|
124
|
+
main: worktree.path == main_worktree_root,
|
|
125
|
+
exists: worktree.exists?,
|
|
126
|
+
dirty: worktree.dirty?,
|
|
127
|
+
held_by_current_shell: worktree.holds_cwd?,
|
|
128
|
+
held_by_other_process: worktree.held_by_other_process?,
|
|
129
|
+
absorbed_into_main: cleanup.fetch( :absorbed, false ),
|
|
130
|
+
pull_request: pull_request,
|
|
131
|
+
cleanup: {
|
|
132
|
+
action: cleanup.fetch( :action ).to_s,
|
|
133
|
+
reason: cleanup.fetch( :reason )
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Shared cleanup classifier used by `worktree list` and `housekeep`.
|
|
139
|
+
def classify_worktree_cleanup( worktree: )
|
|
140
|
+
return { action: :skip, reason: "main worktree", absorbed: false } if worktree.path == main_worktree_root
|
|
141
|
+
return { action: :skip, reason: "detached HEAD", absorbed: false } if worktree.branch.to_s.strip.empty?
|
|
142
|
+
return { action: :skip, reason: "held by current shell", absorbed: false } if worktree.holds_cwd?
|
|
143
|
+
return { action: :skip, reason: "held by another process", absorbed: false } if worktree.held_by_other_process?
|
|
144
|
+
return { action: :reap, reason: "directory missing (destroyed externally)", absorbed: false } unless worktree.exists?
|
|
145
|
+
|
|
146
|
+
if worktree.dirty?
|
|
147
|
+
absorbed = branch_absorbed_into_main?( branch: worktree.branch )
|
|
148
|
+
return { action: :reap, reason: "dirty worktree with content absorbed into main", absorbed: true, force: true } if absorbed
|
|
149
|
+
return { action: :skip, reason: "gh CLI not available for PR check", absorbed: false } unless gh_available?
|
|
150
|
+
|
|
151
|
+
tip_sha = worktree_branch_tip_sha( branch: worktree.branch )
|
|
152
|
+
return { action: :skip, reason: "cannot read branch tip SHA", absorbed: false } if tip_sha.nil?
|
|
153
|
+
|
|
154
|
+
merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
155
|
+
return { action: :reap, reason: "dirty worktree with merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: false, force: true } unless merged_pr.nil?
|
|
156
|
+
|
|
157
|
+
return { action: :skip, reason: "dirty worktree", absorbed: false }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
absorbed = branch_absorbed_into_main?( branch: worktree.branch )
|
|
161
|
+
return { action: :reap, reason: "content absorbed into main", absorbed: true } if absorbed
|
|
162
|
+
return { action: :skip, reason: "gh CLI not available for PR check", absorbed: false } unless gh_available?
|
|
163
|
+
|
|
164
|
+
tip_sha = worktree_branch_tip_sha( branch: worktree.branch )
|
|
165
|
+
return { action: :skip, reason: "cannot read branch tip SHA", absorbed: false } if tip_sha.nil?
|
|
166
|
+
|
|
167
|
+
merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
168
|
+
return { action: :reap, reason: "merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: false } unless merged_pr.nil?
|
|
169
|
+
return { action: :skip, reason: "open PR exists", absorbed: false } if branch_has_open_pr?( branch: worktree.branch )
|
|
170
|
+
|
|
171
|
+
abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
|
|
172
|
+
return { action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr.fetch( :url ) )}", absorbed: false } unless abandoned_pr.nil?
|
|
173
|
+
|
|
174
|
+
{ action: :skip, reason: "no evidence to reap", absorbed: false }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def worktree_branch_tip_sha( branch: )
|
|
178
|
+
git_capture!( "rev-parse", "--verify", branch ).strip
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def worktree_pull_request( branch: )
|
|
184
|
+
return { state: nil, number: nil, url: nil, error: nil } if branch.to_s.strip.empty?
|
|
185
|
+
return { state: nil, number: nil, url: nil, error: "gh unavailable" } unless gh_available?
|
|
186
|
+
|
|
187
|
+
owner, repo = repository_coordinates
|
|
188
|
+
stdout_text, stderr_text, success, = gh_run(
|
|
189
|
+
"api", "repos/#{owner}/#{repo}/pulls",
|
|
190
|
+
"--method", "GET",
|
|
191
|
+
"-f", "state=all",
|
|
192
|
+
"-f", "head=#{owner}:#{branch}",
|
|
193
|
+
"-f", "per_page=100"
|
|
194
|
+
)
|
|
195
|
+
unless success
|
|
196
|
+
error_text = gh_error_text(
|
|
197
|
+
stdout_text: stdout_text,
|
|
198
|
+
stderr_text: stderr_text,
|
|
199
|
+
fallback: "unable to read pull request for #{branch}"
|
|
200
|
+
)
|
|
201
|
+
return { state: nil, number: nil, url: nil, error: error_text }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
entries = Array( JSON.parse( stdout_text ) )
|
|
205
|
+
return { state: nil, number: nil, url: nil, error: nil } if entries.empty?
|
|
206
|
+
|
|
207
|
+
chosen = entries.find { |entry| normalise_rest_pull_request_state( entry: entry ) == "OPEN" } ||
|
|
208
|
+
entries.max_by { |entry| parse_time_or_nil( text: entry[ "updated_at" ] ) || Time.at( 0 ) }
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
state: normalise_rest_pull_request_state( entry: chosen ),
|
|
212
|
+
number: chosen[ "number" ],
|
|
213
|
+
url: chosen[ "html_url" ].to_s,
|
|
214
|
+
error: nil
|
|
215
|
+
}
|
|
216
|
+
rescue JSON::ParserError => exception
|
|
217
|
+
{ state: nil, number: nil, url: nil, error: "invalid gh JSON response (#{exception.message})" }
|
|
218
|
+
rescue StandardError => exception
|
|
219
|
+
{ state: nil, number: nil, url: nil, error: exception.message }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def print_worktree_list( entries: )
|
|
223
|
+
puts_line "Worktrees:"
|
|
224
|
+
return puts_line " none" if entries.empty?
|
|
225
|
+
|
|
226
|
+
entries.each do |entry|
|
|
227
|
+
label = entry.fetch( :main ) ? "#{entry.fetch( :name )} (main)" : entry.fetch( :name )
|
|
228
|
+
state = []
|
|
229
|
+
state << entry.fetch( :branch ) unless entry.fetch( :branch ).to_s.empty?
|
|
230
|
+
state << ( entry.fetch( :exists ) ? ( entry.fetch( :dirty ) ? "dirty" : "clean" ) : "missing" )
|
|
231
|
+
state << "held by current shell" if entry.fetch( :held_by_current_shell )
|
|
232
|
+
state << "held by another process" if entry.fetch( :held_by_other_process )
|
|
233
|
+
state << worktree_pull_request_text( pull_request: entry.fetch( :pull_request ) )
|
|
234
|
+
state << "absorbed into main" if entry.fetch( :absorbed_into_main )
|
|
235
|
+
|
|
236
|
+
recommendation = entry.fetch( :cleanup )
|
|
237
|
+
action = recommendation.fetch( :action ) == "reap" ? "reap" : "keep"
|
|
238
|
+
puts_line "- #{label}: #{state.join( ', ' )}"
|
|
239
|
+
puts_line " Recommendation: #{action} — #{recommendation.fetch( :reason )}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def worktree_pull_request_text( pull_request: )
|
|
244
|
+
return "PR unknown (#{pull_request.fetch( :error )})" unless pull_request.fetch( :error ).nil?
|
|
245
|
+
return "PR none" if pull_request.fetch( :number ).nil?
|
|
246
|
+
|
|
247
|
+
"PR ##{pull_request.fetch( :number )} #{pull_request.fetch( :state )}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def pr_short_ref( url )
|
|
251
|
+
return "PR" if url.nil? || url.empty?
|
|
252
|
+
|
|
253
|
+
match = url.match( /\/pull\/(\d+)$/ )
|
|
254
|
+
match ? "PR ##{match[ 1 ]}" : "PR"
|
|
255
|
+
end
|
|
90
256
|
end
|
|
91
257
|
|
|
92
258
|
include Local
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# Governed recovery path for baseline-red governance checks.
|
|
2
|
+
module Carson
|
|
3
|
+
class Runtime
|
|
4
|
+
module Recover
|
|
5
|
+
GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ hooks/ ].freeze
|
|
6
|
+
|
|
7
|
+
def recover!( check_name:, json_output: false )
|
|
8
|
+
result = {
|
|
9
|
+
command: "recover",
|
|
10
|
+
branch: current_branch,
|
|
11
|
+
check: check_name,
|
|
12
|
+
main_branch: config.main_branch
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if current_branch == config.main_branch
|
|
16
|
+
result[ :error ] = "cannot recover from #{config.main_branch}"
|
|
17
|
+
result[ :recovery ] = "carson worktree create <name>"
|
|
18
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if working_tree_dirty?
|
|
22
|
+
result[ :error ] = "working tree is dirty"
|
|
23
|
+
result[ :recovery ] = "commit or discard local changes, then rerun carson recover --check #{check_name.inspect}"
|
|
24
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless gh_available?
|
|
28
|
+
result[ :error ] = "gh CLI is required for carson recover"
|
|
29
|
+
result[ :recovery ] = "install and authenticate gh, then retry"
|
|
30
|
+
return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
delivery = ledger.active_delivery( repo_path: repository_record.path, branch_name: current_branch )
|
|
34
|
+
if delivery.nil?
|
|
35
|
+
result[ :error ] = "no active delivery found for #{current_branch}"
|
|
36
|
+
result[ :recovery ] = "carson deliver"
|
|
37
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
result[ :pr_number ] = delivery.pull_request_number
|
|
41
|
+
result[ :pr_url ] = delivery.pull_request_url
|
|
42
|
+
|
|
43
|
+
pull_request = recover_pull_request_details( number: delivery.pull_request_number )
|
|
44
|
+
result[ :pr_url ] = pull_request.fetch( :url )
|
|
45
|
+
|
|
46
|
+
if pull_request.fetch( :state ) != "OPEN"
|
|
47
|
+
result[ :error ] = "pull request ##{delivery.pull_request_number} is not open"
|
|
48
|
+
result[ :recovery ] = "carson status"
|
|
49
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if pull_request.fetch( :branch ) != current_branch
|
|
53
|
+
result[ :error ] = "pull request ##{delivery.pull_request_number} belongs to #{pull_request.fetch( :branch )}, not #{current_branch}"
|
|
54
|
+
result[ :recovery ] = "checkout #{pull_request.fetch( :branch )} or rerun carson deliver for #{current_branch}"
|
|
55
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if pull_request.fetch( :head_sha ) != current_head
|
|
59
|
+
result[ :error ] = "pull request ##{delivery.pull_request_number} head no longer matches local #{current_branch}"
|
|
60
|
+
result[ :recovery ] = "push the current branch with carson deliver, then retry"
|
|
61
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
relation = recovery_governance_surface_report( base_branch: pull_request.fetch( :base_branch ) )
|
|
65
|
+
if relation.fetch( :status ) == "error"
|
|
66
|
+
result[ :error ] = relation.fetch( :error )
|
|
67
|
+
result[ :recovery ] = "git diff --name-only #{pull_request.fetch( :base_branch )}...HEAD"
|
|
68
|
+
return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
69
|
+
end
|
|
70
|
+
result[ :changed_files ] = relation.fetch( :files )
|
|
71
|
+
|
|
72
|
+
unless relation.fetch( :related )
|
|
73
|
+
result[ :error ] = "branch does not touch the governance surface for #{check_name}"
|
|
74
|
+
result[ :recovery ] = "update the branch to repair .github/ or hooks/, then rerun carson recover --check #{check_name.inspect}"
|
|
75
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
baseline = default_branch_ci_baseline_report
|
|
79
|
+
result[ :baseline ] = {
|
|
80
|
+
default_branch: baseline.fetch( :default_branch, config.main_branch ),
|
|
81
|
+
head_sha: baseline[ :head_sha ],
|
|
82
|
+
status: baseline.fetch( :status ),
|
|
83
|
+
check_name: check_name
|
|
84
|
+
}
|
|
85
|
+
if baseline.fetch( :status ) == "skipped"
|
|
86
|
+
result[ :error ] = "unable to verify the default-branch baseline: #{baseline.fetch( :skip_reason )}"
|
|
87
|
+
result[ :recovery ] = "run carson audit after fixing GitHub access"
|
|
88
|
+
return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
baseline_entry = recovery_baseline_entry( baseline: baseline, check_name: check_name )
|
|
92
|
+
if baseline_entry.nil?
|
|
93
|
+
result[ :error ] = "#{check_name} is not red on #{baseline.fetch( :default_branch, config.main_branch )}"
|
|
94
|
+
result[ :recovery ] = "run carson audit to confirm the baseline check state"
|
|
95
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
review = check_pr_review(
|
|
99
|
+
number: delivery.pull_request_number,
|
|
100
|
+
branch: current_branch,
|
|
101
|
+
pr_url: delivery.pull_request_url
|
|
102
|
+
)
|
|
103
|
+
result[ :review ] = review
|
|
104
|
+
review_issue = recovery_review_issue( review: review, check_name: check_name )
|
|
105
|
+
unless review_issue.nil?
|
|
106
|
+
result[ :error ] = review_issue.fetch( :error )
|
|
107
|
+
result[ :recovery ] = review_issue.fetch( :recovery )
|
|
108
|
+
return recover_finish(
|
|
109
|
+
result: result,
|
|
110
|
+
exit_code: review_issue.fetch( :exit_code ),
|
|
111
|
+
json_output: json_output
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
checks = recover_required_pr_checks_report( number: delivery.pull_request_number )
|
|
116
|
+
result[ :checks ] = checks
|
|
117
|
+
if checks.fetch( :status ) == "error"
|
|
118
|
+
result[ :error ] = checks.fetch( :error )
|
|
119
|
+
result[ :recovery ] = "gh pr checks #{delivery.pull_request_number} --required"
|
|
120
|
+
return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
other_gate_issue = recovery_other_required_check_issue( checks: checks, check_name: check_name )
|
|
124
|
+
unless other_gate_issue.nil?
|
|
125
|
+
result[ :error ] = other_gate_issue.fetch( :error )
|
|
126
|
+
result[ :recovery ] = other_gate_issue.fetch( :recovery )
|
|
127
|
+
return recover_finish(
|
|
128
|
+
result: result,
|
|
129
|
+
exit_code: other_gate_issue.fetch( :exit_code ),
|
|
130
|
+
json_output: json_output
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
pr_state = pull_request_state( number: delivery.pull_request_number )
|
|
135
|
+
merge_issue = recover_mergeability_issue( pr_state: pr_state )
|
|
136
|
+
unless merge_issue.nil?
|
|
137
|
+
result[ :error ] = merge_issue
|
|
138
|
+
result[ :recovery ] = "resolve the merge conflict, then rerun carson recover --check #{check_name.inspect}"
|
|
139
|
+
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
prepared = ledger.update_delivery(
|
|
143
|
+
delivery: delivery,
|
|
144
|
+
status: "integrating",
|
|
145
|
+
summary: "recovering #{check_name} into #{config.main_branch}"
|
|
146
|
+
)
|
|
147
|
+
merge_exit = recover_merge_pr!(
|
|
148
|
+
number: prepared.pull_request_number,
|
|
149
|
+
owner: pull_request.fetch( :owner ),
|
|
150
|
+
repo: pull_request.fetch( :repo ),
|
|
151
|
+
head_sha: pull_request.fetch( :head_sha ),
|
|
152
|
+
result: result
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if merge_exit == EXIT_OK
|
|
156
|
+
event = ledger.send(
|
|
157
|
+
:record_recovery_event,
|
|
158
|
+
repository: repository_record,
|
|
159
|
+
branch_name: current_branch,
|
|
160
|
+
pr_number: prepared.pull_request_number,
|
|
161
|
+
pr_url: prepared.pull_request_url,
|
|
162
|
+
check_name: check_name,
|
|
163
|
+
default_branch: baseline.fetch( :default_branch, config.main_branch ),
|
|
164
|
+
default_branch_sha: baseline.fetch( :head_sha ),
|
|
165
|
+
pr_sha: pull_request.fetch( :head_sha ),
|
|
166
|
+
actor: recovery_actor,
|
|
167
|
+
merge_method: result.fetch( :merge_method ),
|
|
168
|
+
status: "integrated",
|
|
169
|
+
summary: "recovered #{check_name} into #{config.main_branch}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
integrated = ledger.update_delivery(
|
|
173
|
+
delivery: prepared,
|
|
174
|
+
status: "integrated",
|
|
175
|
+
integrated_at: Time.now.utc.iso8601,
|
|
176
|
+
summary: "recovered #{check_name} into #{config.main_branch}"
|
|
177
|
+
)
|
|
178
|
+
sync_after_merge!( remote: config.git_remote, main: config.main_branch, result: result )
|
|
179
|
+
result[ :delivery ] = delivery_payload( delivery: integrated )
|
|
180
|
+
result[ :recovery_event ] = event
|
|
181
|
+
result[ :summary ] = integrated.summary
|
|
182
|
+
result[ :next_step ] = deliver_next_step( delivery: integrated, result: result )
|
|
183
|
+
return recover_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
gated = ledger.update_delivery(
|
|
187
|
+
delivery: prepared,
|
|
188
|
+
status: "gated",
|
|
189
|
+
cause: "policy",
|
|
190
|
+
summary: result.fetch( :error, "recovery merge failed" )
|
|
191
|
+
)
|
|
192
|
+
result[ :delivery ] = delivery_payload( delivery: gated )
|
|
193
|
+
result[ :summary ] = gated.summary
|
|
194
|
+
result[ :next_step ] = "carson status"
|
|
195
|
+
recover_finish( result: result, exit_code: merge_exit, json_output: json_output )
|
|
196
|
+
rescue StandardError => exception
|
|
197
|
+
result[ :error ] = exception.message
|
|
198
|
+
result[ :recovery ] = "carson status"
|
|
199
|
+
recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def recover_pull_request_details( number: )
|
|
205
|
+
owner, repo = repository_coordinates
|
|
206
|
+
data = gh_json_payload!(
|
|
207
|
+
"api", "repos/#{owner}/#{repo}/pulls/#{number}",
|
|
208
|
+
"--method", "GET",
|
|
209
|
+
fallback: "unable to read pull request ##{number}"
|
|
210
|
+
)
|
|
211
|
+
{
|
|
212
|
+
number: data.fetch( "number" ),
|
|
213
|
+
url: data.fetch( "html_url" ).to_s,
|
|
214
|
+
state: data.fetch( "state" ).to_s.upcase,
|
|
215
|
+
branch: data.dig( "head", "ref" ).to_s,
|
|
216
|
+
head_sha: data.dig( "head", "sha" ).to_s,
|
|
217
|
+
base_branch: data.dig( "base", "ref" ).to_s,
|
|
218
|
+
base_sha: data.dig( "base", "sha" ).to_s,
|
|
219
|
+
owner: owner,
|
|
220
|
+
repo: repo
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def recovery_governance_surface_report( base_branch: )
|
|
225
|
+
stdout_text, stderr_text, success, = git_run( "diff", "--name-only", "#{base_branch}...HEAD" )
|
|
226
|
+
unless success
|
|
227
|
+
error_text = stderr_text.to_s.strip
|
|
228
|
+
error_text = "unable to inspect branch changes against #{base_branch}" if error_text.empty?
|
|
229
|
+
return { status: "error", error: error_text, files: [] }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
files = stdout_text.lines.map( &:strip ).reject( &:empty? )
|
|
233
|
+
related = files.any? do |path|
|
|
234
|
+
GOVERNANCE_SURFACE_PREFIXES.any? { |prefix| path.start_with?( prefix ) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
{ status: "ok", related: related, files: files }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def recovery_baseline_entry( baseline:, check_name: )
|
|
241
|
+
Array( baseline.fetch( :failing ) ).find do |entry|
|
|
242
|
+
entry.fetch( :name ).to_s == check_name
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def recovery_review_issue( review:, check_name: )
|
|
247
|
+
if review.fetch( :status, :pass ) == :error
|
|
248
|
+
return {
|
|
249
|
+
exit_code: EXIT_ERROR,
|
|
250
|
+
error: "unable to assess the review gate: #{review.fetch( :detail )}",
|
|
251
|
+
recovery: "carson review gate"
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if review.fetch( :review, :none ) == :changes_requested
|
|
256
|
+
return {
|
|
257
|
+
exit_code: EXIT_BLOCK,
|
|
258
|
+
error: "review changes are still requested",
|
|
259
|
+
recovery: "address the requested review changes, then rerun carson recover --check #{check_name.inspect}"
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if review.fetch( :review, :none ) == :review_required
|
|
264
|
+
return {
|
|
265
|
+
exit_code: EXIT_BLOCK,
|
|
266
|
+
error: "review approval is still required",
|
|
267
|
+
recovery: "run carson review gate, then rerun carson recover --check #{check_name.inspect}"
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return nil if review.fetch( :status, :pass ) == :pass
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
exit_code: EXIT_BLOCK,
|
|
275
|
+
error: review.fetch( :detail ).to_s,
|
|
276
|
+
recovery: "run carson review gate, then rerun carson recover --check #{check_name.inspect}"
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def recover_required_pr_checks_report( number: )
|
|
281
|
+
stdout_text, stderr_text, success, = gh_run(
|
|
282
|
+
"pr", "checks", number.to_s,
|
|
283
|
+
"--required",
|
|
284
|
+
"--json", "name,state,bucket,workflow,link"
|
|
285
|
+
)
|
|
286
|
+
unless success
|
|
287
|
+
error_text = gh_error_text(
|
|
288
|
+
stdout_text: stdout_text,
|
|
289
|
+
stderr_text: stderr_text,
|
|
290
|
+
fallback: "required checks unavailable"
|
|
291
|
+
)
|
|
292
|
+
return { status: "error", error: error_text, required_total: 0, failing: [], pending: [] }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
entries = JSON.parse( stdout_text )
|
|
296
|
+
failing = entries.select { |entry| check_entry_failing?( entry: entry ) }
|
|
297
|
+
pending = entries.select { |entry| entry[ "bucket" ].to_s == "pending" }
|
|
298
|
+
{
|
|
299
|
+
status: "ok",
|
|
300
|
+
required_total: entries.count,
|
|
301
|
+
failing: normalise_check_entries( entries: failing ),
|
|
302
|
+
pending: normalise_check_entries( entries: pending )
|
|
303
|
+
}
|
|
304
|
+
rescue JSON::ParserError => exception
|
|
305
|
+
{
|
|
306
|
+
status: "error",
|
|
307
|
+
error: "invalid gh JSON response (#{exception.message})",
|
|
308
|
+
required_total: 0,
|
|
309
|
+
failing: [],
|
|
310
|
+
pending: []
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def recovery_other_required_check_issue( checks:, check_name: )
|
|
315
|
+
other_failing = Array( checks.fetch( :failing ) ).reject { |entry| entry.fetch( :name ) == check_name }
|
|
316
|
+
other_pending = Array( checks.fetch( :pending ) ).reject { |entry| entry.fetch( :name ) == check_name }
|
|
317
|
+
return nil if other_failing.empty? && other_pending.empty?
|
|
318
|
+
|
|
319
|
+
names = ( other_failing + other_pending ).map { |entry| entry.fetch( :name ) }.uniq.sort
|
|
320
|
+
details = []
|
|
321
|
+
details << "#{other_failing.count} failing" unless other_failing.empty?
|
|
322
|
+
details << "#{other_pending.count} pending" unless other_pending.empty?
|
|
323
|
+
{
|
|
324
|
+
exit_code: EXIT_BLOCK,
|
|
325
|
+
error: "other required checks are still #{details.join( ' and ' )}: #{names.join( ', ' )}",
|
|
326
|
+
recovery: "fix the other required checks, then rerun carson recover --check #{check_name.inspect}"
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def recover_mergeability_issue( pr_state: )
|
|
331
|
+
return nil unless pr_state.is_a?( Hash )
|
|
332
|
+
|
|
333
|
+
mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
|
|
334
|
+
merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
|
|
335
|
+
return "pull request has merge conflicts" if mergeable == "CONFLICTING"
|
|
336
|
+
return "pull request has merge conflicts" if %w[DIRTY CONFLICTING].include?( merge_state )
|
|
337
|
+
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def recover_merge_pr!( number:, owner:, repo:, head_sha:, result: )
|
|
342
|
+
method = config.govern_merge_method
|
|
343
|
+
result[ :merge_method ] = method
|
|
344
|
+
|
|
345
|
+
stdout_text, stderr_text, success, = gh_run(
|
|
346
|
+
"api", "repos/#{owner}/#{repo}/pulls/#{number}/merge",
|
|
347
|
+
"--method", "PUT",
|
|
348
|
+
"-f", "sha=#{head_sha}",
|
|
349
|
+
"-f", "merge_method=#{method}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if success
|
|
353
|
+
payload = JSON.parse( stdout_text ) rescue {}
|
|
354
|
+
result[ :merge ] = {
|
|
355
|
+
status: "recovered",
|
|
356
|
+
summary: blank_to( value: payload[ "message" ], default: "merged via governed recovery" ),
|
|
357
|
+
method: method
|
|
358
|
+
}
|
|
359
|
+
return EXIT_OK
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
error_text = gh_error_text(
|
|
363
|
+
stdout_text: stdout_text,
|
|
364
|
+
stderr_text: stderr_text,
|
|
365
|
+
fallback: "recovery merge failed"
|
|
366
|
+
)
|
|
367
|
+
result[ :merge ] = {
|
|
368
|
+
status: "blocked",
|
|
369
|
+
summary: error_text,
|
|
370
|
+
recovery: "carson status",
|
|
371
|
+
method: method
|
|
372
|
+
}
|
|
373
|
+
result[ :error ] = error_text
|
|
374
|
+
result[ :recovery ] = "carson status"
|
|
375
|
+
EXIT_ERROR
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def recovery_actor
|
|
379
|
+
actor = ENV.fetch( "USER", ENV.fetch( "LOGNAME", "" ) ).to_s.strip
|
|
380
|
+
actor.empty? ? "unknown" : actor
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def recover_finish( result:, exit_code:, json_output: )
|
|
384
|
+
result[ :exit_code ] = exit_code
|
|
385
|
+
|
|
386
|
+
if json_output
|
|
387
|
+
output.puts JSON.pretty_generate( result )
|
|
388
|
+
else
|
|
389
|
+
print_recover_human( result: result )
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
exit_code
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def print_recover_human( result: )
|
|
396
|
+
if result[ :error ]
|
|
397
|
+
puts_line result.fetch( :error )
|
|
398
|
+
puts_line " → #{result.fetch( :recovery )}" if result[ :recovery ]
|
|
399
|
+
return
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
puts_line "Recovery: #{result.fetch( :branch )} → #{result.fetch( :main_branch )}"
|
|
403
|
+
puts_line "PR ##{result.fetch( :pr_number )} #{result.fetch( :pr_url )}"
|
|
404
|
+
puts_line "Bypassed baseline-red check #{result.fetch( :check ).inspect}."
|
|
405
|
+
puts_line "Merged into #{result.fetch( :main_branch )} with #{result.fetch( :merge_method )}."
|
|
406
|
+
if result[ :synced ] == false
|
|
407
|
+
puts_line "Local #{result.fetch( :main_branch )} sync failed — #{result.fetch( :sync_error )}."
|
|
408
|
+
elsif result[ :synced ]
|
|
409
|
+
puts_line "Synced local #{result.fetch( :main_branch )}."
|
|
410
|
+
end
|
|
411
|
+
puts_line "Recorded recovery audit for #{result.fetch( :check )}."
|
|
412
|
+
puts_line "Check back with #{result.fetch( :next_step )}" if result[ :next_step ]
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
include Recover
|
|
417
|
+
end
|
|
418
|
+
end
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -364,13 +364,13 @@ module Carson
|
|
|
364
364
|
|
|
365
365
|
# Automatically registers the repo for portfolio governance during onboard.
|
|
366
366
|
def auto_register_govern!
|
|
367
|
-
|
|
368
|
-
if config.govern_repos.
|
|
369
|
-
puts_verbose "govern_registration: already registered #{
|
|
367
|
+
canonical_root = realpath_safe( main_worktree_root )
|
|
368
|
+
if config.govern_repos.any? { |path| realpath_safe( path ) == canonical_root }
|
|
369
|
+
puts_verbose "govern_registration: already registered #{canonical_root}"
|
|
370
370
|
return
|
|
371
371
|
end
|
|
372
372
|
|
|
373
|
-
append_govern_repo!( repo_path:
|
|
373
|
+
append_govern_repo!( repo_path: canonical_root )
|
|
374
374
|
puts_line "Registered for portfolio governance."
|
|
375
375
|
end
|
|
376
376
|
|
|
@@ -395,7 +395,8 @@ module Carson
|
|
|
395
395
|
|
|
396
396
|
existing_data = load_existing_config( path: config_path )
|
|
397
397
|
repos = Array( existing_data.dig( "govern", "repos" ) )
|
|
398
|
-
|
|
398
|
+
target = realpath_safe( repo_path )
|
|
399
|
+
updated = repos.reject { |entry| realpath_safe( entry ) == target }
|
|
399
400
|
return if updated.length == repos.length
|
|
400
401
|
|
|
401
402
|
existing_data[ "govern" ] ||= {}
|
|
@@ -412,8 +413,11 @@ module Carson
|
|
|
412
413
|
existing_data = load_existing_config( path: config_path )
|
|
413
414
|
existing_data[ "govern" ] ||= {}
|
|
414
415
|
repos = Array( existing_data[ "govern" ][ "repos" ] )
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
canonical_repo_path = realpath_safe( repo_path )
|
|
417
|
+
unless repos.any? { |entry| realpath_safe( entry ) == canonical_repo_path }
|
|
418
|
+
repos << repo_path
|
|
419
|
+
end
|
|
420
|
+
existing_data[ "govern" ][ "repos" ] = repos
|
|
417
421
|
|
|
418
422
|
FileUtils.mkdir_p( File.dirname( config_path ) )
|
|
419
423
|
File.write( config_path, JSON.pretty_generate( existing_data ) )
|