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.
Files changed (228) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +178 -0
  4. data/app/controllers/proctolog/acceptances_controller.rb +29 -0
  5. data/app/controllers/proctolog/application_controller.rb +32 -0
  6. data/app/controllers/proctolog/bursts_controller.rb +34 -0
  7. data/app/controllers/proctolog/choices_controller.rb +30 -0
  8. data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
  9. data/app/controllers/proctolog/decisions_controller.rb +33 -0
  10. data/app/controllers/proctolog/entries_controller.rb +14 -0
  11. data/app/controllers/proctolog/evidence_controller.rb +23 -0
  12. data/app/controllers/proctolog/freshness_controller.rb +27 -0
  13. data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
  14. data/app/controllers/proctolog/memberships_controller.rb +20 -0
  15. data/app/controllers/proctolog/mentions_controller.rb +16 -0
  16. data/app/controllers/proctolog/moments_controller.rb +26 -0
  17. data/app/controllers/proctolog/note_params.rb +24 -0
  18. data/app/controllers/proctolog/notes_controller.rb +56 -0
  19. data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
  20. data/app/controllers/proctolog/pictures_controller.rb +27 -0
  21. data/app/controllers/proctolog/prompts_controller.rb +28 -0
  22. data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
  23. data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
  24. data/app/controllers/proctolog/put_away_controller.rb +19 -0
  25. data/app/controllers/proctolog/recordings_controller.rb +28 -0
  26. data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
  27. data/app/controllers/proctolog/review_data_controller.rb +12 -0
  28. data/app/controllers/proctolog/rules_controller.rb +20 -0
  29. data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
  30. data/app/controllers/proctolog/scenario_scoped.rb +21 -0
  31. data/app/controllers/proctolog/scenarios_controller.rb +67 -0
  32. data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
  33. data/app/controllers/proctolog/waiting_controller.rb +14 -0
  34. data/app/controllers/proctolog/with_decisions.rb +28 -0
  35. data/app/helpers/proctolog/bursts_helper.rb +52 -0
  36. data/app/helpers/proctolog/decisions_helper.rb +102 -0
  37. data/app/helpers/proctolog/desk_helper.rb +61 -0
  38. data/app/helpers/proctolog/mentions_helper.rb +27 -0
  39. data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
  40. data/app/helpers/proctolog/prompts_helper.rb +21 -0
  41. data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
  42. data/app/helpers/proctolog/recordings_helper.rb +30 -0
  43. data/app/helpers/proctolog/rounds_helper.rb +27 -0
  44. data/app/helpers/proctolog/scenarios_helper.rb +123 -0
  45. data/app/helpers/proctolog/standings_helper.rb +80 -0
  46. data/app/jobs/proctolog/application_job.rb +8 -0
  47. data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
  48. data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
  49. data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
  50. data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
  51. data/app/models/proctolog/acceptance.rb +47 -0
  52. data/app/models/proctolog/app.rb +92 -0
  53. data/app/models/proctolog/browser.rb +42 -0
  54. data/app/models/proctolog/burst.rb +268 -0
  55. data/app/models/proctolog/burst_notices.rb +143 -0
  56. data/app/models/proctolog/chapters.rb +42 -0
  57. data/app/models/proctolog/check.rb +122 -0
  58. data/app/models/proctolog/choices.rb +112 -0
  59. data/app/models/proctolog/code_review.rb +251 -0
  60. data/app/models/proctolog/decision_record.rb +12 -0
  61. data/app/models/proctolog/decisions.rb +199 -0
  62. data/app/models/proctolog/description.rb +165 -0
  63. data/app/models/proctolog/difference.rb +138 -0
  64. data/app/models/proctolog/doctor.rb +127 -0
  65. data/app/models/proctolog/evidence.rb +29 -0
  66. data/app/models/proctolog/failure.rb +7 -0
  67. data/app/models/proctolog/focus.rb +48 -0
  68. data/app/models/proctolog/fresh_sitting.rb +35 -0
  69. data/app/models/proctolog/github.rb +353 -0
  70. data/app/models/proctolog/handover/brief.rb +138 -0
  71. data/app/models/proctolog/handover/layers.rb +128 -0
  72. data/app/models/proctolog/handover/places.rb +98 -0
  73. data/app/models/proctolog/handover/taken.rb +41 -0
  74. data/app/models/proctolog/handover/worklist.rb +35 -0
  75. data/app/models/proctolog/handover.rb +123 -0
  76. data/app/models/proctolog/housekeeping.rb +23 -0
  77. data/app/models/proctolog/kit.rb +36 -0
  78. data/app/models/proctolog/marks.rb +130 -0
  79. data/app/models/proctolog/membership.rb +91 -0
  80. data/app/models/proctolog/mentions.rb +117 -0
  81. data/app/models/proctolog/note.rb +94 -0
  82. data/app/models/proctolog/outside.rb +45 -0
  83. data/app/models/proctolog/overlay/brief.rb +52 -0
  84. data/app/models/proctolog/overlay/written.rb +33 -0
  85. data/app/models/proctolog/overlay.rb +87 -0
  86. data/app/models/proctolog/owner.rb +16 -0
  87. data/app/models/proctolog/pick.rb +47 -0
  88. data/app/models/proctolog/picture.rb +138 -0
  89. data/app/models/proctolog/pieces_rendering.rb +40 -0
  90. data/app/models/proctolog/project.rb +118 -0
  91. data/app/models/proctolog/pull_request.rb +89 -0
  92. data/app/models/proctolog/recording.rb +194 -0
  93. data/app/models/proctolog/release.rb +59 -0
  94. data/app/models/proctolog/rendering.rb +303 -0
  95. data/app/models/proctolog/reports.rb +66 -0
  96. data/app/models/proctolog/response_files.rb +55 -0
  97. data/app/models/proctolog/rounds.rb +305 -0
  98. data/app/models/proctolog/route.rb +151 -0
  99. data/app/models/proctolog/rules.rb +121 -0
  100. data/app/models/proctolog/scenario.rb +126 -0
  101. data/app/models/proctolog/server.rb +273 -0
  102. data/app/models/proctolog/since.rb +108 -0
  103. data/app/models/proctolog/size.rb +30 -0
  104. data/app/models/proctolog/standing.rb +132 -0
  105. data/app/models/proctolog/theme.rb +40 -0
  106. data/app/models/proctolog/unopened/numbers.rb +48 -0
  107. data/app/models/proctolog/unopened/opening.rb +88 -0
  108. data/app/models/proctolog/unopened.rb +203 -0
  109. data/app/models/proctolog/visit.rb +26 -0
  110. data/app/views/layouts/proctolog/application.html.erb +58 -0
  111. data/app/views/proctolog/acceptances/new.html.erb +34 -0
  112. data/app/views/proctolog/bursts/_line.html.erb +28 -0
  113. data/app/views/proctolog/bursts/_merged.html.erb +23 -0
  114. data/app/views/proctolog/bursts/_moved.html.erb +16 -0
  115. data/app/views/proctolog/bursts/_notices.html.erb +39 -0
  116. data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
  117. data/app/views/proctolog/bursts/_rules.html.erb +27 -0
  118. data/app/views/proctolog/bursts/_side.html.erb +49 -0
  119. data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
  120. data/app/views/proctolog/bursts/show.html.erb +54 -0
  121. data/app/views/proctolog/code_reviews/show.html.erb +33 -0
  122. data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
  123. data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
  124. data/app/views/proctolog/decisions/_box.html.erb +27 -0
  125. data/app/views/proctolog/decisions/_choices.html.erb +9 -0
  126. data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
  127. data/app/views/proctolog/decisions/_part.html.erb +61 -0
  128. data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
  129. data/app/views/proctolog/decisions/_theme.html.erb +13 -0
  130. data/app/views/proctolog/decisions/index.html.erb +75 -0
  131. data/app/views/proctolog/notes/_card.html.erb +19 -0
  132. data/app/views/proctolog/notes/_template.html.erb +41 -0
  133. data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
  134. data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
  135. data/app/views/proctolog/prompts/show.html.erb +41 -0
  136. data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
  137. data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
  138. data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
  139. data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
  140. data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
  141. data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
  142. data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
  143. data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
  144. data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
  145. data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
  146. data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
  147. data/app/views/proctolog/pull_requests/show.html.erb +145 -0
  148. data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
  149. data/app/views/proctolog/recordings/_list.html.erb +8 -0
  150. data/app/views/proctolog/recordings/_note.html.erb +22 -0
  151. data/app/views/proctolog/recordings/index.html.erb +33 -0
  152. data/app/views/proctolog/recordings/show.html.erb +118 -0
  153. data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
  154. data/app/views/proctolog/scenarios/_note.html.erb +18 -0
  155. data/app/views/proctolog/scenarios/_open.html.erb +9 -0
  156. data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
  157. data/app/views/proctolog/scenarios/_server.html.erb +20 -0
  158. data/app/views/proctolog/scenarios/index.html.erb +62 -0
  159. data/bin/proctolog +330 -0
  160. data/completions/_proctolog +143 -0
  161. data/config/application.rb +94 -0
  162. data/config/database.yml +16 -0
  163. data/config/environment.rb +6 -0
  164. data/config/initializers/grouse.rb +113 -0
  165. data/config/initializers/grouse_notes.rb +9 -0
  166. data/config/keys.yml +6 -0
  167. data/config/routes.rb +71 -0
  168. data/config/storage.yml +4 -0
  169. data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
  170. data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
  171. data/docs/manual/app.md +157 -0
  172. data/docs/manual/bursts.md +129 -0
  173. data/docs/manual/deciding.md +89 -0
  174. data/docs/manual/desk.png +0 -0
  175. data/docs/manual/films.md +20 -0
  176. data/docs/manual/handover.md +93 -0
  177. data/docs/manual/pr-format.md +75 -0
  178. data/docs/manual/reading.md +233 -0
  179. data/docs/manual/unopened.md +80 -0
  180. data/docs/manual/updates.md +55 -0
  181. data/lib/proctolog/installation.rb +125 -0
  182. data/lib/proctolog/version.rb +7 -0
  183. data/public/asking.js +50 -0
  184. data/public/code-review.js +40 -0
  185. data/public/cursor.js +292 -0
  186. data/public/decision-screenshots.js +97 -0
  187. data/public/decisions.css +949 -0
  188. data/public/decisions.js +1289 -0
  189. data/public/desk.css +582 -0
  190. data/public/desk.js +126 -0
  191. data/public/favicon.png +0 -0
  192. data/public/keys.js +228 -0
  193. data/public/mentions.js +389 -0
  194. data/public/messages.js +103 -0
  195. data/public/notes.js +731 -0
  196. data/public/pick.css +99 -0
  197. data/public/plaque.png +0 -0
  198. data/public/pr-number.js +11 -0
  199. data/public/prompt.css +46 -0
  200. data/public/prompt.js +16 -0
  201. data/public/pull_request.css +805 -0
  202. data/public/recording.css +379 -0
  203. data/public/recording.js +394 -0
  204. data/public/release.js +25 -0
  205. data/public/respond.css +260 -0
  206. data/public/respond.js +95 -0
  207. data/public/review-drafts.js +949 -0
  208. data/public/review-rounds.js +1447 -0
  209. data/public/scenarios.css +253 -0
  210. data/public/scenarios.js +36 -0
  211. data/public/schemas/proctolog-response-v1.json +101 -0
  212. data/public/side.js +133 -0
  213. data/public/standing.css +478 -0
  214. data/public/terminal-apl.svg +6 -0
  215. data/public/terminal-cuneiform.svg +3 -0
  216. data/public/terminal-grain-dots.svg +6 -0
  217. data/public/terminal-grain-weave.svg +6 -0
  218. data/public/terminal-grain-wedges.svg +6 -0
  219. data/public/terminal-shades.svg +3 -0
  220. data/public/terminal.css +4426 -0
  221. data/public/toast.js +26 -0
  222. data/public/try.js +124 -0
  223. data/public/viewer.js +808 -0
  224. data/public/words.js +81 -0
  225. data/skills/proctolog-handover/SKILL.md +77 -0
  226. data/skills/proctolog-pr-writing/SKILL.md +228 -0
  227. data/skills/proctolog-pr-writing/references/example-description.md +33 -0
  228. 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