carson 3.1.0 → 3.3.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/RELEASE.md +35 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +37 -1
- data/lib/carson/runtime/deliver.rb +257 -0
- data/lib/carson/runtime.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91c767b37c33c1d69a377f509b5503ef342fef4ff4f3629439e998938d99b24b
|
|
4
|
+
data.tar.gz: 2b615ad6df7610917d400166a5d735c9bc3f7e51b52eeda33d48446be9916d9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: df671d4ed658b1aa98290cfcdbc5cd47c78c702d83254bdc4e79d9ed5755320b3cd9c08f664e20633830661506fe8a0cdcb0f8c03e1314623a6074a444f7d23d
|
|
7
|
+
data.tar.gz: e05a41a7fb961deac1f63eccb77b8d03eb7d694a557baebaead88f5f38d9bab91a1b4643f71b4bf09741e51946ab2c6707a288e7a22fe2ea981ee5769e370b2f
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,41 @@ Release-note scope rule:
|
|
|
5
5
|
- `RELEASE.md` records only version deltas, breaking changes, and migration actions.
|
|
6
6
|
- Operational usage guides live in `MANUAL.md` and `API.md`.
|
|
7
7
|
|
|
8
|
+
## 3.3.0 — Deliver Refinements
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **`carson deliver --json`** — machine-readable JSON output for agent consumption. The JSON envelope includes `command`, `branch`, `pr_number`, `pr_url`, `ci`, `merged`, `exit_code`, and — on failure — `error` and `recovery` fields.
|
|
13
|
+
- **Recovery-aware errors** — every deliver error path now includes a concrete `recovery` command showing the user exactly what to run next. Human output shows `Recovery: <command>`, JSON output includes a `recovery` field.
|
|
14
|
+
|
|
15
|
+
### UX
|
|
16
|
+
|
|
17
|
+
- JSON output uses `JSON.pretty_generate` for readability when inspected by humans.
|
|
18
|
+
- Recovery commands are context-specific: push failures suggest `git pull --rebase && git push`, main-branch errors suggest `git checkout -b <branch>`, CI failures suggest `gh pr checks` with re-deliver.
|
|
19
|
+
- Human output for CI pending and CI fail states now includes recovery guidance.
|
|
20
|
+
|
|
21
|
+
### Migration
|
|
22
|
+
|
|
23
|
+
- No breaking changes. `carson deliver` without `--json` behaves identically to 3.2.0.
|
|
24
|
+
|
|
25
|
+
## 3.2.0 — Deliver
|
|
26
|
+
|
|
27
|
+
### What changed
|
|
28
|
+
|
|
29
|
+
- **`carson deliver`** — pushes the current branch to the remote, creates a PR if none exists, and reports the PR URL. Collapses the manual push/create-PR/report cycle into one command.
|
|
30
|
+
- **`carson deliver --merge`** — does everything above, plus checks CI status and merges the PR if all checks pass. Reports pending or failing CI with actionable guidance. Uses the configured merge method (squash, rebase, or merge). Syncs main after merge.
|
|
31
|
+
- **`carson deliver --title "..." --body-file <path>`** — optional PR title and body file for custom PR metadata. Title defaults to a humanised branch name.
|
|
32
|
+
|
|
33
|
+
### UX
|
|
34
|
+
|
|
35
|
+
- `carson deliver` guards against delivering from main — exits with an error and recovery guidance.
|
|
36
|
+
- `carson deliver --merge` reports CI status clearly: pass, pending, or failing.
|
|
37
|
+
- Default PR title is generated from the branch name (e.g. `feature/add-search` becomes "Feature: add search").
|
|
38
|
+
|
|
39
|
+
### Migration
|
|
40
|
+
|
|
41
|
+
- No breaking changes. All existing commands continue to work unchanged.
|
|
42
|
+
|
|
8
43
|
## 3.1.0 — Worktree Lifecycle
|
|
9
44
|
|
|
10
45
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.3.0
|
data/lib/carson/cli.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Carson
|
|
|
53
53
|
|
|
54
54
|
def self.build_parser
|
|
55
55
|
OptionParser.new do |opts|
|
|
56
|
-
opts.banner = "Usage: carson [status [--json]|setup|audit|sync|prune [--all]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
|
|
56
|
+
opts.banner = "Usage: carson [status [--json]|setup|audit|sync|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -90,6 +90,8 @@ module Carson
|
|
|
90
90
|
parse_named_subcommand( command: command, usage: "gate|sweep", argv: argv, parser: parser, err: err )
|
|
91
91
|
when "status"
|
|
92
92
|
parse_status_command( argv: argv, err: err )
|
|
93
|
+
when "deliver"
|
|
94
|
+
parse_deliver_command( argv: argv, err: err )
|
|
93
95
|
when "govern"
|
|
94
96
|
parse_govern_subcommand( argv: argv, err: err )
|
|
95
97
|
else
|
|
@@ -249,6 +251,33 @@ module Carson
|
|
|
249
251
|
{ command: "status", json: json_flag }
|
|
250
252
|
end
|
|
251
253
|
|
|
254
|
+
def self.parse_deliver_command( argv:, err: )
|
|
255
|
+
options = { merge: false, json: false, title: nil, body_file: nil }
|
|
256
|
+
deliver_parser = OptionParser.new do |opts|
|
|
257
|
+
opts.banner = "Usage: carson deliver [--merge] [--json] [--title TITLE] [--body-file PATH]"
|
|
258
|
+
opts.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
|
|
259
|
+
opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
260
|
+
opts.on( "--title TITLE", "PR title (defaults to branch name)" ) { |v| options[ :title ] = v }
|
|
261
|
+
opts.on( "--body-file PATH", "File containing PR body text" ) { |v| options[ :body_file ] = v }
|
|
262
|
+
end
|
|
263
|
+
deliver_parser.parse!( argv )
|
|
264
|
+
unless argv.empty?
|
|
265
|
+
err.puts "#{BADGE} Unexpected arguments for deliver: #{argv.join( ' ' )}"
|
|
266
|
+
err.puts deliver_parser
|
|
267
|
+
return { command: :invalid }
|
|
268
|
+
end
|
|
269
|
+
{
|
|
270
|
+
command: "deliver",
|
|
271
|
+
merge: options.fetch( :merge ),
|
|
272
|
+
json: options.fetch( :json ),
|
|
273
|
+
title: options[ :title ],
|
|
274
|
+
body_file: options[ :body_file ]
|
|
275
|
+
}
|
|
276
|
+
rescue OptionParser::ParseError => e
|
|
277
|
+
err.puts "#{BADGE} #{e.message}"
|
|
278
|
+
{ command: :invalid }
|
|
279
|
+
end
|
|
280
|
+
|
|
252
281
|
def self.parse_govern_subcommand( argv:, err: )
|
|
253
282
|
options = {
|
|
254
283
|
dry_run: false,
|
|
@@ -317,6 +346,13 @@ module Carson
|
|
|
317
346
|
runtime.template_check!
|
|
318
347
|
when "template:apply"
|
|
319
348
|
runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) )
|
|
349
|
+
when "deliver"
|
|
350
|
+
runtime.deliver!(
|
|
351
|
+
merge: parsed.fetch( :merge, false ),
|
|
352
|
+
title: parsed.fetch( :title, nil ),
|
|
353
|
+
body_file: parsed.fetch( :body_file, nil ),
|
|
354
|
+
json_output: parsed.fetch( :json, false )
|
|
355
|
+
)
|
|
320
356
|
when "review:gate"
|
|
321
357
|
runtime.review_gate!
|
|
322
358
|
when "review:sweep"
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# PR delivery lifecycle — push, create PR, and optionally merge.
|
|
2
|
+
# Collapses the 8-step manual PR flow into one or two commands.
|
|
3
|
+
# `carson deliver` pushes and creates the PR.
|
|
4
|
+
# `carson deliver --merge` also merges if CI is green.
|
|
5
|
+
# `carson deliver --json` outputs structured result for agent consumption.
|
|
6
|
+
module Carson
|
|
7
|
+
class Runtime
|
|
8
|
+
module Deliver
|
|
9
|
+
# Entry point for `carson deliver`.
|
|
10
|
+
# Pushes current branch, creates a PR if needed, reports the PR URL.
|
|
11
|
+
# With merge: true, also merges if CI passes and cleans up.
|
|
12
|
+
def deliver!( merge: false, title: nil, body_file: nil, json_output: false )
|
|
13
|
+
branch = current_branch
|
|
14
|
+
main = config.main_branch
|
|
15
|
+
remote = config.git_remote
|
|
16
|
+
result = { command: "deliver", branch: branch }
|
|
17
|
+
|
|
18
|
+
# Guard: cannot deliver from main.
|
|
19
|
+
if branch == main
|
|
20
|
+
result[ :error ] = "cannot deliver from #{main}"
|
|
21
|
+
result[ :recovery ] = "git checkout -b <branch-name>"
|
|
22
|
+
return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Step 1: push the branch.
|
|
26
|
+
push_exit = push_branch!( branch: branch, remote: remote, result: result )
|
|
27
|
+
return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
|
|
28
|
+
|
|
29
|
+
# Step 2: find or create the PR.
|
|
30
|
+
pr_number, pr_url = find_or_create_pr!(
|
|
31
|
+
branch: branch, title: title, body_file: body_file, result: result
|
|
32
|
+
)
|
|
33
|
+
if pr_number.nil?
|
|
34
|
+
return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result[ :pr_number ] = pr_number
|
|
38
|
+
result[ :pr_url ] = pr_url
|
|
39
|
+
|
|
40
|
+
# Without --merge, we are done.
|
|
41
|
+
unless merge
|
|
42
|
+
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Step 3: check CI status.
|
|
46
|
+
ci_status = check_pr_ci( number: pr_number )
|
|
47
|
+
result[ :ci ] = ci_status.to_s
|
|
48
|
+
|
|
49
|
+
case ci_status
|
|
50
|
+
when :pass
|
|
51
|
+
# Continue to merge.
|
|
52
|
+
when :pending
|
|
53
|
+
result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
|
|
54
|
+
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
55
|
+
when :fail
|
|
56
|
+
result[ :recovery ] = "gh pr checks #{pr_number} — fix failures, push, then `carson deliver --merge`"
|
|
57
|
+
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
58
|
+
else
|
|
59
|
+
result[ :recovery ] = "gh pr checks #{pr_number}"
|
|
60
|
+
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Step 4: merge.
|
|
64
|
+
merge_exit = merge_pr!( number: pr_number, result: result )
|
|
65
|
+
return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
|
|
66
|
+
|
|
67
|
+
result[ :merged ] = true
|
|
68
|
+
|
|
69
|
+
# Step 5: sync main.
|
|
70
|
+
sync_after_merge!( remote: remote, main: main )
|
|
71
|
+
|
|
72
|
+
deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Outputs the final result — JSON or human-readable — and returns exit code.
|
|
78
|
+
def deliver_finish( result:, exit_code:, json_output: )
|
|
79
|
+
result[ :exit_code ] = exit_code
|
|
80
|
+
|
|
81
|
+
if json_output
|
|
82
|
+
out.puts JSON.pretty_generate( result )
|
|
83
|
+
else
|
|
84
|
+
print_deliver_human( result: result )
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
exit_code
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Human-readable output for deliver results.
|
|
91
|
+
def print_deliver_human( result: )
|
|
92
|
+
exit_code = result.fetch( :exit_code )
|
|
93
|
+
|
|
94
|
+
if result[ :error ]
|
|
95
|
+
puts_line "ERROR: #{result[ :error ]}"
|
|
96
|
+
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if result[ :pr_number ]
|
|
101
|
+
puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if result[ :ci ]
|
|
105
|
+
ci = result[ :ci ]
|
|
106
|
+
case ci
|
|
107
|
+
when "pass"
|
|
108
|
+
puts_line "CI: pass"
|
|
109
|
+
when "pending"
|
|
110
|
+
puts_line "CI: pending — merge when checks complete."
|
|
111
|
+
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
112
|
+
when "fail"
|
|
113
|
+
puts_line "CI: failing — fix before merging."
|
|
114
|
+
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
115
|
+
else
|
|
116
|
+
puts_line "CI: #{ci} — check manually."
|
|
117
|
+
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if result[ :merged ]
|
|
122
|
+
puts_line "Merged PR ##{result[ :pr_number ]} via #{result[ :merge_method ]}."
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Pushes the branch to the remote with tracking.
|
|
127
|
+
def push_branch!( branch:, remote:, result: )
|
|
128
|
+
_, push_stderr, push_success, = git_run( "push", "-u", remote, branch )
|
|
129
|
+
unless push_success
|
|
130
|
+
error_text = push_stderr.to_s.strip
|
|
131
|
+
error_text = "push failed" if error_text.empty?
|
|
132
|
+
result[ :error ] = error_text
|
|
133
|
+
result[ :recovery ] = "git pull #{remote} #{branch} --rebase && git push -u #{remote} #{branch}"
|
|
134
|
+
return EXIT_ERROR
|
|
135
|
+
end
|
|
136
|
+
puts_verbose "pushed #{branch} to #{remote}"
|
|
137
|
+
EXIT_OK
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Finds an existing PR for the branch, or creates a new one.
|
|
141
|
+
# Returns [number, url] or [nil, nil] on failure.
|
|
142
|
+
def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
|
|
143
|
+
# Check for existing PR.
|
|
144
|
+
existing = find_existing_pr( branch: branch )
|
|
145
|
+
return existing if existing.first
|
|
146
|
+
|
|
147
|
+
# Create a new PR.
|
|
148
|
+
create_pr!( branch: branch, title: title, body_file: body_file, result: result )
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Queries gh for an open PR on this branch.
|
|
152
|
+
# Returns [number, url] or [nil, nil].
|
|
153
|
+
def find_existing_pr( branch: )
|
|
154
|
+
stdout, _, success, = gh_run(
|
|
155
|
+
"pr", "view", branch,
|
|
156
|
+
"--json", "number,url"
|
|
157
|
+
)
|
|
158
|
+
if success
|
|
159
|
+
data = JSON.parse( stdout ) rescue nil
|
|
160
|
+
if data && data[ "number" ]
|
|
161
|
+
return [ data[ "number" ], data[ "url" ].to_s ]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
[ nil, nil ]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Creates a PR via gh. Title defaults to branch name humanised.
|
|
168
|
+
# Returns [number, url] or [nil, nil] on failure.
|
|
169
|
+
def create_pr!( branch:, title: nil, body_file: nil, result: )
|
|
170
|
+
pr_title = title || default_pr_title( branch: branch )
|
|
171
|
+
|
|
172
|
+
args = [ "pr", "create", "--title", pr_title, "--head", branch ]
|
|
173
|
+
if body_file && File.exist?( body_file )
|
|
174
|
+
args.push( "--body-file", body_file )
|
|
175
|
+
else
|
|
176
|
+
args.push( "--body", "" )
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
stdout, stderr, success, = gh_run( *args )
|
|
180
|
+
unless success
|
|
181
|
+
error_text = stderr.to_s.strip
|
|
182
|
+
error_text = "pr create failed" if error_text.empty?
|
|
183
|
+
result[ :error ] = error_text
|
|
184
|
+
result[ :recovery ] = "gh pr create --title '#{pr_title}' --head #{branch}"
|
|
185
|
+
return [ nil, nil ]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# gh pr create prints the URL on success. Parse number from it.
|
|
189
|
+
pr_url = stdout.to_s.strip
|
|
190
|
+
pr_number = pr_url.split( "/" ).last.to_i
|
|
191
|
+
if pr_number > 0
|
|
192
|
+
[ pr_number, pr_url ]
|
|
193
|
+
else
|
|
194
|
+
# Fallback: query the just-created PR.
|
|
195
|
+
find_existing_pr( branch: branch )
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Generates a default PR title from the branch name.
|
|
200
|
+
def default_pr_title( branch: )
|
|
201
|
+
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { |c| c.upcase }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
|
|
205
|
+
def check_pr_ci( number: )
|
|
206
|
+
stdout, _, success, = gh_run(
|
|
207
|
+
"pr", "checks", number.to_s,
|
|
208
|
+
"--json", "name,state,conclusion"
|
|
209
|
+
)
|
|
210
|
+
return :none unless success
|
|
211
|
+
|
|
212
|
+
checks = JSON.parse( stdout ) rescue []
|
|
213
|
+
return :none if checks.empty?
|
|
214
|
+
|
|
215
|
+
conclusions = checks.map { |c| c[ "conclusion" ].to_s.upcase }
|
|
216
|
+
states = checks.map { |c| c[ "state" ].to_s.upcase }
|
|
217
|
+
|
|
218
|
+
return :fail if conclusions.any? { |c| c == "FAILURE" || c == "CANCELLED" || c == "TIMED_OUT" }
|
|
219
|
+
return :pending if states.any? { |s| s == "PENDING" || s == "QUEUED" || s == "IN_PROGRESS" } ||
|
|
220
|
+
conclusions.any? { |c| c == "" || c == "PENDING" }
|
|
221
|
+
|
|
222
|
+
:pass
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Merges the PR using the configured merge method.
|
|
226
|
+
def merge_pr!( number:, result: )
|
|
227
|
+
method = config.govern_merge_method
|
|
228
|
+
result[ :merge_method ] = method
|
|
229
|
+
|
|
230
|
+
_, stderr, success, = gh_run(
|
|
231
|
+
"pr", "merge", number.to_s,
|
|
232
|
+
"--#{method}",
|
|
233
|
+
"--delete-branch"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if success
|
|
237
|
+
EXIT_OK
|
|
238
|
+
else
|
|
239
|
+
error_text = stderr.to_s.strip
|
|
240
|
+
error_text = "merge failed" if error_text.empty?
|
|
241
|
+
result[ :error ] = error_text
|
|
242
|
+
result[ :recovery ] = "gh pr merge #{number} --#{method} --delete-branch"
|
|
243
|
+
EXIT_ERROR
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Syncs main after a successful merge.
|
|
248
|
+
def sync_after_merge!( remote:, main: )
|
|
249
|
+
git_run( "checkout", main )
|
|
250
|
+
git_run( "pull", remote, main )
|
|
251
|
+
puts_verbose "synced #{main} from #{remote}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
include Deliver
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/carson/runtime.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: carson
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -52,6 +52,7 @@ files:
|
|
|
52
52
|
- lib/carson/config.rb
|
|
53
53
|
- lib/carson/runtime.rb
|
|
54
54
|
- lib/carson/runtime/audit.rb
|
|
55
|
+
- lib/carson/runtime/deliver.rb
|
|
55
56
|
- lib/carson/runtime/govern.rb
|
|
56
57
|
- lib/carson/runtime/local.rb
|
|
57
58
|
- lib/carson/runtime/local/hooks.rb
|