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
data/lib/carson/repository.rb
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# Passive repository record reconstructed from git state and Carson's ledger.
|
|
2
2
|
module Carson
|
|
3
3
|
class Repository
|
|
4
|
-
attr_reader :path
|
|
4
|
+
attr_reader :path
|
|
5
5
|
|
|
6
|
-
def initialize( path:,
|
|
6
|
+
def initialize( path:, runtime: )
|
|
7
7
|
@path = File.expand_path( path )
|
|
8
|
-
@authority = authority
|
|
9
8
|
@runtime = runtime
|
|
10
9
|
end
|
|
11
10
|
|
|
@@ -35,7 +34,6 @@ module Carson
|
|
|
35
34
|
{
|
|
36
35
|
name: name,
|
|
37
36
|
path: path,
|
|
38
|
-
authority: authority,
|
|
39
37
|
branches: runtime.ledger.active_deliveries( repo_path: path ).map { |delivery| delivery.branch }
|
|
40
38
|
}
|
|
41
39
|
end
|
data/lib/carson/revision.rb
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# Passive ledger record for one feedback-driven revision cycle.
|
|
2
2
|
module Carson
|
|
3
3
|
class Revision
|
|
4
|
-
attr_reader :
|
|
4
|
+
attr_reader :number, :cause, :provider, :status, :started_at, :finished_at, :summary
|
|
5
5
|
|
|
6
|
-
def initialize(
|
|
7
|
-
@id = id
|
|
8
|
-
@delivery_id = delivery_id
|
|
6
|
+
def initialize( number:, cause:, provider:, status:, started_at:, finished_at:, summary: )
|
|
9
7
|
@number = number
|
|
10
8
|
@cause = cause
|
|
11
9
|
@provider = provider
|
|
@@ -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
|
|