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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "open3"
5
+ require "shellwords"
6
+
7
+ module Proctolog
8
+ # How the checkout's own app is run for its scenarios, in the app's own
9
+ # words: the commands and paths of .proctolog/app.yml (docs/manual/app.md).
10
+ # Every part is optional; a scenario that needs a missing one says which.
11
+ class App
12
+ # A Server::Error, so the scenario pages say it where the press was made.
13
+ Missing = Class.new(Server::Error)
14
+
15
+ FILE = "app.yml"
16
+
17
+ # A commit, by its short hash and its first line.
18
+ Arrival = Data.define(:commit, :subject)
19
+
20
+ def self.current = new(Project.current.definitions.join(FILE))
21
+
22
+ attr_reader :file
23
+
24
+ def initialize(file)
25
+ @file = Pathname(file)
26
+ @settings = file.exist? ? YAML.safe_load_file(file) || {} : {}
27
+ end
28
+
29
+ def described? = file.exist?
30
+
31
+ # The commit that brought this file into its repository: a checkout without
32
+ # it is older than the commands the file names. Nil outside Git.
33
+ def arrival
34
+ return @arrival if defined?(@arrival)
35
+
36
+ output, found = Open3.capture2e("git", "-C", file.dirname.to_s, "log", "--diff-filter=A", "-1", "--format=%h%x00%s", "--", file.basename.to_s)
37
+ @arrival = (Arrival.new(*output.strip.split("\0", 2)) if found.success? && output.include?("\0"))
38
+ end
39
+
40
+ def parts = @settings.keys
41
+
42
+ def serve(slug:, port:) = command(:serve, slug:, port:)
43
+
44
+ # What makes the app ready to serve, such as its database; nil when it needs nothing.
45
+ def prepare(slug:, port:) = (command(:prepare, slug:, port:) if @settings.key?("prepare"))
46
+
47
+ # The words of the command that makes a state, its arguments one key=value
48
+ # word each where %{args} stands.
49
+ def state(name, arguments, slug:, port:) = command(:state, slug:, port:, name:, args: arguments.map { |key, value| "#{key}=#{value}" })
50
+
51
+ # Grouse's overlay, labelled with a scenario: `overlay` opens a review
52
+ # session from a brief of one JSON word, and `notes` prints what was
53
+ # written in one. A project that names no `overlay` opens none.
54
+ def overlay? = @settings.key?("overlay")
55
+
56
+ def overlay(brief, slug:, port:) = command(:overlay, slug:, port:, brief: brief.to_json)
57
+
58
+ def notes(session, slug:, port:) = command(:notes, slug:, port:, session:)
59
+
60
+ def sign_in(email, to:) = path(:sign_in, email:, to:)
61
+
62
+ def sign_out(to:) = path(:sign_out, to:)
63
+
64
+ # The ports that answer once the app is up, counted from the one it is given.
65
+ def listens(port) = @settings.fetch("listens", [0]).map { port + it }
66
+
67
+ # Cookies a clean browser starts with, as name=value.
68
+ def cookies = Array(@settings["cookies"])
69
+
70
+ def size(name) = @settings.dig("sizes", name)&.symbolize_keys
71
+
72
+ def sizes = @settings.fetch("sizes", {}).keys
73
+
74
+ # The programs its commands start, as the file names them: past an `env`
75
+ # and the variables it sets, which only say how the program runs.
76
+ def programs = %w[prepare serve state overlay notes].filter_map { @settings[it]&.then { program_of(it) } }.uniq
77
+
78
+ private
79
+
80
+ def program_of(template) = Shellwords.split(template).drop_while { it == "env" || it.match?(/\A\w+=/) }.first
81
+
82
+ def command(part, args: [], **values)
83
+ Shellwords.split(template(part)).flat_map { (it == "%{args}") ? args : format(it, **values) }
84
+ end
85
+
86
+ def path(part, **values) = format(template(part), **values.transform_values { ERB::Util.url_encode(it) })
87
+
88
+ def template(part)
89
+ @settings[part.to_s] or raise Missing, "#{Project.current.from_root(file)} names no #{part}, which this scenario needs. See docs/manual/app.md."
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Where a scenario opens. `mine` is the browser that pressed the button, with
5
+ # its 1Password and its passkeys. `clean` is a Chrome of its own, started
6
+ # with a profile kept under cache/, so that it can be given a size and say it
7
+ # is a phone. `fresh` is that with a new, empty profile each time, for a step
8
+ # that wants the cookie banner up.
9
+ class Browser < Data.define(:name)
10
+ NAMES = %w[mine clean fresh].freeze
11
+ # Chrome will not make a window with tabs narrower than this. An app
12
+ # window will, which is also what an iPhone's web layer looks like.
13
+ NARROWEST_TABBED = 500
14
+
15
+ def self.named(name)
16
+ NAMES.include?(name) or raise ArgumentError, "a browser is #{NAMES.to_sentence(last_word_connector: " or ")}, not #{name.inspect}"
17
+ new(name:)
18
+ end
19
+
20
+ NAMES.each { |browser| define_method(:"#{browser}?") { name == browser } }
21
+
22
+ # Drops the profiles fresh browsers were given over a day ago, whose
23
+ # scenarios are long over (PruneFreshProfilesJob).
24
+ def self.prune_fresh_profiles = Project.current.browsers.glob("fresh-*").select { it.mtime < 1.day.ago }.each(&:rmtree)
25
+
26
+ def command(address, size:)
27
+ ["open", "-na", "Google Chrome", "--args", "--user-data-dir=#{profile(size)}", "--window-size=#{size.width},#{size.height}",
28
+ *("--user-agent=#{size.user_agent}" if size.user_agent), "--no-first-run", "--no-default-browser-check",
29
+ (size.width < NARROWEST_TABBED) ? "--app=#{address}" : address]
30
+ end
31
+
32
+ def launch(address, size:)
33
+ system(*command(address, size:)) or raise Failure, "Chrome could not be opened (is Google Chrome installed?)."
34
+ end
35
+
36
+ private
37
+
38
+ def profile(size) = profiles.join(fresh? ? "fresh-#{SecureRandom.hex(4)}" : "clean-#{size}")
39
+
40
+ def profiles = Project.current.browsers
41
+ end
42
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A body of agent work the desk opens as one: the pull requests a file under
5
+ # bursts/ names, taken together because they came out of one effort.
6
+ class Burst
7
+ Missing = Class.new(StandardError)
8
+
9
+ # Where burst files are, the first to hold a slug winning: the reviewer's
10
+ # own, then the repository's; reviews/ is where they were kept before
11
+ # bursts had their name.
12
+ FOLDERS = -> { Project.current.burst_folders }
13
+
14
+ # `file` is where it was read from.
15
+ attr_reader :slug, :name, :repository, :stacks, :tops, :file
16
+
17
+ delegate :read_at, to: :github
18
+
19
+ def self.all = files.map { from(it) }
20
+
21
+ # One file for each slug, in the order of their slugs.
22
+ def self.files = folders.flat_map { it.glob("*.yml") }.uniq(&:basename).sort_by(&:basename)
23
+
24
+ def self.folders = Array(FOLDERS.respond_to?(:call) ? FOLDERS.call : FOLDERS)
25
+
26
+ # A burst file as the desk reads it. A mistake in it is named with the
27
+ # file and the reason, as Project#settings names one in its own files.
28
+ def self.from(file)
29
+ settings = YAML.safe_load_file(file, symbolize_names: true)
30
+ settings.is_a?(Hash) or raise Failure, "#{file} is not a burst file: it holds no settings."
31
+ numbered(settings, file)
32
+ new(file.basename(".yml").to_s, file:, **settings)
33
+ rescue Psych::Exception => e
34
+ raise Failure, "#{file} is not valid YAML (#{e.message.lines.first&.strip})."
35
+ rescue ArgumentError => e
36
+ raise Failure, "#{file} is not a burst file as the desk reads one (#{e.message})."
37
+ end
38
+
39
+ # Stacks and pull requests are named by their numbers, in lists; a pull
40
+ # request nobody has opened yet by its branch.
41
+ def self.numbered(settings, file)
42
+ %i[stacks tops pull_requests].each do |list|
43
+ named = settings.fetch(list, [])
44
+ named.is_a?(Array) or raise Failure, "#{file} names #{named.inspect} under `#{list}`, which is a list of numbers."
45
+ named.grep_v((list == :stacks) ? Integer : BRANCH_OR_NUMBER).first&.then { raise Failure, "#{file} names #{it.inspect} under `#{list}`: #{(list == :stacks) ? "a stack" : "a pull request"} is named by its number#{", or by its branch while it is not opened" unless list == :stacks}." }
46
+ end
47
+ end
48
+
49
+ # A number, or a branch as git would take one.
50
+ BRANCH_OR_NUMBER = ->(named) { named.is_a?(Integer) || (named.is_a?(String) && named.match?(%r{\A(?!-)[^\s~^:?*\[\\]+\z})) }
51
+ private_class_method :numbered
52
+
53
+ # Every burst, the one written last first: a burst an agent has just
54
+ # made is the work waiting, and one reviewed a while ago sinks.
55
+ def self.newest_first = all.sort_by { -it.file.mtime.to_f }
56
+
57
+ # The burst the desk opens on, and the commands take when none is named:
58
+ # the newest one not put away.
59
+ def self.latest = newest_first.find { !it.put_away? }
60
+
61
+ def self.find(slug) = all.find { it.slug == slug } || raise(Missing, "no burst is named #{slug}")
62
+
63
+ SLUG = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
64
+
65
+ # Writes a burst of the reviewer's own, beside their marks, and returns its
66
+ # file. It is named as addresses and the route name it, `login-fixes`.
67
+ def self.start(slug, name: nil, stacks: [], tops: [], pull_requests: [])
68
+ slug.match?(SLUG) or raise Failure, "#{slug.inspect} is no burst name: lowercase words joined by hyphens, as login-fixes."
69
+ [stacks, tops, pull_requests].any?(&:present?) or raise Failure, "A burst needs pull requests: name some with --pr, --top or --stack."
70
+ taken = folders.map { it.join("#{slug}.yml") }.find(&:exist?) and raise Failure, "#{Project.current.from_root(taken)} is a burst named #{slug} already."
71
+ repository = Project.current.github_repository or raise Failure, "#{Project.current.root} has no GitHub repository as its origin."
72
+
73
+ settings = {name: name || slug.tr("-", " ").capitalize, repository:, stacks:, tops:, pull_requests:}.compact_blank
74
+ Project.current.own_bursts.tap(&:mkpath).join("#{slug}.yml").tap do |file|
75
+ file.write(["# Yours alone, outside the repository. docs/manual/bursts.md says what else it can hold.",
76
+ *settings.map { |key, value| "#{key}: #{value.is_a?(Array) ? "[#{value.map(&:to_json).join(", ")}]" : value.to_json}" }].join("\n") + "\n")
77
+ end
78
+ end
79
+
80
+ # The burst of that name, or else one written as `start` writes it: what a
81
+ # press on a lone pull request or a stack opens, the second time as the first.
82
+ def self.find_or_start(slug, **picks) = all.find { it.slug == slug } || from(start(slug, **picks))
83
+
84
+ # Whether it is the reviewer's own, rather than one the repository shares.
85
+ def own? = file.to_s.start_with?("#{Project.current.own_bursts}/")
86
+
87
+ # `stacks` are GitHub's native stacks, by their numbers; `tops` the tops of
88
+ # chains the desk follows down by base branches; `pull_requests` those
89
+ # that stand alone. A top with nothing under it stands alone as well.
90
+ def initialize(slug, name:, repository:, file: nil, stacks: [], tops: [], pull_requests: [], hands_off: [], before_it_goes: {}, handover: {}, sitting: nil, waiting: [])
91
+ @slug, @name, @repository, @file = slug, name, repository, file
92
+ @stacks, @tops, @pull_requests = stacks, tops, pull_requests
93
+ @hands_off, @before_it_goes = hands_off, before_it_goes
94
+ @terms = Handover::Terms.from(handover)
95
+ @sitting = sitting
96
+ @waiting = waiting
97
+ end
98
+
99
+ def to_param = slug
100
+
101
+ def marks(number) = Marks.of(self, number)
102
+
103
+ def choices = Choices.of(self)
104
+
105
+ def rules = Rules.new(choices, handovers: Handover.kept(self))
106
+
107
+ # How far each pull request's decisions are, by its number.
108
+ def progress(route) = choices.then { |kept| route.to_h { [it.number, kept.progress(it.number, it.description.decisions)] } }
109
+
110
+ # Ticking these decisions of one pull request, to show and then carry out.
111
+ # A description the burst file lists under `hands_off` is another
112
+ # session's, and the desk does not edit it.
113
+ def acceptance(number, decisions)
114
+ raise Acceptance::Refused, refusal(number) if refusal(number)
115
+ Acceptance.new(home: home_of(number), number:, decisions:)
116
+ end
117
+
118
+ # Why the desk will not edit this description, when it will not.
119
+ def refusal(number) = ("The description of ##{number} is not the desk's to edit" if @hands_off.include?(number))
120
+
121
+ # Every decision box of the route, or of the part in focus, and what waits
122
+ # for the owner among them. `pages` maps a pull request's address on
123
+ # GitHub to the desk's page for it.
124
+ def decisions(pages: {}, focus: route) = Decisions.new(route: focus, marks: method(:marks), renderings: method(:rendering), named: @waiting, pages:)
125
+
126
+ # Where every stop of the route stands, in its order: each layer knows the
127
+ # standing of the one it sits on.
128
+ def standings(route)
129
+ progress, scenarios = progress(route), scenarios_on(route)
130
+ route.each_with_object({}) do |stop, standings|
131
+ standings[stop] = Standing.new(stop:, marks: marks(stop.number), progress: progress.fetch(stop.number), under: route.under(stop)&.then { standings[it] },
132
+ above: route.above(stop), scenarios: scenarios.select { it.pr == stop.number }.map(&:name), named: @before_it_goes.fetch(stop.number, []))
133
+ end
134
+ end
135
+
136
+ # What moved in a pull request since the owner's "good to go", to show
137
+ # when its standing says something did.
138
+ def since(stop) = Since.new(stop:, mark: marks(stop.number).good_to_go, repository:, history: home_of(stop.number), root: Project.current.root)
139
+
140
+ # The prompt for a fresh session, out of everything open on this route, or
141
+ # in the part in focus.
142
+ def handover(route, focus: Focus.new(route, nil), worklist: nil) = Handover.new(burst: self, route:, focus:, worklist:, terms: @terms, renderings: method(:rendering), pictures: method(:picture_file))
143
+
144
+ def reports = Reports.new(self)
145
+
146
+ # A description's picture as a file on this Mac, once the desk has shown it.
147
+ def picture_file(asset) = (Project.current.pictures.glob("#{asset}.*").first if Picture.asset?(asset.to_s))
148
+
149
+ # The server its scenarios open the app on: the burst file's `sitting`, or
150
+ # else the checkout under review as it is, on a database of the burst's own.
151
+ def server = @server ||= Server.from(@sitting || {checkout: Project.current.root, port: Server::PORT, slug:})
152
+
153
+ # A checkout the owner can make for one pull request's scenarios when the
154
+ # sitting cannot run them: where the burst keeps its worktrees, or else in
155
+ # the cache. Nil when the app's app.yml came with no commit to pick.
156
+ def fresh_sitting(stop)
157
+ arrival = server.app.arrival or return
158
+ path = @terms.worktree_of("try/#{stop.number}")&.then { Pathname(it).expand_path } || Project.current.cache.join("sittings", stop.number.to_s)
159
+ FreshSitting.new(repository: Project.current.root, path:, head: stop.head_oid.first(12), arrival: arrival.commit, port: server.port, slug: server.slug)
160
+ end
161
+
162
+ def scenarios = @scenarios ||= scenarios_on(route)
163
+
164
+ # The scenarios its file writes, and for each pull request of `route` the
165
+ # file names none of, those its description offers on its own.
166
+ def scenarios_on(route)
167
+ written = Scenario.of(self)
168
+ written + route.reject { |stop| written.any? { it.pr == stop.number } }.flat_map { Scenario.described(it) }
169
+ end
170
+
171
+ def scenario(id) = scenarios.find { it.to_param == id } || raise(Missing, "no scenario is called #{id}")
172
+
173
+ # A stack is placed from the membership kept beside the marks when GitHub
174
+ # cannot say where its pull requests stand. Built once for this burst's
175
+ # reading of a page, which asks for it from the page, its pane and its focus.
176
+ def route(fresh: false)
177
+ return @route if @route && !fresh
178
+
179
+ open = github.open_pull_requests(fresh:)
180
+ unopened = Unopened.waiting(open:).map(&:pull_request)
181
+ among = open + unopened
182
+ @route = Route.new(stacks: @stacks, tops: resolved(@tops, among), loose: resolved(@pull_requests, among), among:, kept: membership.stacks).tap do |route|
183
+ Picture.fetch_ahead(of: [read_at, route.map(&:number), *unopened.map { [it.number, it.head_oid, it.described_at] }]) { pictures_of(route) }
184
+ Mentions.here&.read_ahead
185
+ end
186
+ end
187
+
188
+ # A description as GitHub renders it, for an open pull request, or as
189
+ # this Mac keeps it, for one nobody has opened yet.
190
+ def rendering(number) = home_of(number).rendering(number)
191
+
192
+ # The pull requests it holds now.
193
+ def numbers = route.map(&:number)
194
+
195
+ # The pull requests it held that are no longer open, merged or closed,
196
+ # by their numbers: those its file names and those the owner saw in it.
197
+ def gone = @gone ||= github.gone(held - numbers)
198
+
199
+ # Those of them that went in, the latest first.
200
+ def merged = gone.values.select(&:merged?).sort_by { -it.merged_at.to_f }
201
+
202
+ # Nothing of it is open or waiting unopened, and some of it was merged:
203
+ # the work went in, and the desk puts the burst away. A burst GitHub
204
+ # cannot be asked about now is not taken for finished.
205
+ def finished?
206
+ route.none? && merged.any?
207
+ rescue Github::Unreachable
208
+ false
209
+ end
210
+
211
+ # Put away: finished, or put away by the owner while some of it is still
212
+ # open. The desk no longer opens on it, and lists it only when asked.
213
+ def put_away? = put_away_by_hand? || finished?
214
+
215
+ # The owner's word that they are done with it, kept beside its marks, not
216
+ # in its file: a burst the repository shares is put away for them alone.
217
+ def put_away_by_hand? = put_away_mark.exist?
218
+
219
+ def put_away! = put_away_mark.tap { it.dirname.mkpath }.write("#{Time.current.iso8601}\n")
220
+
221
+ def take_out! = FileUtils.rm_f(put_away_mark)
222
+
223
+ def membership = Membership.of(self)
224
+
225
+ # What joined it, what left, and where its file and GitHub disagree. The
226
+ # first time the desk reads a burst, the membership it counts from is kept.
227
+ def notices
228
+ @notices ||= route.then do |route|
229
+ membership.start(route)
230
+ BurstNotices.new(burst: self, route:, membership:, open: github.open_pull_requests, choices: choices.document, others:, merged: merged.map(&:number))
231
+ end
232
+ end
233
+
234
+ # The other bursts of the repository, which may hold the same pull requests.
235
+ def others = Burst.all.reject { it.slug == slug }.select { it.repository == repository }
236
+
237
+ # Which of its pull requests GitHub has moved on since the desk read them,
238
+ # and which it closed: their numbers. Others of the repository are not its concern.
239
+ def moved
240
+ mine = route.map(&:number)
241
+ github.moved.slice(:changed, :closed).transform_values { it & mine }
242
+ end
243
+
244
+ # Pieces of a description's markdown as GitHub draws them, for a page that has only the markdown.
245
+ def draw(pieces) = PiecesRendering.new(github).call(pieces)
246
+
247
+ private
248
+
249
+ # The pictures its descriptions show, each by its id and its kind, as GitHub's HTML addresses them.
250
+ def pictures_of(route) = route.flat_map { Rendering.pictures(rendering(it.number)) }
251
+
252
+ def github = @github ||= Github.new(@repository)
253
+
254
+ def put_away_mark = Marks.directory_of(self).join("put_away")
255
+
256
+ # Every pull request it was known to hold, by GitHub's number.
257
+ def held = [*@tops, *@pull_requests, *membership.pull_requests, *membership.handed_over].grep(Integer).uniq
258
+
259
+ # What holds a pull request's description, and answers for it: GitHub,
260
+ # or this Mac while nobody has opened it.
261
+ def home_of(number) = PullRequest.unopened?(number) ? Unopened : github
262
+
263
+ # A branch the file names stands for the pull request on it: the one
264
+ # open on GitHub, or else the one waiting unopened. A branch with
265
+ # neither stays as it is named, and is gone.
266
+ def resolved(named, among) = named.map { |name| (name.is_a?(String) && among.find { it.head == name }&.number) || name }
267
+ end
268
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What the burst page says above its route. How the burst moved: the pull
5
+ # requests that joined or left it since the owner last looked, each until
6
+ # the owner dismisses it, and those that left with choices or drafts not
7
+ # yet handed over. And where the burst's file and GitHub disagree, each with
8
+ # its fix, for as long as it is true.
9
+ class BurstNotices
10
+ # A pull request that joined or left, by its number and its title, when
11
+ # the desk still knows it; or one opened on GitHub, with the number it
12
+ # waited unopened as.
13
+ Moved = Data.define(:number, :title, :was) do
14
+ def initialize(was: nil, **) = super
15
+ end
16
+
17
+ # A pull request that left with the owner's choices or drafts, drawn from
18
+ # what they kept of it: nothing of it is read again from GitHub.
19
+ Departed = Data.define(:number, :title, :entries)
20
+
21
+ # One of the owner's choices or drafts on a decision of it.
22
+ Entry = Data.define(:kind, :lead, :text, :draft)
23
+
24
+ # Something the file says that GitHub does not, in a sentence, with how
25
+ # to mend it. `cause` groups it with its kind: :off_the_burst,
26
+ # :empty_stack, :filed_twice or :held_elsewhere.
27
+ Disagreement = Data.define(:cause, :said, :fix)
28
+
29
+ # `open` is every open pull request of the repository, `others` the
30
+ # other bursts of the same repository, `choices` the owner's document,
31
+ # `merged` the numbers of those that left it by going in.
32
+ def initialize(burst:, route:, membership:, open:, choices:, others:, merged: [])
33
+ @burst, @route, @membership, @open, @choices, @others, @merged = burst, route, membership, open, choices, others, merged
34
+ end
35
+
36
+ def joined = @joined ||= @membership.joined(@route).map { moved(it) }
37
+
38
+ # One that left by going in is no notice: the route lists it as merged.
39
+ def left = @left ||= (@membership.left(@route) - @merged).map { moved(it) }
40
+
41
+ # Opened on GitHub since it waited unopened, and its review came with it.
42
+ def opened = @opened ||= @membership.opened.filter_map { |number, was| Moved.new(number:, title: title_of(number), was:) if @route.at(number) }
43
+
44
+ def departed
45
+ @departed ||= @membership.departed(@route).filter_map do |number|
46
+ entries = entries_of(number)
47
+ Departed.new(number:, title: title_of(number), entries:) if entries.any?
48
+ end
49
+ end
50
+
51
+ # A burst whose work all went in has nothing left to mend.
52
+ def disagreements = @disagreements ||= finished? ? [] : [*off_the_burst, *empty_stacks, *filed_twice, *held_elsewhere]
53
+
54
+ def count = [joined, left, opened, departed, disagreements].sum(&:size)
55
+
56
+ def any? = count.positive?
57
+
58
+ private
59
+
60
+ def moved(number) = Moved.new(number:, title: title_of(number))
61
+
62
+ def finished? = @route.none? && @merged.any?
63
+
64
+ # From GitHub while it is open, else from a choice made on it.
65
+ def title_of(number) = @open.find { it.number == number }&.title || on(number).first&.dig("decision", "title")
66
+
67
+ def on(number) = %w[choices pending].flat_map { Array(@choices&.dig(it)) }.select { it.dig("decision", "pr") == number }
68
+
69
+ def entries_of(number)
70
+ on(number).map do |entry|
71
+ Entry.new(kind: entry["kind"], lead: entry.dig("decision", "lead"), text: entry["text"].to_s, draft: Array(@choices&.dig("pending")).include?(entry))
72
+ end
73
+ end
74
+
75
+ def file = @burst.file ? shown(@burst.file) : "the burst file"
76
+
77
+ # Open pull requests that sit, one on another, on a member of the burst,
78
+ # and are neither in it nor in another burst of the repository. Each run
79
+ # is named at the member it sits on.
80
+ def off_the_burst
81
+ held = numbers(@route) | @others.flat_map(&:numbers)
82
+ @route.filter_map do |member|
83
+ run = sitting_on([member.pull_request], held)
84
+ off(member, run) if run.any?
85
+ end
86
+ end
87
+
88
+ # Every open pull request outside `held` that sits on one of `below`, or
89
+ # on one that does, and so on up.
90
+ def sitting_on(below, held)
91
+ above = @open.select { |pull| below.any? { pull.base == it.head } }.reject { held.include?(it.number) }
92
+ above.empty? ? [] : above + sitting_on(above, held + numbers(above))
93
+ end
94
+
95
+ def off(member, run)
96
+ tops = run.reject { |pull| run.any? { it.base == pull.head } }
97
+ fix = "List #{named(tops)} under `tops` in #{file}"
98
+ # GitHub appends to a stack only at its top.
99
+ if member.part.stack? && member.layer == member.of
100
+ fix += ", or link #{them(run)} to stack ##{member.part.id}: `gh stack link #{member.part.id} #{numbers(run).join(" ")}`"
101
+ end
102
+ Disagreement.new(cause: :off_the_burst, said: "#{named(run)} #{be(run)} on ##{member.number}'s branch, not in this burst.", fix: "#{fix}.")
103
+ end
104
+
105
+ def empty_stacks
106
+ return [] unless @route.stacks_known?
107
+
108
+ (@burst.stacks - @open.filter_map(&:stack)).map do |stack|
109
+ Disagreement.new(cause: :empty_stack, said: "Stack ##{stack} has no open pull requests.", fix: "Take it out of `stacks` in #{file}.")
110
+ end
111
+ end
112
+
113
+ # A slug both under bursts/ and under reviews/, where burst files were
114
+ # kept before bursts had their name: the desk opens the first.
115
+ def filed_twice
116
+ opened, *also = Burst.folders.map { it.join("#{@burst.slug}.yml") }.select(&:exist?)
117
+ also.map do |other|
118
+ Disagreement.new(cause: :filed_twice, said: "#{shown(other)} #{(other.realpath == opened.realpath) ? "points to" : "names the same burst as"} #{shown(opened)}, which the desk opens.",
119
+ fix: "Delete #{shown(other)} once no desk from before bursts had their name is left to read it.")
120
+ end
121
+ end
122
+
123
+ def held_elsewhere
124
+ @others.filter_map do |other|
125
+ shared = @route.select { other.numbers.include?(it.number) }
126
+ next if shared.empty?
127
+
128
+ Disagreement.new(cause: :held_elsewhere, said: "#{named(shared)} #{be(shared)} also in #{other.name}, in #{shown(other.file)}.",
129
+ fix: "Each burst keeps its own choices on #{them(shared)}: take #{them(shared)} out of one of the files if only one burst should hold #{them(shared)}.")
130
+ end
131
+ end
132
+
133
+ def shown(path) = Project.current.from_root(path)
134
+
135
+ def numbers(pull_requests) = pull_requests.map(&:number)
136
+
137
+ def named(pull_requests) = pull_requests.map { "##{it.number}" }.to_sentence
138
+
139
+ def be(pull_requests) = pull_requests.one? ? "is" : "are"
140
+
141
+ def them(pull_requests) = pull_requests.one? ? "it" : "them"
142
+ end
143
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Proctolog
6
+ # The steps of a recording and when each was on screen, as a WebVTT chapters
7
+ # file. The capture script writes it beside the video and the player reads
8
+ # it back, so the list beside the video is what the video says. Plain Ruby,
9
+ # with no Rails in it, because capture/film.rb loads it without the desk.
10
+ class Chapters
11
+ include Enumerable
12
+
13
+ Chapter = Data.define(:name, :from, :to)
14
+
15
+ CUE = /^(?<from>[\d:.]+) --> (?<to>[\d:.]+)[^\n]*\n(?<name>[^\n]+)/
16
+
17
+ def self.parse(text) = new(text.scan(CUE).map { |from, to, name| Chapter.new(name: CGI.unescapeHTML(name), from: seconds(from), to: seconds(to)) })
18
+
19
+ def self.read(file) = file.exist? ? parse(file.read) : new([])
20
+
21
+ def self.seconds(stamp) = stamp.split(":").map(&:to_f).reduce { |minutes, more| (minutes * 60) + more }
22
+
23
+ def initialize(chapters) = @chapters = chapters
24
+
25
+ def each(&) = @chapters.each(&)
26
+
27
+ # The step a time falls in: the last that began at or before it, so a
28
+ # pause between two steps belongs to the one before.
29
+ def at(time) = @chapters.rfind { it.from <= time }
30
+
31
+ def to_vtt = "WEBVTT\n\n#{map { cue(it) }.join("\n\n")}\n"
32
+
33
+ private
34
+
35
+ def cue(chapter) = "#{stamp(chapter.from)} --> #{stamp(chapter.to)}\n#{CGI.escapeHTML(chapter.name.split.join(" "))}"
36
+
37
+ def stamp(time)
38
+ ms = (time * 1000).round
39
+ format("%02d:%02d:%02d.%03d", ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000)
40
+ end
41
+ end
42
+ end