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.
data/lib/carson/ledger.rb CHANGED
@@ -1,149 +1,103 @@
1
- # SQLite-backed ledger for Carson's deliveries and revisions.
1
+ # JSON file-backed ledger for Carson's deliveries and revisions.
2
2
  require "fileutils"
3
- require "sqlite3"
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
- prepare!
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:, authority:, pr_number:, pr_url:, status:, summary:, cause: )
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
- with_database do |database|
73
- row = database.get_first_row(
74
- "SELECT * FROM deliveries WHERE repo_path = ? AND branch_name = ? AND head = ? LIMIT 1",
75
- [ repository.path, branch_name, head ]
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
- if row
79
- database.execute(
80
- <<~SQL,
81
- UPDATE deliveries
82
- SET worktree_path = ?, authority = ?, status = ?, pr_number = ?, pr_url = ?,
83
- cause = ?, summary = ?, updated_at = ?
84
- WHERE id = ?
85
- SQL
86
- [ worktree_path, authority, status, pr_number, pr_url, cause, summary, timestamp, row.fetch( "id" ) ]
87
- )
88
- return fetch_delivery( database: database, id: row.fetch( "id" ), repository: repository )
89
- end
90
-
91
- supersede_branch!( database: database, repository: repository, branch_name: branch_name, timestamp: timestamp )
92
- database.execute(
93
- <<~SQL,
94
- INSERT INTO deliveries (
95
- repo_path, branch_name, head, worktree_path, authority, status,
96
- pr_number, pr_url, revision_count, cause, summary, created_at, updated_at
97
- ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ? )
98
- SQL
99
- [
100
- repository.path, branch_name, head, worktree_path, authority, status,
101
- pr_number, pr_url, cause, summary, timestamp, timestamp
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
- with_database do |database|
111
- row = database.get_first_row(
112
- <<~SQL,
113
- SELECT * FROM deliveries
114
- WHERE repo_path = ? AND branch_name = ? AND status IN ( #{active_state_placeholders} )
115
- ORDER BY updated_at DESC
116
- LIMIT 1
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
- with_database do |database|
127
- rows = database.execute(
128
- <<~SQL,
129
- SELECT * FROM deliveries
130
- WHERE repo_path = ? AND status IN ( #{active_state_placeholders} )
131
- ORDER BY created_at ASC, id ASC
132
- SQL
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 queued deliveries ready for integration.
140
- def queued_deliveries( repo_path: )
141
- with_database do |database|
142
- database.execute(
143
- "SELECT * FROM deliveries WHERE repo_path = ? AND status = ? ORDER BY created_at ASC, id ASC",
144
- [ repo_path, "queued" ]
145
- ).map { |row| build_delivery( row: row ) }
146
- end
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
- updates = {}
163
- updates[ "status" ] = status unless status.equal?( UNSET )
164
- updates[ "pr_number" ] = pr_number unless pr_number.equal?( UNSET )
165
- updates[ "pr_url" ] = pr_url unless pr_url.equal?( UNSET )
166
- updates[ "cause" ] = cause unless cause.equal?( UNSET )
167
- updates[ "summary" ] = summary unless summary.equal?( UNSET )
168
- updates[ "worktree_path" ] = worktree_path unless worktree_path.equal?( UNSET )
169
- updates[ "revision_count" ] = revision_count unless revision_count.equal?( UNSET )
170
- updates[ "integrated_at" ] = integrated_at unless integrated_at.equal?( UNSET )
171
- updates[ "superseded_at" ] = superseded_at unless superseded_at.equal?( UNSET )
172
- updates[ "updated_at" ] = now_utc
173
-
174
- with_database do |database|
175
- assignments = updates.keys.map { |key| "#{key} = ?" }.join( ", " )
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 and bumps the delivery counter.
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
- with_database do |database|
189
- next_number = database.get_first_value(
190
- "SELECT COALESCE( MAX(number), 0 ) + 1 FROM revisions WHERE delivery_id = ?",
191
- [ delivery.id ]
192
- ).to_i
193
- database.execute(
194
- <<~SQL,
195
- INSERT INTO revisions ( delivery_id, number, cause, provider, status, started_at, finished_at, summary )
196
- VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
197
- SQL
198
- [
199
- delivery.id, next_number, cause, provider, status, timestamp,
200
- ( status == "completed" || status == "failed" || status == "stalled" ) ? timestamp : nil,
201
- summary
202
- ]
203
- )
204
- database.execute(
205
- "UPDATE deliveries SET revision_count = ?, updated_at = ? WHERE id = ?",
206
- [ next_number, timestamp, delivery.id ]
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
- # Lists revisions for a delivery in ascending order.
215
- def revisions_for_delivery( delivery_id: )
216
- with_database do |database|
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
- def with_database
227
- database = SQLite3::Database.new( path )
228
- database.results_as_hash = true
229
- database.busy_timeout = 5_000
230
- database.execute( "PRAGMA journal_mode = WAL" )
231
- yield database
232
- ensure
233
- database&.close
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 fetch_delivery( database:, id:, repository: nil )
237
- row = database.get_first_row( "SELECT * FROM deliveries WHERE id = ?", [ id ] )
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( row:, repository: nil )
242
- return nil unless row
203
+ def build_delivery( key:, data:, repository: nil )
204
+ return nil unless data
243
205
 
244
- repository ||= Repository.new(
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
- id: row.fetch( "id" ),
209
+ repo_path: data.fetch( "repo_path" ),
252
210
  repository: repository,
253
- branch: row.fetch( "branch_name" ),
254
- head: row.fetch( "head" ),
255
- worktree_path: row.fetch( "worktree_path" ),
256
- authority: row.fetch( "authority" ),
257
- status: row.fetch( "status" ),
258
- pull_request_number: row.fetch( "pr_number" ),
259
- pull_request_url: row.fetch( "pr_url" ),
260
- revision_count: row.fetch( "revision_count" ).to_i,
261
- cause: row.fetch( "cause" ),
262
- summary: row.fetch( "summary" ),
263
- created_at: row.fetch( "created_at" ),
264
- updated_at: row.fetch( "updated_at" ),
265
- integrated_at: row.fetch( "integrated_at" ),
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( row: )
271
- return nil unless row
227
+ def build_revision( data: )
228
+ return nil unless data
272
229
 
273
230
  Revision.new(
274
- id: row.fetch( "id" ),
275
- delivery_id: row.fetch( "delivery_id" ),
276
- number: row.fetch( "number" ).to_i,
277
- cause: row.fetch( "cause" ),
278
- provider: row.fetch( "provider" ),
279
- status: row.fetch( "status" ),
280
- started_at: row.fetch( "started_at" ),
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 supersede_branch!( database:, repository:, branch_name:, timestamp: )
287
- database.execute(
288
- <<~SQL,
289
- UPDATE deliveries
290
- SET status = ?, superseded_at = ?, updated_at = ?
291
- WHERE repo_path = ? AND branch_name = ? AND status IN ( #{active_state_placeholders} )
292
- SQL
293
- [ "superseded", timestamp, timestamp, repository.path, branch_name, *ACTIVE_DELIVERY_STATES ]
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 active_state_placeholders
298
- ACTIVE_DELIVERY_STATES.map { "?" }.join( ", " )
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