carson 4.1.1 → 4.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/API.md +1 -1
- data/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/carson.gemspec +0 -1
- data/lib/carson/ledger.rb +0 -122
- data/lib/carson/runtime/abandon.rb +6 -5
- data/lib/carson/runtime/local/hooks.rb +1 -1
- data/lib/carson/runtime/local/worktree.rb +81 -7
- data/lib/carson/runtime/recover.rb +2 -2
- data/lib/carson/warehouse/bureau.rb +117 -0
- data/lib/carson/warehouse/seal.rb +55 -0
- data/lib/carson/warehouse/workbench.rb +516 -0
- data/lib/carson/warehouse.rb +28 -180
- data/lib/carson/worktree.rb +10 -0
- data/lib/carson.rb +1 -1
- data/lib/{carson/cli.rb → cli.rb} +147 -2
- metadata +11 -28
- /data/config/{.github/hooks → hooks}/command-guard +0 -0
- /data/config/{.github/hooks → hooks}/pre-commit +0 -0
- /data/config/{.github/hooks → hooks}/pre-merge-commit +0 -0
- /data/config/{.github/hooks → hooks}/pre-push +0 -0
- /data/config/{.github/hooks → hooks}/prepare-commit-msg +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57e828abcdfc77caa38be92fa08a41d8d13abd37a09e7a4bf726d07253a48749
|
|
4
|
+
data.tar.gz: 5a6141be7046db72d03971fc9c4f45f1b7c81a47d685b13c97810711ebe9db39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 409ab608b1bed1423f16aa257efcc53f608643112db23020ac1786bd77fcc94a1776df32991be7ff0c00eb5d9b31f6db454e1ea4ad6020b65aaeb4dd186e39aa
|
|
7
|
+
data.tar.gz: a482b66aa5220c17d9d1b4c2852cc10b9cfeaa0d8af803e0b7974c20994754b1eb2affbb4d3a5659804764a398f25f7c822ffe3feaaa61b4c6b7fea46cf4b88a
|
data/API.md
CHANGED
|
@@ -173,7 +173,7 @@ Environment overrides:
|
|
|
173
173
|
- `agent.codex` / `agent.claude`: provider-specific options (reserved).
|
|
174
174
|
- `check_wait`: seconds to wait for CI checks before classifying (default: `30`).
|
|
175
175
|
- `merge.method`: `"squash"` only in governed mode.
|
|
176
|
-
- `state_path`: JSON ledger path for active deliveries and revisions.
|
|
176
|
+
- `state_path`: JSON ledger path for active deliveries and revisions.
|
|
177
177
|
|
|
178
178
|
`template` schema:
|
|
179
179
|
|
data/RELEASE.md
CHANGED
|
@@ -7,6 +7,31 @@ Release-note scope rule:
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 4.2.0
|
|
11
|
+
|
|
12
|
+
### New
|
|
13
|
+
|
|
14
|
+
- **`carson checkin <name>`** — the agent's verb for getting a fresh workbench. Receives the latest production standard before building. Sweeps delivered workbenches automatically — the Warehouse cleans behind the agent, so explicit checkout is rarely needed.
|
|
15
|
+
- **`carson checkout <name>`** — the agent's verb for releasing a workbench when done. Checks the seal (parcel in flight blocks checkout), CWD, process holds, dirty state, and unpushed work. Use at end of session; the common case is handled by the next checkin.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **CLI moved outside the story world** — `lib/carson/cli.rb` → `lib/cli.rb`. `lib/carson/` is domain objects only. checkin and checkout wire CLI directly to Warehouse with no Runtime. This is the template for Runtime dissolution.
|
|
20
|
+
- **`tear_down_workbench!` renamed to `remove_workbench!`** — simpler verb, matches the domain.
|
|
21
|
+
- **Remote branch deletion removed from workbench removal** — GitHub handles remote branch cleanup on PR merge. The Warehouse now only removes the directory and local branch.
|
|
22
|
+
|
|
23
|
+
### UX
|
|
24
|
+
|
|
25
|
+
- `carson checkin` output: `⧓ Workbench ready: <name>` with path and branch.
|
|
26
|
+
- `carson checkout` output: `⧓ Workbench released: <name>`.
|
|
27
|
+
- Agent lifecycle simplified: `checkin → work → deliver → checkin` (repeat). No explicit checkout between tasks.
|
|
28
|
+
|
|
29
|
+
## 4.1.2
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **Hook templates misplaced under `.github/`** — `config/.github/hooks/` moved to `config/hooks/`. Git hooks are local mechanisms installed to `~/.carson/hooks/`, not GitHub configuration. The previous path falsely implied a relationship with GitHub's `.github/` convention.
|
|
34
|
+
|
|
10
35
|
## 4.1.1
|
|
11
36
|
|
|
12
37
|
### Fixed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.2.0
|
data/carson.gemspec
CHANGED
|
@@ -28,7 +28,6 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.bindir = "exe"
|
|
29
29
|
spec.executables = [ "carson" ]
|
|
30
30
|
spec.require_paths = [ "lib" ]
|
|
31
|
-
spec.add_dependency "sqlite3", ">= 1.3", "< 3"
|
|
32
31
|
spec.files = Dir.glob( "{lib,exe,templates,config}/**/*", File::FNM_DOTMATCH ).select { |path| File.file?( path ) } + [
|
|
33
32
|
".github/workflows/carson_policy.yml",
|
|
34
33
|
"README.md",
|
data/lib/carson/ledger.rb
CHANGED
|
@@ -7,12 +7,10 @@ module Carson
|
|
|
7
7
|
class Ledger
|
|
8
8
|
UNSET = Object.new
|
|
9
9
|
ACTIVE_DELIVERY_STATES = Delivery::ACTIVE_STATES
|
|
10
|
-
SQLITE_HEADER = "SQLite format 3\0".b.freeze
|
|
11
10
|
|
|
12
11
|
def initialize( path: )
|
|
13
12
|
@path = File.expand_path( path )
|
|
14
13
|
FileUtils.mkdir_p( File.dirname( @path ) )
|
|
15
|
-
migrate_legacy_state_if_needed!
|
|
16
14
|
end
|
|
17
15
|
|
|
18
16
|
attr_reader :path
|
|
@@ -273,22 +271,6 @@ module Carson
|
|
|
273
271
|
)
|
|
274
272
|
end
|
|
275
273
|
|
|
276
|
-
def migrate_legacy_state_if_needed!
|
|
277
|
-
# Skip lock acquisition entirely when no legacy SQLite file exists.
|
|
278
|
-
# Read-only file checks are safe without the lock; the migration
|
|
279
|
-
# itself is idempotent so a narrow race is harmless.
|
|
280
|
-
return unless state_path_requires_migration?
|
|
281
|
-
|
|
282
|
-
with_state_lock do |lock_file|
|
|
283
|
-
lock_file.flock( File::LOCK_EX )
|
|
284
|
-
source_path = legacy_sqlite_source_path
|
|
285
|
-
next unless source_path
|
|
286
|
-
|
|
287
|
-
state = load_legacy_sqlite_state( path: source_path )
|
|
288
|
-
save_state!( state )
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
|
|
292
274
|
def with_state_lock
|
|
293
275
|
lock_path = "#{path}.lock"
|
|
294
276
|
FileUtils.mkdir_p( File.dirname( lock_path ) )
|
|
@@ -299,110 +281,6 @@ module Carson
|
|
|
299
281
|
end
|
|
300
282
|
end
|
|
301
283
|
|
|
302
|
-
def legacy_sqlite_source_path
|
|
303
|
-
return nil unless state_path_requires_migration?
|
|
304
|
-
return path if sqlite_database_file?( path: path )
|
|
305
|
-
|
|
306
|
-
legacy_path = legacy_state_path
|
|
307
|
-
return nil unless legacy_path
|
|
308
|
-
return legacy_path if sqlite_database_file?( path: legacy_path )
|
|
309
|
-
|
|
310
|
-
nil
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def state_path_requires_migration?
|
|
314
|
-
return true if sqlite_database_file?( path: path )
|
|
315
|
-
return false if File.exist?( path )
|
|
316
|
-
!legacy_state_path.nil?
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def legacy_state_path
|
|
320
|
-
return nil unless path.end_with?( ".json" )
|
|
321
|
-
path.sub( /\.json\z/, ".sqlite3" )
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def sqlite_database_file?( path: )
|
|
325
|
-
return false unless File.file?( path )
|
|
326
|
-
File.binread( path, SQLITE_HEADER.bytesize ) == SQLITE_HEADER
|
|
327
|
-
rescue StandardError
|
|
328
|
-
false
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
def load_legacy_sqlite_state( path: )
|
|
332
|
-
begin
|
|
333
|
-
require "sqlite3"
|
|
334
|
-
rescue LoadError => exception
|
|
335
|
-
raise "legacy SQLite ledger found at #{path}, but sqlite3 support is unavailable: #{exception.message}"
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
database = open_legacy_sqlite_database( path: path )
|
|
339
|
-
deliveries = database.execute( "SELECT * FROM deliveries ORDER BY id ASC" )
|
|
340
|
-
revisions_by_delivery = database.execute(
|
|
341
|
-
"SELECT * FROM revisions ORDER BY delivery_id ASC, number ASC, id ASC"
|
|
342
|
-
).group_by { |row| row.fetch( "delivery_id" ) }
|
|
343
|
-
|
|
344
|
-
state = {
|
|
345
|
-
"deliveries" => {},
|
|
346
|
-
"recovery_events" => [],
|
|
347
|
-
"next_sequence" => 1
|
|
348
|
-
}
|
|
349
|
-
deliveries.each do |row|
|
|
350
|
-
key = delivery_key(
|
|
351
|
-
repo_path: row.fetch( "repo_path" ),
|
|
352
|
-
branch_name: row.fetch( "branch_name" ),
|
|
353
|
-
head: row.fetch( "head" )
|
|
354
|
-
)
|
|
355
|
-
state[ "deliveries" ][ key ] = {
|
|
356
|
-
"sequence" => row.fetch( "id" ).to_i,
|
|
357
|
-
"repo_path" => row.fetch( "repo_path" ),
|
|
358
|
-
"branch_name" => row.fetch( "branch_name" ),
|
|
359
|
-
"head" => row.fetch( "head" ),
|
|
360
|
-
"worktree_path" => row.fetch( "worktree_path" ),
|
|
361
|
-
"status" => row.fetch( "status" ),
|
|
362
|
-
"pr_number" => row.fetch( "pr_number" ),
|
|
363
|
-
"pr_url" => row.fetch( "pr_url" ),
|
|
364
|
-
"pull_request_state" => nil,
|
|
365
|
-
"pull_request_draft" => nil,
|
|
366
|
-
"pull_request_merged_at" => nil,
|
|
367
|
-
"merge_proof" => nil,
|
|
368
|
-
"cause" => row.fetch( "cause" ),
|
|
369
|
-
"summary" => row.fetch( "summary" ),
|
|
370
|
-
"created_at" => row.fetch( "created_at" ),
|
|
371
|
-
"updated_at" => row.fetch( "updated_at" ),
|
|
372
|
-
"integrated_at" => row.fetch( "integrated_at" ),
|
|
373
|
-
"superseded_at" => row.fetch( "superseded_at" ),
|
|
374
|
-
"revisions" => Array( revisions_by_delivery[ row.fetch( "id" ) ] ).map do |revision|
|
|
375
|
-
{
|
|
376
|
-
"number" => revision.fetch( "number" ).to_i,
|
|
377
|
-
"cause" => revision.fetch( "cause" ),
|
|
378
|
-
"provider" => revision.fetch( "provider" ),
|
|
379
|
-
"status" => revision.fetch( "status" ),
|
|
380
|
-
"started_at" => revision.fetch( "started_at" ),
|
|
381
|
-
"finished_at" => revision.fetch( "finished_at" ),
|
|
382
|
-
"summary" => revision.fetch( "summary" )
|
|
383
|
-
}
|
|
384
|
-
end
|
|
385
|
-
}
|
|
386
|
-
end
|
|
387
|
-
normalise_state!( state: state )
|
|
388
|
-
state
|
|
389
|
-
ensure
|
|
390
|
-
database&.close
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def open_legacy_sqlite_database( path: )
|
|
394
|
-
database = SQLite3::Database.new( "file:#{path}?immutable=1", readonly: true, uri: true )
|
|
395
|
-
database.results_as_hash = true
|
|
396
|
-
database.busy_timeout = 5_000
|
|
397
|
-
database
|
|
398
|
-
rescue SQLite3::CantOpenException
|
|
399
|
-
database&.close
|
|
400
|
-
database = SQLite3::Database.new( path, readonly: true )
|
|
401
|
-
database.results_as_hash = true
|
|
402
|
-
database.busy_timeout = 5_000
|
|
403
|
-
database
|
|
404
|
-
end
|
|
405
|
-
|
|
406
284
|
def normalise_state!( state: )
|
|
407
285
|
deliveries = state[ "deliveries" ]
|
|
408
286
|
raise "state file must contain a JSON object at #{path}" unless deliveries.is_a?( Hash )
|
|
@@ -140,17 +140,18 @@ module Carson
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
if worktree
|
|
143
|
-
check =
|
|
143
|
+
check = worktree_warehouse.assess_removal( worktree, force: false, skip_unpushed: true )
|
|
144
144
|
return nil if check.fetch( :status ) == :ok
|
|
145
145
|
|
|
146
|
-
recovery = check
|
|
147
|
-
if check
|
|
146
|
+
recovery = check[ :recovery ]
|
|
147
|
+
if check[ :error ] == "worktree has uncommitted changes"
|
|
148
148
|
recovery = "commit or discard the changes, then retry carson abandon #{branch}"
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
+
exit_code = check[ :status ] == :block ? EXIT_BLOCK : EXIT_ERROR
|
|
151
152
|
return {
|
|
152
|
-
exit_code:
|
|
153
|
-
error: check
|
|
153
|
+
exit_code: exit_code,
|
|
154
|
+
error: check[ :error ],
|
|
154
155
|
recovery: recovery
|
|
155
156
|
}
|
|
156
157
|
end
|
|
@@ -64,7 +64,7 @@ module Carson
|
|
|
64
64
|
|
|
65
65
|
# Canonical hook template location inside Carson repository.
|
|
66
66
|
def hook_template_path( hook_name: )
|
|
67
|
-
File.join( tool_root, "config", "
|
|
67
|
+
File.join( tool_root, "config", "hooks", hook_name )
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Reports full hook health and can enforce stricter action messaging in `check`.
|
|
@@ -1,31 +1,46 @@
|
|
|
1
1
|
# Thin worktree delegate layer on Runtime.
|
|
2
|
-
# Lifecycle operations
|
|
3
|
-
#
|
|
4
|
-
# CWD branch detection).
|
|
2
|
+
# Lifecycle operations delegate to Warehouse::Workbench.
|
|
3
|
+
# Keeps only methods that genuinely belong on Runtime (path resolution,
|
|
4
|
+
# CWD branch detection, output rendering).
|
|
5
5
|
module Carson
|
|
6
6
|
class Runtime
|
|
7
7
|
module Local
|
|
8
8
|
|
|
9
|
-
# --- Delegates to
|
|
9
|
+
# --- Delegates to Warehouse::Workbench ---
|
|
10
10
|
|
|
11
11
|
# Creates a new worktree under .claude/worktrees/<name>.
|
|
12
12
|
def worktree_create!( name:, json_output: false )
|
|
13
|
-
|
|
13
|
+
result = worktree_warehouse.build_workbench!( name: name )
|
|
14
|
+
finish_worktree( result: result, json_output: json_output )
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
# Removes a worktree: directory, git registration, and branch.
|
|
17
18
|
def worktree_remove!( worktree_path:, force: false, skip_unpushed: false, json_output: false )
|
|
18
|
-
|
|
19
|
+
wh = worktree_warehouse
|
|
20
|
+
workbench = wh.workbench_named( worktree_path )
|
|
21
|
+
unless workbench
|
|
22
|
+
return finish_worktree(
|
|
23
|
+
result: { command: "worktree remove", status: "error",
|
|
24
|
+
name: File.basename( worktree_path ),
|
|
25
|
+
error: "#{worktree_path} is not a registered worktree",
|
|
26
|
+
recovery: "git worktree list" },
|
|
27
|
+
json_output: json_output )
|
|
28
|
+
end
|
|
29
|
+
result = wh.remove_workbench!( workbench, force: force, skip_unpushed: skip_unpushed )
|
|
30
|
+
finish_worktree( result: result, json_output: json_output )
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
# Removes agent-owned worktrees whose branch content is already on main.
|
|
34
|
+
# Still uses Worktree.sweep_stale! which needs classify_worktree_cleanup
|
|
35
|
+
# on Runtime. Migrates to warehouse.sweep_workbenches! when housekeep
|
|
36
|
+
# is absorbed (Phase 4 item 40).
|
|
22
37
|
def sweep_stale_worktrees!
|
|
23
38
|
Worktree.sweep_stale!( runtime: self )
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
# Returns all registered worktrees as Carson::Worktree instances.
|
|
27
42
|
def worktree_list
|
|
28
|
-
|
|
43
|
+
worktree_warehouse.workbenches
|
|
29
44
|
end
|
|
30
45
|
|
|
31
46
|
# Human and JSON status surface for all registered worktrees.
|
|
@@ -109,6 +124,65 @@ module Carson
|
|
|
109
124
|
|
|
110
125
|
private
|
|
111
126
|
|
|
127
|
+
# Build a Warehouse for workbench operations.
|
|
128
|
+
# Always rooted at the main worktree so workbench management
|
|
129
|
+
# works correctly even when called from inside a worktree.
|
|
130
|
+
def worktree_warehouse
|
|
131
|
+
Warehouse.new(
|
|
132
|
+
path: main_worktree_root,
|
|
133
|
+
main_label: config.main_branch,
|
|
134
|
+
bureau_address: config.git_remote
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Render a workbench operation result as JSON or human text.
|
|
139
|
+
# Returns the exit code for CLI dispatch.
|
|
140
|
+
def finish_worktree( result:, json_output: false )
|
|
141
|
+
exit_code = result.fetch( :exit_code, nil )
|
|
142
|
+
status = result[ :status ]
|
|
143
|
+
|
|
144
|
+
# Derive exit code from status if not explicitly set.
|
|
145
|
+
exit_code ||= case status
|
|
146
|
+
when "ok" then EXIT_OK
|
|
147
|
+
when "block" then EXIT_BLOCK
|
|
148
|
+
else EXIT_ERROR
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
result[ :exit_code ] = exit_code
|
|
152
|
+
|
|
153
|
+
if json_output
|
|
154
|
+
output.puts JSON.pretty_generate( result )
|
|
155
|
+
else
|
|
156
|
+
print_worktree_result( result: result )
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
exit_code
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Human-readable output for worktree operation results.
|
|
163
|
+
def print_worktree_result( result: )
|
|
164
|
+
command = result[ :command ]
|
|
165
|
+
status = result[ :status ]
|
|
166
|
+
|
|
167
|
+
case status
|
|
168
|
+
when "ok"
|
|
169
|
+
case command
|
|
170
|
+
when "worktree create"
|
|
171
|
+
puts_line "Worktree created: #{result[ :name ]}"
|
|
172
|
+
puts_line " Path: #{result[ :path ]}"
|
|
173
|
+
puts_line " Branch: #{result[ :branch ]}"
|
|
174
|
+
when "worktree remove"
|
|
175
|
+
puts_line "Worktree removed: #{result[ :name ]}" unless verbose?
|
|
176
|
+
end
|
|
177
|
+
when "error"
|
|
178
|
+
puts_line result[ :error ]
|
|
179
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
180
|
+
when "block"
|
|
181
|
+
puts_line "#{result[ :error ]&.capitalize || 'Held'}: #{result[ :name ]}"
|
|
182
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
112
186
|
def worktree_inventory
|
|
113
187
|
worktree_list.map { |worktree| worktree_inventory_entry( worktree: worktree ) }
|
|
114
188
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
module Carson
|
|
3
3
|
class Runtime
|
|
4
4
|
module Recover
|
|
5
|
-
GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ config
|
|
5
|
+
GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ config/hooks/ ].freeze
|
|
6
6
|
|
|
7
7
|
def recover!( check_name:, json_output: false )
|
|
8
8
|
result = {
|
|
@@ -71,7 +71,7 @@ module Carson
|
|
|
71
71
|
|
|
72
72
|
unless relation.fetch( :related )
|
|
73
73
|
result[ :error ] = "branch does not touch the governance surface for #{check_name}"
|
|
74
|
-
result[ :recovery ] = "update the branch to repair .github/ or config
|
|
74
|
+
result[ :recovery ] = "update the branch to repair .github/ or config/hooks/, then rerun carson recover --check #{check_name.inspect}"
|
|
75
75
|
return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# The warehouse's bureau-facing concern.
|
|
2
|
+
# The warehouse owns the connection to the bureau (GitHub).
|
|
3
|
+
# It queries, files, and registers on behalf of the courier.
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Carson
|
|
7
|
+
class Warehouse
|
|
8
|
+
module Bureau
|
|
9
|
+
|
|
10
|
+
# Check the parcel's status at the bureau using the waybill.
|
|
11
|
+
# Calls gh pr view + gh pr checks. Records findings onto the waybill.
|
|
12
|
+
def check_parcel_at_bureau_with( waybill )
|
|
13
|
+
state = fetch_pr_state_for( waybill.tracking_number )
|
|
14
|
+
ci, ci_diagnostic = fetch_ci_state_for( waybill.tracking_number )
|
|
15
|
+
waybill.record( state: state, ci: ci, ci_diagnostic: ci_diagnostic )
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# File a waybill at the bureau for this parcel.
|
|
19
|
+
# Calls gh pr create. Returns a Waybill with tracking number, or nil on failure.
|
|
20
|
+
def file_waybill_for!( parcel, title: nil, body_file: nil )
|
|
21
|
+
filing_title = title || Waybill.default_title_for( parcel.label )
|
|
22
|
+
arguments = [ "pr", "create", "--title", filing_title, "--head", parcel.label ]
|
|
23
|
+
|
|
24
|
+
if body_file && File.exist?( body_file )
|
|
25
|
+
arguments.push( "--body-file", body_file )
|
|
26
|
+
else
|
|
27
|
+
arguments.push( "--body", "" )
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
stdout, _, status = gh( *arguments )
|
|
31
|
+
tracking_number = nil
|
|
32
|
+
url = nil
|
|
33
|
+
|
|
34
|
+
if status.success?
|
|
35
|
+
url = stdout.to_s.strip
|
|
36
|
+
tracking_number = url.split( "/" ).last.to_i
|
|
37
|
+
tracking_number = nil if tracking_number == 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# If create failed or returned no number, try to find an existing PR.
|
|
41
|
+
unless tracking_number
|
|
42
|
+
tracking_number, url = find_existing_waybill_for( parcel.label )
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return nil unless tracking_number
|
|
46
|
+
|
|
47
|
+
Waybill.new( label: parcel.label, tracking_number: tracking_number, url: url )
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Register the parcel at the bureau using the waybill.
|
|
51
|
+
# Calls gh pr merge. Stamps the waybill on success.
|
|
52
|
+
def register_parcel_at_bureau_with!( waybill, method: )
|
|
53
|
+
_, _, status = gh( "pr", "merge", waybill.tracking_number.to_s, "--#{method}" )
|
|
54
|
+
if status.success?
|
|
55
|
+
waybill.stamp( :accepted )
|
|
56
|
+
else
|
|
57
|
+
# Re-check the state — the merge may have revealed a new blocker.
|
|
58
|
+
check_parcel_at_bureau_with( waybill )
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Fetch PR state from the bureau for a tracking number.
|
|
65
|
+
# Returns the parsed state hash, or nil on failure.
|
|
66
|
+
def fetch_pr_state_for( tracking_number )
|
|
67
|
+
stdout, _, status = gh(
|
|
68
|
+
"pr", "view", tracking_number.to_s,
|
|
69
|
+
"--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
|
|
70
|
+
)
|
|
71
|
+
return nil unless status.success?
|
|
72
|
+
|
|
73
|
+
JSON.parse( stdout )
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fetch CI state from the bureau for a tracking number.
|
|
79
|
+
# Returns [ci_symbol, diagnostic_or_nil].
|
|
80
|
+
# Captures the first line of stderr as diagnostic when the command fails.
|
|
81
|
+
def fetch_ci_state_for( tracking_number )
|
|
82
|
+
stdout, stderr, status = gh(
|
|
83
|
+
"pr", "checks", tracking_number.to_s,
|
|
84
|
+
"--json", "name,bucket"
|
|
85
|
+
)
|
|
86
|
+
unless status.success?
|
|
87
|
+
return [ :error, stderr.to_s.strip.lines.first&.strip ]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
checks = JSON.parse( stdout ) rescue []
|
|
91
|
+
return [ :none, nil ] if checks.empty?
|
|
92
|
+
|
|
93
|
+
buckets = checks.map { it[ "bucket" ].to_s.downcase }
|
|
94
|
+
return [ :fail, nil ] if buckets.include?( "fail" )
|
|
95
|
+
return [ :pending, nil ] if buckets.include?( "pending" )
|
|
96
|
+
|
|
97
|
+
[ :pass, nil ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Try to find an existing PR for this label at the bureau.
|
|
101
|
+
# Returns [tracking_number, url] or [nil, nil].
|
|
102
|
+
def find_existing_waybill_for( label )
|
|
103
|
+
stdout, _, status = gh(
|
|
104
|
+
"pr", "view", label,
|
|
105
|
+
"--json", "number,url,state"
|
|
106
|
+
)
|
|
107
|
+
if status.success?
|
|
108
|
+
data = JSON.parse( stdout ) rescue nil
|
|
109
|
+
if data && data[ "number" ] && data[ "state" ] == "OPEN"
|
|
110
|
+
return [ data[ "number" ], data[ "url" ].to_s ]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
[ nil, nil ]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# The warehouse's workbench seal concern.
|
|
2
|
+
# Once a parcel ships and the waybill is filed, the warehouse seals the
|
|
3
|
+
# workbench. No more packing until the delivery outcome is confirmed.
|
|
4
|
+
# The seal marker lives outside the worktree (~/.carson/seals/) so it
|
|
5
|
+
# does not pollute git status.
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module Carson
|
|
10
|
+
class Warehouse
|
|
11
|
+
module Seal
|
|
12
|
+
|
|
13
|
+
# Seal the workbench — no more packing until delivery outcome is confirmed.
|
|
14
|
+
# The courier seals the workbench after shipping and filing the waybill.
|
|
15
|
+
def seal_workbench!( tracking_number: )
|
|
16
|
+
marker = delivering_marker_path
|
|
17
|
+
FileUtils.mkdir_p( File.dirname( marker ) )
|
|
18
|
+
File.write( marker, "#{tracking_number}\n#{@path}" )
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Unseal the workbench — the courier brought back the parcel.
|
|
22
|
+
# Called when the delivery outcome is held or rejected.
|
|
23
|
+
def unseal_workbench!
|
|
24
|
+
File.delete( delivering_marker_path ) if File.exist?( delivering_marker_path )
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Is this workbench sealed for a delivery in flight?
|
|
28
|
+
def sealed?
|
|
29
|
+
File.exist?( delivering_marker_path )
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The tracking number of the in-flight delivery (nil if not sealed).
|
|
33
|
+
def sealed_tracking_number
|
|
34
|
+
return nil unless sealed?
|
|
35
|
+
File.read( delivering_marker_path ).lines.first.strip
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- Transitional aliases ---
|
|
39
|
+
# Keep old names working until all callers are updated.
|
|
40
|
+
alias seal_shelf! seal_workbench!
|
|
41
|
+
alias unseal_shelf! unseal_workbench!
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Path to the delivery marker file.
|
|
46
|
+
# Lives outside the worktree at ~/.carson/seals/<sha256-of-path>
|
|
47
|
+
# so it does not pollute git status.
|
|
48
|
+
def delivering_marker_path
|
|
49
|
+
seals_dir = File.join( Dir.home, ".carson", "seals" )
|
|
50
|
+
key = Digest::SHA256.hexdigest( @path )
|
|
51
|
+
File.join( seals_dir, key )
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|