proctolog 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +178 -0
- data/app/controllers/proctolog/acceptances_controller.rb +29 -0
- data/app/controllers/proctolog/application_controller.rb +32 -0
- data/app/controllers/proctolog/bursts_controller.rb +34 -0
- data/app/controllers/proctolog/choices_controller.rb +30 -0
- data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
- data/app/controllers/proctolog/decisions_controller.rb +33 -0
- data/app/controllers/proctolog/entries_controller.rb +14 -0
- data/app/controllers/proctolog/evidence_controller.rb +23 -0
- data/app/controllers/proctolog/freshness_controller.rb +27 -0
- data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
- data/app/controllers/proctolog/memberships_controller.rb +20 -0
- data/app/controllers/proctolog/mentions_controller.rb +16 -0
- data/app/controllers/proctolog/moments_controller.rb +26 -0
- data/app/controllers/proctolog/note_params.rb +24 -0
- data/app/controllers/proctolog/notes_controller.rb +56 -0
- data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
- data/app/controllers/proctolog/pictures_controller.rb +27 -0
- data/app/controllers/proctolog/prompts_controller.rb +28 -0
- data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
- data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
- data/app/controllers/proctolog/put_away_controller.rb +19 -0
- data/app/controllers/proctolog/recordings_controller.rb +28 -0
- data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
- data/app/controllers/proctolog/review_data_controller.rb +12 -0
- data/app/controllers/proctolog/rules_controller.rb +20 -0
- data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
- data/app/controllers/proctolog/scenario_scoped.rb +21 -0
- data/app/controllers/proctolog/scenarios_controller.rb +67 -0
- data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
- data/app/controllers/proctolog/waiting_controller.rb +14 -0
- data/app/controllers/proctolog/with_decisions.rb +28 -0
- data/app/helpers/proctolog/bursts_helper.rb +52 -0
- data/app/helpers/proctolog/decisions_helper.rb +102 -0
- data/app/helpers/proctolog/desk_helper.rb +61 -0
- data/app/helpers/proctolog/mentions_helper.rb +27 -0
- data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
- data/app/helpers/proctolog/prompts_helper.rb +21 -0
- data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
- data/app/helpers/proctolog/recordings_helper.rb +30 -0
- data/app/helpers/proctolog/rounds_helper.rb +27 -0
- data/app/helpers/proctolog/scenarios_helper.rb +123 -0
- data/app/helpers/proctolog/standings_helper.rb +80 -0
- data/app/jobs/proctolog/application_job.rb +8 -0
- data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
- data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
- data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
- data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
- data/app/models/proctolog/acceptance.rb +47 -0
- data/app/models/proctolog/app.rb +92 -0
- data/app/models/proctolog/browser.rb +42 -0
- data/app/models/proctolog/burst.rb +268 -0
- data/app/models/proctolog/burst_notices.rb +143 -0
- data/app/models/proctolog/chapters.rb +42 -0
- data/app/models/proctolog/check.rb +122 -0
- data/app/models/proctolog/choices.rb +112 -0
- data/app/models/proctolog/code_review.rb +251 -0
- data/app/models/proctolog/decision_record.rb +12 -0
- data/app/models/proctolog/decisions.rb +199 -0
- data/app/models/proctolog/description.rb +165 -0
- data/app/models/proctolog/difference.rb +138 -0
- data/app/models/proctolog/doctor.rb +127 -0
- data/app/models/proctolog/evidence.rb +29 -0
- data/app/models/proctolog/failure.rb +7 -0
- data/app/models/proctolog/focus.rb +48 -0
- data/app/models/proctolog/fresh_sitting.rb +35 -0
- data/app/models/proctolog/github.rb +353 -0
- data/app/models/proctolog/handover/brief.rb +138 -0
- data/app/models/proctolog/handover/layers.rb +128 -0
- data/app/models/proctolog/handover/places.rb +98 -0
- data/app/models/proctolog/handover/taken.rb +41 -0
- data/app/models/proctolog/handover/worklist.rb +35 -0
- data/app/models/proctolog/handover.rb +123 -0
- data/app/models/proctolog/housekeeping.rb +23 -0
- data/app/models/proctolog/kit.rb +36 -0
- data/app/models/proctolog/marks.rb +130 -0
- data/app/models/proctolog/membership.rb +91 -0
- data/app/models/proctolog/mentions.rb +117 -0
- data/app/models/proctolog/note.rb +94 -0
- data/app/models/proctolog/outside.rb +45 -0
- data/app/models/proctolog/overlay/brief.rb +52 -0
- data/app/models/proctolog/overlay/written.rb +33 -0
- data/app/models/proctolog/overlay.rb +87 -0
- data/app/models/proctolog/owner.rb +16 -0
- data/app/models/proctolog/pick.rb +47 -0
- data/app/models/proctolog/picture.rb +138 -0
- data/app/models/proctolog/pieces_rendering.rb +40 -0
- data/app/models/proctolog/project.rb +118 -0
- data/app/models/proctolog/pull_request.rb +89 -0
- data/app/models/proctolog/recording.rb +194 -0
- data/app/models/proctolog/release.rb +59 -0
- data/app/models/proctolog/rendering.rb +303 -0
- data/app/models/proctolog/reports.rb +66 -0
- data/app/models/proctolog/response_files.rb +55 -0
- data/app/models/proctolog/rounds.rb +305 -0
- data/app/models/proctolog/route.rb +151 -0
- data/app/models/proctolog/rules.rb +121 -0
- data/app/models/proctolog/scenario.rb +126 -0
- data/app/models/proctolog/server.rb +273 -0
- data/app/models/proctolog/since.rb +108 -0
- data/app/models/proctolog/size.rb +30 -0
- data/app/models/proctolog/standing.rb +132 -0
- data/app/models/proctolog/theme.rb +40 -0
- data/app/models/proctolog/unopened/numbers.rb +48 -0
- data/app/models/proctolog/unopened/opening.rb +88 -0
- data/app/models/proctolog/unopened.rb +203 -0
- data/app/models/proctolog/visit.rb +26 -0
- data/app/views/layouts/proctolog/application.html.erb +58 -0
- data/app/views/proctolog/acceptances/new.html.erb +34 -0
- data/app/views/proctolog/bursts/_line.html.erb +28 -0
- data/app/views/proctolog/bursts/_merged.html.erb +23 -0
- data/app/views/proctolog/bursts/_moved.html.erb +16 -0
- data/app/views/proctolog/bursts/_notices.html.erb +39 -0
- data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
- data/app/views/proctolog/bursts/_rules.html.erb +27 -0
- data/app/views/proctolog/bursts/_side.html.erb +49 -0
- data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
- data/app/views/proctolog/bursts/show.html.erb +54 -0
- data/app/views/proctolog/code_reviews/show.html.erb +33 -0
- data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
- data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
- data/app/views/proctolog/decisions/_box.html.erb +27 -0
- data/app/views/proctolog/decisions/_choices.html.erb +9 -0
- data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
- data/app/views/proctolog/decisions/_part.html.erb +61 -0
- data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
- data/app/views/proctolog/decisions/_theme.html.erb +13 -0
- data/app/views/proctolog/decisions/index.html.erb +75 -0
- data/app/views/proctolog/notes/_card.html.erb +19 -0
- data/app/views/proctolog/notes/_template.html.erb +41 -0
- data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
- data/app/views/proctolog/prompts/show.html.erb +41 -0
- data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
- data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
- data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
- data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
- data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
- data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
- data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
- data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
- data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
- data/app/views/proctolog/pull_requests/show.html.erb +145 -0
- data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
- data/app/views/proctolog/recordings/_list.html.erb +8 -0
- data/app/views/proctolog/recordings/_note.html.erb +22 -0
- data/app/views/proctolog/recordings/index.html.erb +33 -0
- data/app/views/proctolog/recordings/show.html.erb +118 -0
- data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
- data/app/views/proctolog/scenarios/_note.html.erb +18 -0
- data/app/views/proctolog/scenarios/_open.html.erb +9 -0
- data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
- data/app/views/proctolog/scenarios/_server.html.erb +20 -0
- data/app/views/proctolog/scenarios/index.html.erb +62 -0
- data/bin/proctolog +330 -0
- data/completions/_proctolog +143 -0
- data/config/application.rb +94 -0
- data/config/database.yml +16 -0
- data/config/environment.rb +6 -0
- data/config/initializers/grouse.rb +113 -0
- data/config/initializers/grouse_notes.rb +9 -0
- data/config/keys.yml +6 -0
- data/config/routes.rb +71 -0
- data/config/storage.yml +4 -0
- data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
- data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
- data/docs/manual/app.md +157 -0
- data/docs/manual/bursts.md +129 -0
- data/docs/manual/deciding.md +89 -0
- data/docs/manual/desk.png +0 -0
- data/docs/manual/films.md +20 -0
- data/docs/manual/handover.md +93 -0
- data/docs/manual/pr-format.md +75 -0
- data/docs/manual/reading.md +233 -0
- data/docs/manual/unopened.md +80 -0
- data/docs/manual/updates.md +55 -0
- data/lib/proctolog/installation.rb +125 -0
- data/lib/proctolog/version.rb +7 -0
- data/public/asking.js +50 -0
- data/public/code-review.js +40 -0
- data/public/cursor.js +292 -0
- data/public/decision-screenshots.js +97 -0
- data/public/decisions.css +949 -0
- data/public/decisions.js +1289 -0
- data/public/desk.css +582 -0
- data/public/desk.js +126 -0
- data/public/favicon.png +0 -0
- data/public/keys.js +228 -0
- data/public/mentions.js +389 -0
- data/public/messages.js +103 -0
- data/public/notes.js +731 -0
- data/public/pick.css +99 -0
- data/public/plaque.png +0 -0
- data/public/pr-number.js +11 -0
- data/public/prompt.css +46 -0
- data/public/prompt.js +16 -0
- data/public/pull_request.css +805 -0
- data/public/recording.css +379 -0
- data/public/recording.js +394 -0
- data/public/release.js +25 -0
- data/public/respond.css +260 -0
- data/public/respond.js +95 -0
- data/public/review-drafts.js +949 -0
- data/public/review-rounds.js +1447 -0
- data/public/scenarios.css +253 -0
- data/public/scenarios.js +36 -0
- data/public/schemas/proctolog-response-v1.json +101 -0
- data/public/side.js +133 -0
- data/public/standing.css +478 -0
- data/public/terminal-apl.svg +6 -0
- data/public/terminal-cuneiform.svg +3 -0
- data/public/terminal-grain-dots.svg +6 -0
- data/public/terminal-grain-weave.svg +6 -0
- data/public/terminal-grain-wedges.svg +6 -0
- data/public/terminal-shades.svg +3 -0
- data/public/terminal.css +4426 -0
- data/public/toast.js +26 -0
- data/public/try.js +124 -0
- data/public/viewer.js +808 -0
- data/public/words.js +81 -0
- data/skills/proctolog-handover/SKILL.md +77 -0
- data/skills/proctolog-pr-writing/SKILL.md +228 -0
- data/skills/proctolog-pr-writing/references/example-description.md +33 -0
- metadata +366 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Proctolog
|
|
8
|
+
# A picture of a description, kept on this Mac. The address GitHub keeps for
|
|
9
|
+
# it answers a request that carries gh's token with a redirect to a signed
|
|
10
|
+
# file, good for a few minutes, so the file is fetched once and kept under
|
|
11
|
+
# cache/ by its asset id.
|
|
12
|
+
class Picture
|
|
13
|
+
Unavailable = Class.new(StandardError)
|
|
14
|
+
|
|
15
|
+
EXTENSIONS = %w[png gif jpg jpeg].freeze
|
|
16
|
+
# A picture's id, the UUID in its address on GitHub.
|
|
17
|
+
ASSET = /\h{8}(-\h{4}){3}-\h{12}/
|
|
18
|
+
WHOLE_ASSET = /\A#{ASSET}\z/
|
|
19
|
+
# A picture's file as the desk serves it: its id and its kind.
|
|
20
|
+
FILE = /#{ASSET}\.\w+/
|
|
21
|
+
ADDRESS = "https://github.com/user-attachments/assets/"
|
|
22
|
+
REDIRECTIONS = 3
|
|
23
|
+
|
|
24
|
+
def self.asset?(id) = id.is_a?(String) && id.match?(WHOLE_ASSET)
|
|
25
|
+
|
|
26
|
+
# The file of a picture on this Mac, of whatever kind; nil when it was
|
|
27
|
+
# never fetched.
|
|
28
|
+
def self.on_disk(asset) = EXTENSIONS.map { Project.current.pictures.join("#{asset}.#{it}") }.find(&:exist?)
|
|
29
|
+
|
|
30
|
+
# Its pixels' signature, by which a choice made on it knows whether it
|
|
31
|
+
# was retaken, as kept beside the file. Nil, unknown, until the desk has
|
|
32
|
+
# worked it out: a page only reads it, and says the picture is unverified.
|
|
33
|
+
def self.signature(asset) = signature_file(asset).then { it.read if it.exist? }
|
|
34
|
+
|
|
35
|
+
# Works the signature out and keeps it, as the picture under an asset
|
|
36
|
+
# never changes. ImageMagick takes a few tenths of a second a picture, so
|
|
37
|
+
# the desk does it as it fetches one, and behind the owner's back for
|
|
38
|
+
# those it already holds. Nil when it is not on this Mac or ImageMagick
|
|
39
|
+
# cannot read it.
|
|
40
|
+
def self.sign(asset)
|
|
41
|
+
signature(asset) || on_disk(asset)&.then { Difference.signature(it, cache: Project.current.cache).tap { |worked_out| Difference.write(signature_file(asset), worked_out) } }
|
|
42
|
+
rescue Difference::Unreadable, Errno::ENOENT
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.signature_file(asset) = Project.current.pictures.join("#{asset}.signature")
|
|
47
|
+
|
|
48
|
+
# Takes the pictures of a reading ahead of the pages that show them,
|
|
49
|
+
# behind the owner's back (FetchPicturesAheadJob). Once for each reading
|
|
50
|
+
# of the same pull requests, named by `of`, however often a burst asks
|
|
51
|
+
# again or another holds them too; the block answers their pictures as
|
|
52
|
+
# pairs of id and kind, and is only called the first time, as it reads
|
|
53
|
+
# every description.
|
|
54
|
+
def self.fetch_ahead(of:)
|
|
55
|
+
return unless (@taken_ahead ||= Set.new).add?(of)
|
|
56
|
+
|
|
57
|
+
pictures = yield.uniq.select { |_, extension| EXTENSIONS.include?(extension) }
|
|
58
|
+
FetchPicturesAheadJob.perform_later(pictures) if pictures.any?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The token gh signs in with, which GitHub asks for before it gives out a picture.
|
|
62
|
+
def self.token
|
|
63
|
+
answer, status = Open3.capture2("gh", "auth", "token")
|
|
64
|
+
status.success? ? answer.strip : raise(Unavailable, "gh has no token to fetch pictures with.")
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
raise Unavailable, "gh is not on the PATH."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A picture GitHub never held, an unopened pull request's, kept among the
|
|
70
|
+
# others under an id made of its bytes: the same picture keeps its id
|
|
71
|
+
# however often it is read, and one retaken gets another. Signing is
|
|
72
|
+
# left to fetch_ahead, behind the owner's back. Nil for a kind the desk
|
|
73
|
+
# does not show.
|
|
74
|
+
def self.import(file)
|
|
75
|
+
extension = file.extname.delete_prefix(".").downcase
|
|
76
|
+
return unless EXTENSIONS.include?(extension)
|
|
77
|
+
|
|
78
|
+
bytes = file.binread
|
|
79
|
+
new(Digest::SHA256.hexdigest(bytes).first(32).unpack("a8a4a4a4a12").join("-"), extension).tap { it.send(:store, bytes) unless it.path.exist? }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
attr_reader :asset, :extension
|
|
83
|
+
|
|
84
|
+
def initialize(asset, extension)
|
|
85
|
+
@asset, @extension = asset, extension
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fetched with a token already at hand; a failure is left for the page to tell.
|
|
89
|
+
def fetch(token)
|
|
90
|
+
@token = token
|
|
91
|
+
file
|
|
92
|
+
rescue Unavailable
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def path = directory.join("#{asset}.#{extension}")
|
|
97
|
+
|
|
98
|
+
# Where its bytes are on disk, fetching them first when this is the first time.
|
|
99
|
+
def file
|
|
100
|
+
path.exist? ? path : keep(download)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def directory = Project.current.pictures
|
|
106
|
+
|
|
107
|
+
def keep(bytes)
|
|
108
|
+
store(bytes)
|
|
109
|
+
self.class.sign(asset)
|
|
110
|
+
path
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Written beside the file and moved over it, so that two requests for the
|
|
114
|
+
# same picture never leave, or serve, half of one.
|
|
115
|
+
def store(bytes)
|
|
116
|
+
directory.mkpath
|
|
117
|
+
draft = directory.join("#{asset}.#{SecureRandom.hex(4)}.part")
|
|
118
|
+
draft.binwrite(bytes)
|
|
119
|
+
draft.rename(path)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def download
|
|
123
|
+
signed = get(URI("#{ADDRESS}#{asset}"), "Authorization" => "token #{token}")
|
|
124
|
+
signed.is_a?(Net::HTTPRedirection) or raise Unavailable, "GitHub does not give out the picture #{asset} (it answered #{signed.code})."
|
|
125
|
+
stored = get(URI(signed.fetch("location")))
|
|
126
|
+
stored.is_a?(Net::HTTPSuccess) or raise Unavailable, "GitHub's storage did not give out the picture #{asset} (it answered #{stored.code})."
|
|
127
|
+
stored.body
|
|
128
|
+
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
129
|
+
raise Unavailable, "The picture #{asset} could not be fetched: #{e.message}."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def get(uri, headers = {})
|
|
133
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) { it.get(uri.request_uri, headers) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def token = @token ||= self.class.token
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Pieces of a description's markdown as GitHub would draw them, with the
|
|
5
|
+
# pictures they show served from this Mac like those of a page of the desk.
|
|
6
|
+
class PiecesRendering
|
|
7
|
+
# The extension GitHub's address does not carry: the kind already on this Mac, else the likeliest.
|
|
8
|
+
GUESS = "png"
|
|
9
|
+
SHOWN = %r{user-attachments/assets/(#{Picture::ASSET})}o
|
|
10
|
+
|
|
11
|
+
# How many pieces are drawn at a time: each is a request to GitHub, and a
|
|
12
|
+
# page of returned work asks for dozens at once.
|
|
13
|
+
ABREAST = 6
|
|
14
|
+
|
|
15
|
+
def initialize(github) = @github = github
|
|
16
|
+
|
|
17
|
+
# One piece of HTML for each piece of markdown, in its order.
|
|
18
|
+
def call(pieces)
|
|
19
|
+
pieces.each_slice(ABREAST).flat_map do |slice|
|
|
20
|
+
slice.map { |piece| Thread.new { Rails.application.executor.wrap { @github.rendered(piece) } } }.map(&:value)
|
|
21
|
+
end.map { serve_pictures_from_here(it) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def serve_pictures_from_here(html)
|
|
27
|
+
Nokogiri::HTML5.fragment(html).tap do |fragment|
|
|
28
|
+
fragment.css("img").each do |img|
|
|
29
|
+
asset = img["src"].to_s[SHOWN, 1] or next
|
|
30
|
+
extension = Picture.on_disk(asset)&.extname&.delete(".") || GUESS
|
|
31
|
+
img["src"] = "/pictures/#{asset}.#{extension}"
|
|
32
|
+
img["loading"] = "lazy"
|
|
33
|
+
img.remove_attribute("width")
|
|
34
|
+
img.remove_attribute("height")
|
|
35
|
+
img.parent.replace(img) if img.parent.name == "a"
|
|
36
|
+
end
|
|
37
|
+
end.to_html
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Proctolog
|
|
7
|
+
class Project
|
|
8
|
+
Missing = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
CONFIGURATION = ".proctolog"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_writer :current
|
|
14
|
+
|
|
15
|
+
def current = @current ||= discover(ENV.fetch("PROCTOLOG_PROJECT", Dir.pwd))
|
|
16
|
+
|
|
17
|
+
def discover(directory = Dir.pwd)
|
|
18
|
+
output, status = Open3.capture2e("git", "-C", Pathname(directory).expand_path.to_s, "rev-parse", "--show-toplevel")
|
|
19
|
+
raise Missing, "#{directory} is not inside a Git checkout. Pass --project to choose one." unless status.success?
|
|
20
|
+
|
|
21
|
+
new(output.strip)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :root
|
|
26
|
+
|
|
27
|
+
def initialize(root) = @root = Pathname(root).realpath
|
|
28
|
+
|
|
29
|
+
def definitions = root.join(CONFIGURATION)
|
|
30
|
+
def bursts = definitions.join("bursts")
|
|
31
|
+
def scenarios = definitions.join("scenarios")
|
|
32
|
+
|
|
33
|
+
# Settings true of the repository in every checkout, kept in Git, and
|
|
34
|
+
# those of this machine alone, kept out of it and read over the first.
|
|
35
|
+
def shared = definitions.join("project.yml")
|
|
36
|
+
def local = definitions.join("local.yml")
|
|
37
|
+
|
|
38
|
+
# Where burst files were kept before bursts had their name, read for a
|
|
39
|
+
# slug bursts/ does not hold.
|
|
40
|
+
def reviews = definitions.join("reviews")
|
|
41
|
+
|
|
42
|
+
# A reviewer's own bursts and their scenarios, kept beside their marks: in
|
|
43
|
+
# no repository, and seen from every checkout of this one on this Mac.
|
|
44
|
+
def own_bursts = data.join("bursts")
|
|
45
|
+
def own_scenarios = data.join("scenarios")
|
|
46
|
+
|
|
47
|
+
# Pull requests nobody has opened yet, each in a folder named by its
|
|
48
|
+
# branch, beside the bursts that hold them.
|
|
49
|
+
def unopened = data.join("unopened")
|
|
50
|
+
|
|
51
|
+
# The reviewer's own first, then those the repository shares.
|
|
52
|
+
def burst_folders = [own_bursts, bursts, reviews]
|
|
53
|
+
def scenario_folders = [own_scenarios, scenarios]
|
|
54
|
+
|
|
55
|
+
# A path as the owner names it in a sentence: from the root of the
|
|
56
|
+
# checkout when it is in it, ".proctolog/bursts/first-run.yml", and from
|
|
57
|
+
# the home folder otherwise.
|
|
58
|
+
def from_root(path)
|
|
59
|
+
return Pathname(path).relative_path_from(root) if path.to_s.start_with?("#{root}/")
|
|
60
|
+
|
|
61
|
+
path.to_s.sub(/\A#{Regexp.escape(Dir.home)}(?=\/)/, "~")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The repository's name, as a label says it: "sartoria".
|
|
65
|
+
def name = github_repository&.split("/")&.last || root.basename.to_s
|
|
66
|
+
|
|
67
|
+
def data = data_home.join(repository)
|
|
68
|
+
def cache = cache_home.join(repository)
|
|
69
|
+
def marks = data.join("marks")
|
|
70
|
+
def recordings = data.join("films")
|
|
71
|
+
def pictures = cache.join("pictures")
|
|
72
|
+
def browsers = cache.join("browsers")
|
|
73
|
+
|
|
74
|
+
def setting(name) = settings[name.to_s]
|
|
75
|
+
|
|
76
|
+
def decision_records = Array(setting(:decision_records)).map { root.join(it) }
|
|
77
|
+
|
|
78
|
+
# Where an agent records a rule the owner made: the project's CLAUDE.md,
|
|
79
|
+
# or a decision record in one of its folders, each from the root.
|
|
80
|
+
def rule_destinations = ["CLAUDE.md", *decision_records.map { from_root(it).to_s }]
|
|
81
|
+
|
|
82
|
+
# "owner/name" of the GitHub repository its origin is, when it is one.
|
|
83
|
+
def github_repository
|
|
84
|
+
return @github_repository if defined?(@github_repository)
|
|
85
|
+
|
|
86
|
+
remote, status = Open3.capture2e("git", "-C", root.to_s, "remote", "get-url", "origin")
|
|
87
|
+
@github_repository = (remote.strip[%r{(?:github\.com[:/])(?<repository>[\w.-]+/[\w.-]+?)(?:\.git)?\z}, :repository] if status.success?)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The branch a pull request goes into when nobody names one: origin's
|
|
91
|
+
# own, as this Mac last fetched it.
|
|
92
|
+
def default_branch
|
|
93
|
+
@default_branch ||= Open3.capture2e("git", "-C", root.to_s, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
|
94
|
+
.then { |output, status| (output.strip.delete_prefix("origin/") if status.success?).presence || "main" }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def data_home = Desk.data
|
|
100
|
+
def cache_home = Desk.cache
|
|
101
|
+
|
|
102
|
+
# Asked of git once: every path of the store and the cache is under it, and
|
|
103
|
+
# a page reads dozens of them.
|
|
104
|
+
def repository = @repository ||= github_repository || "checkouts/#{Digest::SHA256.hexdigest(root.to_s)[..11]}"
|
|
105
|
+
|
|
106
|
+
# A key local.yml names replaces project.yml's whole: its themes are the
|
|
107
|
+
# machine's list, not more of the project's.
|
|
108
|
+
def settings = settings_in(shared).merge(settings_in(local))
|
|
109
|
+
|
|
110
|
+
def settings_in(file)
|
|
111
|
+
return {} unless file.file?
|
|
112
|
+
|
|
113
|
+
YAML.safe_load_file(file) || {}
|
|
114
|
+
rescue Psych::Exception => e
|
|
115
|
+
raise Missing, "#{file} is not valid YAML (#{e.message.lines.first&.strip})."
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# One open pull request as GitHub describes it at this moment, or one
|
|
5
|
+
# nobody has opened yet as this Mac keeps it (Unopened).
|
|
6
|
+
class PullRequest < Data.define(:number, :title, :url, :author, :base, :head, :head_oid, :description, :described_at, :committed_at, :files, :threads, :stack_entry)
|
|
7
|
+
# GitHub never writes a number with a leading zero, so the desk numbers
|
|
8
|
+
# the pull requests nobody has opened yet so: 01, 02, and on.
|
|
9
|
+
UNOPENED = /\A0\d+\z/
|
|
10
|
+
|
|
11
|
+
# A number as an address, a file name or a list writes it: GitHub's as
|
|
12
|
+
# an integer, an unopened one's as written. Nil for anything else.
|
|
13
|
+
def self.number(text) = text.to_s.match?(UNOPENED) ? text.to_s : Integer(text.to_s, 10, exception: false)
|
|
14
|
+
|
|
15
|
+
def self.unopened?(number) = number.to_s.match?(UNOPENED)
|
|
16
|
+
|
|
17
|
+
# A review thread on GitHub that nobody resolved yet: where in the code it
|
|
18
|
+
# is, its address, and what was said, each as [who, what].
|
|
19
|
+
ReviewThread = Data.define(:path, :line, :url, :comments) do
|
|
20
|
+
def self.from(node)
|
|
21
|
+
comments = node.dig("comments", "nodes")
|
|
22
|
+
new(path: node["path"], line: node["line"], url: comments.first&.fetch("url"),
|
|
23
|
+
comments: comments.map { [it.dig("author", "login"), it.fetch("body")] })
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A pull request no longer open, as a burst that held it remembers it:
|
|
28
|
+
# merged, with the moment it went in, or closed without merging.
|
|
29
|
+
Gone = Data.define(:number, :title, :url, :merged_at, :description) do
|
|
30
|
+
def self.from(node)
|
|
31
|
+
new(number: node.fetch("number"), title: node.fetch("title"), url: node.fetch("url"),
|
|
32
|
+
merged_at: node["mergedAt"]&.then { Time.iso8601(it) }, description: Description.new(node["body"].to_s))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def merged? = !merged_at.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Its place in one of GitHub's native stacks: the stack's number, and its
|
|
39
|
+
# position from the bottom, 1 first. UNKNOWN stands for a reading that
|
|
40
|
+
# could not ask GitHub where its pull requests stand.
|
|
41
|
+
StackEntry = Data.define(:stack, :position) do
|
|
42
|
+
def self.from(node) = node&.then { new(stack: it.dig("stack", "number"), position: it.fetch("position")) }
|
|
43
|
+
|
|
44
|
+
def unknown? = stack.nil?
|
|
45
|
+
end
|
|
46
|
+
StackEntry::UNKNOWN = StackEntry.new(stack: nil, position: nil)
|
|
47
|
+
|
|
48
|
+
def self.from(node, stack_entry: nil)
|
|
49
|
+
new(
|
|
50
|
+
number: node.fetch("number"),
|
|
51
|
+
title: node.fetch("title"),
|
|
52
|
+
url: node.fetch("url"),
|
|
53
|
+
author: node.dig("author", "login"),
|
|
54
|
+
base: node.fetch("baseRefName"),
|
|
55
|
+
head: node.fetch("headRefName"),
|
|
56
|
+
head_oid: node.fetch("headRefOid"),
|
|
57
|
+
description: Description.new(node.fetch("body")),
|
|
58
|
+
described_at: Time.iso8601(node["lastEditedAt"] || node.fetch("createdAt")),
|
|
59
|
+
committed_at: Time.iso8601(node.dig("commits", "nodes", 0, "commit", "committedDate")),
|
|
60
|
+
files: node.dig("files", "nodes").pluck("path"),
|
|
61
|
+
threads: node.dig("reviewThreads", "nodes").reject { it.fetch("isResolved") }.map { ReviewThread.from(it) },
|
|
62
|
+
stack_entry:
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A reading kept before authors were read names none.
|
|
67
|
+
def initialize(author: nil, files: [], threads: [], stack_entry: nil, **) = super
|
|
68
|
+
|
|
69
|
+
def open_threads = threads.size
|
|
70
|
+
|
|
71
|
+
# The number of the native stack it is in, and its position there; nil
|
|
72
|
+
# when it is in none, or when where it stands is not known.
|
|
73
|
+
def stack = stack_entry&.stack
|
|
74
|
+
|
|
75
|
+
def position = stack_entry&.position
|
|
76
|
+
|
|
77
|
+
def stack_known? = !stack_entry&.unknown?
|
|
78
|
+
|
|
79
|
+
def unopened? = self.class.unopened?(number)
|
|
80
|
+
|
|
81
|
+
def opened? = !unopened?
|
|
82
|
+
|
|
83
|
+
# How a burst file lists it: by its number, or by its branch while
|
|
84
|
+
# nobody has opened it, so the list still holds once someone does.
|
|
85
|
+
def listed_as = opened? ? number : head
|
|
86
|
+
|
|
87
|
+
def to_param = number.to_s
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Proctolog
|
|
6
|
+
# A film of a flow as a capture script left it: <name>.mp4 beside <name>.vtt,
|
|
7
|
+
# under films/<burst>/<pull request number, or "sitting">/. It is found by
|
|
8
|
+
# looking in that folder; no list of recordings is kept anywhere.
|
|
9
|
+
class Recording
|
|
10
|
+
# A frame is asked for a hair before it is shown, so that the frame itself
|
|
11
|
+
# is the first one at or after the time and never the one that follows.
|
|
12
|
+
SLACK = 0.001
|
|
13
|
+
DIRECTORY = -> { Project.current.recordings }
|
|
14
|
+
|
|
15
|
+
# What ffprobe says of the file: its size in pixels, its length, and the
|
|
16
|
+
# time each frame is first shown, which is where "one frame" begins and ends
|
|
17
|
+
# in a film that keeps every frame for as long as it was on screen.
|
|
18
|
+
Probe = Data.define(:width, :height, :length, :frames) do
|
|
19
|
+
def self.from(json)
|
|
20
|
+
stream = json.fetch("streams").first
|
|
21
|
+
new(width: stream.fetch("width"), height: stream.fetch("height"), length: Float(json.dig("format", "duration")),
|
|
22
|
+
frames: json.fetch("packets").map { Float(it.fetch("pts_time")) }.sort)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :file
|
|
27
|
+
|
|
28
|
+
def self.directory = DIRECTORY.respond_to?(:call) ? DIRECTORY.call : DIRECTORY
|
|
29
|
+
|
|
30
|
+
def self.of(burst) = directory.join(burst.slug).glob("*/*.mp4").sort.map { new(it) }
|
|
31
|
+
|
|
32
|
+
def self.in(burst, folder) = directory.join(burst.slug, folder.to_s).glob("*.mp4").sort.map { new(it) }
|
|
33
|
+
|
|
34
|
+
PARAM = %r{\A(?<folder>sitting|\d+)/(?<name>[\w-][\w.-]*)\z}
|
|
35
|
+
|
|
36
|
+
# The recording that "sitting/first-run" names in a burst, when it is there.
|
|
37
|
+
def self.find(burst, param)
|
|
38
|
+
match = PARAM.match(param.to_s) or return
|
|
39
|
+
file = directory.join(burst.slug, match[:folder], "#{match[:name]}.mp4")
|
|
40
|
+
new(file) if file.file?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The recording a note's path names, when its file is still there.
|
|
44
|
+
def self.at(path) = Project.current.data.join(path).then { new(it) if it.file? }
|
|
45
|
+
|
|
46
|
+
def self.probe_command(file) = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height:format=duration:packet=pts_time", "-of", "json", file.to_s]
|
|
47
|
+
|
|
48
|
+
def self.still_command(file, time, to) = ["ffmpeg", "-y", "-loglevel", "error", "-ss", format("%.3f", [time - SLACK, 0].max), "-i", file.to_s, "-frames:v", "1", to.to_s]
|
|
49
|
+
|
|
50
|
+
def initialize(file) = @file = file
|
|
51
|
+
|
|
52
|
+
def name = file.basename(".mp4").to_s
|
|
53
|
+
|
|
54
|
+
def burst_slug = file.dirname.dirname.basename.to_s
|
|
55
|
+
|
|
56
|
+
# The pull request it is about, or "sitting" for the owner's own pass.
|
|
57
|
+
def folder = file.dirname.basename.to_s
|
|
58
|
+
|
|
59
|
+
def number = PullRequest.number(folder)
|
|
60
|
+
|
|
61
|
+
# What the player's address and a moment's form call it.
|
|
62
|
+
def to_param = "#{folder}/#{name}"
|
|
63
|
+
|
|
64
|
+
# What a note keeps of it: the path from the desk's root.
|
|
65
|
+
def path = file.relative_path_from(Project.current.data).to_s
|
|
66
|
+
|
|
67
|
+
def url = "/films/#{file.relative_path_from(self.class.directory)}"
|
|
68
|
+
|
|
69
|
+
def captions_url = url.delete_suffix(".mp4") + ".vtt"
|
|
70
|
+
|
|
71
|
+
def chapters = @chapters ||= Chapters.read(file.sub_ext(".vtt"))
|
|
72
|
+
|
|
73
|
+
delegate :width, :height, :length, :frames, to: :probe
|
|
74
|
+
|
|
75
|
+
def megabytes = file.size / 1_000_000.0
|
|
76
|
+
|
|
77
|
+
# The notes of a marks file that are pinned to a moment of this film.
|
|
78
|
+
def noted_in(marks) = marks.notes.select { it.place in Note::Moment[recording: ^(path)] }
|
|
79
|
+
|
|
80
|
+
# The film's bytes, by which its stills are kept: a film written again is
|
|
81
|
+
# a round of its own, whatever its name.
|
|
82
|
+
def digest = Rails.cache.fetch([:film_digest, file.to_s, file.mtime.to_f]) { Digest::SHA256.file(file).hexdigest }
|
|
83
|
+
|
|
84
|
+
# The picture a step opens on: the capture script's own when it kept the
|
|
85
|
+
# frames the steps opened on, else taken from the film once. Kept in the
|
|
86
|
+
# cache under the film's digest. Nil when it cannot be had, without ffmpeg.
|
|
87
|
+
def still(index)
|
|
88
|
+
chapter = chapters.to_a[index] or return
|
|
89
|
+
keep_stills
|
|
90
|
+
kept = stills.join("#{index}.png")
|
|
91
|
+
return kept if kept.exist?
|
|
92
|
+
|
|
93
|
+
part = stills.join("#{index}.#{SecureRandom.hex(4)}.part.png")
|
|
94
|
+
system(*self.class.still_command(file, chapter.from, part), out: File::NULL, err: File::NULL) or return
|
|
95
|
+
part.rename(kept)
|
|
96
|
+
kept
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def stills = Project.current.cache.join("stills", digest)
|
|
100
|
+
|
|
101
|
+
# The frames the capture script kept as each step opened, beside the film.
|
|
102
|
+
def openings = file.sub_ext(".steps")
|
|
103
|
+
|
|
104
|
+
# Kept once, when the desk first shows the film: the names of its steps,
|
|
105
|
+
# and the capture script's own frames when it kept one for every step.
|
|
106
|
+
# Only those are compared with another round: frames decoded from a film
|
|
107
|
+
# differ between two encodes of the same screens.
|
|
108
|
+
def keep_stills
|
|
109
|
+
return if stills.join("steps.json").exist?
|
|
110
|
+
|
|
111
|
+
stills.mkpath
|
|
112
|
+
lossless = chapters.any? && chapters.each_with_index.all? { |_, index| openings.join("#{index}.png").file? }
|
|
113
|
+
chapters.each_with_index { |_, index| FileUtils.cp(openings.join("#{index}.png"), stills.join("#{index}.png")) } if lossless
|
|
114
|
+
Difference.write(stills.join("steps.json"), {lossless:, names: chapters.map(&:name)}.to_json)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# How this round's steps stand against the round before, the film's digest
|
|
118
|
+
# then, by their names: each changed, new, or as it was, with a line that
|
|
119
|
+
# says which case holds. An unmarked strip never claims nothing changed.
|
|
120
|
+
Changes = Data.define(:said, :steps) do
|
|
121
|
+
def of(name) = steps[name]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def changes(earlier)
|
|
125
|
+
keep_stills
|
|
126
|
+
return Changes.new(said: "No earlier round to compare", steps: {}) unless earlier
|
|
127
|
+
|
|
128
|
+
before, now = [earlier, digest].map { Project.current.cache.join("stills", it) }
|
|
129
|
+
kept = [before, now].map { JSON.parse(it.join("steps.json").read) if it.join("steps.json").file? }
|
|
130
|
+
return Changes.new(said: "Not compared: a round without the capture script's own frames", steps: {}) unless kept.all? { it&.fetch("lossless") }
|
|
131
|
+
|
|
132
|
+
steps = kept.last.fetch("names").each_with_index.to_h do |name, index|
|
|
133
|
+
earlier_index = kept.first.fetch("names").index(name) or next [name, :new]
|
|
134
|
+
result = Difference.between(before.join("#{earlier_index}.png"), now.join("#{index}.png"), cache: Project.current.cache)
|
|
135
|
+
return Changes.new(said: "Not compared: ImageMagick missing", steps: {}) unless result.compared?
|
|
136
|
+
|
|
137
|
+
[name, result.same? ? :same : :changed]
|
|
138
|
+
end
|
|
139
|
+
Changes.new(said: changes_in_words(steps.values), steps:)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The rounds a burst's films went through: the digest each film had, in
|
|
143
|
+
# the order the desk first showed them, kept beside the burst's marks.
|
|
144
|
+
class Rounds
|
|
145
|
+
def initialize(burst) = @file = Marks.directory_of(burst).join("films.yml")
|
|
146
|
+
|
|
147
|
+
# Notes a film's digest when the desk shows it for the first time, with
|
|
148
|
+
# its stills kept for a later round, and answers the digest of the
|
|
149
|
+
# round before it, if there was one.
|
|
150
|
+
def before(recording)
|
|
151
|
+
recording.keep_stills
|
|
152
|
+
seen = all.fetch(recording.to_param, [])
|
|
153
|
+
unless seen.include?(recording.digest)
|
|
154
|
+
seen += [recording.digest]
|
|
155
|
+
@file.dirname.mkpath
|
|
156
|
+
Difference.write(@file, all.merge(recording.to_param => seen).to_yaml)
|
|
157
|
+
end
|
|
158
|
+
seen[seen.index(recording.digest) - 1] if seen.index(recording.digest).positive?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def all = @file.file? ? YAML.safe_load_file(@file) || {} : {}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The first moment a frame is on screen at or before `time`: the frame the
|
|
167
|
+
# owner sees when the film is paused there.
|
|
168
|
+
def frame_at(time) = frames.take_while { it <= time + SLACK }.last || 0.0
|
|
169
|
+
|
|
170
|
+
# The moment of a film that a note is pinned to: that frame saved as a
|
|
171
|
+
# picture in `folder`, where an agent can look at it.
|
|
172
|
+
def moment(time, box:, keep_in:)
|
|
173
|
+
shown = frame_at(time)
|
|
174
|
+
picture = keep_in.join("#{folder}-#{name}-#{(shown * 1000).round}.png")
|
|
175
|
+
keep_in.mkpath
|
|
176
|
+
system(*self.class.still_command(file, shown, picture)) or raise Failure, "ffmpeg could not save the frame at #{format("%.1f", shown)} s of #{name} as a picture, so no note was kept. Is ffmpeg installed, and may it write to #{keep_in}?"
|
|
177
|
+
Note::Moment.new(recording: path, time: shown.round(3), step: chapters.at(shown)&.name, frame: picture.to_s, box:)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def changes_in_words(marks)
|
|
183
|
+
changed, added = marks.count(:changed), marks.count(:new)
|
|
184
|
+
[changed.zero? ? "No step changed" : "#{changed} #{"step".pluralize(changed)} changed", ("#{added} new" if added.positive?)].compact.join(", ")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def probe = @probe ||= Rails.cache.fetch([file.to_s, file.mtime.to_f]) { Probe.from(JSON.parse(ask)) }
|
|
188
|
+
|
|
189
|
+
def ask
|
|
190
|
+
answer, status = Open3.capture2(*self.class.probe_command(file))
|
|
191
|
+
status.success? ? answer : raise(ArgumentError, "ffprobe could not read #{file}")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module Proctolog
|
|
7
|
+
# A version of PRoctolog: the one running, and the latest RubyGems holds as
|
|
8
|
+
# the desk last asked, once a day while it serves (Housekeeping). Only the
|
|
9
|
+
# asking waits on the network: a page, `serve` and doctor read the answer
|
|
10
|
+
# kept in the cache, and say nothing until there is one.
|
|
11
|
+
class Release < Data.define(:version)
|
|
12
|
+
include Comparable
|
|
13
|
+
|
|
14
|
+
# Where the gem is published, and asked of. PROCTOLOG_GEM_SERVER names a
|
|
15
|
+
# mirror, or a server of a trial's own.
|
|
16
|
+
def self.server = ENV.fetch("PROCTOLOG_GEM_SERVER", "https://rubygems.org").chomp("/")
|
|
17
|
+
|
|
18
|
+
# What a RubyGems that cannot be reached raises, which only means: not today.
|
|
19
|
+
UNANSWERED = [SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, JSON::ParserError, KeyError].freeze
|
|
20
|
+
|
|
21
|
+
def self.running = new(VERSION)
|
|
22
|
+
|
|
23
|
+
# The latest as last asked, or nil before the first answer.
|
|
24
|
+
def self.latest
|
|
25
|
+
new(JSON.parse(kept.read).fetch("version")) if kept.exist?
|
|
26
|
+
rescue JSON::ParserError, KeyError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The latest, when it is newer than the one running.
|
|
31
|
+
def self.newer = latest&.then { it if it > running }
|
|
32
|
+
|
|
33
|
+
# Asks RubyGems, keeps the answer and returns it; nil when nothing
|
|
34
|
+
# answered. RubyGems says "unknown" of a gem it has never held.
|
|
35
|
+
def self.ask(answer: -> { asked(URI("#{server}/api/v1/versions/proctolog/latest.json")) })
|
|
36
|
+
version = JSON.parse(answer.call).fetch("version")
|
|
37
|
+
return unless Gem::Version.correct?(version)
|
|
38
|
+
|
|
39
|
+
kept.dirname.mkpath
|
|
40
|
+
kept.write(JSON.generate(version:, asked_at: Time.now.utc.iso8601))
|
|
41
|
+
new(version)
|
|
42
|
+
rescue *UNANSWERED
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.kept = Desk.cache.join("latest_release.json")
|
|
47
|
+
|
|
48
|
+
def self.asked(uri) = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) { it.get(uri.path).body }
|
|
49
|
+
private_class_method :asked
|
|
50
|
+
|
|
51
|
+
def <=>(other) = Gem::Version.new(version) <=> Gem::Version.new(other.version)
|
|
52
|
+
|
|
53
|
+
def to_s = version
|
|
54
|
+
|
|
55
|
+
# What the owner is told of a newer one: what to run, and where. `update`
|
|
56
|
+
# takes it however this PRoctolog was installed, a checkout too.
|
|
57
|
+
def news = "PRoctolog #{self} is out; you have #{Release.running}. To update, run `proctolog update` in a terminal, then restart the desk: stop it and run `proctolog serve` again."
|
|
58
|
+
end
|
|
59
|
+
end
|