1#!/usr/bin/env nix-shell
2#!nix-shell -i ruby -p "ruby.withPackages (ps: with ps; [ curb nokogiri ])" nix-prefetch-git
3
4require 'set'
5require 'json'
6require 'uri'
7require 'shellwords'
8require 'erb'
9require 'rubygems'
10require 'curb'
11require 'nokogiri'
12
13# Performs a GET to an arbitrary address.
14# +url+:: the URL
15def get url, &block
16 curl = Curl::Easy.new(url) do |http|
17 http.follow_location = false
18 http.headers['User-Agent'] = 'nixpkgs dwarf fortress update bot'
19 yield http if block_given?
20 end
21
22 curl.perform
23 curl.body_str
24end
25
26# Performs a GET on the Github API.
27# +url+:: the relative URL to api.github.com
28def get_gh url, &block
29 ret = get URI.join('https://api.github.com/', url) do |http|
30 http.headers['Accept'] = 'application/vnd.github+json'
31 http.headers['Authorization'] = "Bearer #{ENV['GH_TOKEN']}" if ENV.include?('GH_TOKEN')
32 http.headers['X-GitHub-Api-Version'] = '2022-11-28'
33 yield http if block_given?
34 end
35 JSON.parse(ret, symbolize_names: true)
36end
37
38def normalize_keys hash
39 Hash[hash.map {
40 [
41 _1.to_s,
42 _2.is_a?(Hash) ? normalize_keys(_2) : _2
43 ]
44 }]
45end
46
47module Mergeable
48 # Merges this Mergeable with something else.
49 # +other+:: The other Mergeable.
50 def merge other
51 if !other
52 return self
53 end
54
55 if !other.is_a?(Mergeable) || self.members != other.members
56 raise "invalid right-hand operand for merge: #{other.members}"
57 end
58
59 hash = {}
60 self.members.each do |member|
61 if @@expensive && @@expensive.include?(member)
62 # Already computed
63 hash[member] = other[member] || self.send(member)
64 elsif self.send(member) && self.send(member).is_a?(Mergeable)
65 # Merge it
66 hash[member] = self.send(member).merge(other.send(member))
67 elsif self.send(member) && self.send(member).is_a?(Hash)
68 hash[member] = Hash[other.send(member).map {
69 [_1, self.send(member)[_1] && self.send(member)[_1].is_a?(Mergeable) ? self.send(member)[_1].merge(_2) : _2]
70 }]
71 else
72 # Compute it
73 hash[member] = other.send(member)
74 end
75 end
76 self.class.new(**hash)
77 end
78
79 # Marks some attributes as expensive.
80 def expensive *attrs
81 @@expensive ||= Set.new
82 attrs.each {@@expensive << _1}
83 self
84 end
85
86 # Materializes this object.
87 def materialize!
88 self.members.each do |name|
89 member = self.send(name)
90 if member.respond_to?(:materialize!)
91 member.materialize!
92 end
93 self[name] = member
94 end
95 self
96 end
97end
98
99module Versionable
100 # Parses the version.
101 def parsed_version
102 @version ||= Gem::Version.create(self.version.partition('-').first)
103 end
104
105 # Drops the last component of the version for chunking.
106 def major_version
107 @major_version ||= Gem::Version.create(self.parsed_version.canonical_segments[..-2].join('.'))
108 end
109
110 # Compares the major version.
111 def =~ other
112 self.major_version == other.major_version
113 end
114
115 # Negation of the above.
116 def !~ other
117 !(self =~ other)
118 end
119
120 # Compares two versions.
121 def <=> other
122 other.parsed_version <=> self.parsed_version
123 end
124end
125
126class DFUrl < Struct.new(:url, :output_hash, keyword_init: true)
127 include Mergeable
128 extend Mergeable
129
130 expensive :output_hash
131
132 # Converts this DFUrl to a hash.
133 def to_h
134 {
135 url: self.url,
136 outputHash: self.output_hash
137 }
138 end
139
140 # Returns or computes the output hash.
141 def output_hash
142 return super if super
143 self.output_hash = `nix-prefetch-url #{Shellwords.escape(self.url.to_s)} | xargs nix-hash --to-sri --type sha256`.strip
144 super
145 end
146
147 # Converts this DFUrl from a hash.
148 # +hash+:: The hash
149 def self.from_hash hash
150 DFUrl.new(
151 url: hash.fetch(:url),
152 output_hash: hash[:outputHash]
153 )
154 end
155end
156
157class DFGithub < Struct.new(:url, :revision, :output_hash, keyword_init: true)
158 include Mergeable
159 extend Mergeable
160
161 expensive :output_hash
162
163 # Converts this DFGithub to a hash.
164 def to_h
165 {
166 url: self.url,
167 revision: self.revision,
168 outputHash: self.output_hash
169 }
170 end
171
172 # Returns or computes the output hash.
173 def output_hash
174 return super if super
175 url = URI.parse(self.url.to_s)
176 if ENV['GH_TOKEN']
177 url.userinfo = ENV['GH_TOKEN']
178 end
179 self.output_hash = JSON.parse(`nix-prefetch-git --no-deepClone --fetch-submodules #{Shellwords.escape(url.to_s)} #{Shellwords.escape(self.revision.to_s)}`, symbolize_names: true).fetch(:hash)
180 super
181 end
182
183 # Converts a hash to a DFGithub.
184 # +hash+:: The hash
185 def self.from_hash hash
186 DFGithub.new(
187 url: hash.fetch(:url),
188 revision: hash.fetch(:revision),
189 output_hash: hash[:outputHash]
190 )
191 end
192end
193
194class DFVersion < Struct.new(:version, :urls, keyword_init: true)
195 include Mergeable
196 extend Mergeable
197 include Versionable
198
199 # Converts a DFVersion to a hash.
200 def to_h
201 {
202 version: self.version,
203 urls: Hash[self.urls.map {
204 [_1, _2.to_h]
205 }]
206 }
207 end
208
209 # Converts a hash to a DFVersion.
210 # +hash+:: The hash
211 def self.from_hash hash
212 DFVersion.new(
213 version: hash.fetch(:version),
214 urls: Hash[hash.fetch(:urls).map {
215 [_1, DFUrl.from_hash(_2)]
216 }]
217 )
218 end
219
220 # Converts an HTML node to a DFVersion.
221 # +base+:: The base URL for DF downloads.
222 # +node+:: The HTML node
223 def self.from_node base, node
224 match = node.text.match(/DF\s+(\d+\.\d+(?:\.\d+)?)/)
225 if match
226 systems = {}
227 node.css('a').each do |a|
228 case a['href']
229 when /osx\.tar/ then systems[:darwin] = DFUrl.new(url: URI.join(base, a['href']).to_s)
230 when /linux\.tar/ then systems[:linux] = DFUrl.new(url: URI.join(base, a['href']).to_s)
231 end
232 end
233
234 if systems.empty?
235 nil
236 else
237 DFVersion.new(version: match[1], urls: systems)
238 end
239 else
240 nil
241 end
242 end
243
244 # Returns all DFVersions from the download page.
245 # +cutoff+:: The minimum version
246 def self.all cutoff:
247 cutoff = Gem::Version.create(cutoff)
248
249 base = 'https://www.bay12games.com/dwarves/'
250 res = get URI.join(base, 'older_versions.html')
251 parsed = Nokogiri::HTML(res)
252
253 # Figure out which versions we care about.
254 parsed.css('p.menu').map {DFVersion.from_node(base, _1)}.select {
255 _1 && _1.parsed_version >= cutoff
256 }.sort.chunk {
257 _1.major_version
258 }.map {|*, versions|
259 versions.max_by {_1.parsed_version}
260 }.to_a
261 end
262end
263
264class DFHackVersion < Struct.new(:version, :git, :xml_rev, keyword_init: true)
265 include Mergeable
266 extend Mergeable
267 include Versionable
268
269 expensive :xml_rev
270
271 # Returns the download URL.
272 def git
273 return super if super
274 self.git = DFGithub.new(
275 url: "https://github.com/DFHack/dfhack.git",
276 revision: self.version
277 )
278 super
279 end
280
281 # Converts this DFHackVersion to a hash.
282 def to_h
283 {
284 version: self.version,
285 git: self.git.to_h,
286 xmlRev: self.xml_rev,
287 }
288 end
289
290 # Returns the revision number in the version. Defaults to 0.
291 def rev
292 return @rev if @rev
293 rev = self.version.match(/-r([\d\.]+)\z/)
294 @rev = rev[1].to_f if rev
295 @rev ||= 0
296 @rev
297 end
298
299 # Returns the XML revision, fetching it if necessary.
300 def xml_rev
301 return super if super
302 url = "repos/dfhack/dfhack/contents/library/xml?ref=#{URI.encode_uri_component(self.git.revision)}"
303 body = get_gh url
304 self.xml_rev = body.fetch(:sha)
305 super
306 end
307
308 # Compares two DFHack versions.
309 # +other+:: the other dfhack version
310 def <=> other
311 ret = super
312 ret = other.rev <=> self.rev if ret == 0
313 ret
314 end
315
316 # Returns a version from a hash.
317 # +hash+:: the hash
318 def self.from_hash hash
319 DFHackVersion.new(
320 version: hash.fetch(:version),
321 git: DFGithub.from_hash(hash.fetch(:git)),
322 xml_rev: hash[:xmlRev]
323 )
324 end
325
326 # Returns a release from a github object.
327 # +github_obj+:: The github object. Returns null for prereleases.
328 def self.from_github github_obj
329 if github_obj.fetch(:prerelease)
330 return nil
331 end
332 version = github_obj.fetch(:tag_name)
333 DFHackVersion.new(version: version)
334 end
335
336 # Returns all dfhack versions.
337 # +cutoff+:: The cutoff version.
338 def self.all cutoff:
339 cutoff = Gem::Version.create(cutoff)
340 ret = {}
341 (1..).each do |page|
342 url = "repos/dfhack/dfhack/releases?per_page=100&page=#{page}"
343 releases = get_gh url
344
345 releases.each do |release|
346 release = DFHackVersion.from_github(release)
347 if release && release.parsed_version >= cutoff
348 ret[release.major_version] ||= {}
349 ret[release.major_version][release.parsed_version] ||= []
350 ret[release.major_version][release.parsed_version] << release
351 end
352 end
353
354 break if releases.length < 1
355 end
356
357 ret.each do |_, dfhack_major_versions|
358 dfhack_major_versions.each do |_, dfhack_minor_versions|
359 dfhack_minor_versions.sort!
360 end
361 end
362
363 ret
364 end
365end
366
367class DFWithHackVersion < Struct.new(:df, :hack, keyword_init: true)
368 include Mergeable
369 extend Mergeable
370
371 # Converts this DFWithHackVersion to a hash.
372 def to_h
373 {
374 df: self.df.to_h,
375 hack: self.hack.to_h
376 }
377 end
378
379 # Converts a hash to a DFWithHackVersion.
380 # +hash+:: the hash to convert
381 def self.from_hash hash
382 DFWithHackVersion.new(
383 df: DFVersion.from_hash(hash.fetch(:df)),
384 hack: DFHackVersion.from_hash(hash.fetch(:hack))
385 )
386 end
387end
388
389class DFWithHackVersions < Struct.new(:latest, :versions, keyword_init: true)
390 include Mergeable
391 extend Mergeable
392
393 # Initializes this DFWithHackVersions.
394 def initialize *args, **kw
395 super *args, **kw
396 self.latest ||= {}
397 self.versions ||= {}
398 end
399
400 # Converts this DFWithHackVersions to a hash.
401 def to_h
402 {
403 latest: self.latest,
404 versions: Hash[self.versions.map {
405 [_1.to_s, _2.to_h]
406 }]
407 }
408 end
409
410 # Loads this DFWithHackVersions.
411 # +cutoff+:: The minimum version to load.
412 def load! cutoff:
413 df_versions = DFVersion.all(cutoff: cutoff)
414 dfhack_versions = DFHackVersion.all(cutoff: cutoff)
415
416 df_versions.each do |df_version|
417 latest_dfhack_version = nil
418 corresponding_dfhack_versions = dfhack_versions.dig(df_version.major_version, df_version.parsed_version)
419 if corresponding_dfhack_versions
420 latest_dfhack_version = corresponding_dfhack_versions.first
421 end
422
423 if latest_dfhack_version
424 df_version.urls.each do |platform, url|
425 if !self.latest[platform] || df_version.parsed_version > Gem::Version.create(self.latest[platform])
426 self.latest[platform] = df_version.version
427 end
428 end
429 self.versions[df_version.version] = DFWithHackVersion.new(df: df_version, hack: latest_dfhack_version)
430 end
431 end
432
433 self.materialize!
434 self
435 end
436
437 # Converts a hash to a DFWithHackVersions.
438 # +hash+:: The hash
439 def self.from_hash hash
440 DFWithHackVersions.new(
441 latest: hash.fetch(:latest),
442 versions: Hash[hash.fetch(:versions).map {
443 [_1.to_s, DFWithHackVersion.from_hash(_2)]
444 }]
445 )
446 end
447end
448
449class Therapist < Struct.new(:version, :max_df_version, :git, keyword_init: true)
450 include Mergeable
451 extend Mergeable
452 include Versionable
453
454 expensive :max_df_version
455
456 # Converts this Therapist instance to a hash.
457 def to_h
458 {
459 version: self.version,
460 maxDfVersion: self.max_df_version,
461 git: self.git.to_h
462 }
463 end
464
465 # Returns the max supported DF version.
466 def max_df_version
467 return super if super
468 url = "repos/Dwarf-Therapist/Dwarf-Therapist/contents/share/memory_layouts/linux?ref=#{URI.encode_uri_component(self.git.revision)}"
469 body = get_gh url
470
471 # Figure out the max supported memory layout.
472 max_version = nil
473 max_version_str = nil
474 body.each do |item|
475 name = item[:name] || ""
476 match = name.match(/\Av(?:0\.)?(\d+\.\d+)-classic_linux\d*\.ini/)
477 if match
478 version = Gem::Version.create(match[1])
479 if !max_version || version > max_version
480 max_version = version
481 max_version_str = match[1]
482 end
483 end
484 end
485
486 self.max_df_version = max_version_str
487
488 super
489 end
490
491 # Returns a Github URL.
492 def git
493 return super if super
494 self.git = DFGithub.new(
495 url: "https://github.com/Dwarf-Therapist/Dwarf-Therapist.git",
496 revision: 'v' + self.version
497 )
498 super
499 end
500
501 # Loads this therapist instance from Github.
502 def load!
503 latest = self.class.latest
504 self.version = latest.version
505 self.max_df_version = latest.max_df_version
506 self.git = latest.git
507 self.materialize!
508 self
509 end
510
511 # Loads a hash into this Therapist instance.
512 # +hash+: the hash
513 def self.from_hash hash
514 Therapist.new(
515 version: hash.fetch(:version),
516 max_df_version: hash[:maxDfVersion],
517 git: DFGithub.from_hash(hash.fetch(:git))
518 )
519 end
520
521 # Returns a release from a github object.
522 # +github_obj+:: The github object. Returns null for prereleases.
523 def self.from_github github_obj
524 if github_obj.fetch(:prerelease)
525 return nil
526 end
527
528 version = github_obj.fetch(:tag_name)
529 match = version.match(/\Av([\d\.]+)\z/)
530 if match
531 Therapist.new(version: match[1])
532 else
533 nil
534 end
535 end
536
537 # Returns the latest Therapist version.
538 def self.latest
539 url = "repos/Dwarf-Therapist/Dwarf-Therapist/releases"
540 releases = get_gh url
541
542 releases.each do |release|
543 release = Therapist.from_github(release)
544 if release
545 return release
546 end
547 end
548
549 nil
550 end
551end
552
553class DFLock < Struct.new(:game, :therapist, keyword_init: true)
554 include Mergeable
555 extend Mergeable
556
557 # Initializes this DFLock.
558 def initialize *args, **kw
559 super *args, **kw
560 self.game ||= DFWithHackVersions.new
561 self.therapist ||= Therapist.new
562 end
563
564 # Converts this DFLock to a hash.
565 def to_h
566 {
567 game: self.game.to_h,
568 therapist: self.therapist.to_h
569 }
570 end
571
572 # Returns an array containing all versions.
573 def all_versions
574 self.game.versions.keys.map {"DF #{_1}"}.to_a + ["DT #{self.therapist.version}"]
575 end
576
577 # Loads this DFLock.
578 # +cutoff+:: The minimum DF version to load.
579 def load! cutoff:
580 self.game.load! cutoff: cutoff
581 self.therapist.load!
582 end
583
584 # Converts a hash to a DFLock.
585 # +hash+:: The hash
586 def self.from_hash hash
587 DFLock.new(
588 game: DFWithHackVersions.from_hash(hash.fetch(:game)),
589 therapist: Therapist.from_hash(hash.fetch(:therapist))
590 )
591 end
592end
593
594# 0.43 and below has a broken dfhack.
595new_df_lock = DFLock.new
596new_df_lock.load! cutoff: '0.44'
597
598df_lock_file = File.join(__dir__, 'df.lock.json')
599df_lock, df_lock_json = if File.file?(df_lock_file)
600 json = JSON.parse(File.read(df_lock_file), symbolize_names: true)
601 [DFLock.from_hash(json), json]
602 else
603 [DFLock.new, {}]
604 end
605
606new_df_lock_json = df_lock.merge(new_df_lock).to_h
607json = JSON.pretty_generate(new_df_lock_json)
608json << "\n"
609STDERR.puts json
610File.write(df_lock_file, json)
611
612# See if there were any changes.
613changed_paths = []
614if normalize_keys(df_lock_json) != normalize_keys(new_df_lock_json)
615 all_old_versions = df_lock.all_versions
616 all_new_versions = new_df_lock.all_versions
617 just_old_versions = all_old_versions - all_new_versions
618 just_new_versions = all_new_versions - all_old_versions
619 changes = just_old_versions.zip(just_new_versions)
620
621 template = ERB.new(<<-EOF, trim_mode: '<>-')
622dwarf-fortress-packages: <%= changes.map {|old, new| '%s -> %s' % [old, new]}.join('; ') %>
623
624Performed the following automatic DF updates:
625
626<% changes.each do |old, new| %>
627- <%= old -%> -> <%= new -%>
628<% end %>
629EOF
630
631 changed_paths << {
632 attrPath: 'dwarf-fortress-packages',
633 oldVersion: just_old_versions.join('; '),
634 newVersion: just_new_versions.join('; '),
635 files: [
636 File.realpath(df_lock_file)
637 ],
638 commitMessage: template.result(binding)
639 }
640end
641
642STDOUT.puts JSON.pretty_generate(changed_paths)