carson 3.1.0 → 3.2.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: 21eb861872b515965379fc3dc8afe24fceb228589b479e475999116aca5eca2a
4
+ data.tar.gz: da1ac628ac9da29969d973a5265c7ddff472370c1865e08af69f8f7bff3203e5
5
5
  SHA512:
6
- metadata.gz: 38cb7ebf2e65293d5ea26bfb527314d86f7889949db033649fc49822b65feb7b7ea0e8fa5f35b6d959024ff4d280691432efde50dc15c37bf223665cd7d908b7
7
- data.tar.gz: d05902ee1637919dd0a9781fa2de46e4370d2806df83c02c730584095f18811baea6168f3614d81900229dd5ec3c141ffb2ce6a40a8c338f233aa1a88a2a9b81
6
+ metadata.gz: ebafee0b573830243f4706fd5884ab9cece7ec07ecf657b936ea76d48c79af9eec30a9af2ac23ea1d7e7fbad604d49573aa67c2c53e9a500f16225684e81c67c
7
+ data.tar.gz: 0aea5423f60e9d33ca4f42f15b5f7bc4ecfd219960cec6d65d0c868fdc53a44831cb0eb4d12264f489b1fd7baafbf55b7226386b9fb1a7e8e4f360b06f9106f7
data/RELEASE.md CHANGED
@@ -5,6 +5,24 @@ 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.2.0 — Deliver
9
+
10
+ ### What changed
11
+
12
+ - **`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.
13
+ - **`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.
14
+ - **`carson deliver --title "..." --body-file <path>`** — optional PR title and body file for custom PR metadata. Title defaults to a humanised branch name.
15
+
16
+ ### UX
17
+
18
+ - `carson deliver` guards against delivering from main — exits with an error and recovery guidance.
19
+ - `carson deliver --merge` reports CI status clearly: pass, pending, or failing.
20
+ - Default PR title is generated from the branch name (e.g. `feature/add-search` becomes "Feature: add search").
21
+
22
+ ### Migration
23
+
24
+ - No breaking changes. All existing commands continue to work unchanged.
25
+
8
26
  ## 3.1.0 — Worktree Lifecycle
9
27
 
10
28
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.1.0
1
+ 3.2.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] [--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,31 @@ 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, title: nil, body_file: nil }
256
+ deliver_parser = OptionParser.new do |opts|
257
+ opts.banner = "Usage: carson deliver [--merge] [--title TITLE] [--body-file PATH]"
258
+ opts.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
259
+ opts.on( "--title TITLE", "PR title (defaults to branch name)" ) { |v| options[ :title ] = v }
260
+ opts.on( "--body-file PATH", "File containing PR body text" ) { |v| options[ :body_file ] = v }
261
+ end
262
+ deliver_parser.parse!( argv )
263
+ unless argv.empty?
264
+ err.puts "#{BADGE} Unexpected arguments for deliver: #{argv.join( ' ' )}"
265
+ err.puts deliver_parser
266
+ return { command: :invalid }
267
+ end
268
+ {
269
+ command: "deliver",
270
+ merge: options.fetch( :merge ),
271
+ title: options[ :title ],
272
+ body_file: options[ :body_file ]
273
+ }
274
+ rescue OptionParser::ParseError => e
275
+ err.puts "#{BADGE} #{e.message}"
276
+ { command: :invalid }
277
+ end
278
+
252
279
  def self.parse_govern_subcommand( argv:, err: )
253
280
  options = {
254
281
  dry_run: false,
@@ -317,6 +344,12 @@ module Carson
317
344
  runtime.template_check!
318
345
  when "template:apply"
319
346
  runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) )
347
+ when "deliver"
348
+ runtime.deliver!(
349
+ merge: parsed.fetch( :merge, false ),
350
+ title: parsed.fetch( :title, nil ),
351
+ body_file: parsed.fetch( :body_file, nil )
352
+ )
320
353
  when "review:gate"
321
354
  runtime.review_gate!
322
355
  when "review:sweep"
@@ -0,0 +1,192 @@
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
+ module Carson
6
+ class Runtime
7
+ module Deliver
8
+ # Entry point for `carson deliver`.
9
+ # Pushes current branch, creates a PR if needed, reports the PR URL.
10
+ # With merge: true, also merges if CI passes and cleans up.
11
+ def deliver!( merge: false, title: nil, body_file: nil )
12
+ branch = current_branch
13
+ main = config.main_branch
14
+ remote = config.git_remote
15
+
16
+ # Guard: cannot deliver from main.
17
+ if branch == main
18
+ puts_line "ERROR: cannot deliver from #{main}. Switch to a feature branch first."
19
+ return EXIT_ERROR
20
+ end
21
+
22
+ # Step 1: push the branch.
23
+ push_result = push_branch!( branch: branch, remote: remote )
24
+ return push_result unless push_result == EXIT_OK
25
+
26
+ # Step 2: find or create the PR.
27
+ pr_number, pr_url = find_or_create_pr!(
28
+ branch: branch, title: title, body_file: body_file
29
+ )
30
+ return EXIT_ERROR if pr_number.nil?
31
+
32
+ puts_line "PR: ##{pr_number} #{pr_url}"
33
+
34
+ # Without --merge, we are done.
35
+ return EXIT_OK unless merge
36
+
37
+ # Step 3: check CI status.
38
+ ci_status = check_pr_ci( number: pr_number )
39
+ case ci_status
40
+ when :pass
41
+ puts_line "CI: pass"
42
+ when :pending
43
+ puts_line "CI: pending — merge when checks complete."
44
+ return EXIT_OK
45
+ when :fail
46
+ puts_line "CI: failing — fix before merging."
47
+ return EXIT_BLOCK
48
+ else
49
+ puts_line "CI: unknown — check manually."
50
+ return EXIT_OK
51
+ end
52
+
53
+ # Step 4: merge.
54
+ merge_result = merge_pr!( number: pr_number )
55
+ return merge_result unless merge_result == EXIT_OK
56
+
57
+ # Step 5: sync main.
58
+ sync_after_merge!( remote: remote, main: main )
59
+
60
+ EXIT_OK
61
+ end
62
+
63
+ private
64
+
65
+ # Pushes the branch to the remote with tracking.
66
+ def push_branch!( branch:, remote: )
67
+ _, push_stderr, push_success, = git_run( "push", "-u", remote, branch )
68
+ unless push_success
69
+ error_text = push_stderr.to_s.strip
70
+ error_text = "push failed" if error_text.empty?
71
+ puts_line "ERROR: #{error_text}"
72
+ return EXIT_ERROR
73
+ end
74
+ puts_verbose "pushed #{branch} to #{remote}"
75
+ EXIT_OK
76
+ end
77
+
78
+ # Finds an existing PR for the branch, or creates a new one.
79
+ # Returns [number, url] or [nil, nil] on failure.
80
+ def find_or_create_pr!( branch:, title: nil, body_file: nil )
81
+ # Check for existing PR.
82
+ existing = find_existing_pr( branch: branch )
83
+ return existing if existing.first
84
+
85
+ # Create a new PR.
86
+ create_pr!( branch: branch, title: title, body_file: body_file )
87
+ end
88
+
89
+ # Queries gh for an open PR on this branch.
90
+ # Returns [number, url] or [nil, nil].
91
+ def find_existing_pr( branch: )
92
+ stdout, _, success, = gh_run(
93
+ "pr", "view", branch,
94
+ "--json", "number,url"
95
+ )
96
+ if success
97
+ data = JSON.parse( stdout ) rescue nil
98
+ if data && data[ "number" ]
99
+ return [ data[ "number" ], data[ "url" ].to_s ]
100
+ end
101
+ end
102
+ [ nil, nil ]
103
+ end
104
+
105
+ # Creates a PR via gh. Title defaults to branch name humanised.
106
+ # Returns [number, url] or [nil, nil] on failure.
107
+ def create_pr!( branch:, title: nil, body_file: nil )
108
+ pr_title = title || default_pr_title( branch: branch )
109
+
110
+ args = [ "pr", "create", "--title", pr_title, "--head", branch ]
111
+ if body_file && File.exist?( body_file )
112
+ args.push( "--body-file", body_file )
113
+ else
114
+ args.push( "--body", "" )
115
+ end
116
+
117
+ stdout, stderr, success, = gh_run( *args )
118
+ unless success
119
+ error_text = stderr.to_s.strip
120
+ error_text = "pr create failed" if error_text.empty?
121
+ puts_line "ERROR: #{error_text}"
122
+ return [ nil, nil ]
123
+ end
124
+
125
+ # gh pr create prints the URL on success. Parse number from it.
126
+ pr_url = stdout.to_s.strip
127
+ pr_number = pr_url.split( "/" ).last.to_i
128
+ if pr_number > 0
129
+ [ pr_number, pr_url ]
130
+ else
131
+ # Fallback: query the just-created PR.
132
+ find_existing_pr( branch: branch )
133
+ end
134
+ end
135
+
136
+ # Generates a default PR title from the branch name.
137
+ def default_pr_title( branch: )
138
+ branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { |c| c.upcase }
139
+ end
140
+
141
+ # Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
142
+ def check_pr_ci( number: )
143
+ stdout, _, success, = gh_run(
144
+ "pr", "checks", number.to_s,
145
+ "--json", "name,state,conclusion"
146
+ )
147
+ return :none unless success
148
+
149
+ checks = JSON.parse( stdout ) rescue []
150
+ return :none if checks.empty?
151
+
152
+ conclusions = checks.map { |c| c[ "conclusion" ].to_s.upcase }
153
+ states = checks.map { |c| c[ "state" ].to_s.upcase }
154
+
155
+ return :fail if conclusions.any? { |c| c == "FAILURE" || c == "CANCELLED" || c == "TIMED_OUT" }
156
+ return :pending if states.any? { |s| s == "PENDING" || s == "QUEUED" || s == "IN_PROGRESS" } ||
157
+ conclusions.any? { |c| c == "" || c == "PENDING" }
158
+
159
+ :pass
160
+ end
161
+
162
+ # Merges the PR using the configured merge method.
163
+ def merge_pr!( number: )
164
+ method = config.govern_merge_method
165
+ stdout, stderr, success, = gh_run(
166
+ "pr", "merge", number.to_s,
167
+ "--#{method}",
168
+ "--delete-branch"
169
+ )
170
+
171
+ if success
172
+ puts_line "Merged PR ##{number} via #{method}."
173
+ EXIT_OK
174
+ else
175
+ error_text = stderr.to_s.strip
176
+ error_text = "merge failed" if error_text.empty?
177
+ puts_line "ERROR: #{error_text}"
178
+ EXIT_ERROR
179
+ end
180
+ end
181
+
182
+ # Syncs main after a successful merge.
183
+ def sync_after_merge!( remote:, main: )
184
+ git_run( "checkout", main )
185
+ git_run( "pull", remote, main )
186
+ puts_verbose "synced #{main} from #{remote}"
187
+ end
188
+ end
189
+
190
+ include Deliver
191
+ end
192
+ 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.2.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