carson 3.23.3 → 3.27.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 +26 -8
- data/MANUAL.md +51 -22
- data/README.md +9 -16
- data/RELEASE.md +25 -1
- data/VERSION +1 -1
- data/carson.gemspec +0 -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 +242 -222
- 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 +4 -22
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Close abandoned delivery work and clean up its branch/worktree when safe.
|
|
2
|
+
module Carson
|
|
3
|
+
class Runtime
|
|
4
|
+
module Abandon
|
|
5
|
+
def abandon!( target:, json_output: false )
|
|
6
|
+
result = { command: "abandon", target: target }
|
|
7
|
+
|
|
8
|
+
unless gh_available?
|
|
9
|
+
result[ :error ] = "gh CLI is required for carson abandon"
|
|
10
|
+
result[ :recovery ] = "install and authenticate gh, then retry"
|
|
11
|
+
return abandon_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
resolution = resolve_abandon_target( target: target )
|
|
15
|
+
if resolution.nil?
|
|
16
|
+
result[ :error ] = "no branch or pull request found for #{target}"
|
|
17
|
+
result[ :recovery ] = "use a PR number, PR URL, or existing branch name"
|
|
18
|
+
return abandon_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
branch = resolution.fetch( :branch )
|
|
22
|
+
pull_request = resolution.fetch( :pull_request )
|
|
23
|
+
worktree = resolution.fetch( :worktree )
|
|
24
|
+
|
|
25
|
+
result[ :branch ] = branch
|
|
26
|
+
result[ :pr_number ] = pull_request&.fetch( :number, nil )
|
|
27
|
+
result[ :pr_url ] = pull_request&.fetch( :url, nil )
|
|
28
|
+
result[ :worktree_path ] = worktree&.path
|
|
29
|
+
|
|
30
|
+
preflight = abandon_preflight_issue( branch: branch, worktree: worktree )
|
|
31
|
+
unless preflight.nil?
|
|
32
|
+
result[ :error ] = preflight.fetch( :error )
|
|
33
|
+
result[ :recovery ] = preflight.fetch( :recovery )
|
|
34
|
+
return abandon_finish( result: result, exit_code: preflight.fetch( :exit_code ), json_output: json_output )
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if pull_request&.fetch( :state ) == "OPEN"
|
|
38
|
+
close_exit = close_pull_request!( number: pull_request.fetch( :number ), result: result )
|
|
39
|
+
return abandon_finish( result: result, exit_code: close_exit, json_output: json_output ) unless close_exit == EXIT_OK
|
|
40
|
+
result[ :pull_request_closed ] = true
|
|
41
|
+
else
|
|
42
|
+
result[ :pull_request_closed ] = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if worktree
|
|
46
|
+
remove_exit = with_captured_output do
|
|
47
|
+
worktree_remove!( worktree_path: worktree.path, json_output: false )
|
|
48
|
+
end
|
|
49
|
+
unless remove_exit == EXIT_OK
|
|
50
|
+
result[ :error ] = "worktree cleanup failed for #{worktree.path}"
|
|
51
|
+
result[ :recovery ] = "run carson worktree remove #{File.basename( worktree.path )}"
|
|
52
|
+
return abandon_finish( result: result, exit_code: remove_exit, json_output: json_output )
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result[ :worktree_removed ] = true
|
|
56
|
+
result[ :branch_deleted ] = !local_branch_exists?( branch: branch )
|
|
57
|
+
result[ :remote_deleted ] = !remote_branch_exists?( branch: branch )
|
|
58
|
+
else
|
|
59
|
+
branch_deleted, remote_deleted = delete_branch_refs!( branch: branch )
|
|
60
|
+
result[ :worktree_removed ] = false
|
|
61
|
+
result[ :branch_deleted ] = branch_deleted
|
|
62
|
+
result[ :remote_deleted ] = remote_deleted
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
mark_delivery_abandoned!( branch: branch )
|
|
66
|
+
result[ :summary ] = "abandoned delivery cleaned up"
|
|
67
|
+
abandon_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def resolve_abandon_target( target: )
|
|
73
|
+
pull_request = pull_request_from_target( target: target )
|
|
74
|
+
branch = pull_request&.fetch( :branch ) || target.to_s.strip
|
|
75
|
+
branch = branch_from_pull_request_url( target: target ) if branch.empty?
|
|
76
|
+
return nil if branch.to_s.strip.empty?
|
|
77
|
+
|
|
78
|
+
worktree = worktree_list.find { |entry| entry.branch == branch && entry.path != main_worktree_root }
|
|
79
|
+
branch_exists = local_branch_exists?( branch: branch ) || remote_branch_exists?( branch: branch ) || !pull_request.nil?
|
|
80
|
+
return nil unless branch_exists
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
branch: branch,
|
|
84
|
+
pull_request: pull_request,
|
|
85
|
+
worktree: worktree
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def pull_request_from_target( target: )
|
|
90
|
+
number = pull_request_number_from_target( target: target )
|
|
91
|
+
return pull_request_details_for_number( number: number ) unless number.nil?
|
|
92
|
+
|
|
93
|
+
pull_request = worktree_pull_request( branch: target )
|
|
94
|
+
return nil if pull_request.fetch( :number ).nil?
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
number: pull_request.fetch( :number ),
|
|
98
|
+
url: pull_request.fetch( :url ),
|
|
99
|
+
state: pull_request.fetch( :state ),
|
|
100
|
+
branch: target
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def pull_request_number_from_target( target: )
|
|
105
|
+
text = target.to_s.strip
|
|
106
|
+
return Integer( text ) if text.match?( /\A\d+\z/ )
|
|
107
|
+
|
|
108
|
+
match = text.match( %r{/pull/(\d+)} )
|
|
109
|
+
return nil if match.nil?
|
|
110
|
+
|
|
111
|
+
Integer( match[ 1 ] )
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def pull_request_details_for_number( number: )
|
|
117
|
+
stdout_text, stderr_text, success, = gh_run(
|
|
118
|
+
"pr", "view", number.to_s,
|
|
119
|
+
"--json", "number,url,state,headRefName"
|
|
120
|
+
)
|
|
121
|
+
return nil unless success
|
|
122
|
+
|
|
123
|
+
data = JSON.parse( stdout_text )
|
|
124
|
+
{
|
|
125
|
+
number: data.fetch( "number" ),
|
|
126
|
+
url: data.fetch( "url" ).to_s,
|
|
127
|
+
state: data.fetch( "state" ).to_s,
|
|
128
|
+
branch: data.fetch( "headRefName" ).to_s
|
|
129
|
+
}
|
|
130
|
+
rescue JSON::ParserError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def abandon_preflight_issue( branch:, worktree: )
|
|
135
|
+
if config.protected_branches.include?( branch )
|
|
136
|
+
return { exit_code: EXIT_BLOCK, error: "cannot abandon protected branch #{branch}", recovery: "choose a feature branch instead" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if worktree
|
|
140
|
+
check = Worktree.remove_check( path: worktree.path, runtime: self, force: false )
|
|
141
|
+
return nil if check.fetch( :status ) == :ok
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
exit_code: check.fetch( :exit_code ),
|
|
145
|
+
error: check.fetch( :error ),
|
|
146
|
+
recovery: check.fetch( :recovery )
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
return { exit_code: EXIT_BLOCK, error: "current branch is #{branch}", recovery: "switch to main or a different branch, then retry" } if current_branch == branch
|
|
151
|
+
return nil unless local_branch_exists?( branch: branch )
|
|
152
|
+
|
|
153
|
+
unpushed = Worktree.branch_unpushed_issue( branch: branch, worktree_path: repo_root, runtime: self )
|
|
154
|
+
return nil if unpushed.nil?
|
|
155
|
+
|
|
156
|
+
{ exit_code: EXIT_BLOCK, error: unpushed.fetch( :error ), recovery: unpushed.fetch( :recovery ) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def close_pull_request!( number:, result: )
|
|
160
|
+
_, stderr_text, success, = gh_run( "pr", "close", number.to_s )
|
|
161
|
+
return EXIT_OK if success
|
|
162
|
+
|
|
163
|
+
result[ :error ] = gh_error_text( stdout_text: "", stderr_text: stderr_text, fallback: "unable to close pull request ##{number}" )
|
|
164
|
+
result[ :recovery ] = "gh pr close #{number}"
|
|
165
|
+
EXIT_ERROR
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def delete_branch_refs!( branch: )
|
|
169
|
+
branch_deleted = false
|
|
170
|
+
remote_deleted = false
|
|
171
|
+
|
|
172
|
+
if local_branch_exists?( branch: branch ) && !config.protected_branches.include?( branch )
|
|
173
|
+
_, _, success, = git_run( "branch", "-D", branch )
|
|
174
|
+
branch_deleted = success
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if remote_branch_exists?( branch: branch ) && !config.protected_branches.include?( branch )
|
|
178
|
+
_, _, success, = git_run( "push", config.git_remote, "--delete", branch )
|
|
179
|
+
remote_deleted = success
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
[ branch_deleted, remote_deleted ]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def local_branch_exists?( branch: )
|
|
186
|
+
_, _, success, = git_run( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
|
|
187
|
+
success
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def remote_branch_exists?( branch: )
|
|
191
|
+
stdout_text, _, success, = git_run( "ls-remote", "--heads", config.git_remote, branch )
|
|
192
|
+
return false unless success
|
|
193
|
+
|
|
194
|
+
!stdout_text.to_s.strip.empty?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def mark_delivery_abandoned!( branch: )
|
|
198
|
+
delivery = ledger.active_delivery( repo_path: repository_record.path, branch_name: branch )
|
|
199
|
+
return if delivery.nil?
|
|
200
|
+
|
|
201
|
+
ledger.update_delivery(
|
|
202
|
+
delivery: delivery,
|
|
203
|
+
status: "failed",
|
|
204
|
+
cause: "abandoned",
|
|
205
|
+
summary: "abandoned by carson abandon"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def branch_from_pull_request_url( target: )
|
|
210
|
+
number = pull_request_number_from_target( target: target )
|
|
211
|
+
return "" if number.nil?
|
|
212
|
+
|
|
213
|
+
pull_request_details_for_number( number: number )&.fetch( :branch, "" ).to_s
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def abandon_finish( result:, exit_code:, json_output: )
|
|
217
|
+
result[ :exit_code ] = exit_code
|
|
218
|
+
|
|
219
|
+
if json_output
|
|
220
|
+
output.puts JSON.pretty_generate( result )
|
|
221
|
+
else
|
|
222
|
+
if result[ :error ]
|
|
223
|
+
puts_line result.fetch( :error )
|
|
224
|
+
puts_line " → #{result.fetch( :recovery )}" if result[ :recovery ]
|
|
225
|
+
else
|
|
226
|
+
pr_ref = result[ :pr_number ] ? "PR ##{result[ :pr_number ]}" : "no PR"
|
|
227
|
+
puts_line "Abandoned #{result.fetch( :branch )} (#{pr_ref})."
|
|
228
|
+
puts_line " #{result.fetch( :summary )}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
exit_code
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
include Abandon
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Carson
|
|
|
38
38
|
hooks_status = hooks_ok ? "ok" : "mismatch"
|
|
39
39
|
unless hooks_ok
|
|
40
40
|
audit_state = "block"
|
|
41
|
-
audit_concise_problems << "Hooks
|
|
41
|
+
audit_concise_problems << "Hooks don't match — run carson refresh."
|
|
42
42
|
end
|
|
43
43
|
puts_verbose ""
|
|
44
44
|
puts_verbose "[Main Sync Status]"
|
|
@@ -158,7 +158,7 @@ module Carson
|
|
|
158
158
|
puts_verbose( audit_state == "block" ? "ACTION: local policy block must be resolved before commit/push." : "ACTION: no local hard block detected." )
|
|
159
159
|
unless verbose?
|
|
160
160
|
audit_concise_problems.each { |problem| puts_line problem }
|
|
161
|
-
puts_line
|
|
161
|
+
puts_line format_audit_state( audit_state )
|
|
162
162
|
end
|
|
163
163
|
end
|
|
164
164
|
exit_code
|
|
@@ -219,6 +219,16 @@ module Carson
|
|
|
219
219
|
# rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
|
|
220
220
|
private
|
|
221
221
|
# rubocop:enable Layout/AccessModifierIndentation
|
|
222
|
+
|
|
223
|
+
def format_audit_state( state )
|
|
224
|
+
case state
|
|
225
|
+
when "ok" then "Audit passed."
|
|
226
|
+
when "block" then "Audit blocked."
|
|
227
|
+
when "attention" then "Audit: needs attention."
|
|
228
|
+
else "Audit: #{state}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
222
232
|
def audit_working_tree_report
|
|
223
233
|
dirty_reason = dirty_worktree_reason
|
|
224
234
|
return { dirty: false, context: nil, status: "ok" } if dirty_reason.nil?
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
# Branch delivery lifecycle — push, create/update PR, and
|
|
2
|
-
# `carson deliver`
|
|
1
|
+
# Branch delivery lifecycle — push, create/update PR, wait for merge readiness, and integrate when clear.
|
|
2
|
+
# `carson deliver` owns the synchronous happy path for single-branch delivery.
|
|
3
3
|
module Carson
|
|
4
4
|
class Runtime
|
|
5
5
|
module Deliver
|
|
6
6
|
# Entry point for `carson deliver`.
|
|
7
|
-
# Pushes the current branch, ensures a PR exists, records delivery state,
|
|
7
|
+
# Pushes the current branch, ensures a PR exists, records delivery state,
|
|
8
|
+
# waits for merge readiness, and integrates when the path is clear.
|
|
8
9
|
# When --commit is supplied, Carson creates one all-dirty agent-authored commit first.
|
|
9
10
|
def deliver!( title: nil, body_file: nil, commit_message: nil, json_output: false )
|
|
10
11
|
branch_name = current_branch
|
|
@@ -68,7 +69,6 @@ module Carson
|
|
|
68
69
|
branch_name: branch.name,
|
|
69
70
|
head: branch.head || current_head,
|
|
70
71
|
worktree_path: branch.worktree || repo_root,
|
|
71
|
-
authority: config.govern_authority,
|
|
72
72
|
pr_number: pr_number,
|
|
73
73
|
pr_url: pr_url,
|
|
74
74
|
status: "preparing",
|
|
@@ -76,13 +76,22 @@ module Carson
|
|
|
76
76
|
cause: nil
|
|
77
77
|
)
|
|
78
78
|
delivery = assess_delivery!( delivery: delivery, branch_name: branch.name )
|
|
79
|
+
delivery = wait_for_delivery_readiness!( delivery: delivery, branch_name: branch.name )
|
|
80
|
+
delivery = integrate_delivery_now!(
|
|
81
|
+
delivery: delivery,
|
|
82
|
+
branch_name: branch.name,
|
|
83
|
+
remote: remote_name,
|
|
84
|
+
main: main_branch,
|
|
85
|
+
result: result
|
|
86
|
+
) if delivery.ready?
|
|
79
87
|
|
|
80
88
|
result[ :pr_number ] = pr_number
|
|
81
89
|
result[ :pr_url ] = pr_url
|
|
82
|
-
result[ :ci ] = check_pr_ci( number: pr_number ).to_s
|
|
90
|
+
result[ :ci ] = delivery.integrated? ? "pass" : check_pr_ci( number: pr_number ).to_s
|
|
83
91
|
result[ :delivery ] = delivery_payload( delivery: delivery )
|
|
92
|
+
result[ :main_branch ] = main_branch
|
|
84
93
|
result[ :summary ] = delivery.summary
|
|
85
|
-
result[ :next_step ] =
|
|
94
|
+
result[ :next_step ] = deliver_next_step( delivery: delivery, result: result )
|
|
86
95
|
|
|
87
96
|
deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
88
97
|
end
|
|
@@ -194,7 +203,8 @@ module Carson
|
|
|
194
203
|
def assess_delivery!( delivery:, branch_name: )
|
|
195
204
|
review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
|
|
196
205
|
ci = check_pr_ci( number: delivery.pull_request_number )
|
|
197
|
-
|
|
206
|
+
pr_state = pull_request_state( number: delivery.pull_request_number )
|
|
207
|
+
status, cause, summary = delivery_assessment( ci: ci, review: review, pr_state: pr_state )
|
|
198
208
|
|
|
199
209
|
ledger.update_delivery(
|
|
200
210
|
delivery: delivery,
|
|
@@ -207,7 +217,96 @@ module Carson
|
|
|
207
217
|
)
|
|
208
218
|
end
|
|
209
219
|
|
|
210
|
-
def
|
|
220
|
+
def wait_for_delivery_readiness!( delivery:, branch_name: )
|
|
221
|
+
return delivery unless delivery_gate_waitable?( delivery: delivery )
|
|
222
|
+
return delivery unless config.govern_check_wait.positive?
|
|
223
|
+
|
|
224
|
+
deadline = Process.clock_gettime( Process::CLOCK_MONOTONIC ) + config.govern_check_wait
|
|
225
|
+
interval = deliver_ci_poll_seconds
|
|
226
|
+
puts_verbose "waiting up to #{config.govern_check_wait}s for delivery gates to settle"
|
|
227
|
+
|
|
228
|
+
loop do
|
|
229
|
+
remaining = deadline - Process.clock_gettime( Process::CLOCK_MONOTONIC )
|
|
230
|
+
break if remaining <= 0
|
|
231
|
+
|
|
232
|
+
sleep [ interval, remaining ].min
|
|
233
|
+
delivery = assess_delivery!( delivery: delivery, branch_name: branch_name )
|
|
234
|
+
break unless delivery_gate_waitable?( delivery: delivery )
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
delivery
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def delivery_gate_waitable?( delivery: )
|
|
241
|
+
return false unless delivery.status == "gated"
|
|
242
|
+
return true if delivery.cause == "ci"
|
|
243
|
+
|
|
244
|
+
delivery.cause == "review" && delivery.summary == "waiting for review"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def deliver_ci_poll_seconds
|
|
248
|
+
# Reuse the review poll interval for CI/review delivery polling.
|
|
249
|
+
# The config key predates the synchronous deliver loop.
|
|
250
|
+
seconds = config.review_poll_seconds.to_i
|
|
251
|
+
seconds.positive? ? seconds : 5
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def integrate_delivery_now!( delivery:, branch_name:, remote:, main:, result: )
|
|
255
|
+
pr_state = pull_request_state( number: delivery.pull_request_number )
|
|
256
|
+
if pr_state && pr_state[ "state" ] == "MERGED"
|
|
257
|
+
integrated = ledger.update_delivery(
|
|
258
|
+
delivery: delivery,
|
|
259
|
+
status: "integrated",
|
|
260
|
+
integrated_at: Time.now.utc.iso8601,
|
|
261
|
+
summary: "integrated into #{main}"
|
|
262
|
+
)
|
|
263
|
+
sync_after_merge!( remote: remote, main: main, result: result )
|
|
264
|
+
return integrated
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if pr_state && pr_state[ "state" ] == "CLOSED"
|
|
268
|
+
return ledger.update_delivery(
|
|
269
|
+
delivery: delivery,
|
|
270
|
+
status: "failed",
|
|
271
|
+
cause: "policy",
|
|
272
|
+
summary: "pull request closed without integration"
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
prepared = ledger.update_delivery(
|
|
277
|
+
delivery: delivery,
|
|
278
|
+
status: "integrating",
|
|
279
|
+
summary: "integrating into #{main}"
|
|
280
|
+
)
|
|
281
|
+
merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
|
|
282
|
+
if merge_exit == EXIT_OK
|
|
283
|
+
integrated = ledger.update_delivery(
|
|
284
|
+
delivery: prepared,
|
|
285
|
+
status: "integrated",
|
|
286
|
+
integrated_at: Time.now.utc.iso8601,
|
|
287
|
+
summary: "integrated into #{main}"
|
|
288
|
+
)
|
|
289
|
+
sync_after_merge!( remote: remote, main: main, result: result )
|
|
290
|
+
return integrated
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
merge_error = result.delete( :error )
|
|
294
|
+
merge_recovery = result.delete( :recovery )
|
|
295
|
+
result[ :merge ] = {
|
|
296
|
+
status: "blocked",
|
|
297
|
+
summary: merge_error || "merge failed",
|
|
298
|
+
recovery: merge_recovery,
|
|
299
|
+
method: result[ :merge_method ]
|
|
300
|
+
}
|
|
301
|
+
ledger.update_delivery(
|
|
302
|
+
delivery: prepared,
|
|
303
|
+
status: "gated",
|
|
304
|
+
cause: "policy",
|
|
305
|
+
summary: result.dig( :merge, :summary )
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def delivery_assessment( ci:, review:, pr_state: )
|
|
211
310
|
return [ "gated", "ci", "waiting for CI checks" ] if ci == :pending
|
|
212
311
|
return [ "gated", "ci", "CI checks are failing" ] if ci == :fail
|
|
213
312
|
return [ "gated", "review", "review changes requested" ] if review.fetch( :review, :none ) == :changes_requested
|
|
@@ -215,12 +314,28 @@ module Carson
|
|
|
215
314
|
return [ "gated", "review", review.fetch( :detail ).to_s ] if review.fetch( :status, :pass ) == :fail
|
|
216
315
|
return [ "gated", "policy", "unable to assess review gate: #{review.fetch( :detail )}" ] if review.fetch( :status, :pass ) == :error
|
|
217
316
|
|
|
317
|
+
merge_result = mergeability_assessment( pr_state: pr_state )
|
|
318
|
+
return merge_result if merge_result
|
|
319
|
+
|
|
218
320
|
[ "queued", nil, "ready to integrate into #{config.main_branch}" ]
|
|
219
321
|
end
|
|
220
322
|
|
|
323
|
+
def mergeability_assessment( pr_state: )
|
|
324
|
+
return nil unless pr_state.is_a?( Hash )
|
|
325
|
+
|
|
326
|
+
mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
|
|
327
|
+
merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
|
|
328
|
+
|
|
329
|
+
return [ "gated", "merge", "pull request has merge conflicts" ] if mergeable == "CONFLICTING" || merge_state == "DIRTY" || merge_state == "CONFLICTING"
|
|
330
|
+
return [ "gated", "merge", "merge is blocked by repository policy" ] if merge_state == "BLOCKED"
|
|
331
|
+
return [ "queued", nil, "ready to integrate into #{config.main_branch} (branch is behind base but still mergeable)" ] if merge_state == "BEHIND"
|
|
332
|
+
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
|
|
221
336
|
def delivery_payload( delivery: )
|
|
222
337
|
{
|
|
223
|
-
|
|
338
|
+
key: delivery.key,
|
|
224
339
|
status: delivery.status,
|
|
225
340
|
head: delivery.head,
|
|
226
341
|
worktree_path: delivery.worktree_path,
|
|
@@ -229,6 +344,14 @@ module Carson
|
|
|
229
344
|
}
|
|
230
345
|
end
|
|
231
346
|
|
|
347
|
+
def deliver_next_step( delivery:, result: )
|
|
348
|
+
return "carson sync" if delivery.integrated? && result[ :synced ] == false
|
|
349
|
+
return "carson housekeep" if delivery.integrated?
|
|
350
|
+
return "carson status" if delivery.blocked?
|
|
351
|
+
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
|
|
232
355
|
# Outputs the final result — JSON or human-readable — and returns exit code.
|
|
233
356
|
def deliver_finish( result:, exit_code:, json_output: )
|
|
234
357
|
result[ :exit_code ] = exit_code
|
|
@@ -250,15 +373,39 @@ module Carson
|
|
|
250
373
|
return
|
|
251
374
|
end
|
|
252
375
|
|
|
376
|
+
if result[ :delivery ]
|
|
377
|
+
branch = result[ :branch ]
|
|
378
|
+
main = result[ :main_branch ] || "main"
|
|
379
|
+
puts_line "Delivery: #{branch} → #{main}"
|
|
380
|
+
end
|
|
253
381
|
if result[ :commit ]
|
|
254
|
-
puts_line "
|
|
382
|
+
puts_line "Committed: #{result.dig( :commit, :summary )}"
|
|
255
383
|
end
|
|
256
|
-
puts_line "PR
|
|
384
|
+
puts_line "PR ##{result[ :pr_number ]} #{result[ :pr_url ]}" if result[ :pr_number ]
|
|
257
385
|
if result[ :delivery ]
|
|
258
|
-
|
|
386
|
+
status = result.dig( :delivery, :status )
|
|
387
|
+
summary = result[ :summary ]
|
|
388
|
+
if status == "integrated"
|
|
389
|
+
if result[ :merge_method ]
|
|
390
|
+
puts_line "Merged into #{main} with #{result[ :merge_method ]}."
|
|
391
|
+
else
|
|
392
|
+
puts_line "Merged into #{main}."
|
|
393
|
+
end
|
|
394
|
+
if result[ :synced ] == false
|
|
395
|
+
puts_line "Local #{main} sync failed — #{result[ :sync_error ]}."
|
|
396
|
+
elsif result[ :synced ]
|
|
397
|
+
puts_line "Synced local #{main}."
|
|
398
|
+
end
|
|
399
|
+
elsif status == "gated"
|
|
400
|
+
puts_line "Held at gate — #{summary}."
|
|
401
|
+
puts_line " → #{result.dig( :merge, :recovery )}" if result.dig( :merge, :recovery )
|
|
402
|
+
elsif status == "failed"
|
|
403
|
+
puts_line "Delivery failed — #{summary}."
|
|
404
|
+
else
|
|
405
|
+
puts_line "All clear — #{summary}."
|
|
406
|
+
end
|
|
259
407
|
end
|
|
260
|
-
puts_line "
|
|
261
|
-
puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
|
|
408
|
+
puts_line "Check back with #{result[ :next_step ]}" if result[ :next_step ]
|
|
262
409
|
end
|
|
263
410
|
|
|
264
411
|
# Pushes the branch to the remote with tracking.
|
|
@@ -396,7 +543,7 @@ module Carson
|
|
|
396
543
|
def pull_request_state( number: )
|
|
397
544
|
stdout, _, success, = gh_run(
|
|
398
545
|
"pr", "view", number.to_s,
|
|
399
|
-
"--json", "number,state,isDraft,url"
|
|
546
|
+
"--json", "number,state,isDraft,url,mergeStateStatus,mergeable"
|
|
400
547
|
)
|
|
401
548
|
return nil unless success
|
|
402
549
|
|
|
@@ -16,10 +16,9 @@ module Carson
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def govern_cycle!( dry_run:, json_output: )
|
|
19
|
-
print_header "Carson Govern"
|
|
20
19
|
repositories = governed_repo_paths
|
|
21
20
|
repositories = [ repository_record.path ] if repositories.empty?
|
|
22
|
-
|
|
21
|
+
print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
|
|
23
22
|
|
|
24
23
|
report = {
|
|
25
24
|
cycle_at: Time.now.utc.iso8601,
|
|
@@ -65,13 +64,12 @@ module Carson
|
|
|
65
64
|
|
|
66
65
|
def govern_repo!( repo_path:, dry_run: )
|
|
67
66
|
scoped_runtime = repo_runtime_for( repo_path: repo_path )
|
|
68
|
-
repository = Repository.new( path: repo_path,
|
|
67
|
+
repository = Repository.new( path: repo_path, runtime: scoped_runtime )
|
|
69
68
|
deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
|
|
70
69
|
|
|
71
70
|
repo_report = {
|
|
72
71
|
repository: repository.name,
|
|
73
72
|
path: repo_path,
|
|
74
|
-
authority: repository.authority,
|
|
75
73
|
deliveries: [],
|
|
76
74
|
error: nil
|
|
77
75
|
}
|
|
@@ -84,7 +82,7 @@ module Carson
|
|
|
84
82
|
puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}"
|
|
85
83
|
|
|
86
84
|
reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
|
|
87
|
-
|
|
85
|
+
next_to_integrate = reconciled.find( &:ready? )&.key
|
|
88
86
|
|
|
89
87
|
reconciled.each do |delivery|
|
|
90
88
|
delivery_report = scoped_runtime.send(
|
|
@@ -92,7 +90,7 @@ module Carson
|
|
|
92
90
|
delivery: delivery,
|
|
93
91
|
repo_path: repo_path,
|
|
94
92
|
dry_run: dry_run,
|
|
95
|
-
|
|
93
|
+
next_to_integrate: next_to_integrate
|
|
96
94
|
)
|
|
97
95
|
repo_report[ :deliveries ] << delivery_report
|
|
98
96
|
end
|
|
@@ -140,9 +138,9 @@ module Carson
|
|
|
140
138
|
assess_delivery!( delivery: delivery, branch_name: delivery.branch )
|
|
141
139
|
end
|
|
142
140
|
|
|
143
|
-
def decide_delivery_action( delivery:, repo_path:, dry_run:,
|
|
141
|
+
def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
|
|
144
142
|
report = {
|
|
145
|
-
|
|
143
|
+
key: delivery.key,
|
|
146
144
|
branch: delivery.branch,
|
|
147
145
|
status: delivery.status,
|
|
148
146
|
summary: delivery.summary,
|
|
@@ -154,13 +152,18 @@ module Carson
|
|
|
154
152
|
return report
|
|
155
153
|
end
|
|
156
154
|
|
|
157
|
-
if delivery.ready? && delivery.
|
|
155
|
+
if delivery.ready? && delivery.key == next_to_integrate
|
|
158
156
|
report[ :action ] = dry_run ? "would_integrate" : "integrate"
|
|
159
157
|
report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
|
|
160
158
|
return report
|
|
161
159
|
end
|
|
162
160
|
|
|
163
161
|
if delivery.blocked?
|
|
162
|
+
if merge_blocked_delivery?( delivery: delivery )
|
|
163
|
+
report[ :action ] = dry_run ? "would_hold" : "hold"
|
|
164
|
+
return report
|
|
165
|
+
end
|
|
166
|
+
|
|
164
167
|
if delivery.revision_count >= 3
|
|
165
168
|
report[ :action ] = dry_run ? "would_escalate" : "escalate"
|
|
166
169
|
report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
|
|
@@ -244,8 +247,7 @@ module Carson
|
|
|
244
247
|
updated = ledger.update_delivery(
|
|
245
248
|
delivery: delivery,
|
|
246
249
|
status: "gated",
|
|
247
|
-
summary: "revision #{revision.number} completed — waiting for reassessment"
|
|
248
|
-
revision_count: revision.number
|
|
250
|
+
summary: "revision #{revision.number} completed — waiting for reassessment"
|
|
249
251
|
)
|
|
250
252
|
return reconcile_delivery!( delivery: updated )
|
|
251
253
|
end
|
|
@@ -256,8 +258,7 @@ module Carson
|
|
|
256
258
|
ledger.update_delivery(
|
|
257
259
|
delivery: delivery,
|
|
258
260
|
status: "gated",
|
|
259
|
-
summary: "revision #{revision.number} failed: #{result.summary}"
|
|
260
|
-
revision_count: revision.number
|
|
261
|
+
summary: "revision #{revision.number} failed: #{result.summary}"
|
|
261
262
|
)
|
|
262
263
|
end
|
|
263
264
|
end
|
|
@@ -287,10 +288,13 @@ module Carson
|
|
|
287
288
|
end
|
|
288
289
|
end
|
|
289
290
|
|
|
291
|
+
def merge_blocked_delivery?( delivery: )
|
|
292
|
+
delivery.cause == "merge"
|
|
293
|
+
end
|
|
294
|
+
|
|
290
295
|
def housekeep_repo!( repo_path: )
|
|
291
296
|
scoped_runtime = repo_runtime_for( repo_path: repo_path )
|
|
292
|
-
|
|
293
|
-
scoped_runtime.prune! if sync_status == EXIT_OK
|
|
297
|
+
scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
|
|
294
298
|
end
|
|
295
299
|
|
|
296
300
|
def select_agent_provider
|
|
@@ -394,7 +398,7 @@ module Carson
|
|
|
394
398
|
end
|
|
395
399
|
|
|
396
400
|
def prior_attempt( delivery: )
|
|
397
|
-
revision =
|
|
401
|
+
revision = delivery.revisions.last
|
|
398
402
|
return nil unless revision&.failed?
|
|
399
403
|
{ summary: revision.summary.to_s, dispatched_at: revision.started_at.to_s }
|
|
400
404
|
end
|
|
@@ -429,17 +433,40 @@ module Carson
|
|
|
429
433
|
next
|
|
430
434
|
end
|
|
431
435
|
|
|
432
|
-
if repo_report[ :deliveries ].empty?
|
|
433
|
-
puts_line "#{repo_report[ :repository ]}: no active deliveries"
|
|
434
|
-
next
|
|
435
|
-
end
|
|
436
|
+
next if repo_report[ :deliveries ].empty?
|
|
436
437
|
|
|
437
438
|
repo_report[ :deliveries ].each do |delivery|
|
|
438
|
-
|
|
439
|
+
action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ] )
|
|
440
|
+
puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
|
|
439
441
|
puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
|
|
440
442
|
end
|
|
441
443
|
end
|
|
442
444
|
end
|
|
445
|
+
|
|
446
|
+
def format_govern_action( status:, action: )
|
|
447
|
+
case action
|
|
448
|
+
when "integrate"
|
|
449
|
+
format_govern_integration_outcome( status: status )
|
|
450
|
+
when "would_integrate" then "ready to integrate (dry run)"
|
|
451
|
+
when "hold" then "held at gate"
|
|
452
|
+
when "would_hold" then "would hold at gate (dry run)"
|
|
453
|
+
when "revise" then "revision dispatched"
|
|
454
|
+
when "would_revise" then "would revise (dry run)"
|
|
455
|
+
when "escalate" then "escalated"
|
|
456
|
+
when "would_escalate" then "would escalate (dry run)"
|
|
457
|
+
else status
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def format_govern_integration_outcome( status: )
|
|
462
|
+
case status
|
|
463
|
+
when "integrated" then "integrated"
|
|
464
|
+
when "gated" then "held at gate"
|
|
465
|
+
when "failed" then "integration failed"
|
|
466
|
+
when "escalated" then "integration escalated"
|
|
467
|
+
else status
|
|
468
|
+
end
|
|
469
|
+
end
|
|
443
470
|
end
|
|
444
471
|
|
|
445
472
|
include Govern
|