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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What every page of the desk shares: its top line, how far the decisions
5
+ # are, and the list of keys that ? opens.
6
+ module DeskHelper
7
+ # A page of the desk in its top line, marked when it is the one shown.
8
+ def desk_link(name, path, on:) = link_to(name, path, aria: {current: ("page" if controller_name == on)})
9
+
10
+ # When GitHub was read, on this Mac's clock: "at 15:45" today, "on 12 Oct at 15:45" before.
11
+ def read_at_in_words(time)
12
+ time = time.localtime
13
+ time.strftime((time.to_date == Date.current) ? "at %H:%M" : "on %-d %b at %H:%M")
14
+ end
15
+
16
+ # How far one pull request is, as the side pane marks its row: every
17
+ # decision reviewed, some, none yet (which takes no mark), or none to
18
+ # review at all. The page's script writes the same marks as choices are made.
19
+ def side_mark(progress)
20
+ if !progress.any? then "-"
21
+ elsif progress.done? then "x"
22
+ elsif progress.reviewed.positive? then "~"
23
+ end
24
+ end
25
+
26
+ def side_fraction(progress) = ("#{progress.reviewed}/#{progress.total}" if progress.any?)
27
+
28
+ # The whole burst in one line: "6 of 439 decisions reviewed" over a bar.
29
+ # Focused on a part, what is left in it against the burst: "120 / 380
30
+ # decisions left, part / burst", over the part's bar.
31
+ def side_gauge(burst, part: nil)
32
+ said = part ? "#{number_with_delimiter(part.left)} / #{number_with_delimiter(burst.left)} decisions left, part / burst" : "#{number_with_delimiter(burst.reviewed)} of #{number_with_delimiter(burst.total)} decisions reviewed"
33
+ shown = part || burst
34
+ safe_join([tag.span(said), tag.meter(value: shown.reviewed, max: [shown.total, 1].max)])
35
+ end
36
+
37
+ # "12 of 21 decisions reviewed, 2 of them ticked on GitHub, 1 draft unfinished".
38
+ def progress_in_words(progress)
39
+ ["#{number_with_delimiter(progress.reviewed)} of #{pluralize(number_with_delimiter(progress.total), "decision")} reviewed",
40
+ ("#{progress.ticked} of them ticked on GitHub" if progress.ticked.positive?),
41
+ ("#{pluralize(progress.drafts, "draft")} unfinished" if progress.drafts.positive?)].compact.join(", ")
42
+ end
43
+
44
+ # One group of keys in the list: `keys` maps the keys, a space between
45
+ # those that go together, to what they do.
46
+ def keys_group(title, keys)
47
+ tag.section do
48
+ safe_join([tag.h3(title), tag.dl { safe_join(keys.map { |pressed, does| tag.div { tag.dt(keycaps(pressed)) + tag.dd(does) } }) }])
49
+ end
50
+ end
51
+
52
+ # The line the bar at the foot of a page starts with: its name, then pairs like the list's.
53
+ def mode_line(name, hints)
54
+ safe_join([tag.strong(name), *hints.map { |pressed, does| tag.span { keycaps(pressed) + " #{does}" } }])
55
+ end
56
+
57
+ private
58
+
59
+ def keycaps(pressed) = safe_join(pressed.split.map { tag.kbd(it) })
60
+ end
61
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Words the owner or the agent wrote, as a page shows them: each # that
5
+ # names a pull request of the repository goes to it, its title beside. One
6
+ # of the burst's own goes to its page on the desk, any other to GitHub. A #
7
+ # the desk has not read of yet, or that names no pull request, stays as
8
+ # written.
9
+ module MentionsHelper
10
+ # Words cut on each # they hold, which stands apart among the pieces.
11
+ EACH_MENTION = /(#{Mentions::WRITTEN})/
12
+
13
+ def with_mentions(text, burst: nil) = safe_join(text.to_s.split(EACH_MENTION).map { mention_of(it, burst) || it })
14
+
15
+ private
16
+
17
+ def mention_of(piece, burst)
18
+ mention = piece[/\A#(\d+)\z/, 1]&.then { mentions&.[](it.to_i) } or return
19
+ address = burst&.route&.at(mention.number) ? burst_pull_request_path(burst, mention.number) : mention.url
20
+ tag.span(class: "mention", data: {state: mention.state}) do
21
+ safe_join([link_to(piece, address), tag.span(mention.title, class: "mention-title"), (tag.span("· #{mention.state}", class: "mention-state") unless mention.open?)].compact, " ")
22
+ end
23
+ end
24
+
25
+ def mentions = defined?(@mentions) ? @mentions : (@mentions = Mentions.here)
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module OpenPullRequestsHelper
5
+ # What a pull request on the pick says of itself, after its title: whose it
6
+ # is, what it sits on when that is not main, and whether it asks anything.
7
+ def pick_facts(pick, pull_request)
8
+ decisions = pull_request.description.decisions
9
+ [
10
+ (pick.yours?(pull_request) ? "yours" : pull_request.author&.then { "by @#{it}" }),
11
+ ("on #{pull_request.base}" unless pull_request.base.in?(%w[main master])),
12
+ decisions.any? ? pluralize(decisions.size, "decision") : "not written for PRoctolog yet"
13
+ ].compact.join(" · ")
14
+ end
15
+
16
+ # Why a burst is put away, after its other facts: its work went in, or
17
+ # the owner was done with it; nothing while it is out.
18
+ def put_away_in_words(burst)
19
+ if burst.put_away_by_hand? then " · put away by you"
20
+ elsif burst.finished? then " · finished, #{pluralize(burst.merged.size, "pull request")} merged"
21
+ end
22
+ end
23
+
24
+ # What one nobody has opened yet says of itself: its branch, what it
25
+ # would go into, and whether it asks anything.
26
+ def unopened_facts(pull_request)
27
+ decisions = pull_request.description.decisions
28
+ safe_join([tag.code(pull_request.head), "into #{pull_request.base}", (decisions.any? ? pluralize(decisions.size, "decision") : "asks nothing")], " · ")
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module PromptsHelper
5
+ # A link that opens a new Claude Code session in the Claude app, in the
6
+ # checkout under review, with this typed in. Nothing is sent until the
7
+ # owner sends it there. Spaces are written %20: a plus is not read as a
8
+ # space by every handler.
9
+ def new_session_address(file) = "claude://code/new?folder=#{ERB::Util.url_encode(Project.current.root.to_s)}&q=#{ERB::Util.url_encode(session_prompt(file))}"
10
+
11
+ # What that session is asked, also put on the clipboard for a session started by hand.
12
+ def session_prompt(file) = "Read #{file} and carry it out. It is the brief of my own hand-over from the review desk, and the rest of its folder holds the work: follow it as if I had typed it."
13
+
14
+ def handed_over(handover)
15
+ threads = handover.stops.sum { it.pull_request.threads.size }
16
+ asked = handover.stops.count { handover.worklist.of(it.number).items.any? || it.pull_request.threads.any? }
17
+ [pluralize(handover.worklist.items.size, "request"), ("#{pluralize(threads, "unresolved review thread")} on GitHub" if threads.positive?)]
18
+ .compact.to_sentence + " on #{pluralize(asked, "pull request")}"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module PullRequestsHelper
5
+ # "Layer 19 of 23" or "Stands alone".
6
+ def position_of(stop) = stop.stacked? ? "Layer #{stop.layer} of #{stop.of}" : "Stands alone"
7
+
8
+ # "not opened, on recovery/emails" for a pull request nobody has opened
9
+ # yet; nil for one open on GitHub.
10
+ def not_opened(stop) = (safe_join(["not opened, on ", tag.code(stop.pull_request.head)]) if stop.pull_request.unopened?)
11
+
12
+ # "#1060 Title", the number being the desk's page for it.
13
+ def pull_request_link(burst, stop)
14
+ safe_join([link_to("##{stop.number}", burst_pull_request_path(burst, stop.number), class: "number"), stop.title], " ")
15
+ end
16
+
17
+ # A section as GitHub rendered it, to be shown as it is. Under "Decisions for the owner" each box
18
+ # has what stands under it on the decisions page, the same partial; where
19
+ # GitHub's list and the Markdown's disagree, the boxes are left as they are.
20
+ def section_html(section, burst:, boxes:)
21
+ if section.heading == Description::DECISIONS && section.boxes.size == boxes.size
22
+ section.furnish_boxes { |position| render("proctolog/decisions/beneath", box: boxes[position], burst:, back: box_on_its_page(burst, boxes[position])) }
23
+ end
24
+ section.html
25
+ end
26
+
27
+ # "1 of 4 met", of the conditions that can be judged, beside the Standing section's heading.
28
+ def conditions_met(standing) = "#{standing.judged.count(&:met)} of #{standing.judged.size} met"
29
+
30
+ # One line of the outline: a section by name, and its count when it has one.
31
+ # `waits` says the count is of something the owner still has to do, which the
32
+ # outline sets in full relief; `waits_for` leaves the counting to the script;
33
+ # `apart` begins the sections that follow the description. A line with
34
+ # somewhere to go `to` leaves the page instead, and is no section.
35
+ def outline_line(name, count: nil, waits: false, waits_for: nil, apart: false, to: nil, **options)
36
+ said = safe_join([tag.span(name, class: "name"), tag.span(count, class: "count", data: {waits: (waits if count), waits_for:})])
37
+ to ? link_to(said, to, class: ("apart" if apart), **options) : tag.button(said, type: "button", class: ("apart" if apart), data: {fold: ""})
38
+ end
39
+
40
+ # What a section is worth a glance at, said quietly beside its heading.
41
+ def count_of(section)
42
+ case section.heading
43
+ when Description::DECISIONS then ((open = section.open_decisions).zero? ? "none open" : "#{open} open")
44
+ when "Screenshots" then pluralize(section.figures.size, "picture")
45
+ else "#{number_with_delimiter(section.words)} words"
46
+ end
47
+ end
48
+
49
+ # Open when it is the summary, or when it holds a note someone still has to act on.
50
+ def unfolded?(section) = section.heading == "Summary" || section.noted?
51
+
52
+ # The same files as another pull request, longest overlap first.
53
+ def by_overlap(shared) = shared.sort_by { |stop, files| [-files.size, stop.number] }
54
+
55
+ # A note's own place in words: what it quotes, or which picture it is on.
56
+ def place_of(note, rendering)
57
+ return unless note.place
58
+
59
+ case note.place
60
+ in Note::Quote => quote then tag.blockquote(quote.exact, class: "words")
61
+ in Note::Region => region then tag.p(safe_join(["On the picture ", tag.code(region.picture), (" (retaken since)" if rendering.retaken?(note))]), class: "where")
62
+ in Note::Scenario => scenario then tag.p("On the scenario #{scenario.name}", class: "where")
63
+ in Note::Moment => moment then tag.p(moment_place(note, moment), class: "where")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module RecordingsHelper
5
+ # 83.4 as "1:23", and 3725 as "1:02:05".
6
+ def timecode(seconds)
7
+ hours, rest = seconds.floor.divmod(3600)
8
+ minutes, whole = rest.divmod(60)
9
+ hours.positive? ? format("%d:%02d:%02d", hours, minutes, whole) : format("%d:%02d", minutes, whole)
10
+ end
11
+
12
+ # A recording's name as a title: "first-run" is "First run".
13
+ def recording_title(recording) = recording.name.tr("-_", " ").upcase_first
14
+
15
+ # Where a folder of recordings belongs: the owner's sitting, or a pull request.
16
+ def recording_folder(folder) = (folder == "sitting") ? "My sitting" : "##{folder}"
17
+
18
+ # How far along a film a time is, for a mark on its timeline.
19
+ def film_position(time, recording) = "#{(100 * time / recording.length).clamp(0, 100).round(3)}%"
20
+
21
+ # Where a note on a moment is: a link to that moment while the recording is still there.
22
+ def moment_place(note, moment)
23
+ text = "In #{File.basename(moment.recording, ".mp4")} at #{timecode(moment.time)}"
24
+ recording = Recording.at(moment.recording)
25
+ recording ? link_to(text, burst_recording_path(recording.burst_slug, recording.to_param, anchor: "note-#{note.id}")) : text
26
+ end
27
+
28
+ def player_link(burst, recording, text = recording_title(recording)) = link_to(text, burst_recording_path(burst, recording.to_param))
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module RoundsHelper
5
+ # What a page's script needs of the rounds: the requests that stand, the
6
+ # heads read, the answers that came back, and the batches the desk wrote,
7
+ # which a page takes in as its own so that it knows the answer to a pass
8
+ # an agent took itself. On a pull request's page, its description now
9
+ # and as each handover the desk kept found it, by batch, so the page can
10
+ # say what changed since.
11
+ def round_data(burst, pr: nil)
12
+ route = burst.route
13
+ rounds = Rounds.new(burst, route:)
14
+ responses = ResponseFiles.new(burst)
15
+ {requests: rounds.requests, batches: Handover.kept(burst).map(&:batch), reports: responses.all, errors: responses.errors,
16
+ responseFolder: responses.folder.to_s, heads: rounds.heads,
17
+ **(route.at(pr)&.then { |stop| {descriptions: descriptions_of(burst, stop)} } || {})}
18
+ end
19
+
20
+ private
21
+
22
+ def descriptions_of(burst, stop)
23
+ handed_over = Handover.kept(burst).filter_map { |kept| kept.description_of(stop.number)&.then { [kept.batch_id, it] } }.to_h
24
+ {now: stop.description.to_s, handedOver: handed_over}
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module ScenariosHelper
5
+ # "up at http://localhost:3300", "starting", "down".
6
+ def server_status(server)
7
+ case server.state
8
+ when :up then safe_join(["The server is up at ", link_to(server.address, server.address), "."])
9
+ when :starting then "The server is starting. This takes a minute or two."
10
+ else server.crashed? ? "The server has stopped." : "The server is down."
11
+ end
12
+ end
13
+
14
+ # Where a scenario opens, in a few words.
15
+ def where_it_opens(scenario)
16
+ size = scenario.size.is_a?(Array) ? scenario.size.join("x") : scenario.size
17
+ case scenario.browser.name
18
+ when "mine" then "In your own Chrome. It replaces whoever is signed in on this app, on every port of localhost, in that browser."
19
+ when "clean" then "In a Chrome of its own, #{size}"
20
+ else "In a new Chrome, #{size}, cookie banner up"
21
+ end
22
+ end
23
+
24
+ # A step of a description, with its `code` set in code.
25
+ def with_code(text)
26
+ safe_join(text.split(/`([^`]*)`/).each_with_index.map { |part, index| index.odd? ? tag.code(part) : part })
27
+ end
28
+
29
+ # What a step's Markdown sets apart from its words: code, bold and links.
30
+ MARKED = /(`[^`]*`|\*\*[^*\n]+\*\*|\[[^\]]+\]\([^)\s]+\))/
31
+ LINK = /\A\[(?<words>[^\]]+)\]\((?<to>[^)\s]+)\)\z/
32
+
33
+ # A step of a description as GitHub sets it. A link to one of the
34
+ # description's pictures stays on the page, where a press opens the
35
+ # picture to look at closely; any other leaves the desk.
36
+ def step_words(text) = safe_join(text.split(MARKED).map { marked(it) })
37
+
38
+ BROWSERS = {"mine" => "your own Chrome", "clean" => "a Chrome of its own", "fresh" => "a new Chrome"}.freeze
39
+
40
+ # How a scenario starts, in a line under its button: whom it signs in, the
41
+ # page it opens at, and its size and browser where those are its own.
42
+ def how_it_starts(scenario, browser: true)
43
+ who = if scenario.recipe then "as #{with_article(scenario.recipe.humanize(capitalize: false))}#{", signed out" if scenario.signed_out}"
44
+ elsif scenario.signed_out then "signed out"
45
+ else "as whoever is signed in"
46
+ end
47
+ size = Array(scenario.size).join("x") unless scenario.size == "desktop" || scenario.browser.mine?
48
+ safe_join([who, ", at ", page_it_opens(scenario), *(", #{size}" if size), *(", in #{BROWSERS.fetch(scenario.browser.name)}" if browser), "."])
49
+ end
50
+
51
+ # The line over the scenarios' buttons: what pressing one does, and the
52
+ # browser they share, while they can be pressed; when they wait, for what.
53
+ def tries_lede(scenario, ready:, shared:)
54
+ browser = " in #{BROWSERS.fetch(scenario.browser.name)}" if shared
55
+ ready ? "Each sets its state up and opens#{browser} where its steps begin." : "Each opens#{browser} once the server is up."
56
+ end
57
+
58
+ # A path as the owner would type it: from the checkout's root, or from ~.
59
+ def shown_path(path) = Project.current.from_root(path).to_s
60
+
61
+ # A checkout by its last two folders, which tell it apart; the whole path
62
+ # on hover.
63
+ def folder(path) = tag.code(["…", *Pathname(path).each_filename.to_a.last(2)].join("/"), title: shown_path(path))
64
+
65
+ # The commit that brought the app its app.yml, by its hash and its words.
66
+ def arrival_of(app)
67
+ app.arrival&.then { safe_join([tag.code(it.commit), ", “", it.subject.truncate(44), "”"]) } || "the commit that added it"
68
+ end
69
+
70
+ # Words to copy as they are, with the button that copies them.
71
+ def copyable(text)
72
+ tag.div(class: "copyable") { tag.pre(tag.code(text)) + tag.button("copy", type: "button", class: "quiet", data: {copy: ""}) }
73
+ end
74
+
75
+ def day_of(time) = time&.localtime&.strftime("%-d %b") || "an unknown day"
76
+
77
+ # The end of a server's log, each line cut to a width that reads.
78
+ def log_tail(server) = server.last_lines(16).lines.map { it.chomp.truncate(220) }.join("\n")
79
+
80
+ # What was last seen of a scenario, and whether that was on the commit it
81
+ # is on now.
82
+ # "Grouse's overlay opened twice, last at 20 Oct 14:32. 3 notes pinned from it."
83
+ def overlays_line(overlays)
84
+ at = Time.iso8601(overlays.last.opened).localtime.strftime("%-d %b %H:%M")
85
+ times = {1 => "once", 2 => "twice"}.fetch(overlays.size) { "#{overlays.size} times" }
86
+ "Grouse's overlay opened #{times}, last at #{at}. #{pluralize(overlays.sum { it.pinned.size }, "note")} pinned from it."
87
+ end
88
+
89
+ def seen_line(seen, head)
90
+ return "Not seen yet." unless seen
91
+
92
+ at = Time.iso8601(seen["seen"]).localtime.strftime("%-d %b %H:%M")
93
+ moved = " On an earlier commit than the present one." if head && seen["head"] != head
94
+ "#{Scenario::RESULTS.key(seen["result"])}, #{at}.#{moved}"
95
+ end
96
+
97
+ # The pull requests that have scenarios, in the order of the route.
98
+ def scenarios_by_pull_request(scenarios, route)
99
+ order = route.map(&:number)
100
+ scenarios.select(&:pr).group_by(&:pr).sort_by { |number, _| order.index(number) || order.size }
101
+ end
102
+
103
+ private
104
+
105
+ def marked(piece)
106
+ if (code = piece[/\A`(.*)`\z/, 1]) then tag.code(code)
107
+ elsif (bold = piece[/\A\*\*(.+)\*\*\z/, 1]) then tag.strong(bold)
108
+ elsif (link = piece.match(LINK)) then link_to(link[:words], link[:to], **(link[:to].start_with?("#") ? {} : {target: "_blank", rel: "noopener"}))
109
+ else piece
110
+ end
111
+ end
112
+
113
+ def page_it_opens(scenario)
114
+ case scenario.opens
115
+ when %r{\A/} then tag.code(scenario.opens)
116
+ when "lands_on" then "where its member lands"
117
+ else "the #{scenario.opens.tr("_", " ")} its state makes"
118
+ end
119
+ end
120
+
121
+ def with_article(words) = "#{words.match?(/\A[aeiou]/i) ? "an" : "a"} #{words}"
122
+ end
123
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module StandingsHelper
5
+ # The four things a pull request can be, in the order the owner reads them.
6
+ TURNS = {owner: "Your turn", agent: "The agent's turn", settled: "Settled", waiting: "Waiting on"}.freeze
7
+
8
+ VERDICTS = {true => "Met", false => "Not met", nil => "Yours to judge"}.freeze
9
+
10
+ # "Your turn", or "Waiting on" and what, in the standing's own words.
11
+ def turn_of(standing) = [TURNS.fetch(standing.turn), standing.marks.waiting_on].compact.join(" ")
12
+
13
+ def turn_tag(standing) = tag.span(turn_of(standing), class: "turn", data: {turn: standing.turn})
14
+
15
+ def reasons_of(standing) = line_of(standing, beside_the_count(standing, standing.open))
16
+
17
+ # On the route a change since the mark has a column of its own.
18
+ def route_reasons_of(standing) = line_of(standing, beside_the_count(standing, standing.otherwise_open))
19
+
20
+ # How many pull requests are at each turn, the turns with none included.
21
+ def turn_counts(standings)
22
+ counts = standings.each_value.map(&:turn).tally
23
+ TURNS.keys.index_with { counts.fetch(it, 0) }
24
+ end
25
+
26
+ # Whose the pull requests of a burst are, as the route's first line says it.
27
+ IN_A_SENTENCE = {owner: "yours to review", agent: "with the agent", settled: "settled", waiting: "waiting on something"}.freeze
28
+
29
+ # "25 are yours to review, 2 are with the agent." The owner's share stands out.
30
+ def turns_in_words(standings)
31
+ parts = turn_counts(standings).select { |_, count| count.positive? }.map do |turn, count|
32
+ tag.span("#{count} #{(count == 1) ? "is" : "are"} #{IN_A_SENTENCE.fetch(turn)}", class: ("yours" if turn == :owner))
33
+ end
34
+ safe_join([safe_join(parts, ", "), "."])
35
+ end
36
+
37
+ # What a line of the route says at its right: whose the pull request is when
38
+ # it is not the owner's, then how far its decisions are.
39
+ def beside_a_line(standing)
40
+ whose = {agent: "with the agent", settled: "settled", waiting: "waiting"}[standing.turn]
41
+ safe_join([whose, (tag.span(side_fraction(standing.progress), class: "fraction") if standing.progress.any?)].compact, " · ")
42
+ end
43
+
44
+ def verdict_of(condition) = VERDICTS.fetch(condition.met)
45
+
46
+ # What the drawer of a pull request's page holds, said beside its heading.
47
+ def drawer_count(standing, scenarios)
48
+ judged = standing.conditions.reject { it.met.nil? }
49
+ ["#{judged.count(&:met)} of #{pluralize(judged.size, "condition")} met", (pluralize(scenarios.size, "scenario") if scenarios.any?)].compact.join(" · ")
50
+ end
51
+
52
+ # The route's last column: once there is a mark, what moved since it.
53
+ def changed_since_mark(standing, pull_request)
54
+ return last_change(pull_request) unless standing.good_to_go?
55
+
56
+ standing.changed? ? tag.span("changed since your mark: #{standing.changed.to_sentence}", class: "moved") : "unchanged since your mark"
57
+ end
58
+
59
+ # "12 Oct 09:40" on this Mac's clock, for a time or the ISO 8601 text of one.
60
+ def clock(time) = time.to_time.localtime.strftime("%-d %b %H:%M")
61
+
62
+ # A commit by its first eight characters; the name of a branch whole.
63
+ def short(commit) = tag.code(commit.to_s.match?(/\A\h{40}\z/) ? commit[0, 8] : commit)
64
+
65
+ def lines_of(file) = file.binary? ? "binary" : safe_join([tag.span("+#{file.added}", class: "added"), tag.span("−#{file.removed}", class: "removed")], " ")
66
+
67
+ private
68
+
69
+ # The pages that give these reasons show how many decisions are reviewed
70
+ # right beside them, so the decisions are named only when nothing else is.
71
+ def beside_the_count(standing, reasons) = (reasons - standing.to_review).presence || reasons
72
+
73
+ # What is open, as a line. Under a mark nothing but decisions can be, and they are said as what the mark left.
74
+ def line_of(standing, reasons)
75
+ return "nothing is open" if reasons.empty?
76
+
77
+ [("with" if standing.settled?), reasons.join(" · ")].compact.join(" ")
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What the desk does behind the owner's back, and on a schedule
5
+ # (Housekeeping), in its own process: nothing of it outlives `serve`.
6
+ class ApplicationJob < ActiveJob::Base
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Once a day (Housekeeping): which PRoctolog is the latest, for the pages to
5
+ # say when it is newer than the one running.
6
+ class AskForReleaseJob < ApplicationJob
7
+ def perform = Release.ask
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Takes a reading's pictures ahead of the pages that show them, so a page
5
+ # that shows forty waits neither on forty requests nor on forty runs of
6
+ # ImageMagick: those on this Mac without a signature are signed, and those
7
+ # not on it yet are fetched, and signed as they land. One that cannot be
8
+ # fetched now is fetched when a page asks for it, and says why there.
9
+ class FetchPicturesAheadJob < ApplicationJob
10
+ # How many pictures are fetched, or signed, at a time.
11
+ ABREAST = 6
12
+
13
+ # gh has no token now: a page that asks for a picture says so. Those on
14
+ # this Mac need none, and are signed first.
15
+ discard_on Picture::Unavailable
16
+
17
+ # Each picture as a pair of its id and its kind.
18
+ def perform(pictures)
19
+ missing, held = pictures.map { Picture.new(*it) }.partition { !it.path.exist? }
20
+ abreast(held) { Picture.sign(it.asset) }
21
+ return if missing.empty?
22
+
23
+ token = Picture.token
24
+ abreast(missing) { it.fetch(token) }
25
+ end
26
+
27
+ private
28
+
29
+ # Each picture handed to the block, ABREAST at a time, while the job
30
+ # holds the app's executor.
31
+ def abreast(pictures, &each)
32
+ waiting = Queue.new(pictures).tap(&:close)
33
+ Array.new([ABREAST, pictures.size].min) { Thread.new { while (picture = waiting.pop) do each.call(picture) end } }.each(&:join)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The desk's own prune, once a day (Housekeeping): the Chrome profiles of
5
+ # fresh browsers, one for every scenario opened in one.
6
+ class PruneFreshProfilesJob < ApplicationJob
7
+ def perform = Browser.prune_fresh_profiles
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Reads the pull requests a # can name in a repository the desk has never
5
+ # read, so that the first # the owner types finds them there.
6
+ class ReadMentionsAheadJob < ApplicationJob
7
+ # The first # asks again, and says what GitHub answered.
8
+ discard_on Github::Unreachable
9
+
10
+ def perform(repository) = Mentions.new(Github.new(repository)).up_to_date
11
+ end
12
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Ticking decision boxes of one description: the one way the desk writes to
5
+ # GitHub, or to the file of a pull request nobody has opened yet. It is
6
+ # made to be shown before it is carried out: the boxes it will tick, and
7
+ # the command. Carried out, it reads the description again at that moment,
8
+ # ticks only lines that stand in it exactly once, changes nothing else, and
9
+ # checks that its home then holds what it sent.
10
+ class Acceptance
11
+ Refused = Class.new(StandardError)
12
+
13
+ attr_reader :number, :decisions
14
+
15
+ # `home` holds the description: Github, or Unopened.
16
+ def initialize(home:, number:, decisions:)
17
+ @home, @number, @decisions = home, number, decisions
18
+ end
19
+
20
+ FILE = "<the description, with these boxes ticked>"
21
+
22
+ # The command as the owner would type it, for showing first. The file is a
23
+ # temporary one, written at the moment it runs.
24
+ def command = @home.rewriting(number, "FILE").shelljoin.sub("FILE", FILE)
25
+
26
+ # `seen` is the digest of the description the owner was shown the command
27
+ # for: an edit since then, by anyone, stops it.
28
+ def carry_out(seen: nil)
29
+ before = @home.description_now(number)
30
+ seen.nil? || Description.new(before).digest == seen or raise Refused, "The description of ##{number} changed since you saw the command. Look at it again."
31
+ after = self.class.ticked(before, decisions.map(&:line))
32
+ @home.rewrite(number, after)
33
+ @home.description_now(number).strip == after.strip or raise Refused, "##{number} does not hold what was sent: read it before anything else"
34
+ decisions.map(&:lead)
35
+ end
36
+
37
+ # The text with each of these unticked lines ticked. Line endings are kept
38
+ # as they are; a line that is missing, or stands more than once, stops it.
39
+ def self.ticked(text, lines)
40
+ lines.reduce(text) do |ticking, line|
41
+ box = /^#{Regexp.escape(line)}(?=\r?$)/
42
+ ticking.scan(box).one? or raise Refused, "#{ticking.scan(box).size} lines, not one, read: #{line.truncate(90)}"
43
+ ticking.sub(box) { line.sub("- [ ]", "- [x]") }
44
+ end
45
+ end
46
+ end
47
+ end