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 +4 -4
- data/RELEASE.md +18 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +34 -1
- data/lib/carson/runtime/deliver.rb +192 -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: 21eb861872b515965379fc3dc8afe24fceb228589b479e475999116aca5eca2a
|
|
4
|
+
data.tar.gz: da1ac628ac9da29969d973a5265c7ddff472370c1865e08af69f8f7bff3203e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
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
|
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.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
|