1+ # frozen_string_literal: true
2+
3+ require 'rake'
4+ require 'json'
5+ require 'uri'
6+ require 'net/http'
7+ require 'rubygems/version'
8+ require 'rubygems/requirement'
9+ require 'colorize'
10+ require 'tty-table'
11+ require 'set'
12+
13+ # ----- Constants -----
14+ REPO_ROOT = File . expand_path ( '..' , __dir__ )
15+ COMPONENT_DIR = File . join ( REPO_ROOT , 'configs' , 'components' )
16+ COMPONENT_GLOB = File . join ( COMPONENT_DIR , 'rubygem-*.rb' )
17+
18+ MAINT_START = /^\s *### Maintained by update_gems automation ###\s *$/
19+ MAINT_END = /^\s *### End automated maintenance section ###\s *$/
20+ PINNED_LINE = /^\s *#\s *PINNED\b .*$/
21+ VER_LINE = /^\s *pkg\. version\s +['"](?<version>[^'"]+)['"]\s *$/
22+ SHA_LINE = /^\s *pkg\. (?:sha256sum|md5sum)\s +['"](?<sha>[0-9a-fA-F]+)['"]\s *$/
23+ BUILD_REQ = /^\s *pkg\. build_requires\s +['"]rubygem-([^'"]+)['"]\s *$/
24+ GEM_TYPE = /^\s *#\s *GEM\s +TYPE:\s *(?<platform>[A-Za-z0-9\- _\. ]+)\s *$/
25+
26+ TARGET_RUBY_VER = ENV [ 'TARGET_RUBY' ] &.strip || '3.2'
27+ MAX_TABLE_WIDTH = 140
28+ VERSIONS_CACHE = { }
29+
30+ def create_component_file ( path , gemname , version , sha , deps = [ ] )
31+ lines = [ ]
32+ lines << "#####\n "
33+ lines << "# Component release information:\n "
34+ lines << "# https://rubygems.org/gems/#{ gemname } \n "
35+ lines << "#####\n "
36+ lines << "component 'rubygem-#{ gemname } ' do |pkg, settings, platform|\n "
37+ lines << " ### Maintained by update_gems automation ###\n "
38+ lines << " pkg.version '#{ version } '\n "
39+ lines << " pkg.sha256sum '#{ sha } '\n "
40+ deps . each { |name | lines << " pkg.build_requires 'rubygem-#{ name } '\n " }
41+ lines << " ### End automated maintenance section ###\n "
42+ lines << "\n "
43+ lines << " instance_eval File.read('configs/components/_base-rubygem.rb')\n "
44+ lines << "end\n "
45+ File . write ( path , lines . join , encoding : 'UTF-8' )
46+ end
47+
48+ # ----- Table and progress output -----
49+ def color_status ( s )
50+ case s
51+ when 'UP TO DATE' then s . green
52+ when 'UPDATED' then s . yellow
53+ when 'ADDED' then s . cyan
54+ when 'ERROR' then s . red
55+ when 'UNKNOWN' then s . red
56+ else s
57+ end
58+ end
59+
60+ def print_table ( headers , rows )
61+ comp_w , status_w , version_w = 50 , 12 , 32
62+ deps_w = [ MAX_TABLE_WIDTH - ( comp_w + status_w + version_w + 13 ) , 10 ] . max
63+ table = TTY ::Table . new headers , rows
64+ puts table . render ( :ascii , width : MAX_TABLE_WIDTH , resize : true , multiline : true , padding : [ 0 , 1 , 0 , 1 ] ) { |r |
65+ r . alignments = [ :left , :center , :left , :left ]
66+ r . border . separator = :each_row
67+ r . column_widths = [ comp_w , status_w , version_w , deps_w ]
68+ }
69+ end
70+
71+ @progress_max_width = 0
72+ def progress_print ( msg , io : $stderr)
73+ clean = msg [ 0 , MAX_TABLE_WIDTH - 1 ]
74+ @progress_max_width = [ @progress_max_width , clean . length ] . max
75+ io . print ( "\r #{ clean . ljust ( @progress_max_width ) } " )
76+ io . flush
77+ end
78+
79+ def progress_clear ( io : $stderr)
80+ return if @progress_max_width <= 0
81+ io . print ( "\r #{ ' ' * @progress_max_width } \r " )
82+ io . flush
83+ @progress_max_width = 0
84+ end
85+
86+ # ----- Rubygems API access -----
87+ def http ( url )
88+ uri = URI ( url )
89+ Net ::HTTP . start ( uri . host , uri . port , use_ssl : uri . scheme == 'https' ) do |h |
90+ r = Net ::HTTP ::Get . new ( uri )
91+ r [ 'User-Agent' ] = "openvox-runtime-script/1.0"
92+ res = h . request ( r )
93+ raise "HTTP #{ res . code } #{ uri } " unless res . is_a? ( Net ::HTTPSuccess )
94+ res . body
95+ end
96+ end
97+
98+ def get_versions ( name )
99+ VERSIONS_CACHE [ name ] ||= JSON . parse ( http ( "https://rubygems.org/api/v1/versions/#{ name } .json" ) )
100+ end
101+
102+ def get_version_details ( name , version )
103+ enc = URI . encode_www_form_component ( version . to_s )
104+ JSON . parse ( http ( "https://rubygems.org/api/v2/rubygems/#{ name } /versions/#{ enc } .json" ) )
105+ end
106+
107+ # ----- Fetching gem metadata -----
108+ def ruby_req_ok? ( req_str )
109+ req = ( req_str . nil? || req_str . strip . empty? ) ? '>= 0' : req_str
110+ req = req . split ( ',' ) . map ( &:strip )
111+ Gem ::Requirement . new ( req ) . satisfied_by? ( Gem ::Version . new ( TARGET_RUBY_VER ) )
112+ end
113+
114+ def find_sha ( name , version , platform )
115+ # The v2 API only returns SHA for the "ruby" platform
116+ if platform . nil? || platform == '' || platform == 'ruby'
117+ details = get_version_details ( name , version )
118+ raise "SHA not found in details for gem #{ name } version #{ version } " unless details [ 'sha' ]
119+ return details [ 'sha' ]
120+ end
121+
122+ # Otherwise, use the v1 API to find the specific platform
123+ list = get_versions ( name )
124+ metadata = list . find { |v | v [ 'number' ] == version && v [ 'platform' ] == platform }
125+ raise "Version #{ version } (platform: #{ platform } ) not found for gem #{ name } " unless metadata
126+ raise "SHA not found in metadata for gem #{ name } version #{ version } platform #{ platform } " unless metadata [ 'sha' ]
127+ metadata [ 'sha' ]
128+ end
129+
130+ def get_metadata ( name :, version : nil , platforms : [ 'ruby' ] )
131+ all = get_versions ( name )
132+ raise "No versions found for gem #{ name } " if all . empty?
133+
134+ # Choose version, either the one passed in, or the latest compatible
135+ new_version = version
136+ unless new_version
137+ candidates = all . select do |v |
138+ next false if v [ 'prerelease' ]
139+ next false if v [ 'yanked' ]
140+ next false unless v [ 'platform' ] . nil? || v [ 'platform' ] == 'ruby'
141+ ruby_req_ok? ( v [ 'ruby_version' ] )
142+ end
143+ raise "No compatible versions found for gem #{ name } " if candidates . empty?
144+ new_version = candidates . max_by { |v | Gem ::Version . new ( v [ 'number' ] ) } [ 'number' ]
145+ end
146+
147+ # Gather SHAs for requested platforms
148+ platforms = platforms . compact . uniq
149+ shas = { }
150+ platforms . each do |platform |
151+ shas [ platform ] = find_sha ( name , new_version , platform )
152+ end
153+
154+ details = get_version_details ( name , new_version )
155+ deps = ( details . dig ( 'dependencies' , 'runtime' ) || [ ] )
156+
157+ { 'version' => new_version , 'shas' => shas , 'dependencies' => deps }
158+ end
159+
160+ # ----- Processing component files -----
161+ def process_component ( path , gemname )
162+ lines = File . read ( path , encoding : 'UTF-8' ) . lines
163+
164+ # Find maintenance block
165+ start = lines . index { |l | l =~ MAINT_START } or raise "Automated maintenance section not found in #{ path } "
166+ end_rel = lines [ ( start + 1 ) ..] . to_a . index { |l | l =~ MAINT_END } or raise "Automated maintenance section not closed in #{ path } "
167+ finish = start + 1 + end_rel
168+
169+ body = lines [ ( start + 1 ) ...finish ]
170+ old_body_str = body . join
171+
172+ # First pass: read current version, pinned, deps, and platforms
173+ pinned = false
174+ current_version = nil
175+ old_deps = [ ]
176+ platforms = [ 'ruby' ]
177+ prev = nil
178+ body . each do |line |
179+ if ( m = line . match ( VER_LINE ) )
180+ current_version = m [ :version ]
181+ pinned = !!( prev && prev =~ PINNED_LINE )
182+ elsif ( m = line . match ( BUILD_REQ ) )
183+ old_deps << m [ 1 ]
184+ elsif ( m = line . match ( GEM_TYPE ) )
185+ platforms << m [ :platform ]
186+ end
187+ prev = line
188+ end
189+ raise "pkg.version not found in maintenance section for #{ path } " unless current_version
190+
191+ # Resolve target version, shas, and deps
192+ metadata = get_metadata ( name : gemname , version : pinned ? current_version : nil , platforms : platforms . uniq )
193+ target_version = metadata [ 'version' ]
194+ shas = metadata [ 'shas' ]
195+ new_deps = metadata [ 'dependencies' ] . map { |d | d [ 'name' ] } . uniq . sort - [ gemname ]
196+ newly_added = new_deps - old_deps
197+
198+ # Generate new block body
199+ new_body = [ ]
200+ current_platform = nil
201+ body . each do |l |
202+ if ( m = l . match ( VER_LINE ) )
203+ new_body << l . sub ( m [ :version ] , target_version )
204+ elsif ( m = l . match ( GEM_TYPE ) )
205+ current_platform = m [ :platform ]
206+ new_body << l
207+ elsif ( m = l . match ( SHA_LINE ) )
208+ platform = current_platform || 'ruby'
209+ raise "No SHA found for platform #{ platform } of gem #{ gemname } " unless shas [ platform ]
210+ new_body << l . sub ( 'md5sum' , 'sha256sum' ) . sub ( m [ :sha ] , shas [ platform ] )
211+ current_platform = nil
212+ elsif l =~ BUILD_REQ
213+ # Drop existing build_requires
214+ next
215+ else
216+ new_body << l
217+ end
218+ end
219+
220+ # Append new build_requires at end of block body
221+ new_deps . each { |name | new_body << " pkg.build_requires 'rubygem-#{ name } '\n " }
222+
223+ new_body_str = new_body . join
224+ block_changed = ( old_body_str != new_body_str )
225+ lines [ ( start + 1 ) ...finish ] = new_body if block_changed
226+
227+ version_changed = ( current_version != target_version )
228+
229+ File . write ( path , lines . join , encoding : 'UTF-8' ) if block_changed
230+
231+ status = ( version_changed || newly_added . any? || block_changed ) ? 'UPDATED' : 'UP TO DATE'
232+ ver_col = version_changed ? "#{ current_version } -> #{ target_version } " : ''
233+
234+ # Report missing components so the task can create them
235+ missing = new_deps . select { |name | !File . exist? ( File . join ( COMPONENT_DIR , "rubygem-#{ name } .rb" ) ) }
236+
237+ { name : gemname , status : status , version : ver_col , deps_added : newly_added . map { |n | "rubygem-#{ n } " } , missing : missing }
238+ end
239+
240+
241+ namespace :vox do
242+ desc 'Update rubygem components and print a summary table or JSON of changes'
243+ task :update_gems do
244+ results = [ ]
245+ files = Dir . glob ( COMPONENT_GLOB ) . select { |p | File . file? ( p ) }
246+ total = files . length
247+
248+ files . each_with_index do |path , i |
249+ basename = File . basename ( path , '.rb' )
250+ gemname = basename . sub ( /^rubygem-/ , '' )
251+ progress_print ( "Processing (#{ i + 1 } /#{ total } ): #{ basename } " )
252+ results << process_component ( path , gemname )
253+ end
254+ progress_clear
255+
256+ # Create missing component files after processing all
257+ # Because some of the added components may have runtime
258+ # dependencies themselves that are also missing, we need
259+ # to keep a running queue of missing components to add.
260+ added = [ ]
261+ queue = results . flat_map { |r | r [ :missing ] || [ ] } . uniq
262+ seen = Set . new
263+ until queue . empty?
264+ name = queue . shift
265+ next if seen . include? ( name )
266+ seen << name
267+
268+ progress_print ( "Creating component for missing gem: #{ name } " )
269+
270+ path = File . join ( COMPONENT_DIR , "rubygem-#{ name } .rb" )
271+ next if File . exist? ( path )
272+
273+ m = get_metadata ( name : name , platforms : [ 'ruby' ] )
274+ ver = m [ 'version' ]
275+ sha = m [ 'shas' ] [ 'ruby' ]
276+ deps = ( m [ 'dependencies' ] || [ ] ) . map { |d | d [ 'name' ] } . uniq . sort - [ name ]
277+
278+ create_component_file ( path , name , ver , sha , deps )
279+ added << { name : name , version : ver , deps : deps }
280+
281+ deps . each do |name |
282+ path = File . join ( COMPONENT_DIR , "rubygem-#{ name } .rb" )
283+ queue << name unless File . exist? ( path ) || seen . include? ( name ) || queue . include? ( name )
284+ end
285+ end
286+ progress_clear
287+
288+ # JSON output
289+ if !( ARGV & [ '--json' , '-j' , '--format=json' ] ) . empty?
290+ payload = {
291+ ruby_version_used_for_checks : TARGET_RUBY_VER ,
292+ results : results ,
293+ added : added ,
294+ }
295+ puts JSON . pretty_generate ( payload )
296+ next
297+ end
298+
299+ # Table output
300+ headers = [ 'Component' , 'Status' , 'Version update' , 'Dependencies added' ]
301+ rows = results . map do |r |
302+ [
303+ "rubygem-#{ r [ :name ] } " ,
304+ color_status ( r [ :status ] ) ,
305+ r [ :version ] ,
306+ r [ :deps_added ] . join ( ', ' ) ,
307+ ]
308+ end
309+ added . each do |info |
310+ rows << [
311+ "rubygem-#{ info [ :name ] } " ,
312+ color_status ( 'ADDED' ) ,
313+ info [ :version ] . to_s ,
314+ info [ :deps ] . to_a . uniq . sort . map { |dn | "rubygem-#{ dn } " } . join ( ', ' ) ,
315+ ]
316+ end
317+ print_table ( headers , rows )
318+ end
319+ end
0 commit comments