carson 3.27.0 → 3.28.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.
data/lib/carson/ledger.rb CHANGED
@@ -7,36 +7,40 @@ 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
20
  # Creates or refreshes a delivery for the same branch head.
19
- def upsert_delivery( repository:, branch_name:, head:, worktree_path:, pr_number:, pr_url:, status:, summary:, cause: )
21
+ def upsert_delivery(
22
+ repository:, branch_name:, head:, worktree_path:, pr_number:, pr_url:, status:, summary:, cause:,
23
+ pull_request_state: nil, pull_request_draft: nil, pull_request_merged_at: nil, merge_proof: nil
24
+ )
20
25
  timestamp = now_utc
21
26
 
22
27
  with_state do |state|
28
+ repo_paths = repo_identity_paths( repo_path: repository.path )
29
+ matches = matching_deliveries(
30
+ state: state,
31
+ repo_paths: repo_paths,
32
+ branch_name: branch_name,
33
+ head: head
34
+ )
23
35
  key = delivery_key( repo_path: repository.path, branch_name: branch_name, head: head )
24
- existing = state[ "deliveries" ][ key ]
25
-
26
- if existing
27
- existing[ "repo_path" ] = repository.path
28
- existing[ "worktree_path" ] = worktree_path
29
- existing[ "status" ] = status
30
- existing[ "pr_number" ] = pr_number
31
- existing[ "pr_url" ] = pr_url
32
- existing[ "cause" ] = cause
33
- existing[ "summary" ] = summary
34
- existing[ "updated_at" ] = timestamp
35
- return build_delivery( key: key, data: existing, repository: repository )
36
- end
36
+ sequence = matches.map { |_existing_key, data| delivery_sequence( data: data ) }.compact.min
37
+ created_at = matches.map { |_existing_key, data| data.fetch( "created_at", "" ).to_s }.reject( &:empty? ).min || timestamp
38
+ revisions = merged_revisions( entries: matches )
39
+ matches.each { |existing_key, _data| state[ "deliveries" ].delete( existing_key ) }
37
40
 
38
41
  supersede_branch!( state: state, repo_path: repository.path, branch_name: branch_name, timestamp: timestamp )
39
42
  state[ "deliveries" ][ key ] = {
43
+ "sequence" => sequence || next_delivery_sequence!( state: state ),
40
44
  "repo_path" => repository.path,
41
45
  "branch_name" => branch_name,
42
46
  "head" => head,
@@ -44,13 +48,17 @@ module Carson
44
48
  "status" => status,
45
49
  "pr_number" => pr_number,
46
50
  "pr_url" => pr_url,
51
+ "pull_request_state" => pull_request_state,
52
+ "pull_request_draft" => pull_request_draft,
53
+ "pull_request_merged_at" => pull_request_merged_at,
54
+ "merge_proof" => serialise_merge_proof( merge_proof: merge_proof ),
47
55
  "cause" => cause,
48
56
  "summary" => summary,
49
- "created_at" => timestamp,
57
+ "created_at" => created_at,
50
58
  "updated_at" => timestamp,
51
59
  "integrated_at" => nil,
52
60
  "superseded_at" => nil,
53
- "revisions" => []
61
+ "revisions" => revisions
54
62
  }
55
63
  build_delivery( key: key, data: state[ "deliveries" ][ key ], repository: repository )
56
64
  end
@@ -69,7 +77,23 @@ module Carson
69
77
 
70
78
  return nil if candidates.empty?
71
79
 
72
- key, data = candidates.max_by { |k, d| [ d[ "updated_at" ].to_s, k ] }
80
+ key, data = candidates.max_by { |k, d| [ d[ "updated_at" ].to_s, delivery_sequence( data: d ), k ] }
81
+ build_delivery( key: key, data: data )
82
+ end
83
+
84
+ # Looks up the newest delivery for a branch across active and terminal states.
85
+ def latest_delivery( repo_path:, branch_name: )
86
+ state = load_state
87
+ repo_paths = repo_identity_paths( repo_path: repo_path )
88
+
89
+ candidates = state[ "deliveries" ].select do |_key, data|
90
+ repo_paths.include?( data[ "repo_path" ] ) &&
91
+ data[ "branch_name" ] == branch_name
92
+ end
93
+
94
+ return nil if candidates.empty?
95
+
96
+ key, data = candidates.max_by { |k, d| [ d[ "updated_at" ].to_s, delivery_sequence( data: d ), k ] }
73
97
  build_delivery( key: key, data: data )
74
98
  end
75
99
 
@@ -80,7 +104,7 @@ module Carson
80
104
 
81
105
  state[ "deliveries" ]
82
106
  .select { |_key, data| repo_paths.include?( data[ "repo_path" ] ) && ACTIVE_DELIVERY_STATES.include?( data[ "status" ] ) }
83
- .sort_by { |key, data| [ data[ "created_at" ].to_s, key ] }
107
+ .sort_by { |key, data| [ delivery_sequence( data: data ), key ] }
84
108
  .map { |key, data| build_delivery( key: key, data: data ) }
85
109
  end
86
110
 
@@ -95,7 +119,7 @@ module Carson
95
119
  data[ "status" ] == "integrated" &&
96
120
  !data[ "worktree_path" ].to_s.strip.empty?
97
121
  end
98
- .sort_by { |key, data| [ data[ "integrated_at" ].to_s, data[ "updated_at" ].to_s, key ] }
122
+ .sort_by { |key, data| [ data[ "integrated_at" ].to_s, delivery_sequence( data: data ), key ] }
99
123
  .map { |key, data| build_delivery( key: key, data: data ) }
100
124
  end
101
125
 
@@ -105,6 +129,10 @@ module Carson
105
129
  status: UNSET,
106
130
  pr_number: UNSET,
107
131
  pr_url: UNSET,
132
+ pull_request_state: UNSET,
133
+ pull_request_draft: UNSET,
134
+ pull_request_merged_at: UNSET,
135
+ merge_proof: UNSET,
108
136
  cause: UNSET,
109
137
  summary: UNSET,
110
138
  worktree_path: UNSET,
@@ -112,12 +140,15 @@ module Carson
112
140
  superseded_at: UNSET
113
141
  )
114
142
  with_state do |state|
115
- data = state[ "deliveries" ][ delivery.key ]
116
- raise "delivery not found: #{delivery.key}" unless data
143
+ key, data = resolve_delivery_entry( state: state, delivery: delivery )
117
144
 
118
145
  data[ "status" ] = status unless status.equal?( UNSET )
119
146
  data[ "pr_number" ] = pr_number unless pr_number.equal?( UNSET )
120
147
  data[ "pr_url" ] = pr_url unless pr_url.equal?( UNSET )
148
+ data[ "pull_request_state" ] = pull_request_state unless pull_request_state.equal?( UNSET )
149
+ data[ "pull_request_draft" ] = pull_request_draft unless pull_request_draft.equal?( UNSET )
150
+ data[ "pull_request_merged_at" ] = pull_request_merged_at unless pull_request_merged_at.equal?( UNSET )
151
+ data[ "merge_proof" ] = serialise_merge_proof( merge_proof: merge_proof ) unless merge_proof.equal?( UNSET )
121
152
  data[ "cause" ] = cause unless cause.equal?( UNSET )
122
153
  data[ "summary" ] = summary unless summary.equal?( UNSET )
123
154
  data[ "worktree_path" ] = worktree_path unless worktree_path.equal?( UNSET )
@@ -125,7 +156,7 @@ module Carson
125
156
  data[ "superseded_at" ] = superseded_at unless superseded_at.equal?( UNSET )
126
157
  data[ "updated_at" ] = now_utc
127
158
 
128
- build_delivery( key: delivery.key, data: data, repository: delivery.repository )
159
+ build_delivery( key: key, data: data, repository: delivery.repository )
129
160
  end
130
161
  end
131
162
 
@@ -134,8 +165,7 @@ module Carson
134
165
  timestamp = now_utc
135
166
 
136
167
  with_state do |state|
137
- data = state[ "deliveries" ][ delivery.key ]
138
- raise "delivery not found: #{delivery.key}" unless data
168
+ _key, data = resolve_delivery_entry( state: state, delivery: delivery )
139
169
 
140
170
  revisions = data[ "revisions" ] ||= []
141
171
  next_number = ( revisions.map { |r| r[ "number" ].to_i }.max || 0 ) + 1
@@ -166,11 +196,7 @@ module Carson
166
196
 
167
197
  # Acquires file lock, loads state, yields for mutation, saves atomically, releases lock.
168
198
  def with_state
169
- lock_path = "#{path}.lock"
170
- FileUtils.mkdir_p( File.dirname( lock_path ) )
171
- FileUtils.touch( lock_path )
172
-
173
- File.open( lock_path, File::RDWR | File::CREAT ) do |lock_file|
199
+ with_state_lock do |lock_file|
174
200
  lock_file.flock( File::LOCK_EX )
175
201
  state = load_state
176
202
  result = yield state
@@ -180,21 +206,22 @@ module Carson
180
206
  end
181
207
 
182
208
  def load_state
183
- return { "deliveries" => {}, "recovery_events" => [] } unless File.exist?( path )
209
+ return { "deliveries" => {}, "recovery_events" => [] } unless File.exist?( path )
184
210
 
185
- raw = File.read( path )
211
+ raw = File.binread( path )
186
212
  return { "deliveries" => {}, "recovery_events" => [] } if raw.strip.empty?
187
213
 
188
214
  parsed = JSON.parse( raw )
189
215
  raise "state file must contain a JSON object at #{path}" unless parsed.is_a?( Hash )
190
216
  parsed[ "deliveries" ] ||= {}
191
- parsed[ "recovery_events" ] ||= []
217
+ normalise_state!( state: parsed )
192
218
  parsed
193
- rescue JSON::ParserError => exception
219
+ rescue JSON::ParserError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => exception
194
220
  raise "invalid JSON in state file #{path}: #{exception.message}"
195
221
  end
196
222
 
197
223
  def save_state!( state )
224
+ normalise_state!( state: state )
198
225
  tmp_path = "#{path}.tmp"
199
226
  File.write( tmp_path, JSON.pretty_generate( state ) + "\n" )
200
227
  File.rename( tmp_path, path )
@@ -218,6 +245,10 @@ module Carson
218
245
  status: data.fetch( "status" ),
219
246
  pull_request_number: data[ "pr_number" ],
220
247
  pull_request_url: data[ "pr_url" ],
248
+ pull_request_state: data[ "pull_request_state" ],
249
+ pull_request_draft: data[ "pull_request_draft" ],
250
+ pull_request_merged_at: data[ "pull_request_merged_at" ],
251
+ merge_proof: deserialise_merge_proof( merge_proof: data[ "merge_proof" ] ),
221
252
  revisions: revisions,
222
253
  cause: data[ "cause" ],
223
254
  summary: data[ "summary" ],
@@ -242,6 +273,233 @@ module Carson
242
273
  )
243
274
  end
244
275
 
276
+ def migrate_legacy_state_if_needed!
277
+ with_state_lock do |lock_file|
278
+ lock_file.flock( File::LOCK_EX )
279
+ source_path = legacy_sqlite_source_path
280
+ next unless source_path
281
+
282
+ state = load_legacy_sqlite_state( path: source_path )
283
+ save_state!( state )
284
+ end
285
+ end
286
+
287
+ def with_state_lock
288
+ lock_path = "#{path}.lock"
289
+ FileUtils.mkdir_p( File.dirname( lock_path ) )
290
+ FileUtils.touch( lock_path )
291
+
292
+ File.open( lock_path, File::RDWR | File::CREAT ) do |lock_file|
293
+ yield lock_file
294
+ end
295
+ end
296
+
297
+ def legacy_sqlite_source_path
298
+ return nil unless state_path_requires_migration?
299
+ return path if sqlite_database_file?( path: path )
300
+
301
+ legacy_path = legacy_state_path
302
+ return nil unless legacy_path
303
+ return legacy_path if sqlite_database_file?( path: legacy_path )
304
+
305
+ nil
306
+ end
307
+
308
+ def state_path_requires_migration?
309
+ return true if sqlite_database_file?( path: path )
310
+ return false if File.exist?( path )
311
+ !legacy_state_path.nil?
312
+ end
313
+
314
+ def legacy_state_path
315
+ return nil unless path.end_with?( ".json" )
316
+ path.sub( /\.json\z/, ".sqlite3" )
317
+ end
318
+
319
+ def sqlite_database_file?( path: )
320
+ return false unless File.file?( path )
321
+ File.binread( path, SQLITE_HEADER.bytesize ) == SQLITE_HEADER
322
+ rescue StandardError
323
+ false
324
+ end
325
+
326
+ def load_legacy_sqlite_state( path: )
327
+ begin
328
+ require "sqlite3"
329
+ rescue LoadError => exception
330
+ raise "legacy SQLite ledger found at #{path}, but sqlite3 support is unavailable: #{exception.message}"
331
+ end
332
+
333
+ database = open_legacy_sqlite_database( path: path )
334
+ deliveries = database.execute( "SELECT * FROM deliveries ORDER BY id ASC" )
335
+ revisions_by_delivery = database.execute(
336
+ "SELECT * FROM revisions ORDER BY delivery_id ASC, number ASC, id ASC"
337
+ ).group_by { |row| row.fetch( "delivery_id" ) }
338
+
339
+ state = {
340
+ "deliveries" => {},
341
+ "recovery_events" => [],
342
+ "next_sequence" => 1
343
+ }
344
+ deliveries.each do |row|
345
+ key = delivery_key(
346
+ repo_path: row.fetch( "repo_path" ),
347
+ branch_name: row.fetch( "branch_name" ),
348
+ head: row.fetch( "head" )
349
+ )
350
+ state[ "deliveries" ][ key ] = {
351
+ "sequence" => row.fetch( "id" ).to_i,
352
+ "repo_path" => row.fetch( "repo_path" ),
353
+ "branch_name" => row.fetch( "branch_name" ),
354
+ "head" => row.fetch( "head" ),
355
+ "worktree_path" => row.fetch( "worktree_path" ),
356
+ "status" => row.fetch( "status" ),
357
+ "pr_number" => row.fetch( "pr_number" ),
358
+ "pr_url" => row.fetch( "pr_url" ),
359
+ "pull_request_state" => nil,
360
+ "pull_request_draft" => nil,
361
+ "pull_request_merged_at" => nil,
362
+ "merge_proof" => nil,
363
+ "cause" => row.fetch( "cause" ),
364
+ "summary" => row.fetch( "summary" ),
365
+ "created_at" => row.fetch( "created_at" ),
366
+ "updated_at" => row.fetch( "updated_at" ),
367
+ "integrated_at" => row.fetch( "integrated_at" ),
368
+ "superseded_at" => row.fetch( "superseded_at" ),
369
+ "revisions" => Array( revisions_by_delivery[ row.fetch( "id" ) ] ).map do |revision|
370
+ {
371
+ "number" => revision.fetch( "number" ).to_i,
372
+ "cause" => revision.fetch( "cause" ),
373
+ "provider" => revision.fetch( "provider" ),
374
+ "status" => revision.fetch( "status" ),
375
+ "started_at" => revision.fetch( "started_at" ),
376
+ "finished_at" => revision.fetch( "finished_at" ),
377
+ "summary" => revision.fetch( "summary" )
378
+ }
379
+ end
380
+ }
381
+ end
382
+ normalise_state!( state: state )
383
+ state
384
+ ensure
385
+ database&.close
386
+ end
387
+
388
+ def open_legacy_sqlite_database( path: )
389
+ database = SQLite3::Database.new( "file:#{path}?immutable=1", readonly: true, uri: true )
390
+ database.results_as_hash = true
391
+ database.busy_timeout = 5_000
392
+ database
393
+ rescue SQLite3::CantOpenException
394
+ database&.close
395
+ database = SQLite3::Database.new( path, readonly: true )
396
+ database.results_as_hash = true
397
+ database.busy_timeout = 5_000
398
+ database
399
+ end
400
+
401
+ def normalise_state!( state: )
402
+ deliveries = state[ "deliveries" ]
403
+ raise "state file must contain a JSON object at #{path}" unless deliveries.is_a?( Hash )
404
+ state[ "recovery_events" ] = Array( state[ "recovery_events" ] )
405
+
406
+ sequence_counts = Hash.new( 0 )
407
+ deliveries.each_value do |data|
408
+ data[ "revisions" ] = Array( data[ "revisions" ] )
409
+ data[ "merge_proof" ] = serialise_merge_proof( merge_proof: data[ "merge_proof" ] ) if data.key?( "merge_proof" )
410
+ sequence = integer_or_nil( value: data[ "sequence" ] )
411
+ sequence_counts[ sequence ] += 1 unless sequence.nil? || sequence <= 0
412
+ end
413
+
414
+ max_sequence = sequence_counts.keys.max.to_i
415
+ next_sequence = max_sequence + 1
416
+ deliveries.keys.sort_by { |key| [ deliveries.fetch( key ).fetch( "created_at", "" ).to_s, key ] }.each do |key|
417
+ data = deliveries.fetch( key )
418
+ sequence = integer_or_nil( value: data[ "sequence" ] )
419
+ if sequence.nil? || sequence <= 0 || sequence_counts[ sequence ] > 1
420
+ sequence = next_sequence
421
+ next_sequence += 1
422
+ end
423
+ data[ "sequence" ] = sequence
424
+ end
425
+
426
+ recorded_next = integer_or_nil( value: state[ "next_sequence" ] ) || 1
427
+ state[ "next_sequence" ] = [ recorded_next, next_sequence ].max
428
+ end
429
+
430
+ def next_delivery_sequence!( state: )
431
+ sequence = integer_or_nil( value: state[ "next_sequence" ] ) || 1
432
+ state[ "next_sequence" ] = sequence + 1
433
+ sequence
434
+ end
435
+
436
+ def integer_or_nil( value: )
437
+ Integer( value )
438
+ rescue ArgumentError, TypeError
439
+ nil
440
+ end
441
+
442
+ def delivery_sequence( data: )
443
+ integer_or_nil( value: data[ "sequence" ] ) || 0
444
+ end
445
+
446
+ def matching_deliveries( state:, repo_paths:, branch_name:, head: UNSET )
447
+ state[ "deliveries" ].select do |_key, data|
448
+ next false unless repo_paths.include?( data[ "repo_path" ] )
449
+ next false unless data[ "branch_name" ] == branch_name
450
+ next false unless head.equal?( UNSET ) || data[ "head" ] == head
451
+
452
+ true
453
+ end
454
+ end
455
+
456
+ def resolve_delivery_entry( state:, delivery: )
457
+ data = state[ "deliveries" ][ delivery.key ]
458
+ return [ delivery.key, data ] if data
459
+
460
+ repo_paths = repo_identity_paths( repo_path: delivery.repo_path )
461
+ match = matching_deliveries(
462
+ state: state,
463
+ repo_paths: repo_paths,
464
+ branch_name: delivery.branch,
465
+ head: delivery.head
466
+ ).max_by { |key, row| [ row[ "updated_at" ].to_s, delivery_sequence( data: row ), key ] }
467
+ raise "delivery not found: #{delivery.key}" unless match
468
+
469
+ match
470
+ end
471
+
472
+ def merged_revisions( entries: )
473
+ entries
474
+ .flat_map { |_key, data| Array( data[ "revisions" ] ) }
475
+ .map do |revision|
476
+ {
477
+ "number" => revision.fetch( "number", 0 ).to_i,
478
+ "cause" => revision[ "cause" ],
479
+ "provider" => revision[ "provider" ],
480
+ "status" => revision[ "status" ],
481
+ "started_at" => revision[ "started_at" ],
482
+ "finished_at" => revision[ "finished_at" ],
483
+ "summary" => revision[ "summary" ]
484
+ }
485
+ end
486
+ .uniq do |revision|
487
+ [
488
+ revision[ "cause" ],
489
+ revision[ "provider" ],
490
+ revision[ "status" ],
491
+ revision[ "started_at" ],
492
+ revision[ "finished_at" ],
493
+ revision[ "summary" ]
494
+ ]
495
+ end
496
+ .sort_by { |revision| [ revision.fetch( "started_at", "" ).to_s, revision.fetch( "number", 0 ).to_i ] }
497
+ .each_with_index
498
+ .map do |revision, index|
499
+ revision.merge( "number" => index + 1 )
500
+ end
501
+ end
502
+
245
503
  def supersede_branch!( state:, repo_path:, branch_name:, timestamp: )
246
504
  repo_paths = repo_identity_paths( repo_path: repo_path )
247
505
  state[ "deliveries" ].each do |_key, data|
@@ -293,8 +551,34 @@ module Carson
293
551
  nil
294
552
  end
295
553
 
554
+ def serialise_merge_proof( merge_proof: )
555
+ return nil unless merge_proof.is_a?( Hash )
556
+
557
+ {
558
+ "applicable" => merge_proof[ :applicable ].nil? ? merge_proof[ "applicable" ] : merge_proof[ :applicable ],
559
+ "proven" => merge_proof[ :proven ].nil? ? merge_proof[ "proven" ] : merge_proof[ :proven ],
560
+ "basis" => merge_proof[ :basis ] || merge_proof[ "basis" ],
561
+ "summary" => merge_proof[ :summary ] || merge_proof[ "summary" ],
562
+ "main_branch" => merge_proof[ :main_branch ] || merge_proof[ "main_branch" ],
563
+ "changed_files_count" => ( merge_proof[ :changed_files_count ] || merge_proof[ "changed_files_count" ] || 0 ).to_i
564
+ }
565
+ end
566
+
567
+ def deserialise_merge_proof( merge_proof: )
568
+ return nil unless merge_proof.is_a?( Hash )
569
+
570
+ {
571
+ applicable: merge_proof[ "applicable" ],
572
+ proven: merge_proof[ "proven" ],
573
+ basis: merge_proof[ "basis" ],
574
+ summary: merge_proof[ "summary" ],
575
+ main_branch: merge_proof[ "main_branch" ],
576
+ changed_files_count: merge_proof.fetch( "changed_files_count", 0 ).to_i
577
+ }
578
+ end
579
+
296
580
  def now_utc
297
- Time.now.utc.iso8601
581
+ Time.now.utc.iso8601( 6 )
298
582
  end
299
583
 
300
584
  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: )