carson 3.30.3 → 4.0.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 +19 -0
- data/VERSION +1 -1
- data/lib/carson/config.rb +11 -2
- data/lib/carson/courier.rb +259 -0
- data/lib/carson/parcel.rb +28 -0
- data/lib/carson/runtime/deliver.rb +44 -102
- data/lib/carson/warehouse.rb +151 -0
- data/lib/carson/waybill.rb +243 -0
- data/lib/carson.rb +76 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96b7009510f859ab0892bffc34678701dc74412a4f4b9f17f1be3710cb82d0c3
|
|
4
|
+
data.tar.gz: 236de5bb54d69845b4710b8ffe946a64534a1db5ea068e2a8e34d566d6c438c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 334f80f636c8285fbb7e3bb4481b99efecf0b69700b337ad03a63cc1b9fb0fe0d1ab49765371aa57ede881958cb0d2d9f9cff9104b50d007352edd94fb1cd22e
|
|
7
|
+
data.tar.gz: b6a92f602a9d0fdd9b22ba320616c6fe2db66d51230895b88e32962edaa5c43cfcadfe828278afbeb4111585b10d04781708c2d17dcdb03df10320fa1674e6c9
|
data/RELEASE.md
CHANGED
|
@@ -15,6 +15,25 @@ Release-note scope rule:
|
|
|
15
15
|
- `--all` removed from all repo commands; use `carson list --json` to script batch operations
|
|
16
16
|
- `refresh` is now portfolio-only (always refreshes all governed repos)
|
|
17
17
|
|
|
18
|
+
## 4.0.0
|
|
19
|
+
|
|
20
|
+
### Breaking
|
|
21
|
+
|
|
22
|
+
- **Courier waits at the registry** — `carson deliver` now polls the bureau up to 6 times (default 30s interval) instead of checking once and leaving. PRs that pass CI are merged automatically without re-running `carson deliver`. Agents that relied on instant return from `carson deliver` should expect it to block for up to 3 minutes.
|
|
23
|
+
- **Hold reasons renamed** — JSON output field `hold_reason` values changed: `inspector_pending` → `pending_at_registry`, `inspector_failed` → `failed_at_registry`, `inspector_error` → `error_at_registry`. Agents parsing these values must update.
|
|
24
|
+
- **Hold messages include recovery commands** — Human output for held deliveries now shows actionable recovery steps (e.g. `→ git rebase origin/main` then `→ carson deliver`) instead of prose descriptions.
|
|
25
|
+
- **Bureau model simplified** — No more "customs" or "inspector" in the domain model. The bureau is a registry where bureaucrats check parcels. Internal only — no user-facing impact beyond the hold_reason rename above.
|
|
26
|
+
|
|
27
|
+
### New
|
|
28
|
+
|
|
29
|
+
- **`deliver.poll_interval_at_registry` config** — Controls how long the courier waits between registry checks (default 30 seconds). Override via config or `CARSON_POLL_INTERVAL_AT_REGISTRY` environment variable.
|
|
30
|
+
- **`filed` outcome** — When the courier exhausts all checks without a definitive answer, it reports "filed" with `→ carson status` as the next step, instead of the old "deferred" with `→ carson deliver`.
|
|
31
|
+
|
|
32
|
+
### Migration
|
|
33
|
+
|
|
34
|
+
- If you parse `hold_reason` from JSON output, update: `inspector_pending` → `pending_at_registry`, `inspector_failed` → `failed_at_registry`, `inspector_error` → `error_at_registry`.
|
|
35
|
+
- `carson deliver` now takes up to ~3 minutes (6 checks × 30s). Adjust timeouts in CI or automation scripts if needed.
|
|
36
|
+
|
|
18
37
|
## 3.30.3
|
|
19
38
|
|
|
20
39
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4.0.0
|
data/lib/carson/config.rb
CHANGED
|
@@ -32,7 +32,8 @@ module Carson
|
|
|
32
32
|
:workflow_style,
|
|
33
33
|
:govern_repos, :govern_merge_method,
|
|
34
34
|
:govern_agent_provider, :govern_state_path,
|
|
35
|
-
:govern_check_wait
|
|
35
|
+
:govern_check_wait,
|
|
36
|
+
:poll_interval_at_registry
|
|
36
37
|
|
|
37
38
|
def self.load( repo_root: )
|
|
38
39
|
base_data = default_data
|
|
@@ -81,7 +82,10 @@ module Carson
|
|
|
81
82
|
"audit" => {
|
|
82
83
|
"advisory_check_names" => [ "Scheduled review sweep", "Carson governance", "Tag, release, publish" ]
|
|
83
84
|
},
|
|
84
|
-
"
|
|
85
|
+
"deliver" => {
|
|
86
|
+
"poll_interval_at_registry" => 30
|
|
87
|
+
},
|
|
88
|
+
"govern" => {
|
|
85
89
|
"repos" => [],
|
|
86
90
|
"merge" => {
|
|
87
91
|
"method" => "squash"
|
|
@@ -167,6 +171,8 @@ module Carson
|
|
|
167
171
|
audit = fetch_hash_section( data: copy, key: "audit" )
|
|
168
172
|
advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
|
|
169
173
|
audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
|
|
174
|
+
deliver = fetch_hash_section( data: copy, key: "deliver" )
|
|
175
|
+
deliver[ "poll_interval_at_registry" ] = env_integer( key: "CARSON_POLL_INTERVAL_AT_REGISTRY", fallback: deliver.fetch( "poll_interval_at_registry" ) )
|
|
170
176
|
govern = fetch_hash_section( data: copy, key: "govern" )
|
|
171
177
|
govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
|
|
172
178
|
govern[ "repos" ] = govern_repos unless govern_repos.empty?
|
|
@@ -238,6 +244,9 @@ module Carson
|
|
|
238
244
|
audit_hash = fetch_hash( hash: data, key: "audit" )
|
|
239
245
|
@audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
|
|
240
246
|
|
|
247
|
+
deliver_hash = fetch_hash( hash: data, key: "deliver" )
|
|
248
|
+
@poll_interval_at_registry = fetch_non_negative_integer( hash: deliver_hash, key: "poll_interval_at_registry" )
|
|
249
|
+
|
|
241
250
|
govern_hash = fetch_hash( hash: data, key: "govern" )
|
|
242
251
|
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
|
|
243
252
|
govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Carson Co.
|
|
2
|
+
module Carson
|
|
3
|
+
# The delivery person — picks up parcels and delivers them to the registry.
|
|
4
|
+
#
|
|
5
|
+
# The courier is a Carson employee assigned to a warehouse. They pick up
|
|
6
|
+
# a parcel, ship it to the bureau, file a waybill, and wait at the
|
|
7
|
+
# registry while the bureaucrats check it.
|
|
8
|
+
#
|
|
9
|
+
# The courier is a thin orchestrator: it creates a Waybill and sends it
|
|
10
|
+
# messages. The domain logic lives in the objects, not the courier.
|
|
11
|
+
#
|
|
12
|
+
# == The bureau
|
|
13
|
+
#
|
|
14
|
+
# The bureau is a registry (GitHub) where bureaucrats work. They check
|
|
15
|
+
# parcels (CI, review, mergeability) and either accept them into the
|
|
16
|
+
# registry or hold them with a reason.
|
|
17
|
+
#
|
|
18
|
+
# == Situations the courier encounters
|
|
19
|
+
#
|
|
20
|
+
# Each numbered situation is handled by a specific guard or branch in the
|
|
21
|
+
# delivery flow. The number appears in the code comment where it's handled.
|
|
22
|
+
#
|
|
23
|
+
# 01. Parcel on main — cannot deliver from the destination.
|
|
24
|
+
# 02. Parcel behind standard — not based on client's latest standard.
|
|
25
|
+
# 03. Shipping fails — warehouse couldn't push to the bureau.
|
|
26
|
+
# 04. Waybill filing fails — bureau rejected the paperwork.
|
|
27
|
+
# 05. Pending at registry — bureaucrats still checking (CI running).
|
|
28
|
+
# 06. Failed at registry — bureaucrats rejected (CI failed).
|
|
29
|
+
# 07. Review pending — review still in progress.
|
|
30
|
+
# 08. Review changes requested — reviewer wants corrections.
|
|
31
|
+
# 09. Merge conflict — parcel conflicts with registry contents.
|
|
32
|
+
# 10. Behind standard (post-filing) — standard changed since shipping.
|
|
33
|
+
# 11. Policy block — bureau regulation prevents acceptance.
|
|
34
|
+
# 12. Draft waybill — form not finalised.
|
|
35
|
+
# 13. Mergeability pending — bureau still processing eligibility.
|
|
36
|
+
# 14. Acceptance succeeds — parcel enters the registry. Delivered.
|
|
37
|
+
# 15. Acceptance fails — classify why, report.
|
|
38
|
+
# 16. Bureau unreachable — cannot contact the bureau.
|
|
39
|
+
# 17. Parcel already delivered — already in registry.
|
|
40
|
+
# 18. Waybill closed — cancelled by someone externally.
|
|
41
|
+
#
|
|
42
|
+
# == Design: wait and poll at the registry
|
|
43
|
+
#
|
|
44
|
+
# The courier waits at the registry while the bureaucrats check the parcel.
|
|
45
|
+
# It polls up to MAX_CHECKS_AT_REGISTRY times, pausing between each check.
|
|
46
|
+
# If the bureaucrats give a definitive answer (accepted or rejected), the
|
|
47
|
+
# courier acts immediately. If the checks are exhausted without a definitive
|
|
48
|
+
# answer, the courier reports "filed" — the parcel is still at the registry.
|
|
49
|
+
#
|
|
50
|
+
# == Future: destination modes
|
|
51
|
+
#
|
|
52
|
+
# Currently remote-centred (ship → waybill → registry → acceptance).
|
|
53
|
+
# A future local-centred mode merges locally; remote is a synced backup.
|
|
54
|
+
# The destination mode should be injectable, not baked in.
|
|
55
|
+
class Courier
|
|
56
|
+
# Exit codes — shared contract between Carson employees and the CLI.
|
|
57
|
+
OK = 0
|
|
58
|
+
ERROR = 1
|
|
59
|
+
BLOCKED = 2
|
|
60
|
+
|
|
61
|
+
# The courier checks the registry up to 6 times before leaving.
|
|
62
|
+
MAX_CHECKS_AT_REGISTRY = 6
|
|
63
|
+
|
|
64
|
+
def initialize( warehouse, ledger: nil, merge_method: "rebase", poll_interval_at_registry: 30 )
|
|
65
|
+
@warehouse = warehouse
|
|
66
|
+
@ledger = ledger
|
|
67
|
+
@merge_method = merge_method
|
|
68
|
+
@poll_interval_at_registry = poll_interval_at_registry
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Deliver a parcel to the registry.
|
|
72
|
+
# Ships it, files a waybill, waits at the registry for the bureaucrats.
|
|
73
|
+
def deliver( parcel, title: nil, body_file: nil, commit_message: nil )
|
|
74
|
+
result = {
|
|
75
|
+
command: "deliver",
|
|
76
|
+
label: parcel.label,
|
|
77
|
+
remote_main: "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# 01. Parcel on main — cannot deliver from the destination.
|
|
81
|
+
if parcel.on_main?( @warehouse.main_label )
|
|
82
|
+
return blocked( result,
|
|
83
|
+
"cannot deliver from #{@warehouse.main_label}",
|
|
84
|
+
recovery: "carson worktree create <name>" )
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Dirty tree guard — the warehouse knows if its floor is clean.
|
|
88
|
+
if commit_message && @warehouse.clean?
|
|
89
|
+
return blocked( result,
|
|
90
|
+
"working tree is already clean",
|
|
91
|
+
recovery: "carson deliver" )
|
|
92
|
+
end
|
|
93
|
+
if !commit_message && !@warehouse.clean?
|
|
94
|
+
return blocked( result,
|
|
95
|
+
"working tree is dirty",
|
|
96
|
+
recovery: "carson deliver --commit \"describe this delivery\"" )
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Submit compliance — ensure templates are in sync before delivery.
|
|
100
|
+
compliance = @warehouse.submit_compliance!
|
|
101
|
+
unless compliance[ :compliant ]
|
|
102
|
+
return error( result, compliance[ :error ] || "compliance check failed" )
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Pack the parcel if the sender provided a commit message.
|
|
106
|
+
# Skip if compliance already committed everything (tree is now clean).
|
|
107
|
+
if commit_message && !@warehouse.clean?
|
|
108
|
+
unless @warehouse.pack!( message: commit_message )
|
|
109
|
+
return error( result, "packing failed — nothing to commit?" )
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
# Refresh parcel head — compliance or pack may have created commits.
|
|
113
|
+
parcel = Parcel.new( label: parcel.label, head: @warehouse.current_head, shelf: parcel.shelf )
|
|
114
|
+
|
|
115
|
+
# 02. Parcel behind standard — not based on client's latest standard.
|
|
116
|
+
@warehouse.fetch_latest( registry: @warehouse.main_label )
|
|
117
|
+
unless @warehouse.based_on_latest_standard?( parcel )
|
|
118
|
+
remote_main = "#{@warehouse.bureau_address}/#{@warehouse.main_label}"
|
|
119
|
+
return blocked( result,
|
|
120
|
+
"branch is behind #{remote_main}",
|
|
121
|
+
recovery: "git rebase #{remote_main}, then carson deliver" )
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The courier picks up the parcel — start tracking.
|
|
125
|
+
record( parcel, status: "preparing", summary: "delivery accepted" )
|
|
126
|
+
|
|
127
|
+
# 03. Shipping fails — warehouse couldn't push to the bureau.
|
|
128
|
+
unless @warehouse.ship( parcel )
|
|
129
|
+
return error( result, "push failed" )
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# File a waybill with the bureau.
|
|
133
|
+
waybill = Waybill.new(
|
|
134
|
+
label: parcel.label,
|
|
135
|
+
warehouse_path: @warehouse.path
|
|
136
|
+
)
|
|
137
|
+
waybill.file!( title: title, body_file: body_file )
|
|
138
|
+
|
|
139
|
+
# 04. Waybill filing fails — bureau rejected the paperwork.
|
|
140
|
+
unless waybill.filed?
|
|
141
|
+
return error( result, "PR creation failed", recovery: "carson deliver" )
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
result[ :tracking_number ] = waybill.tracking_number
|
|
145
|
+
result[ :url ] = waybill.url
|
|
146
|
+
|
|
147
|
+
# Wait at the registry while the bureaucrats check the parcel.
|
|
148
|
+
wait_and_poll_at_registry( waybill, result )
|
|
149
|
+
|
|
150
|
+
# Update the ledger with the final outcome.
|
|
151
|
+
record( parcel, status: result[ :outcome ] || "filed", summary: result[ :hold_reason ] )
|
|
152
|
+
|
|
153
|
+
result[ :exit ] ||= OK
|
|
154
|
+
result
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Wait at the registry, polling the bureaucrats up to MAX_CHECKS_AT_REGISTRY
|
|
160
|
+
# times. The courier stays until a definitive answer comes back or the
|
|
161
|
+
# checks are exhausted.
|
|
162
|
+
def wait_and_poll_at_registry( waybill, result )
|
|
163
|
+
MAX_CHECKS_AT_REGISTRY.times do |check|
|
|
164
|
+
waybill.refresh!
|
|
165
|
+
|
|
166
|
+
# 14/17. Already accepted — parcel is in the registry.
|
|
167
|
+
if waybill.accepted?
|
|
168
|
+
result[ :outcome ] = "delivered"
|
|
169
|
+
result[ :synced ] = @warehouse.receive_latest_standard!
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 18. Waybill closed — cancelled externally.
|
|
174
|
+
if waybill.rejected?
|
|
175
|
+
result[ :outcome ] = "rejected"
|
|
176
|
+
result[ :exit ] = BLOCKED
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Cleared or mergeability pending — try to accept.
|
|
181
|
+
if waybill.cleared? || waybill.mergeability_pending?
|
|
182
|
+
waybill.accept!( method: @merge_method )
|
|
183
|
+
|
|
184
|
+
if waybill.accepted?
|
|
185
|
+
result[ :outcome ] = "delivered"
|
|
186
|
+
result[ :synced ] = @warehouse.receive_latest_standard!
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 05-12. Definitively blocked — courier takes parcel back.
|
|
192
|
+
if definitively_blocked?( waybill )
|
|
193
|
+
result[ :outcome ] = "held"
|
|
194
|
+
result[ :exit ] = BLOCKED
|
|
195
|
+
result[ :hold_reason ] = waybill.hold_reason
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Still waiting — pause before the next check.
|
|
200
|
+
pause_between_polls unless check == MAX_CHECKS_AT_REGISTRY - 1
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Exhausted all checks — bureau hasn't given a definitive answer.
|
|
204
|
+
result[ :outcome ] = "filed"
|
|
205
|
+
result[ :hold_reason ] = waybill.hold_reason
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Is the waybill blocked by something that won't resolve by waiting?
|
|
209
|
+
# CI failure, merge conflict, policy block — the courier should take
|
|
210
|
+
# the parcel back immediately.
|
|
211
|
+
def definitively_blocked?( waybill )
|
|
212
|
+
return false unless waybill.held?
|
|
213
|
+
reason = waybill.hold_reason
|
|
214
|
+
[ "failed_at_registry", "merge_conflict",
|
|
215
|
+
"behind_registry", "policy_block", "draft" ].include?( reason )
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Pause between poll checks. Overridable for test isolation.
|
|
219
|
+
def pause_between_polls
|
|
220
|
+
sleep @poll_interval_at_registry
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Record a delivery state change in the ledger.
|
|
224
|
+
# No-op when no ledger is injected (e.g. tests).
|
|
225
|
+
def record( parcel, status:, summary: nil )
|
|
226
|
+
return unless @ledger
|
|
227
|
+
|
|
228
|
+
# The ledger needs a repository-like object with .path pointing
|
|
229
|
+
# to the main warehouse root (not a side shelf).
|
|
230
|
+
repo = Struct.new( :path ).new( @warehouse.main_worktree_root )
|
|
231
|
+
@ledger.upsert_delivery(
|
|
232
|
+
repository: repo,
|
|
233
|
+
branch_name: parcel.label,
|
|
234
|
+
head: parcel.head,
|
|
235
|
+
worktree_path: @warehouse.path,
|
|
236
|
+
pr_number: nil,
|
|
237
|
+
pr_url: nil,
|
|
238
|
+
status: status,
|
|
239
|
+
summary: summary,
|
|
240
|
+
cause: nil
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Build a blocked result — the courier cannot proceed.
|
|
245
|
+
def blocked( result, message, recovery: nil )
|
|
246
|
+
result[ :exit ] = BLOCKED
|
|
247
|
+
result[ :error ] = message
|
|
248
|
+
result[ :recovery ] = recovery
|
|
249
|
+
result
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def error( result, message, recovery: nil )
|
|
253
|
+
result[ :exit ] = ERROR
|
|
254
|
+
result[ :error ] = message
|
|
255
|
+
result[ :recovery ] = recovery
|
|
256
|
+
result
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# The committed changes on a branch — the thing being delivered.
|
|
2
|
+
#
|
|
3
|
+
# In the FedEx metaphor, a parcel sits on a shelf (worktree) identified
|
|
4
|
+
# by a label (branch name). Agents put committed changes — the parcel —
|
|
5
|
+
# on a branch, then call Carson to deliver it.
|
|
6
|
+
#
|
|
7
|
+
# The parcel does not deliver itself. The courier does that.
|
|
8
|
+
# The parcel does not pack itself. The warehouse does that.
|
|
9
|
+
module Carson
|
|
10
|
+
# The committed changes being delivered. Knows its label (branch),
|
|
11
|
+
# head (commit SHA), and shelf (worktree). The protagonist of every
|
|
12
|
+
# delivery — without a parcel, there is nothing to deliver.
|
|
13
|
+
class Parcel
|
|
14
|
+
attr_reader :label, :head, :shelf
|
|
15
|
+
|
|
16
|
+
def initialize( label:, head:, shelf: nil )
|
|
17
|
+
@label = label
|
|
18
|
+
@head = head
|
|
19
|
+
@shelf = shelf
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Is this parcel sitting on the main shelf?
|
|
23
|
+
# The main shelf is the destination — you cannot deliver FROM the destination.
|
|
24
|
+
def on_main?( main_label )
|
|
25
|
+
label == main_label
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -6,119 +6,61 @@ module Carson
|
|
|
6
6
|
DELIVER_MERGE_ATTEMPT_CAP = 3
|
|
7
7
|
|
|
8
8
|
# Entry point for `carson deliver`.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# When --commit is supplied, Carson creates one all-dirty agent-authored commit first.
|
|
9
|
+
# Delegates to the OO domain model: Warehouse → Courier → Waybill.
|
|
10
|
+
# The Courier orchestrates the delivery; Carson renders the result.
|
|
12
11
|
def deliver!( title: nil, body_file: nil, commit_message: nil, json_output: false )
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
branch: branch_name,
|
|
19
|
-
git_remote: remote_name,
|
|
20
|
-
watch_window_seconds: config.govern_check_wait.to_i,
|
|
21
|
-
waited_seconds: 0,
|
|
22
|
-
merge_attempted: false
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if branch_name == main_branch
|
|
26
|
-
result[ :error ] = "cannot deliver from #{main_branch}"
|
|
27
|
-
result[ :recovery ] = "carson worktree create <name>"
|
|
28
|
-
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
initial_dirty = working_tree_dirty?
|
|
32
|
-
if initial_dirty && commit_message.to_s.strip.empty?
|
|
33
|
-
result[ :error ] = "working tree is dirty"
|
|
34
|
-
result[ :recovery ] = "carson deliver --commit \"describe this delivery\""
|
|
35
|
-
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
if !initial_dirty && !commit_message.to_s.strip.empty?
|
|
39
|
-
result[ :commit ] = blocked_commit_payload(
|
|
40
|
-
message: commit_message,
|
|
41
|
-
summary: "blocked — working tree is already clean"
|
|
42
|
-
)
|
|
43
|
-
result[ :error ] = "working tree is already clean"
|
|
44
|
-
result[ :recovery ] = "carson deliver"
|
|
45
|
-
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
sync_exit, sync_diagnostics = deliver_template_sync
|
|
49
|
-
if sync_exit == EXIT_ERROR
|
|
50
|
-
result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
|
|
51
|
-
return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
|
|
52
|
-
end
|
|
53
|
-
template_sync_committed = sync_exit == EXIT_BLOCK
|
|
54
|
-
|
|
55
|
-
unless commit_message.to_s.strip.empty?
|
|
56
|
-
commit_exit = prepare_delivery_commit!(
|
|
57
|
-
commit_message: commit_message,
|
|
58
|
-
template_sync_committed: template_sync_committed,
|
|
59
|
-
result: result
|
|
60
|
-
)
|
|
61
|
-
return deliver_finish( result: result, exit_code: commit_exit, json_output: json_output ) unless commit_exit == EXIT_OK
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
freshness = assess_branch_freshness(
|
|
65
|
-
head_ref: current_head,
|
|
66
|
-
remote: remote_name,
|
|
67
|
-
main: main_branch
|
|
12
|
+
warehouse = Warehouse.new(
|
|
13
|
+
path: work_dir,
|
|
14
|
+
main_label: config.main_branch,
|
|
15
|
+
bureau_address: config.git_remote,
|
|
16
|
+
compliance_checker: method( :deliver_compliance_checker )
|
|
68
17
|
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
push_exit = push_branch!( branch: branch_name, remote: remote_name, result: result )
|
|
79
|
-
return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
|
|
18
|
+
parcel = Parcel.new(
|
|
19
|
+
label: current_branch,
|
|
20
|
+
head: current_head
|
|
21
|
+
)
|
|
22
|
+
courier = Courier.new( warehouse,
|
|
23
|
+
ledger: ledger,
|
|
24
|
+
merge_method: config.govern_merge_method,
|
|
25
|
+
poll_interval_at_registry: config.poll_interval_at_registry
|
|
26
|
+
)
|
|
80
27
|
|
|
81
|
-
|
|
82
|
-
branch: branch_name,
|
|
28
|
+
result = courier.deliver( parcel,
|
|
83
29
|
title: title,
|
|
84
30
|
body_file: body_file,
|
|
85
|
-
|
|
86
|
-
)
|
|
87
|
-
return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output ) if pr_number.nil?
|
|
88
|
-
|
|
89
|
-
branch = branch_record( name: branch_name )
|
|
90
|
-
delivery = ledger.upsert_delivery(
|
|
91
|
-
repository: repository_record,
|
|
92
|
-
branch_name: branch.name,
|
|
93
|
-
head: branch.head || current_head,
|
|
94
|
-
worktree_path: branch.worktree || repo_root,
|
|
95
|
-
pr_number: pr_number,
|
|
96
|
-
pr_url: pr_url,
|
|
97
|
-
status: "preparing",
|
|
98
|
-
summary: "delivery accepted",
|
|
99
|
-
cause: nil
|
|
100
|
-
)
|
|
101
|
-
delivery = settle_delivery!(
|
|
102
|
-
delivery: delivery,
|
|
103
|
-
branch_name: branch.name,
|
|
104
|
-
remote: remote_name,
|
|
105
|
-
main: main_branch,
|
|
106
|
-
result: result
|
|
31
|
+
commit_message: commit_message
|
|
107
32
|
)
|
|
108
33
|
|
|
109
|
-
result
|
|
110
|
-
result[ :pr_url ] = pr_url
|
|
111
|
-
result[ :ci ] = "pass" if delivery.integrated?
|
|
112
|
-
result[ :delivery ] = delivery_payload( delivery: delivery )
|
|
113
|
-
result[ :main_branch ] = main_branch
|
|
114
|
-
result[ :summary ] = delivery.summary
|
|
115
|
-
result[ :next_step ] = deliver_next_step( delivery: delivery, result: result )
|
|
116
|
-
|
|
117
|
-
deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
34
|
+
deliver_oo_finish( result: result, json_output: json_output )
|
|
118
35
|
end
|
|
119
36
|
|
|
120
37
|
private
|
|
121
38
|
|
|
39
|
+
# --- OO bridge methods ---
|
|
40
|
+
|
|
41
|
+
# Compliance checker for the Warehouse. Wraps the existing template_apply!
|
|
42
|
+
# machinery and returns the hash contract submit_compliance! expects.
|
|
43
|
+
def deliver_compliance_checker( _warehouse )
|
|
44
|
+
sync_exit, sync_diagnostics = deliver_template_sync
|
|
45
|
+
case sync_exit
|
|
46
|
+
when EXIT_OK
|
|
47
|
+
{ compliant: true, committed: false }
|
|
48
|
+
when EXIT_BLOCK
|
|
49
|
+
{ compliant: true, committed: true }
|
|
50
|
+
else
|
|
51
|
+
{ compliant: false, committed: false, error: sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Render the OO result — JSON or human via Carson.report.
|
|
56
|
+
def deliver_oo_finish( result:, json_output: )
|
|
57
|
+
format = json_output ? :json : :human
|
|
58
|
+
Carson.report( result, format: format, output: output )
|
|
59
|
+
result[ :exit ] || Courier::OK
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- Legacy deliver methods (used by receive!, status!, etc.) ---
|
|
63
|
+
|
|
122
64
|
def prepare_delivery_commit!( commit_message:, template_sync_committed:, result: )
|
|
123
65
|
if working_tree_dirty?
|
|
124
66
|
return create_delivery_commit!( commit_message: commit_message, result: result )
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# A governed repository. In the FedEx metaphor, the warehouse is where
|
|
2
|
+
# parcels (committed changes) are stored on shelves (worktrees) with
|
|
3
|
+
# labels (branches). Git commands are hidden inside — callers never
|
|
4
|
+
# see git terms.
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Carson
|
|
8
|
+
# A governed repository — the warehouse where parcels are stored on
|
|
9
|
+
# shelves (worktrees) with labels (branches). Wraps git operations
|
|
10
|
+
# with story-language methods. An intelligent warehouse that manages
|
|
11
|
+
# itself: packing parcels, checking compliance, and sweeping up.
|
|
12
|
+
class Warehouse
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
def initialize( path:, main_label: "main", bureau_address: "github", compliance_checker: nil )
|
|
16
|
+
@path = path
|
|
17
|
+
@main_label = main_label
|
|
18
|
+
@bureau_address = bureau_address
|
|
19
|
+
@compliance_checker = compliance_checker
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# --- What the warehouse knows ---
|
|
23
|
+
|
|
24
|
+
# The label on the current shelf (branch name).
|
|
25
|
+
def current_label
|
|
26
|
+
git( "rev-parse", "--abbrev-ref", "HEAD" ).first.strip
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The tip of the parcel on the current shelf (commit SHA).
|
|
30
|
+
def current_head
|
|
31
|
+
git( "rev-parse", "HEAD" ).first.strip
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The destination label (from config).
|
|
35
|
+
def main_label
|
|
36
|
+
@main_label
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The bureau's address (remote name).
|
|
40
|
+
def bureau_address
|
|
41
|
+
@bureau_address
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Is the warehouse floor clean? No uncommitted changes on the current shelf.
|
|
45
|
+
def clean?
|
|
46
|
+
output, _, status = git( "status", "--porcelain" )
|
|
47
|
+
status.success? && output.strip.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Warehouse operations ---
|
|
51
|
+
|
|
52
|
+
# Ship a parcel to the bureau.
|
|
53
|
+
# The warehouse sends the parcel's label to the remote.
|
|
54
|
+
def ship( parcel, remote: bureau_address )
|
|
55
|
+
_, _, status = git( "push", "-u", remote, parcel.label )
|
|
56
|
+
status.success?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get latest registry state from the bureau (git fetch).
|
|
60
|
+
# Returns true on success, false on failure.
|
|
61
|
+
def fetch_latest( remote: bureau_address, registry: nil )
|
|
62
|
+
arguments = [ "fetch", remote ]
|
|
63
|
+
arguments << registry if registry
|
|
64
|
+
_, _, status = git( *arguments )
|
|
65
|
+
status.success?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Is the parcel based on the client's latest production standard?
|
|
69
|
+
# Checks whether the registry tip is an ancestor of the parcel's head.
|
|
70
|
+
def based_on_latest_standard?( parcel, registry: "#{bureau_address}/#{main_label}" )
|
|
71
|
+
_, _, status = git( "merge-base", "--is-ancestor", registry, parcel.head )
|
|
72
|
+
status.success?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Ensure the warehouse complies with company standards (template sync).
|
|
76
|
+
# Delegates to the injected compliance checker. If no checker is set,
|
|
77
|
+
# the warehouse assumes compliance — no templates to enforce.
|
|
78
|
+
# Returns a hash: { compliant: true/false, committed: true/false, error: nil/string }
|
|
79
|
+
def submit_compliance!
|
|
80
|
+
return { compliant: true, committed: false } unless @compliance_checker
|
|
81
|
+
|
|
82
|
+
@compliance_checker.call( self )
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Update the warehouse's production standard — rebase onto latest registry state.
|
|
86
|
+
# Called after the bureau refuses a parcel for being behind standard.
|
|
87
|
+
# Returns true on success, false on failure.
|
|
88
|
+
def rebase_on_latest_standard!( registry: "#{bureau_address}/#{main_label}" )
|
|
89
|
+
_, _, status = git( "rebase", registry )
|
|
90
|
+
status.success?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Pack a parcel — stage all changes and commit.
|
|
94
|
+
# Returns true on success, false on failure.
|
|
95
|
+
def pack!( message: )
|
|
96
|
+
git( "add", "-A" )
|
|
97
|
+
_, _, status = git( "commit", "-m", message )
|
|
98
|
+
status.success?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Receive the latest standard from the registry after a parcel is accepted.
|
|
102
|
+
# Fast-forwards local main without switching branches.
|
|
103
|
+
# Returns true on success, false on failure.
|
|
104
|
+
def receive_latest_standard!( remote: bureau_address )
|
|
105
|
+
_, _, status = Open3.capture3(
|
|
106
|
+
"git", "-C", main_worktree_root,
|
|
107
|
+
"fetch", remote, "#{main_label}:#{main_label}"
|
|
108
|
+
)
|
|
109
|
+
status.success?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- Inventory ---
|
|
113
|
+
|
|
114
|
+
# All shelves (worktree paths).
|
|
115
|
+
def shelves
|
|
116
|
+
output, = git( "worktree", "list", "--porcelain" )
|
|
117
|
+
output.lines
|
|
118
|
+
.select { it.start_with?( "worktree " ) }
|
|
119
|
+
.map { it.sub( "worktree ", "" ).strip }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# All labels (branch names).
|
|
123
|
+
def labels
|
|
124
|
+
output, = git( "branch", "--format", "%(refname:short)" )
|
|
125
|
+
output.lines.map { it.strip }.reject { it.empty? }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Has this label been merged into main?
|
|
129
|
+
def label_absorbed?( name )
|
|
130
|
+
merged_output, = git( "branch", "--merged", main_label, "--format", "%(refname:short)" )
|
|
131
|
+
merged_output.lines.map { it.strip }.include?( name )
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The main warehouse location — resolves correctly even from a side shelf.
|
|
135
|
+
# Used by sync! and ledger recording to always reference the canonical path.
|
|
136
|
+
def main_worktree_root
|
|
137
|
+
git_common_dir, = git( "rev-parse", "--path-format=absolute", "--git-common-dir" )
|
|
138
|
+
common = git_common_dir.strip
|
|
139
|
+
# If it ends with /.git, the parent is the main worktree root.
|
|
140
|
+
common.end_with?( "/.git" ) ? File.dirname( common ) : common
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# All git commands go through this single gateway.
|
|
146
|
+
# Returns [stdout, stderr, status].
|
|
147
|
+
def git( *arguments )
|
|
148
|
+
Open3.capture3( "git", "-C", path, *arguments )
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# The shipping document filed with the bureau (GitHub PR).
|
|
2
|
+
#
|
|
3
|
+
# The courier files a waybill with the bureau when delivering a parcel.
|
|
4
|
+
# The waybill has a tracking number (PR number), knows the bureau's
|
|
5
|
+
# response (cleared/held/accepted/rejected), and can ask the bureau
|
|
6
|
+
# to accept the parcel into the registry.
|
|
7
|
+
#
|
|
8
|
+
# The bureau is a registry where bureaucrats work. They check parcels
|
|
9
|
+
# (CI, review, mergeability) and either accept them into the registry
|
|
10
|
+
# or hold them with a reason.
|
|
11
|
+
#
|
|
12
|
+
# The waybill uses gh CLI internally — that's a tool, not the domain.
|
|
13
|
+
require "json"
|
|
14
|
+
require "open3"
|
|
15
|
+
|
|
16
|
+
module Carson
|
|
17
|
+
# The shipping document filed with the bureau (GitHub PR). Has a
|
|
18
|
+
# tracking number, knows the bureaucrats' response (cleared/held/
|
|
19
|
+
# accepted/rejected), and can ask the bureau to accept the parcel
|
|
20
|
+
# into the registry. Uses gh CLI internally — that's a tool, not
|
|
21
|
+
# the domain.
|
|
22
|
+
class Waybill
|
|
23
|
+
attr_reader :tracking_number, :url, :label
|
|
24
|
+
|
|
25
|
+
def initialize( label:, warehouse_path:, tracking_number: nil, url: nil, review_gate: nil )
|
|
26
|
+
@label = label
|
|
27
|
+
@warehouse_path = warehouse_path
|
|
28
|
+
@tracking_number = tracking_number
|
|
29
|
+
@url = url
|
|
30
|
+
@review_gate = review_gate
|
|
31
|
+
@state = nil
|
|
32
|
+
@ci = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- Filing ---
|
|
36
|
+
|
|
37
|
+
# Has the waybill been filed with the bureau?
|
|
38
|
+
def filed?
|
|
39
|
+
!tracking_number.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# File the waybill with the bureau. Creates a PR on GitHub.
|
|
43
|
+
def file!( title: nil, body_file: nil )
|
|
44
|
+
filing_title = title || default_title
|
|
45
|
+
arguments = [ "pr", "create", "--title", filing_title, "--head", label ]
|
|
46
|
+
|
|
47
|
+
if body_file && File.exist?( body_file )
|
|
48
|
+
arguments.push( "--body-file", body_file )
|
|
49
|
+
else
|
|
50
|
+
arguments.push( "--body", "" )
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
stdout, stderr, success, = gh( *arguments )
|
|
54
|
+
if success
|
|
55
|
+
@url = stdout.to_s.strip
|
|
56
|
+
@tracking_number = @url.split( "/" ).last.to_i
|
|
57
|
+
@tracking_number = nil if @tracking_number == 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# If create failed or returned no number, try to find existing.
|
|
61
|
+
find_existing! unless filed?
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate a human-readable title from the label.
|
|
66
|
+
def default_title
|
|
67
|
+
label.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) do |character|
|
|
68
|
+
character.upcase
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- Bureau's response ---
|
|
73
|
+
|
|
74
|
+
# Check with the bureau for the latest on this waybill.
|
|
75
|
+
def refresh!
|
|
76
|
+
@state = fetch_state
|
|
77
|
+
@ci = fetch_ci
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Has the bureau accepted the parcel into the registry?
|
|
82
|
+
def accepted?
|
|
83
|
+
@state&.dig( "state" ) == "MERGED"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Has the bureau rejected the waybill (closed without merge)?
|
|
87
|
+
def rejected?
|
|
88
|
+
@state&.dig( "state" ) == "CLOSED"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Is the waybill still a draft?
|
|
92
|
+
def draft?
|
|
93
|
+
@state&.dig( "isDraft" ) || false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Has the bureau cleared the parcel for delivery?
|
|
97
|
+
# All bureaucrats pass, no merge blocks, merge state is clean.
|
|
98
|
+
def cleared?
|
|
99
|
+
return false unless filed?
|
|
100
|
+
return false if draft?
|
|
101
|
+
return false unless @ci == :pass
|
|
102
|
+
return false if merge_conflicting? || merge_behind? || merge_policy_blocked?
|
|
103
|
+
merge_status = @state&.dig( "mergeStateStatus" ).to_s.upcase
|
|
104
|
+
mergeable = @state&.dig( "mergeable" ).to_s.upcase
|
|
105
|
+
merge_status == "CLEAN" || mergeable == "MERGEABLE"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Is something blocking this waybill?
|
|
109
|
+
def held?
|
|
110
|
+
return false if cleared? || accepted? || rejected?
|
|
111
|
+
filed?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Why is the waybill being held?
|
|
115
|
+
def hold_reason
|
|
116
|
+
return "draft" if draft?
|
|
117
|
+
return "pending_at_registry" if @ci == :pending
|
|
118
|
+
return "failed_at_registry" if @ci == :fail
|
|
119
|
+
return "error_at_registry" if @ci == :error
|
|
120
|
+
return "merge_conflict" if merge_conflicting?
|
|
121
|
+
return "behind_registry" if merge_behind?
|
|
122
|
+
return "policy_block" if merge_policy_blocked?
|
|
123
|
+
"mergeability_pending"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Human-readable explanation of why the waybill is held.
|
|
127
|
+
def hold_summary
|
|
128
|
+
case hold_reason
|
|
129
|
+
when "draft" then "waybill is still a draft"
|
|
130
|
+
when "pending_at_registry" then "waiting for bureaucrats to check"
|
|
131
|
+
when "failed_at_registry" then "bureaucrats rejected the parcel"
|
|
132
|
+
when "error_at_registry" then "unable to reach the bureaucrats"
|
|
133
|
+
when "merge_conflict" then "parcel has conflicts with registry"
|
|
134
|
+
when "behind_registry" then "parcel is behind the registry"
|
|
135
|
+
when "policy_block" then "blocked by bureau policy"
|
|
136
|
+
else "waiting for bureau assessment"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Is the hold specifically because mergeability is still pending?
|
|
141
|
+
def mergeability_pending?
|
|
142
|
+
hold_reason == "mergeability_pending"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --- Acceptance ---
|
|
146
|
+
|
|
147
|
+
# Ask the bureau to accept the parcel into the registry.
|
|
148
|
+
# Updates own state after the attempt.
|
|
149
|
+
def accept!( method: )
|
|
150
|
+
gh( "pr", "merge", tracking_number.to_s, "--#{method}" )
|
|
151
|
+
refresh!
|
|
152
|
+
self
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- Observation data for delivery records ---
|
|
156
|
+
|
|
157
|
+
# Returns a hash of the bureau's current state for tracking records.
|
|
158
|
+
def to_observation
|
|
159
|
+
return {} unless @state.is_a?( Hash )
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
pull_request_state: @state[ "state" ],
|
|
163
|
+
pull_request_draft: @state[ "isDraft" ],
|
|
164
|
+
pull_request_merged_at: @state[ "mergedAt" ]
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- Test support ---
|
|
169
|
+
|
|
170
|
+
# Stub the bureau's response for testing without gh CLI.
|
|
171
|
+
def stub_bureau_response( state: nil, ci: nil )
|
|
172
|
+
@state = state if state
|
|
173
|
+
@ci = ci if ci
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def merge_conflicting?
|
|
179
|
+
status = @state&.dig( "mergeStateStatus" ).to_s.upcase
|
|
180
|
+
mergeable = @state&.dig( "mergeable" ).to_s.upcase
|
|
181
|
+
mergeable == "CONFLICTING" || status == "DIRTY" || status == "CONFLICTING"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def merge_behind?
|
|
185
|
+
@state&.dig( "mergeStateStatus" ).to_s.upcase == "BEHIND"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def merge_policy_blocked?
|
|
189
|
+
@state&.dig( "mergeStateStatus" ).to_s.upcase == "BLOCKED"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fetch_state
|
|
193
|
+
stdout, _, success, = gh(
|
|
194
|
+
"pr", "view", tracking_number.to_s,
|
|
195
|
+
"--json", "number,state,isDraft,url,mergeStateStatus,mergeable,mergedAt"
|
|
196
|
+
)
|
|
197
|
+
return nil unless success
|
|
198
|
+
|
|
199
|
+
JSON.parse( stdout )
|
|
200
|
+
rescue JSON::ParserError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def fetch_ci
|
|
205
|
+
stdout, _, success, = gh(
|
|
206
|
+
"pr", "checks", tracking_number.to_s,
|
|
207
|
+
"--json", "name,bucket"
|
|
208
|
+
)
|
|
209
|
+
return :error unless success
|
|
210
|
+
|
|
211
|
+
checks = JSON.parse( stdout ) rescue []
|
|
212
|
+
return :none if checks.empty?
|
|
213
|
+
|
|
214
|
+
buckets = checks.map do |entry|
|
|
215
|
+
entry[ "bucket" ].to_s.downcase
|
|
216
|
+
end
|
|
217
|
+
return :fail if buckets.include?( "fail" )
|
|
218
|
+
return :pending if buckets.include?( "pending" )
|
|
219
|
+
|
|
220
|
+
:pass
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def find_existing!
|
|
224
|
+
stdout, _, success, = gh(
|
|
225
|
+
"pr", "view", label,
|
|
226
|
+
"--json", "number,url,state"
|
|
227
|
+
)
|
|
228
|
+
if success
|
|
229
|
+
data = JSON.parse( stdout ) rescue nil
|
|
230
|
+
if data && data[ "number" ] && data[ "state" ] == "OPEN"
|
|
231
|
+
@tracking_number = data[ "number" ]
|
|
232
|
+
@url = data[ "url" ].to_s
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# All gh commands go through this single gateway.
|
|
238
|
+
def gh( *arguments )
|
|
239
|
+
stdout, stderr, status = Open3.capture3( "gh", *arguments, chdir: @warehouse_path )
|
|
240
|
+
[ stdout, stderr, status.success?, status.exitstatus ]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
data/lib/carson.rb
CHANGED
|
@@ -3,6 +3,78 @@ require_relative "carson/version"
|
|
|
3
3
|
|
|
4
4
|
module Carson
|
|
5
5
|
BADGE = "\u29D3".freeze # ⧓ BLACK BOWTIE (U+29D3)
|
|
6
|
+
|
|
7
|
+
# The company renders results for whoever is listening.
|
|
8
|
+
# JSON is the primary format (agents consume it). Human-readable is secondary.
|
|
9
|
+
# Domain objects return result hashes — Carson decides how to present them.
|
|
10
|
+
def self.report( result, format: :json, output: $stdout )
|
|
11
|
+
case format
|
|
12
|
+
when :json
|
|
13
|
+
require "json"
|
|
14
|
+
output.puts JSON.pretty_generate( result )
|
|
15
|
+
when :human
|
|
16
|
+
report_human( result, output: output )
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Human-readable delivery report — technical language for agents and humans.
|
|
21
|
+
# Story language is internal (source code). Output speaks the client's language.
|
|
22
|
+
def self.report_human( result, output: $stdout )
|
|
23
|
+
if result[ :error ]
|
|
24
|
+
output.puts "#{BADGE} #{result[ :error ]}"
|
|
25
|
+
output.puts " \u2192 #{result[ :recovery ]}" if result[ :recovery ]
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
output.puts "#{BADGE} Delivery: #{result[ :label ]}" if result[ :label ]
|
|
30
|
+
output.puts "#{BADGE} PR ##{result[ :tracking_number ]} #{result[ :url ]}" if result[ :tracking_number ]
|
|
31
|
+
|
|
32
|
+
remote_main = result[ :remote_main ] || "origin/main"
|
|
33
|
+
|
|
34
|
+
case result[ :outcome ]
|
|
35
|
+
when "delivered"
|
|
36
|
+
output.puts "#{BADGE} Merged."
|
|
37
|
+
output.puts "#{BADGE} Local main synced." if result[ :synced ]
|
|
38
|
+
when "held"
|
|
39
|
+
diagnosis, *recovery_steps = translate_hold( result[ :hold_reason ], remote_main: remote_main )
|
|
40
|
+
output.puts "#{BADGE} #{diagnosis}"
|
|
41
|
+
recovery_steps.each do |step|
|
|
42
|
+
output.puts " \u2192 #{step}"
|
|
43
|
+
end
|
|
44
|
+
when "rejected"
|
|
45
|
+
output.puts "#{BADGE} PR closed externally."
|
|
46
|
+
when "filed"
|
|
47
|
+
output.puts "#{BADGE} Bureau hasn't responded yet. Run carson status to check back."
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Translate internal hold reasons to agent-actionable output.
|
|
52
|
+
# Returns [ diagnosis, *recovery_steps ]. The diagnosis says what
|
|
53
|
+
# happened. Each recovery step is a command the agent can execute.
|
|
54
|
+
def self.translate_hold( reason, remote_main: "origin/main" )
|
|
55
|
+
case reason
|
|
56
|
+
when "draft"
|
|
57
|
+
[ "PR is still a draft." ]
|
|
58
|
+
when "pending_at_registry"
|
|
59
|
+
[ "Waiting for CI checks.", "carson status" ]
|
|
60
|
+
when "failed_at_registry"
|
|
61
|
+
[ "CI checks failed.", "carson deliver" ]
|
|
62
|
+
when "error_at_registry"
|
|
63
|
+
[ "Unable to assess CI checks.", "carson status" ]
|
|
64
|
+
when "merge_conflict"
|
|
65
|
+
[ "Merge conflict with #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
|
|
66
|
+
when "behind_registry"
|
|
67
|
+
[ "Branch is behind #{remote_main}.", "git rebase #{remote_main}", "carson deliver" ]
|
|
68
|
+
when "policy_block"
|
|
69
|
+
[ "Blocked by branch protection rules." ]
|
|
70
|
+
when "mergeability_pending"
|
|
71
|
+
[ "GitHub is calculating mergeability.", "carson status" ]
|
|
72
|
+
else
|
|
73
|
+
[ "Waiting for merge readiness.", "carson status" ]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :report_human
|
|
6
78
|
end
|
|
7
79
|
|
|
8
80
|
require_relative "carson/repository"
|
|
@@ -10,6 +82,10 @@ require_relative "carson/branch"
|
|
|
10
82
|
require_relative "carson/delivery"
|
|
11
83
|
require_relative "carson/revision"
|
|
12
84
|
require_relative "carson/ledger"
|
|
85
|
+
require_relative "carson/parcel"
|
|
86
|
+
require_relative "carson/warehouse"
|
|
87
|
+
require_relative "carson/waybill"
|
|
88
|
+
require_relative "carson/courier"
|
|
13
89
|
require_relative "carson/worktree"
|
|
14
90
|
require_relative "carson/config"
|
|
15
91
|
require_relative "carson/adapters/git"
|
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:
|
|
4
|
+
version: 4.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -70,8 +70,10 @@ files:
|
|
|
70
70
|
- lib/carson/branch.rb
|
|
71
71
|
- lib/carson/cli.rb
|
|
72
72
|
- lib/carson/config.rb
|
|
73
|
+
- lib/carson/courier.rb
|
|
73
74
|
- lib/carson/delivery.rb
|
|
74
75
|
- lib/carson/ledger.rb
|
|
76
|
+
- lib/carson/parcel.rb
|
|
75
77
|
- lib/carson/repository.rb
|
|
76
78
|
- lib/carson/revision.rb
|
|
77
79
|
- lib/carson/runtime.rb
|
|
@@ -100,6 +102,8 @@ files:
|
|
|
100
102
|
- lib/carson/runtime/setup.rb
|
|
101
103
|
- lib/carson/runtime/status.rb
|
|
102
104
|
- lib/carson/version.rb
|
|
105
|
+
- lib/carson/warehouse.rb
|
|
106
|
+
- lib/carson/waybill.rb
|
|
103
107
|
- lib/carson/worktree.rb
|
|
104
108
|
homepage: https://github.com/wanghailei/carson
|
|
105
109
|
licenses:
|