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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 046c14a614687e668eae6d9f3df0eadbe02fcb103a1c93f25657791a0d72962c
4
- data.tar.gz: 26ebca4203a0a9b3df1e38795580188bcf6951049304ae0eb4899deda35106f8
3
+ metadata.gz: 91c767b37c33c1d69a377f509b5503ef342fef4ff4f3629439e998938d99b24b
4
+ data.tar.gz: 2b615ad6df7610917d400166a5d735c9bc3f7e51b52eeda33d48446be9916d9c
5
5
  SHA512:
6
- metadata.gz: 38cb7ebf2e65293d5ea26bfb527314d86f7889949db033649fc49822b65feb7b7ea0e8fa5f35b6d959024ff4d280691432efde50dc15c37bf223665cd7d908b7
7
- data.tar.gz: d05902ee1637919dd0a9781fa2de46e4370d2806df83c02c730584095f18811baea6168f3614d81900229dd5ec3c141ffb2ce6a40a8c338f233aa1a88a2a9b81
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.0
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
@@ -221,3 +221,4 @@ require_relative "runtime/review"
221
221
  require_relative "runtime/govern"
222
222
  require_relative "runtime/setup"
223
223
  require_relative "runtime/status"
224
+ require_relative "runtime/deliver"
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.1.0
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