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,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Every box under "Decisions for the owner" across a burst's pull requests,
5
+ # for one page. A box is in one state: open, answered otherwise, accepted
6
+ # from the desk, or ticked elsewhere. It waits for the owner when the owner
7
+ # starred it or the burst file names it. Nothing is sorted away: every box
8
+ # is listed under "all", and the counts of the states add up to it.
9
+ class Decisions
10
+ Changed = Class.new(StandardError)
11
+
12
+ # The ways to look at the boxes, with the words the page uses for them.
13
+ # The four states are also filters; "waiting" cuts across them.
14
+ FILTERS = {"waiting" => "Waiting for you", "open" => "Open", "answered" => "Answered otherwise", "accepted" => "Accepted",
15
+ "elsewhere" => "Ticked elsewhere", "all" => "All"}.freeze
16
+
17
+ # What the burst file says waits for the owner: a box, by the first words
18
+ # of its lead, or a question that has no box; and who said so.
19
+ Named = Data.define(:pr, :words, :question, :by) do
20
+ def initialize(words: nil, question: nil, by: nil, **) = super
21
+
22
+ def question? = question.present?
23
+
24
+ def names?(stop, decision) = pr == stop.number && words.present? && decision.plain_lead.start_with?(words)
25
+ end
26
+
27
+ # One box as GitHub renders it, and where it stands. `quote` is its lead
28
+ # as a note holds it, `answers` the owner's live changes to it, `asked`
29
+ # the live questions and remarks on it, which leave it open, `accepted`
30
+ # whether the owner ticked it from the desk, `named` the entries of the
31
+ # burst file that name it.
32
+ Box = Data.define(:stop, :decision, :html, :quote, :answers, :asked, :starred, :accepted, :named) do
33
+ delegate :position, :lead, :plain_lead, :alternative, to: :decision
34
+
35
+ # The owner's live notes on it, the newest last: notes are numbered as they are written.
36
+ def notes = (answers + asked).sort_by { [it.written_at, it.id[/\d+\z/].to_i] }
37
+
38
+ def state
39
+ if decision.ticked then accepted ? :accepted : :elsewhere
40
+ elsif answers.any? then :answered
41
+ else :open
42
+ end
43
+ end
44
+
45
+ def waiting? = decision.open? && (starred || named.any?)
46
+
47
+ # Open and waiting for no one: what one gesture may accept.
48
+ def routine? = state == :open && !waiting?
49
+
50
+ def to_param = "#{stop.number}-#{position}"
51
+ end
52
+
53
+ # What one pull request shows under a filter: its boxes, its questions
54
+ # that have no box, and the leads accepted from the desk that its
55
+ # description no longer holds.
56
+ Part = Data.define(:stop, :boxes, :questions, :rewritten) do
57
+ # Boxes in runs, each under the line the description leads it in with.
58
+ def groups(of = boxes) = of.slice_when { |one, other| one.decision.under != other.decision.under }.map { [it.first.decision.under, it] }
59
+
60
+ def blocking = boxes.select { it.decision.blocking? }
61
+
62
+ def non_blocking = boxes.reject { it.decision.blocking? }
63
+ end
64
+
65
+ # `marks` answers a pull request's number with the owner's marks on it,
66
+ # `renderings` with GitHub's HTML of its description. `pages` maps the
67
+ # address of a pull request on GitHub to the desk's page for it.
68
+ def initialize(route:, marks:, renderings:, named: [], pages: {})
69
+ @route, @marks, @renderings, @pages = route, marks, renderings, pages
70
+ @named = named.map { Named.new(**it) }
71
+ end
72
+
73
+ def boxes = @boxes ||= @route.flat_map { boxes_of(it) }
74
+
75
+ # The boxes a filter lists, and the one a page was just saved from
76
+ # (`keeping`, a box's `to_param`) wherever it now falls: it stays where the
77
+ # owner is, reading its new state, and goes from the list at the next visit.
78
+ def only(filter, keeping: nil) = boxes.select { shown?(it, filter) || it.to_param == keeping }
79
+
80
+ # What a filter lists: boxes, and under "waiting" the questions as well.
81
+ def count(filter) = only(filter).size + ((filter == "waiting") ? questions.size : 0)
82
+
83
+ # A filter the page was asked for, or the one worth opening on.
84
+ def filter_for(name)
85
+ return name if FILTERS.key?(name)
86
+
87
+ count("waiting").positive? ? "waiting" : "open"
88
+ end
89
+
90
+ # The boxes of one theme across the burst, in the route's order.
91
+ ThemeGroup = Data.define(:theme, :boxes)
92
+
93
+ # The boxes a filter lists, by the theme each names: the project's themes
94
+ # in its own order, then "other", where a box naming none or one the
95
+ # project does not list goes.
96
+ def by_theme(filter, keeping: nil)
97
+ themes = Theme.all
98
+ only(filter, keeping:).group_by { Theme.named(it.decision.theme_name, themes) || Theme::OTHER }
99
+ .sort_by { |theme, _| themes.index(theme) || themes.size }
100
+ .map { |theme, boxes| ThemeGroup.new(theme:, boxes:) }
101
+ end
102
+
103
+ def parts(filter, keeping: nil)
104
+ shown = only(filter, keeping:).group_by { it.stop.number }
105
+ @route.filter_map do |stop|
106
+ part = Part.new(stop:, boxes: shown.fetch(stop.number, []), questions: questions_of(stop, filter), rewritten: rewritten_of(stop, filter))
107
+ part if [part.boxes, part.questions, part.rewritten].any?(&:any?)
108
+ end
109
+ end
110
+
111
+ # Questions the burst file names that belong to no box, for the pull
112
+ # requests of the route: those are the ones a page lists.
113
+ def questions = @named.select(&:question?).select { @route.at(it.pr) }
114
+
115
+ # Entries of the burst file that name a box, and no box begins with their words.
116
+ def unmatched = @named.reject(&:question?).reject { |entry| boxes.any? { entry.names?(it.stop, it.decision) } }
117
+
118
+ # How many boxes of a pull request are in each state.
119
+ def tally(stop) = boxes.select { it.stop.number == stop.number }.map(&:state).tally
120
+
121
+ # The open boxes of a pull request that wait for no one.
122
+ def routine(stop) = boxes.select { it.stop.number == stop.number && it.routine? }
123
+
124
+ # The box a page named: it must still stand there, with that lead.
125
+ def box(number, position, lead)
126
+ boxes.find { it.stop.number == number && it.position == position && it.lead == lead } or raise Changed, gone(number)
127
+ end
128
+
129
+ # The unticked boxes of one pull request at these places, to tick.
130
+ def to_accept(number, positions)
131
+ chosen = boxes.select { it.stop.number == number && positions.include?(it.position) && it.decision.open? }
132
+ chosen.size == positions.uniq.size && chosen.any? or raise Changed, gone(number)
133
+ chosen
134
+ end
135
+
136
+ # The boxes of one pull request alone, which its own page shows.
137
+ def boxes_of(stop)
138
+ decisions = stop.description.decisions
139
+ marks = @marks.call(stop.number)
140
+ section = Rendering.new(@renderings.call(stop.number), pages: @pages).sections.find { it.heading == Description::DECISIONS }
141
+ items = section ? section.boxes : []
142
+ items = [] unless items.size == decisions.size # shortcut: if GitHub's list and the Markdown's disagree, show the Markdown; upgrade when one does
143
+ on_boxes = notes_on_boxes(marks.notes, stop.description)
144
+
145
+ decisions.zip(items).map do |decision, item|
146
+ answers, asked = on_boxes.fetch(decision, []).partition(&:change?)
147
+ Box.new(stop:, decision:, html: html_of(decision, item), quote: quote_of(decision, item, section), answers:, asked:,
148
+ starred: marks.starred.include?(decision.lead), accepted: marks.accepted.include?(decision.lead),
149
+ named: @named.select { it.names?(stop, decision) })
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def shown?(box, filter)
156
+ case filter
157
+ when "all" then true
158
+ when "waiting" then box.waiting?
159
+ else box.state.to_s == filter
160
+ end
161
+ end
162
+
163
+ def gone(number) = "The boxes of ##{number} are not as the page showed them: read it again."
164
+
165
+ def questions_of(stop, filter) = (filter == "waiting") ? questions.select { it.pr == stop.number } : []
166
+
167
+ # Leads the owner accepted from the desk that no box of the description has now.
168
+ def rewritten_of(stop, filter)
169
+ return [] unless %w[accepted all].include?(filter)
170
+
171
+ @marks.call(stop.number).accepted - stop.description.decisions.map(&:lead)
172
+ end
173
+
174
+ def html_of(decision, item)
175
+ return ERB::Util.html_escape(decision.text) unless item
176
+
177
+ item.css("input.task-list-item-checkbox").each(&:remove)
178
+ item.inner_html.strip
179
+ end
180
+
181
+ # The lead as the page has it, with the words around it from the section's
182
+ # text, so that a note finds this box and not another that repeats the words.
183
+ def quote_of(decision, item, section)
184
+ quote = Note::Quote.new(exact: decision.plain_lead, before: nil, after: nil, section: Description::DECISIONS)
185
+ spot = item && quote.locate(item.text) or return quote
186
+
187
+ start = item.xpath("preceding::text()").sum { it.content.length } + spot.begin
188
+ finish = start + spot.size
189
+ text = section.text
190
+ quote.with(exact: item.text[spot], before: text[[start - Note::NEAR, 0].max...start], after: text[finish, Note::NEAR])
191
+ end
192
+
193
+ # The owner's live notes on words of a box, by the decision they fall in.
194
+ def notes_on_boxes(notes, description)
195
+ on_boxes = notes.select { it.live? && (it.place in Note::Quote[section: Description::DECISIONS]) }
196
+ on_boxes.group_by { description.decision_quoted_by(it.place) }.except(nil)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A pull request's description, read for what the desk shows of it: the task
5
+ # boxes under "Decisions for the owner", the pictures it shows, its length.
6
+ class Description
7
+ DECISIONS = "Decisions for the owner"
8
+ NON_BLOCKING = /^###\s+Non-blocking\s*$/i
9
+ BOX = /^- \[([ xX])\] (.+)$/
10
+ PICTURE = %r{<img\b[^>]*\bsrc="(https://github\.com/user-attachments/assets/[^"]+)"}
11
+ # A picture shown, as an <img> or as Markdown's ![alt](address), and a
12
+ # decision's link to one by its alt text.
13
+ IMAGE = %r{<img\b[^>]*>|!\[[^\]]*\]\(https://github\.com/user-attachments/assets/[^)\s]+\)}
14
+ LINK = /\(#screenshot-([^)\s]+)\)/
15
+
16
+ # A picture the description shows: its alt text, which a decision links
17
+ # it by, and its asset on GitHub.
18
+ Shot = Data.define(:name, :asset)
19
+
20
+ # One box under "Decisions for the owner": its place among them, its
21
+ # words, whether it is ticked, the line the description leads its group
22
+ # in with ("Still open"), when it has one, and its grade: :blocking, or
23
+ # :non_blocking under a "### Non-blocking" heading. A description that
24
+ # grades none has every decision blocking. `row` is its line in the
25
+ # description, which a check names it by; `pictures` the shots it rests on.
26
+ Decision = Data.define(:position, :text, :ticked, :under, :grade, :row, :pictures) do
27
+ def initialize(grade: :blocking, row: nil, pictures: [], **) = super
28
+
29
+ def open? = !ticked
30
+
31
+ def blocking? = grade == :blocking
32
+
33
+ # The theme its hidden marker names, as the marker spells it; nil
34
+ # without one. Theme.named says which theme of the project that is.
35
+ def theme_name = text[Theme::MARKER, 1]
36
+
37
+ # Its words without theme markers: what its key is made from and what a
38
+ # choice compares, so naming or mending a theme never reopens a choice.
39
+ def bare_text = Theme.unmarked(text)
40
+
41
+ def key = text[/<!-- proctolog:decision ([\w.-]+) -->/, 1] || Digest::SHA256.hexdigest(bare_text)[0, 16]
42
+
43
+ # What was built, as the description puts it: the bold sentence, or the
44
+ # first one. It is how a decision is known again after an edit, so a
45
+ # colon does not end it: "Still open: after a refused send, ..." opens
46
+ # five boxes of one description.
47
+ def lead = bare_text[/\A\*\*(.+?)\*\*/, 1] || bare_text[/\A.+?[.?!](?=\s|\z)/] || bare_text
48
+
49
+ # The lead as the page reads it, with no Markdown around the words.
50
+ def plain_lead = plain(lead)
51
+
52
+ # Whether the box holds the words a quote is of.
53
+ def holds?(quote) = plain(text).include?(plain(quote.exact))
54
+
55
+ # Whether a quote could be about this decision: it quotes words of the
56
+ # box, or it quotes more than the lead and so takes in the whole lead.
57
+ # Other boxes may hold the same words: Description#decision_quoted_by tells.
58
+ def quoted_by?(quote) = holds?(quote) || plain(quote.exact).include?(plain_lead)
59
+
60
+ # Whether the quote is exactly this decision's lead.
61
+ def led_by?(quote) = plain_lead == plain(quote.exact)
62
+
63
+ # How many characters of the quote's surroundings stand round its words
64
+ # within this box; 0 when its words are not in the box.
65
+ def fit(quote)
66
+ around = Note::Quote.new(exact: plain(quote.exact), before: flat(quote.before), after: flat(quote.after), section: nil)
67
+ around.fit_in(flat(text)).to_i
68
+ end
69
+
70
+ def alternative = visible(text)[/Alternative: (.+)\z/, 1]
71
+
72
+ # The line as it stands in the description.
73
+ def line = "- [#{ticked ? "x" : " "}] #{text}"
74
+
75
+ private
76
+
77
+ def visible(words) = words.to_s.gsub(/<!--.*?-->/, "").strip
78
+ def plain(words) = visible(words).delete("*`").squish
79
+
80
+ # Like `plain`, for words with other text beside them: the spaces at the ends are part of what is compared.
81
+ def flat(words) = words.to_s.gsub(/<!--.*?-->/, "").delete("*`").gsub(/\s+/, " ")
82
+ end
83
+
84
+ def initialize(markdown)
85
+ @markdown = markdown.to_s.gsub("\r\n", "\n")
86
+ end
87
+
88
+ def to_s = @markdown
89
+
90
+ def words = @markdown.split.size
91
+
92
+ def pictures = @markdown.scan(PICTURE).flatten
93
+
94
+ # The pull requests it names, as "#1234".
95
+ def mentions = @markdown.scan(/#([1-9]\d{2,4})\b/).flatten.map(&:to_i).uniq
96
+
97
+ def digest = "sha256:#{Digest::SHA256.hexdigest(@markdown)}"
98
+
99
+ # The boxes after "### Non-blocking" are non-blocking, and the line that
100
+ # leads a group in starts again under it.
101
+ def decisions
102
+ under, grade = nil, :blocking
103
+ rows(DECISIONS).each_with_object([]) do |(line, row), found|
104
+ case line
105
+ when BOX then found << Decision.new(position: found.size, text: $2, ticked: $1 != " ", under:, grade:, row:, pictures: pictures_in($2))
106
+ when NON_BLOCKING then under, grade = nil, :non_blocking
107
+ when /^\S/ then under = line.strip.delete_suffix(":").delete("*")
108
+ end
109
+ end
110
+ end
111
+
112
+ # Every picture it shows, in its order.
113
+ def shots
114
+ @shots ||= @markdown.scan(IMAGE).filter_map do |shown|
115
+ asset = shown[%r{user-attachments/assets/(#{Picture::ASSET})}, 1] or next
116
+ Shot.new(name: shown[/\balt="([^"]*)"/, 1] || shown[/\A!\[([^\]]*)\]/, 1], asset:)
117
+ end
118
+ end
119
+
120
+ # The names of the pictures some line of it links: a decision, or a step
121
+ # of a scenario.
122
+ def linked = @markdown.scan(LINK).flatten.to_set
123
+
124
+ # The pictures a decision's words rest on: those its links name, a pair
125
+ # as both its halves, and those shown in its own line, each once.
126
+ def pictures_in(text) = (text.scan(LINK).flatten.flat_map { named(it) } + Description.new(text).shots).uniq
127
+
128
+ # A picture by its alt text, or a pair's halves when no picture has the
129
+ # name itself, as the desk's page pairs them.
130
+ def named(name)
131
+ halves = %w[before after].filter_map { |half| shots.find { it.name == "#{name}-#{half}" } }
132
+ shots.select { it.name == name }.presence || ((halves.size == 2) ? halves : [])
133
+ end
134
+
135
+ # The lines of a section under its heading, each with its line number in
136
+ # the description; none when it has no such section.
137
+ def rows(heading)
138
+ first = @markdown.lines.index { it[/\A## (.+)$/, 1]&.strip == heading } or return []
139
+ section(heading).lines.drop(1).each.with_index(first + 2).to_a
140
+ end
141
+
142
+ # The decision a quote is about: the box whose lead it is, else the only
143
+ # box that holds its words (or, when none does, whose lead it takes in).
144
+ # Of several, the one whose surroundings are those the quote saw, and none
145
+ # when they cannot tell the boxes apart, since "Alternative:" is in every box.
146
+ def decision_quoted_by(quote)
147
+ holding = decisions.select { it.holds?(quote) }.presence || decisions.select { it.quoted_by?(quote) }
148
+ candidates = holding.select { it.led_by?(quote) }.presence || holding
149
+ candidates.one? ? candidates.first : apart(candidates, quote)
150
+ end
151
+
152
+ def section(heading) = sections.fetch(heading, "")
153
+
154
+ private
155
+
156
+ # The box among several whose surroundings plainly are the quote's own.
157
+ def apart(candidates, quote)
158
+ fits = candidates.index_with { it.fit(quote) }
159
+ best, next_best = fits.values.max(2)
160
+ fits.key(best) if best.to_i >= Note::CONFIRMING && best > next_best
161
+ end
162
+
163
+ def sections = @sections ||= @markdown.split(/^(?=## )/).index_by { it[/\A## (.+)$/, 1]&.strip }
164
+ end
165
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Proctolog
8
+ # Whether two pictures show the same pixels, and where they do not: the
9
+ # boxes of the changed regions, a mask of every changed pixel, and that
10
+ # mask's pixel signature, by which the same change is known in two pairs.
11
+ # Pictures of different sizes are compared from the top left, and say so.
12
+ # Plain Ruby over ImageMagick, with no Rails in it, because the capture
13
+ # kit's compare.rb loads it without the desk. Each answer is kept in the
14
+ # cache root it is given, under the two pictures' pixel signatures.
15
+ class Difference
16
+ # A changed region, in pixels from the top left.
17
+ Box = Data.define(:left, :top, :width, :height) do
18
+ def right = left + width
19
+ def bottom = top + height
20
+
21
+ def near?(other, gap) = left - gap <= other.right && other.left - gap <= right && top - gap <= other.bottom && other.top - gap <= bottom
22
+
23
+ def +(other)
24
+ left, top = [self.left, other.left].min, [self.top, other.top].min
25
+ Box.new(left:, top:, width: [right, other.right].max - left, height: [bottom, other.bottom].max - top)
26
+ end
27
+ end
28
+
29
+ # What a comparison found. One that could not be made, for want of
30
+ # ImageMagick or of a picture it can read, is not compared.
31
+ Result = Data.define(:compared, :same, :boxes, :mask, :mask_signature, :sizes) do
32
+ def self.from(stored) = new(**stored, boxes: stored.fetch(:boxes).map { Box.new(**it) }, mask: stored[:mask]&.then { Pathname(it) })
33
+
34
+ def compared? = compared
35
+ def same? = same
36
+ def different_sizes? = sizes.uniq.size > 1
37
+
38
+ def to_h = super.merge(boxes: boxes.map(&:to_h), mask: mask&.to_s)
39
+ end
40
+
41
+ NOT_COMPARED = Result.new(compared: false, same: nil, boxes: [], mask: nil, mask_signature: nil, sizes: [])
42
+
43
+ # Changes nearer than this, in pixels, are one region: the letters of a
44
+ # changed word are one place to look, not one each.
45
+ GAP = 12
46
+
47
+ def self.between(before, after, cache:) = new(before, after, cache:).result
48
+
49
+ # A picture's pixel signature: a re-encoding or a change of metadata
50
+ # leaves it as it was. Kept by the file's bytes, so it is asked once.
51
+ def self.signature(file, cache:)
52
+ kept = Pathname(cache).join("signatures", Digest::SHA256.file(file).hexdigest)
53
+ return kept.read if kept.exist?
54
+
55
+ identify(file, "%#").tap { |signature| write(kept, signature) }
56
+ end
57
+
58
+ def self.identify(file, format)
59
+ output, status = Open3.capture2("magick", "identify", "-format", format, "#{file}[0]", err: File::NULL)
60
+ status.success? ? output.strip : raise(Unreadable, file.to_s)
61
+ end
62
+
63
+ # Beside the file and moved over it, so a reader never meets half of one.
64
+ def self.write(file, text)
65
+ file.dirname.mkpath
66
+ file.sub_ext("#{file.extname}.part").tap { it.write(text) }.rename(file)
67
+ end
68
+
69
+ Unreadable = Class.new(StandardError)
70
+
71
+ def initialize(before, after, cache:)
72
+ @before, @after, @cache = Pathname(before), Pathname(after), Pathname(cache).join("differences")
73
+ end
74
+
75
+ def result
76
+ signatures = [@before, @after].map { self.class.signature(it, cache: @cache.dirname) }
77
+ kept = @cache.join("#{signatures.join("-")}.json")
78
+ return Result.from(JSON.parse(kept.read, symbolize_names: true)) if kept.exist?
79
+
80
+ compare(signatures, mask: kept.sub_ext(".png")).tap { self.class.write(kept, it.to_h.to_json) }
81
+ rescue Errno::ENOENT, Unreadable
82
+ NOT_COMPARED
83
+ end
84
+
85
+ private
86
+
87
+ def compare(signatures, mask:)
88
+ sizes = [@before, @after].map { self.class.identify(it, "%w %h").split.map(&:to_i) }
89
+ return Result.new(compared: true, same: true, boxes: [], mask: nil, mask_signature: nil, sizes:) if signatures.uniq.size == 1 && sizes.uniq.size == 1
90
+
91
+ draw_mask(sizes, mask)
92
+ boxes = regions(mask)
93
+ Result.new(compared: true, same: boxes.empty?, boxes:, mask:, mask_signature: self.class.identify(mask, "%#"), sizes:)
94
+ end
95
+
96
+ # White where any channel differs, black elsewhere, on a canvas as large
97
+ # as the larger picture: what one picture has and the other lacks counts.
98
+ def draw_mask(sizes, mask)
99
+ canvas = sizes.transpose.map(&:max).join("x")
100
+ part = mask.sub_ext(".png.part")
101
+ mask.dirname.mkpath
102
+ run("magick", *laid(@before, canvas), *laid(@after, canvas), "-compose", "difference", "-composite",
103
+ "-separate", "-evaluate-sequence", "max", "-threshold", "0", "-depth", "8", "PNG8:#{part}")
104
+ part.rename(mask)
105
+ end
106
+
107
+ def laid(file, canvas) = ["(", "#{file}[0]", "-alpha", "off", "-background", "black", "-gravity", "NorthWest", "-extent", canvas, ")"]
108
+
109
+ # A run of changed pixels as ImageMagick lists it: its box, and white,
110
+ # which a palette names in sRGB and a grey mask in grey.
111
+ CHANGED = /^\s*\d+: (\d+)x(\d+)\+(\d+)\+(\d+) \S+ \d+ (?:gray\(255\)|srgb\(255,255,255\)|white)\s*$/
112
+
113
+ # The changed regions, each a run of changed pixels with those near it.
114
+ # ImageMagick lists the runs on one stream or the other, by its build.
115
+ def regions(mask)
116
+ listed, status = Open3.capture2e("magick", mask.to_s, "-define", "connected-components:verbose=true", "-connected-components", "8", "null:")
117
+ status.success? or raise Unreadable, mask.to_s
118
+ found = listed.scan(CHANGED)
119
+ .map { |width, height, left, top| Box.new(left: left.to_i, top: top.to_i, width: width.to_i, height: height.to_i) }
120
+ merged(found).sort_by { [it.top, it.left] }
121
+ end
122
+
123
+ def merged(boxes)
124
+ boxes.each_with_object([]) do |box, regions|
125
+ while (near = regions.find { it.near?(box, GAP) })
126
+ regions.delete(near)
127
+ box += near
128
+ end
129
+ regions << box
130
+ end
131
+ end
132
+
133
+ def run(*command)
134
+ output, status = Open3.capture2(*command, err: File::NULL)
135
+ status.success? ? output : raise(Unreadable, command.join(" "))
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+
6
+ module Proctolog
7
+ # What this Mac and this checkout give the desk, a line each with the fix.
8
+ # A need that is missing keeps the desk from working; a nicety that is
9
+ # missing only turns its feature off. `proctolog doctor` prints it.
10
+ class Doctor
11
+ MARKS = {fine: "ok", missing: "!!", off: "--"}.freeze
12
+
13
+ Finding = Data.define(:state, :line) do
14
+ def missing? = state == :missing
15
+ def to_s = "#{MARKS.fetch(state)} #{line}"
16
+ end
17
+
18
+ NEEDS = %i[ruby gh launcher checkout repository bursts].freeze
19
+ NICETIES = %i[release skills chrome diffle imagemagick ffmpeg app port].freeze
20
+
21
+ # project is nil when the directory is no Git checkout.
22
+ def initialize(project, home: Dir.home, path: ENV.fetch("PATH", ""))
23
+ @project, @home, @path = project, Pathname(home), path.split(File::PATH_SEPARATOR)
24
+ end
25
+
26
+ def findings = @findings ||= (NEEDS + NICETIES).filter_map { send(it) }
27
+
28
+ def fine? = findings.none?(&:missing?)
29
+
30
+ def to_s = findings.join("\n")
31
+
32
+ private
33
+
34
+ def ruby = fine("Ruby #{RUBY_VERSION}")
35
+
36
+ def gh
37
+ return missing("gh is not on the PATH: brew install gh, then gh auth login") unless command?("gh")
38
+
39
+ succeeds?("gh", "auth", "status") ? fine("gh, signed in") : missing("gh is not signed in: gh auth login")
40
+ end
41
+
42
+ # RubyGems' launcher for a gem, or the one bin/install leaves in ~/.local/bin for a checkout.
43
+ def launcher
44
+ if (folder = @path.find { File.executable?(File.join(it, "proctolog")) }) then fine("proctolog in #{folder}")
45
+ elsif (bin = @home.join(".local/bin")).join("proctolog").exist? then missing("#{bin} is not on the PATH: add it in ~/.zshrc")
46
+ else missing("PRoctolog is not installed: gem install proctolog, or ruby bin/install in a checkout of it")
47
+ end
48
+ end
49
+
50
+ def checkout = @project ? fine("Reviewing #{@project.root}") : missing("Not in a Git checkout: cd into the one you review, or pass --project")
51
+
52
+ # Asked only of a gh that is there: a missing one has its own line.
53
+ def repository
54
+ return unless @project && command?("gh")
55
+ return missing("#{@project.root}'s origin is not on GitHub") unless (name = @project.github_repository)
56
+
57
+ succeeds?("gh", "repo", "view", name, "--json", "name") ? fine("GitHub answers for #{name}") : missing("gh cannot read #{name}: ask for access to it")
58
+ end
59
+
60
+ def bursts
61
+ return unless @project
62
+
63
+ files = Burst.files
64
+ return missing("No bursts to open: start one with proctolog burst new NAME --pr NUMBER") if files.empty?
65
+
66
+ fine("Bursts: #{files.map { it.basename(".yml") }.join(", ")}")
67
+ end
68
+
69
+ # The skills an agent needs, by what the line calls them.
70
+ SKILLS = {"proctolog-pr-writing" => "PR-writing", "proctolog-handover" => "handover"}.freeze
71
+
72
+ def skills
73
+ folders = %w[.claude/skills .agents/skills].select { |folder| SKILLS.keys.all? { @home.join(folder, it).exist? } }
74
+ missing = SKILLS.reject { |skill, _| %w[.claude/skills .agents/skills].any? { @home.join(it, skill).exist? } }.values
75
+ if missing.any? then off("The #{missing.to_sentence} #{"skill".pluralize(missing.size)} #{missing.one? ? "is" : "are"} not linked: proctolog install")
76
+ else fine("The #{SKILLS.values.to_sentence} skills, in ~/#{folders.join(" and ~/")}")
77
+ end
78
+ end
79
+
80
+ # As the desk last asked RubyGems: a doctor waits on no network for it.
81
+ def release = (newer = Release.newer) ? off(newer.news) : fine("PRoctolog #{Release.running}")
82
+
83
+ def chrome
84
+ require "ferrum"
85
+ Ferrum::Browser::Options::Chrome.options.detect_path ? fine("Chrome, for browser tests and scenarios") : off("No Chrome: browser tests skip and scenarios cannot open")
86
+ end
87
+
88
+ def diffle = command?("diffle") ? fine("Diffle, for d on a pull request") : off("No Diffle, so d opens no code: brew install diffle")
89
+
90
+ def imagemagick = command?("magick") ? fine("ImageMagick, for comparing pictures") : off("No ImageMagick, so pictures are not compared: brew install imagemagick")
91
+
92
+ def ffmpeg = command?("ffmpeg") ? fine("ffmpeg, for the stills of films") : off("No ffmpeg, so films have no stills: brew install ffmpeg")
93
+
94
+ # Each command app.yml names runs from a program the checkout or the PATH holds.
95
+ def app
96
+ return unless @project
97
+
98
+ app = App.new(@project.definitions.join(App::FILE))
99
+ return off("No .proctolog/app.yml, so scenario buttons cannot run the app: see docs/manual/app.md") unless app.described?
100
+
101
+ lacking = app.programs.reject { runnable?(it) }
102
+ return missing("app.yml runs #{lacking.to_sentence}, which neither the checkout nor the PATH holds") if lacking.any?
103
+
104
+ fine("app.yml names #{app.parts.to_sentence}")
105
+ end
106
+
107
+ def port = answers?(4040) ? off("Port 4040 answers already: a desk may be open there, or serve --port 4041") : fine("Port 4040 is free")
108
+
109
+ def fine(line) = Finding.new(:fine, line)
110
+ def missing(line) = Finding.new(:missing, line)
111
+ def off(line) = Finding.new(:off, line)
112
+
113
+ def command?(name) = @path.any? { File.executable?(File.join(it, name)) }
114
+
115
+ # A program app.yml names: a path from the checkout's root, or a command on the PATH.
116
+ def runnable?(program) = program.include?("/") ? @project.root.join(program).executable? : command?(program)
117
+
118
+ def succeeds?(*command) = Open3.capture2e({"PATH" => @path.join(File::PATH_SEPARATOR)}, *command).last.success?
119
+
120
+ def answers?(port)
121
+ Socket.tcp("localhost", port, connect_timeout: 1, &:close)
122
+ true
123
+ rescue SystemCallError
124
+ false
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A picture an agent's answer rests on: a file on this Mac that the answer's
5
+ # evidence names, from the root or from the checkout under review. The desk
6
+ # gives out a file only when an answer of the burst names it, and only a
7
+ # picture, so its address reads nothing else the Mac holds.
8
+ class Evidence
9
+ KINDS = %w[png jpg jpeg gif webp].freeze
10
+
11
+ def self.picture?(path) = KINDS.include?(File.extname(path.to_s).delete(".").downcase)
12
+
13
+ def initialize(burst) = @burst = burst
14
+
15
+ # The file named, or nil when no answer names it, it is no picture, or it is not on this Mac.
16
+ def picture(path)
17
+ return unless self.class.picture?(path) && named.include?(path)
18
+
19
+ Project.current.root.join(path).then { it if it.file? }
20
+ end
21
+
22
+ private
23
+
24
+ def named = answers.flat_map { it["results"] }.flat_map { Array(it["evidence"]) }.filter_map { it["path"] }.to_set
25
+
26
+ # Those the desk keeps with the choices, and those waiting in the folder of responses.
27
+ def answers = [*Array(@burst.choices.document&.dig("responses")), *ResponseFiles.new(@burst).all]
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Something went wrong that the owner can mend or should know of, with a
5
+ # sentence for the page it happened on. The application controller says it.
6
+ Failure = Class.new(StandardError)
7
+ end