carson 3.24.0 → 3.27.1
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 +26 -8
- data/MANUAL.md +54 -25
- data/README.md +9 -16
- data/RELEASE.md +31 -2
- data/VERSION +1 -1
- data/hooks/command-guard +60 -16
- data/lib/carson/cli.rb +116 -5
- data/lib/carson/config.rb +3 -8
- data/lib/carson/delivery.rb +17 -9
- data/lib/carson/ledger.rb +462 -224
- data/lib/carson/repository.rb +2 -4
- data/lib/carson/revision.rb +2 -4
- data/lib/carson/runtime/abandon.rb +238 -0
- data/lib/carson/runtime/audit.rb +12 -2
- data/lib/carson/runtime/deliver.rb +162 -15
- data/lib/carson/runtime/govern.rb +48 -21
- data/lib/carson/runtime/housekeep.rb +189 -153
- data/lib/carson/runtime/local/onboard.rb +4 -3
- data/lib/carson/runtime/local/prune.rb +6 -11
- data/lib/carson/runtime/local/sync.rb +9 -0
- data/lib/carson/runtime/local/worktree.rb +166 -0
- data/lib/carson/runtime/recover.rb +418 -0
- data/lib/carson/runtime/setup.rb +11 -7
- data/lib/carson/runtime/status.rb +39 -28
- data/lib/carson/runtime.rb +3 -1
- data/lib/carson/worktree.rb +128 -53
- metadata +3 -1
data/lib/carson/ledger.rb
CHANGED
|
@@ -1,149 +1,103 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JSON file-backed ledger for Carson's deliveries and revisions.
|
|
2
2
|
require "fileutils"
|
|
3
|
-
require "
|
|
3
|
+
require "json"
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
6
|
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
|
|
10
11
|
|
|
11
12
|
def initialize( path: )
|
|
12
13
|
@path = File.expand_path( path )
|
|
13
|
-
|
|
14
|
+
FileUtils.mkdir_p( File.dirname( @path ) )
|
|
15
|
+
migrate_legacy_state_if_needed!
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
attr_reader :path
|
|
17
19
|
|
|
18
|
-
# Ensures the SQLite schema exists before Carson uses the ledger.
|
|
19
|
-
def prepare!
|
|
20
|
-
FileUtils.mkdir_p( File.dirname( path ) )
|
|
21
|
-
|
|
22
|
-
with_database do |database|
|
|
23
|
-
database.execute_batch( <<~SQL )
|
|
24
|
-
CREATE TABLE IF NOT EXISTS deliveries (
|
|
25
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
-
repo_path TEXT NOT NULL,
|
|
27
|
-
branch_name TEXT NOT NULL,
|
|
28
|
-
head TEXT NOT NULL,
|
|
29
|
-
worktree_path TEXT,
|
|
30
|
-
authority TEXT NOT NULL,
|
|
31
|
-
status TEXT NOT NULL,
|
|
32
|
-
pr_number INTEGER,
|
|
33
|
-
pr_url TEXT,
|
|
34
|
-
revision_count INTEGER NOT NULL DEFAULT 0,
|
|
35
|
-
cause TEXT,
|
|
36
|
-
summary TEXT,
|
|
37
|
-
created_at TEXT NOT NULL,
|
|
38
|
-
updated_at TEXT NOT NULL,
|
|
39
|
-
integrated_at TEXT,
|
|
40
|
-
superseded_at TEXT
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
CREATE UNIQUE INDEX IF NOT EXISTS index_deliveries_on_identity
|
|
44
|
-
ON deliveries ( repo_path, branch_name, head );
|
|
45
|
-
|
|
46
|
-
CREATE INDEX IF NOT EXISTS index_deliveries_on_state
|
|
47
|
-
ON deliveries ( repo_path, status, created_at );
|
|
48
|
-
|
|
49
|
-
CREATE TABLE IF NOT EXISTS revisions (
|
|
50
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
-
delivery_id INTEGER NOT NULL,
|
|
52
|
-
number INTEGER NOT NULL,
|
|
53
|
-
cause TEXT NOT NULL,
|
|
54
|
-
provider TEXT NOT NULL,
|
|
55
|
-
status TEXT NOT NULL,
|
|
56
|
-
started_at TEXT NOT NULL,
|
|
57
|
-
finished_at TEXT,
|
|
58
|
-
summary TEXT,
|
|
59
|
-
FOREIGN KEY ( delivery_id ) REFERENCES deliveries ( id )
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
CREATE UNIQUE INDEX IF NOT EXISTS index_revisions_on_delivery_number
|
|
63
|
-
ON revisions ( delivery_id, number );
|
|
64
|
-
SQL
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
20
|
# Creates or refreshes a delivery for the same branch head.
|
|
69
|
-
def upsert_delivery( repository:, branch_name:, head:, worktree_path:,
|
|
21
|
+
def upsert_delivery( repository:, branch_name:, head:, worktree_path:, pr_number:, pr_url:, status:, summary:, cause: )
|
|
70
22
|
timestamp = now_utc
|
|
71
23
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
24
|
+
with_state do |state|
|
|
25
|
+
repo_paths = repo_identity_paths( repo_path: repository.path )
|
|
26
|
+
matches = matching_deliveries(
|
|
27
|
+
state: state,
|
|
28
|
+
repo_paths: repo_paths,
|
|
29
|
+
branch_name: branch_name,
|
|
30
|
+
head: head
|
|
76
31
|
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
]
|
|
103
|
-
)
|
|
104
|
-
fetch_delivery( database: database, id: database.last_insert_row_id, repository: repository )
|
|
32
|
+
key = delivery_key( repo_path: repository.path, branch_name: branch_name, head: head )
|
|
33
|
+
sequence = matches.map { |_existing_key, data| delivery_sequence( data: data ) }.compact.min
|
|
34
|
+
created_at = matches.map { |_existing_key, data| data.fetch( "created_at", "" ).to_s }.reject( &:empty? ).min || timestamp
|
|
35
|
+
revisions = merged_revisions( entries: matches )
|
|
36
|
+
matches.each { |existing_key, _data| state[ "deliveries" ].delete( existing_key ) }
|
|
37
|
+
|
|
38
|
+
supersede_branch!( state: state, repo_path: repository.path, branch_name: branch_name, timestamp: timestamp )
|
|
39
|
+
state[ "deliveries" ][ key ] = {
|
|
40
|
+
"sequence" => sequence || next_delivery_sequence!( state: state ),
|
|
41
|
+
"repo_path" => repository.path,
|
|
42
|
+
"branch_name" => branch_name,
|
|
43
|
+
"head" => head,
|
|
44
|
+
"worktree_path" => worktree_path,
|
|
45
|
+
"status" => status,
|
|
46
|
+
"pr_number" => pr_number,
|
|
47
|
+
"pr_url" => pr_url,
|
|
48
|
+
"cause" => cause,
|
|
49
|
+
"summary" => summary,
|
|
50
|
+
"created_at" => created_at,
|
|
51
|
+
"updated_at" => timestamp,
|
|
52
|
+
"integrated_at" => nil,
|
|
53
|
+
"superseded_at" => nil,
|
|
54
|
+
"revisions" => revisions
|
|
55
|
+
}
|
|
56
|
+
build_delivery( key: key, data: state[ "deliveries" ][ key ], repository: repository )
|
|
105
57
|
end
|
|
106
58
|
end
|
|
107
59
|
|
|
108
60
|
# Looks up the active delivery for a branch, if one exists.
|
|
109
61
|
def active_delivery( repo_path:, branch_name: )
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
SQL
|
|
118
|
-
[ repo_path, branch_name, *ACTIVE_DELIVERY_STATES ]
|
|
119
|
-
)
|
|
120
|
-
build_delivery( row: row ) if row
|
|
62
|
+
state = load_state
|
|
63
|
+
repo_paths = repo_identity_paths( repo_path: repo_path )
|
|
64
|
+
|
|
65
|
+
candidates = state[ "deliveries" ].select do |_key, data|
|
|
66
|
+
repo_paths.include?( data[ "repo_path" ] ) &&
|
|
67
|
+
data[ "branch_name" ] == branch_name &&
|
|
68
|
+
ACTIVE_DELIVERY_STATES.include?( data[ "status" ] )
|
|
121
69
|
end
|
|
70
|
+
|
|
71
|
+
return nil if candidates.empty?
|
|
72
|
+
|
|
73
|
+
key, data = candidates.max_by { |k, d| [ d[ "updated_at" ].to_s, delivery_sequence( data: d ), k ] }
|
|
74
|
+
build_delivery( key: key, data: data )
|
|
122
75
|
end
|
|
123
76
|
|
|
124
77
|
# Lists active deliveries for a repository in creation order.
|
|
125
78
|
def active_deliveries( repo_path: )
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
[ repo_path, *ACTIVE_DELIVERY_STATES ]
|
|
134
|
-
)
|
|
135
|
-
rows.map { |row| build_delivery( row: row ) }
|
|
136
|
-
end
|
|
79
|
+
state = load_state
|
|
80
|
+
repo_paths = repo_identity_paths( repo_path: repo_path )
|
|
81
|
+
|
|
82
|
+
state[ "deliveries" ]
|
|
83
|
+
.select { |_key, data| repo_paths.include?( data[ "repo_path" ] ) && ACTIVE_DELIVERY_STATES.include?( data[ "status" ] ) }
|
|
84
|
+
.sort_by { |key, data| [ delivery_sequence( data: data ), key ] }
|
|
85
|
+
.map { |key, data| build_delivery( key: key, data: data ) }
|
|
137
86
|
end
|
|
138
87
|
|
|
139
|
-
# Lists
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
88
|
+
# Lists integrated deliveries that still retain a worktree path.
|
|
89
|
+
def integrated_deliveries( repo_path: )
|
|
90
|
+
state = load_state
|
|
91
|
+
repo_paths = repo_identity_paths( repo_path: repo_path )
|
|
92
|
+
|
|
93
|
+
state[ "deliveries" ]
|
|
94
|
+
.select do |_key, data|
|
|
95
|
+
repo_paths.include?( data[ "repo_path" ] ) &&
|
|
96
|
+
data[ "status" ] == "integrated" &&
|
|
97
|
+
!data[ "worktree_path" ].to_s.strip.empty?
|
|
98
|
+
end
|
|
99
|
+
.sort_by { |key, data| [ data[ "integrated_at" ].to_s, delivery_sequence( data: data ), key ] }
|
|
100
|
+
.map { |key, data| build_delivery( key: key, data: data ) }
|
|
147
101
|
end
|
|
148
102
|
|
|
149
103
|
# Updates a delivery record in place.
|
|
@@ -155,151 +109,435 @@ module Carson
|
|
|
155
109
|
cause: UNSET,
|
|
156
110
|
summary: UNSET,
|
|
157
111
|
worktree_path: UNSET,
|
|
158
|
-
revision_count: UNSET,
|
|
159
112
|
integrated_at: UNSET,
|
|
160
113
|
superseded_at: UNSET
|
|
161
114
|
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
database.execute(
|
|
177
|
-
"UPDATE deliveries SET #{assignments} WHERE id = ?",
|
|
178
|
-
updates.values + [ delivery.id ]
|
|
179
|
-
)
|
|
180
|
-
fetch_delivery( database: database, id: delivery.id, repository: delivery.repository )
|
|
115
|
+
with_state do |state|
|
|
116
|
+
key, data = resolve_delivery_entry( state: state, delivery: delivery )
|
|
117
|
+
|
|
118
|
+
data[ "status" ] = status unless status.equal?( UNSET )
|
|
119
|
+
data[ "pr_number" ] = pr_number unless pr_number.equal?( UNSET )
|
|
120
|
+
data[ "pr_url" ] = pr_url unless pr_url.equal?( UNSET )
|
|
121
|
+
data[ "cause" ] = cause unless cause.equal?( UNSET )
|
|
122
|
+
data[ "summary" ] = summary unless summary.equal?( UNSET )
|
|
123
|
+
data[ "worktree_path" ] = worktree_path unless worktree_path.equal?( UNSET )
|
|
124
|
+
data[ "integrated_at" ] = integrated_at unless integrated_at.equal?( UNSET )
|
|
125
|
+
data[ "superseded_at" ] = superseded_at unless superseded_at.equal?( UNSET )
|
|
126
|
+
data[ "updated_at" ] = now_utc
|
|
127
|
+
|
|
128
|
+
build_delivery( key: key, data: data, repository: delivery.repository )
|
|
181
129
|
end
|
|
182
130
|
end
|
|
183
131
|
|
|
184
|
-
# Records one revision cycle against a delivery
|
|
132
|
+
# Records one revision cycle against a delivery.
|
|
185
133
|
def record_revision( delivery:, cause:, provider:, status:, summary: )
|
|
186
134
|
timestamp = now_utc
|
|
187
135
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
208
|
-
build_revision(
|
|
209
|
-
row: database.get_first_row( "SELECT * FROM revisions WHERE id = ?", [ database.last_insert_row_id ] )
|
|
210
|
-
)
|
|
136
|
+
with_state do |state|
|
|
137
|
+
_key, data = resolve_delivery_entry( state: state, delivery: delivery )
|
|
138
|
+
|
|
139
|
+
revisions = data[ "revisions" ] ||= []
|
|
140
|
+
next_number = ( revisions.map { |r| r[ "number" ].to_i }.max || 0 ) + 1
|
|
141
|
+
finished = %w[completed failed stalled].include?( status ) ? timestamp : nil
|
|
142
|
+
|
|
143
|
+
revision_data = {
|
|
144
|
+
"number" => next_number,
|
|
145
|
+
"cause" => cause,
|
|
146
|
+
"provider" => provider,
|
|
147
|
+
"status" => status,
|
|
148
|
+
"started_at" => timestamp,
|
|
149
|
+
"finished_at" => finished,
|
|
150
|
+
"summary" => summary
|
|
151
|
+
}
|
|
152
|
+
revisions << revision_data
|
|
153
|
+
data[ "updated_at" ] = timestamp
|
|
154
|
+
|
|
155
|
+
build_revision( data: revision_data )
|
|
211
156
|
end
|
|
212
157
|
end
|
|
213
158
|
|
|
214
|
-
#
|
|
215
|
-
def revisions_for_delivery(
|
|
216
|
-
|
|
217
|
-
database.execute(
|
|
218
|
-
"SELECT * FROM revisions WHERE delivery_id = ? ORDER BY number ASC, id ASC",
|
|
219
|
-
[ delivery_id ]
|
|
220
|
-
).map { |row| build_revision( row: row ) }
|
|
221
|
-
end
|
|
159
|
+
# Returns revisions for a delivery in ascending order.
|
|
160
|
+
def revisions_for_delivery( delivery: )
|
|
161
|
+
delivery.revisions.sort_by( &:number )
|
|
222
162
|
end
|
|
223
163
|
|
|
224
164
|
private
|
|
225
165
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
166
|
+
# Acquires file lock, loads state, yields for mutation, saves atomically, releases lock.
|
|
167
|
+
def with_state
|
|
168
|
+
with_state_lock do |lock_file|
|
|
169
|
+
lock_file.flock( File::LOCK_EX )
|
|
170
|
+
state = load_state
|
|
171
|
+
result = yield state
|
|
172
|
+
save_state!( state )
|
|
173
|
+
result
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def load_state
|
|
178
|
+
return { "deliveries" => {}, "recovery_events" => [] } unless File.exist?( path )
|
|
179
|
+
|
|
180
|
+
raw = File.binread( path )
|
|
181
|
+
return { "deliveries" => {}, "recovery_events" => [] } if raw.strip.empty?
|
|
182
|
+
|
|
183
|
+
parsed = JSON.parse( raw )
|
|
184
|
+
raise "state file must contain a JSON object at #{path}" unless parsed.is_a?( Hash )
|
|
185
|
+
parsed[ "deliveries" ] ||= {}
|
|
186
|
+
normalise_state!( state: parsed )
|
|
187
|
+
parsed
|
|
188
|
+
rescue JSON::ParserError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => exception
|
|
189
|
+
raise "invalid JSON in state file #{path}: #{exception.message}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def save_state!( state )
|
|
193
|
+
normalise_state!( state: state )
|
|
194
|
+
tmp_path = "#{path}.tmp"
|
|
195
|
+
File.write( tmp_path, JSON.pretty_generate( state ) + "\n" )
|
|
196
|
+
File.rename( tmp_path, path )
|
|
234
197
|
end
|
|
235
198
|
|
|
236
|
-
def
|
|
237
|
-
|
|
238
|
-
build_delivery( row: row, repository: repository )
|
|
199
|
+
def delivery_key( repo_path:, branch_name:, head: )
|
|
200
|
+
"#{repo_path}:#{branch_name}:#{head}"
|
|
239
201
|
end
|
|
240
202
|
|
|
241
|
-
def build_delivery(
|
|
242
|
-
return nil unless
|
|
203
|
+
def build_delivery( key:, data:, repository: nil )
|
|
204
|
+
return nil unless data
|
|
243
205
|
|
|
244
|
-
|
|
245
|
-
path: row.fetch( "repo_path" ),
|
|
246
|
-
authority: row.fetch( "authority" ),
|
|
247
|
-
runtime: nil
|
|
248
|
-
)
|
|
206
|
+
revisions = Array( data[ "revisions" ] ).map { |r| build_revision( data: r ) }
|
|
249
207
|
|
|
250
208
|
Delivery.new(
|
|
251
|
-
|
|
209
|
+
repo_path: data.fetch( "repo_path" ),
|
|
252
210
|
repository: repository,
|
|
253
|
-
branch:
|
|
254
|
-
head:
|
|
255
|
-
worktree_path:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
superseded_at: row.fetch( "superseded_at" )
|
|
211
|
+
branch: data.fetch( "branch_name" ),
|
|
212
|
+
head: data.fetch( "head" ),
|
|
213
|
+
worktree_path: data[ "worktree_path" ],
|
|
214
|
+
status: data.fetch( "status" ),
|
|
215
|
+
pull_request_number: data[ "pr_number" ],
|
|
216
|
+
pull_request_url: data[ "pr_url" ],
|
|
217
|
+
revisions: revisions,
|
|
218
|
+
cause: data[ "cause" ],
|
|
219
|
+
summary: data[ "summary" ],
|
|
220
|
+
created_at: data.fetch( "created_at" ),
|
|
221
|
+
updated_at: data.fetch( "updated_at" ),
|
|
222
|
+
integrated_at: data[ "integrated_at" ],
|
|
223
|
+
superseded_at: data[ "superseded_at" ]
|
|
267
224
|
)
|
|
268
225
|
end
|
|
269
226
|
|
|
270
|
-
def build_revision(
|
|
271
|
-
return nil unless
|
|
227
|
+
def build_revision( data: )
|
|
228
|
+
return nil unless data
|
|
272
229
|
|
|
273
230
|
Revision.new(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
finished_at: row.fetch( "finished_at" ),
|
|
282
|
-
summary: row.fetch( "summary" )
|
|
231
|
+
number: data.fetch( "number" ).to_i,
|
|
232
|
+
cause: data.fetch( "cause" ),
|
|
233
|
+
provider: data.fetch( "provider" ),
|
|
234
|
+
status: data.fetch( "status" ),
|
|
235
|
+
started_at: data.fetch( "started_at" ),
|
|
236
|
+
finished_at: data[ "finished_at" ],
|
|
237
|
+
summary: data[ "summary" ]
|
|
283
238
|
)
|
|
284
239
|
end
|
|
285
240
|
|
|
286
|
-
def
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
241
|
+
def migrate_legacy_state_if_needed!
|
|
242
|
+
with_state_lock do |lock_file|
|
|
243
|
+
lock_file.flock( File::LOCK_EX )
|
|
244
|
+
source_path = legacy_sqlite_source_path
|
|
245
|
+
next unless source_path
|
|
246
|
+
|
|
247
|
+
state = load_legacy_sqlite_state( path: source_path )
|
|
248
|
+
save_state!( state )
|
|
249
|
+
end
|
|
295
250
|
end
|
|
296
251
|
|
|
297
|
-
def
|
|
298
|
-
|
|
252
|
+
def with_state_lock
|
|
253
|
+
lock_path = "#{path}.lock"
|
|
254
|
+
FileUtils.mkdir_p( File.dirname( lock_path ) )
|
|
255
|
+
FileUtils.touch( lock_path )
|
|
256
|
+
|
|
257
|
+
File.open( lock_path, File::RDWR | File::CREAT ) do |lock_file|
|
|
258
|
+
yield lock_file
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def legacy_sqlite_source_path
|
|
263
|
+
return nil unless state_path_requires_migration?
|
|
264
|
+
return path if sqlite_database_file?( path: path )
|
|
265
|
+
|
|
266
|
+
legacy_path = legacy_state_path
|
|
267
|
+
return nil unless legacy_path
|
|
268
|
+
return legacy_path if sqlite_database_file?( path: legacy_path )
|
|
269
|
+
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def state_path_requires_migration?
|
|
274
|
+
return true if sqlite_database_file?( path: path )
|
|
275
|
+
return false if File.exist?( path )
|
|
276
|
+
!legacy_state_path.nil?
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def legacy_state_path
|
|
280
|
+
return nil unless path.end_with?( ".json" )
|
|
281
|
+
path.sub( /\.json\z/, ".sqlite3" )
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def sqlite_database_file?( path: )
|
|
285
|
+
return false unless File.file?( path )
|
|
286
|
+
File.binread( path, SQLITE_HEADER.bytesize ) == SQLITE_HEADER
|
|
287
|
+
rescue StandardError
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def load_legacy_sqlite_state( path: )
|
|
292
|
+
begin
|
|
293
|
+
require "sqlite3"
|
|
294
|
+
rescue LoadError => exception
|
|
295
|
+
raise "legacy SQLite ledger found at #{path}, but sqlite3 support is unavailable: #{exception.message}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
database = open_legacy_sqlite_database( path: path )
|
|
299
|
+
deliveries = database.execute( "SELECT * FROM deliveries ORDER BY id ASC" )
|
|
300
|
+
revisions_by_delivery = database.execute(
|
|
301
|
+
"SELECT * FROM revisions ORDER BY delivery_id ASC, number ASC, id ASC"
|
|
302
|
+
).group_by { |row| row.fetch( "delivery_id" ) }
|
|
303
|
+
|
|
304
|
+
state = {
|
|
305
|
+
"deliveries" => {},
|
|
306
|
+
"recovery_events" => [],
|
|
307
|
+
"next_sequence" => 1
|
|
308
|
+
}
|
|
309
|
+
deliveries.each do |row|
|
|
310
|
+
key = delivery_key(
|
|
311
|
+
repo_path: row.fetch( "repo_path" ),
|
|
312
|
+
branch_name: row.fetch( "branch_name" ),
|
|
313
|
+
head: row.fetch( "head" )
|
|
314
|
+
)
|
|
315
|
+
state[ "deliveries" ][ key ] = {
|
|
316
|
+
"sequence" => row.fetch( "id" ).to_i,
|
|
317
|
+
"repo_path" => row.fetch( "repo_path" ),
|
|
318
|
+
"branch_name" => row.fetch( "branch_name" ),
|
|
319
|
+
"head" => row.fetch( "head" ),
|
|
320
|
+
"worktree_path" => row.fetch( "worktree_path" ),
|
|
321
|
+
"status" => row.fetch( "status" ),
|
|
322
|
+
"pr_number" => row.fetch( "pr_number" ),
|
|
323
|
+
"pr_url" => row.fetch( "pr_url" ),
|
|
324
|
+
"cause" => row.fetch( "cause" ),
|
|
325
|
+
"summary" => row.fetch( "summary" ),
|
|
326
|
+
"created_at" => row.fetch( "created_at" ),
|
|
327
|
+
"updated_at" => row.fetch( "updated_at" ),
|
|
328
|
+
"integrated_at" => row.fetch( "integrated_at" ),
|
|
329
|
+
"superseded_at" => row.fetch( "superseded_at" ),
|
|
330
|
+
"revisions" => Array( revisions_by_delivery[ row.fetch( "id" ) ] ).map do |revision|
|
|
331
|
+
{
|
|
332
|
+
"number" => revision.fetch( "number" ).to_i,
|
|
333
|
+
"cause" => revision.fetch( "cause" ),
|
|
334
|
+
"provider" => revision.fetch( "provider" ),
|
|
335
|
+
"status" => revision.fetch( "status" ),
|
|
336
|
+
"started_at" => revision.fetch( "started_at" ),
|
|
337
|
+
"finished_at" => revision.fetch( "finished_at" ),
|
|
338
|
+
"summary" => revision.fetch( "summary" )
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
}
|
|
342
|
+
end
|
|
343
|
+
normalise_state!( state: state )
|
|
344
|
+
state
|
|
345
|
+
ensure
|
|
346
|
+
database&.close
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def open_legacy_sqlite_database( path: )
|
|
350
|
+
database = SQLite3::Database.new( "file:#{path}?immutable=1", readonly: true, uri: true )
|
|
351
|
+
database.results_as_hash = true
|
|
352
|
+
database.busy_timeout = 5_000
|
|
353
|
+
database
|
|
354
|
+
rescue SQLite3::CantOpenException
|
|
355
|
+
database&.close
|
|
356
|
+
database = SQLite3::Database.new( path, readonly: true )
|
|
357
|
+
database.results_as_hash = true
|
|
358
|
+
database.busy_timeout = 5_000
|
|
359
|
+
database
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def normalise_state!( state: )
|
|
363
|
+
deliveries = state[ "deliveries" ]
|
|
364
|
+
raise "state file must contain a JSON object at #{path}" unless deliveries.is_a?( Hash )
|
|
365
|
+
state[ "recovery_events" ] = Array( state[ "recovery_events" ] )
|
|
366
|
+
|
|
367
|
+
sequence_counts = Hash.new( 0 )
|
|
368
|
+
deliveries.each_value do |data|
|
|
369
|
+
data[ "revisions" ] = Array( data[ "revisions" ] )
|
|
370
|
+
sequence = integer_or_nil( value: data[ "sequence" ] )
|
|
371
|
+
sequence_counts[ sequence ] += 1 unless sequence.nil? || sequence <= 0
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
max_sequence = sequence_counts.keys.max.to_i
|
|
375
|
+
next_sequence = max_sequence + 1
|
|
376
|
+
deliveries.keys.sort_by { |key| [ deliveries.fetch( key ).fetch( "created_at", "" ).to_s, key ] }.each do |key|
|
|
377
|
+
data = deliveries.fetch( key )
|
|
378
|
+
sequence = integer_or_nil( value: data[ "sequence" ] )
|
|
379
|
+
if sequence.nil? || sequence <= 0 || sequence_counts[ sequence ] > 1
|
|
380
|
+
sequence = next_sequence
|
|
381
|
+
next_sequence += 1
|
|
382
|
+
end
|
|
383
|
+
data[ "sequence" ] = sequence
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
recorded_next = integer_or_nil( value: state[ "next_sequence" ] ) || 1
|
|
387
|
+
state[ "next_sequence" ] = [ recorded_next, next_sequence ].max
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def next_delivery_sequence!( state: )
|
|
391
|
+
sequence = integer_or_nil( value: state[ "next_sequence" ] ) || 1
|
|
392
|
+
state[ "next_sequence" ] = sequence + 1
|
|
393
|
+
sequence
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def integer_or_nil( value: )
|
|
397
|
+
Integer( value )
|
|
398
|
+
rescue ArgumentError, TypeError
|
|
399
|
+
nil
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def delivery_sequence( data: )
|
|
403
|
+
integer_or_nil( value: data[ "sequence" ] ) || 0
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def matching_deliveries( state:, repo_paths:, branch_name:, head: UNSET )
|
|
407
|
+
state[ "deliveries" ].select do |_key, data|
|
|
408
|
+
next false unless repo_paths.include?( data[ "repo_path" ] )
|
|
409
|
+
next false unless data[ "branch_name" ] == branch_name
|
|
410
|
+
next false unless head.equal?( UNSET ) || data[ "head" ] == head
|
|
411
|
+
|
|
412
|
+
true
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def resolve_delivery_entry( state:, delivery: )
|
|
417
|
+
data = state[ "deliveries" ][ delivery.key ]
|
|
418
|
+
return [ delivery.key, data ] if data
|
|
419
|
+
|
|
420
|
+
repo_paths = repo_identity_paths( repo_path: delivery.repo_path )
|
|
421
|
+
match = matching_deliveries(
|
|
422
|
+
state: state,
|
|
423
|
+
repo_paths: repo_paths,
|
|
424
|
+
branch_name: delivery.branch,
|
|
425
|
+
head: delivery.head
|
|
426
|
+
).max_by { |key, row| [ row[ "updated_at" ].to_s, delivery_sequence( data: row ), key ] }
|
|
427
|
+
raise "delivery not found: #{delivery.key}" unless match
|
|
428
|
+
|
|
429
|
+
match
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def merged_revisions( entries: )
|
|
433
|
+
entries
|
|
434
|
+
.flat_map { |_key, data| Array( data[ "revisions" ] ) }
|
|
435
|
+
.map do |revision|
|
|
436
|
+
{
|
|
437
|
+
"number" => revision.fetch( "number", 0 ).to_i,
|
|
438
|
+
"cause" => revision[ "cause" ],
|
|
439
|
+
"provider" => revision[ "provider" ],
|
|
440
|
+
"status" => revision[ "status" ],
|
|
441
|
+
"started_at" => revision[ "started_at" ],
|
|
442
|
+
"finished_at" => revision[ "finished_at" ],
|
|
443
|
+
"summary" => revision[ "summary" ]
|
|
444
|
+
}
|
|
445
|
+
end
|
|
446
|
+
.uniq do |revision|
|
|
447
|
+
[
|
|
448
|
+
revision[ "cause" ],
|
|
449
|
+
revision[ "provider" ],
|
|
450
|
+
revision[ "status" ],
|
|
451
|
+
revision[ "started_at" ],
|
|
452
|
+
revision[ "finished_at" ],
|
|
453
|
+
revision[ "summary" ]
|
|
454
|
+
]
|
|
455
|
+
end
|
|
456
|
+
.sort_by { |revision| [ revision.fetch( "started_at", "" ).to_s, revision.fetch( "number", 0 ).to_i ] }
|
|
457
|
+
.each_with_index
|
|
458
|
+
.map do |revision, index|
|
|
459
|
+
revision.merge( "number" => index + 1 )
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def supersede_branch!( state:, repo_path:, branch_name:, timestamp: )
|
|
464
|
+
repo_paths = repo_identity_paths( repo_path: repo_path )
|
|
465
|
+
state[ "deliveries" ].each do |_key, data|
|
|
466
|
+
next unless repo_paths.include?( data[ "repo_path" ] )
|
|
467
|
+
next unless data[ "branch_name" ] == branch_name
|
|
468
|
+
next unless ACTIVE_DELIVERY_STATES.include?( data[ "status" ] )
|
|
469
|
+
|
|
470
|
+
data[ "status" ] = "superseded"
|
|
471
|
+
data[ "superseded_at" ] = timestamp
|
|
472
|
+
data[ "updated_at" ] = timestamp
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def repo_identity_paths( repo_path: )
|
|
477
|
+
canonical_path = File.expand_path( repo_path )
|
|
478
|
+
canonical_realpath = realpath_or_nil( path: canonical_path )
|
|
479
|
+
worktree_gitdirs = Dir.glob( File.join( canonical_path, ".git", "worktrees", "*", "gitdir" ) )
|
|
480
|
+
paths = worktree_gitdirs.each_with_object( path_aliases( path: canonical_path ) ) do |gitdir_path, identities|
|
|
481
|
+
worktree_git_path = File.read( gitdir_path ).to_s.strip
|
|
482
|
+
next if worktree_git_path.empty?
|
|
483
|
+
|
|
484
|
+
worktree_path = File.dirname( File.expand_path( worktree_git_path, File.dirname( gitdir_path ) ) )
|
|
485
|
+
identities.concat( path_aliases( path: worktree_path ) )
|
|
486
|
+
|
|
487
|
+
worktree_realpath = realpath_or_nil( path: worktree_path )
|
|
488
|
+
next unless canonical_realpath && worktree_realpath
|
|
489
|
+
|
|
490
|
+
canonical_prefix = File.join( canonical_realpath, "" )
|
|
491
|
+
next unless worktree_realpath.start_with?( canonical_prefix )
|
|
492
|
+
|
|
493
|
+
relative_path = worktree_realpath.delete_prefix( canonical_prefix )
|
|
494
|
+
identities << File.join( canonical_path, relative_path ) unless relative_path.empty?
|
|
495
|
+
end
|
|
496
|
+
paths.uniq
|
|
497
|
+
rescue StandardError
|
|
498
|
+
[ File.expand_path( repo_path ) ]
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def path_aliases( path: )
|
|
502
|
+
expanded_path = File.expand_path( path )
|
|
503
|
+
aliases = [ expanded_path, realpath_or_nil( path: expanded_path ) ]
|
|
504
|
+
aliases << expanded_path.delete_prefix( "/private" ) if expanded_path.start_with?( "/private/" )
|
|
505
|
+
aliases.compact.uniq
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def realpath_or_nil( path: )
|
|
509
|
+
File.realpath( path )
|
|
510
|
+
rescue StandardError
|
|
511
|
+
nil
|
|
299
512
|
end
|
|
300
513
|
|
|
301
514
|
def now_utc
|
|
302
|
-
Time.now.utc.iso8601
|
|
515
|
+
Time.now.utc.iso8601( 6 )
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def record_recovery_event( repository:, branch_name:, pr_number:, pr_url:, check_name:, default_branch:, default_branch_sha:, pr_sha:, actor:, merge_method:, status:, summary: )
|
|
519
|
+
timestamp = now_utc
|
|
520
|
+
|
|
521
|
+
with_state do |state|
|
|
522
|
+
state[ "recovery_events" ] ||= []
|
|
523
|
+
event = {
|
|
524
|
+
"repository" => repository.path,
|
|
525
|
+
"branch_name" => branch_name,
|
|
526
|
+
"pr_number" => pr_number,
|
|
527
|
+
"pr_url" => pr_url,
|
|
528
|
+
"check_name" => check_name,
|
|
529
|
+
"default_branch" => default_branch,
|
|
530
|
+
"default_branch_sha" => default_branch_sha,
|
|
531
|
+
"pr_sha" => pr_sha,
|
|
532
|
+
"actor" => actor,
|
|
533
|
+
"merge_method" => merge_method,
|
|
534
|
+
"status" => status,
|
|
535
|
+
"summary" => summary,
|
|
536
|
+
"recorded_at" => timestamp
|
|
537
|
+
}
|
|
538
|
+
state[ "recovery_events" ] << event
|
|
539
|
+
event
|
|
540
|
+
end
|
|
303
541
|
end
|
|
304
542
|
end
|
|
305
543
|
end
|