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,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A description as GitHub rendered it, made ready for the desk's page: cut
5
+ # into sections at each <h2>, its pictures served from this Mac, its links to
6
+ # pull requests of the burst pointing at the desk, and the owner's notes
7
+ # drawn where they belong.
8
+ #
9
+ # The words of a section are those of GitHub's nodes alone: a note quotes
10
+ # `section.text`, and the page's script measures the same text in the browser.
11
+ # What the desk adds holds no words of a section: marks and boxes on pictures
12
+ # have no text of their own, and an element it puts in (a "+", a form, the
13
+ # notes under a box) carries `data-desk`, which both sides skip.
14
+ class Rendering
15
+ # A picture's lasting identity is the UUID inside its signed address.
16
+ UPLOAD = %r{/\d+-(?<asset>\h{8}(?:-\h{4}){3}-\h{12})\.(?<extension>\w+)\?}
17
+
18
+ # A picture GitHub never held, an unopened pull request's, at the address
19
+ # the desk already serves it from.
20
+ SERVED = %r{\A/pictures/(?<asset>\h{8}(?:-\h{4}){3}-\h{12})\.(?<extension>\w+)\z}
21
+
22
+ # Each picture an HTML shows, by its id and its kind.
23
+ def self.pictures(html) = html.to_s.scan(/src="([^"]*)"/).flatten.filter_map { (it.match(UPLOAD) || it.match(SERVED))&.captures }.uniq
24
+
25
+ # A half of a before/after pair, by its picture's name.
26
+ PAIR = /\A(?<name>.+)-(?<half>before|after)\z/
27
+
28
+ # One part of a description: what stands under an <h2>, or before the first.
29
+ class Section
30
+ attr_reader :heading, :body, :notes
31
+
32
+ def initialize(heading, body)
33
+ @heading, @body, @notes = heading, body, []
34
+ end
35
+
36
+ def text = nodes.map(&:content).join
37
+
38
+ def words = text.split.size
39
+
40
+ # Each picture by itself: one alone, or each half of a pair.
41
+ def figures = body.css("figure.picture:not(.pair), figure.pair > .half")
42
+
43
+ def open_decisions = body.css("li.task-list-item").count { !it.at_css("input[checked]") }
44
+
45
+ # The boxes of a task list, not the lists inside them.
46
+ def boxes = body.css("li.task-list-item").reject { it.ancestors("li").any? }
47
+
48
+ # The decisions as the desk shows them: each box numbered, and the
49
+ # non-blocking ones folded under their heading.
50
+ def lay_out_decisions
51
+ number_boxes
52
+ fold_non_blocking
53
+ end
54
+
55
+ # Named as Description#decisions numbers them, so that a link can point at one.
56
+ def number_boxes = boxes.each_with_index { |box, position| box["id"] = "decision-#{position}" }
57
+
58
+ # Adds at the end of each box what the block answers for its place, as
59
+ # the desk's own: the words of the section stay as they were.
60
+ def furnish_boxes = boxes.each_with_index { |box, position| box.add_child(yield(position).to_s.strip) }
61
+
62
+ # The non-blocking decisions folded away under their own heading:
63
+ # GitHub's "Non-blocking" heading becomes the fold's summary, and what
64
+ # follows it in the section goes inside. The words of the section, and
65
+ # their order, stay as they were; the count beside the heading is the
66
+ # desk's.
67
+ def fold_non_blocking
68
+ heading = body.element_children.find { it.name == "h3" && it.text.strip.casecmp?("Non-blocking") } or return
69
+ fold = element("details", "class" => "non-blocking", "data-non-blocking" => "")
70
+ summary = element("summary", {})
71
+ heading.children.each { summary << it }
72
+ summary << element("span", "class" => "count", "data-desk" => "", "data-non-blocking-count" => "")
73
+ heading.xpath("following-sibling::node()").each { fold << it }
74
+ fold.prepend_child(summary)
75
+ heading.replace(fold)
76
+ end
77
+
78
+ def noted? = notes.any?(&:live?)
79
+
80
+ def html = body.to_html
81
+
82
+ # Wraps the slice of each text node that a range of the text covers, so
83
+ # that words crossing <strong> or <code> are marked in several pieces.
84
+ def mark(range, note)
85
+ pieces = slices(range).map { |node, slice| wrap(node, slice, note) }
86
+ return false if pieces.empty?
87
+
88
+ pieces.first["id"] = "note-#{note.id}"
89
+ notes << note
90
+ true
91
+ end
92
+
93
+ # A box on a picture, in percent of its size; a box with no width and no
94
+ # height is a pin.
95
+ def draw(figure, note, box)
96
+ x, y, width, height = box.map { (it * 100).round(2) }
97
+ pin = width.zero? && height.zero?
98
+ style = {left: x, top: y, width:, height:}.then { pin ? it.slice(:left, :top) : it }.map { |property, percent| "#{property}: #{percent}%" }.join("; ")
99
+ figure.add_child(element("span", tags(note).merge("class" => pin ? "region pin" : "region", "id" => "note-#{note.id}", "style" => style)))
100
+ notes << note
101
+ end
102
+
103
+ private
104
+
105
+ def document = body.document
106
+
107
+ def element(name, attributes) = document.create_element(name, attributes)
108
+
109
+ def tags(note) = {"data-note" => note.id, "data-kind" => note.kind, "data-state" => note.state}
110
+
111
+ # The text nodes that are GitHub's, in order.
112
+ def nodes = body.xpath(".//text()[not(ancestor::*[@data-desk])]")
113
+
114
+ def slices(range)
115
+ at = 0
116
+ nodes.filter_map do |node|
117
+ from, to = at, (at += node.content.length)
118
+ inside = ([range.begin, from].max - from)...([range.end, to].min - from)
119
+ [node, inside] if inside.size.positive? && node.content[inside].present?
120
+ end
121
+ end
122
+
123
+ def wrap(node, slice, note)
124
+ text = node.content
125
+ element("mark", tags(note).merge("class" => "note")).tap do |mark|
126
+ mark.add_child(document.create_text_node(text[slice]))
127
+ node.replace(mark)
128
+ mark.add_previous_sibling(document.create_text_node(text[...slice.begin])) unless slice.begin.zero?
129
+ mark.add_next_sibling(document.create_text_node(text[slice.end..])) unless slice.end == text.length
130
+ end
131
+ end
132
+ end
133
+
134
+ # What was drawn and what was not: `margin` holds the notes found a place,
135
+ # in the order of the page; `about` those that are about the whole pull
136
+ # request; `lost` those still waiting on someone whose words or picture are
137
+ # gone; `set_aside` the rest, withdrawn or confirmed and out of place.
138
+ attr_reader :sections, :margin, :about, :lost, :set_aside
139
+
140
+ # `pages` maps the address of a pull request on GitHub to the desk's page
141
+ # for it. `linked`, the names of the pictures some line links, folds away
142
+ # those none does; without it every picture stands.
143
+ def initialize(html, notes: [], pages: {}, linked: nil)
144
+ fragment = Nokogiri::HTML5.fragment(html.to_s)
145
+ @retaken = Set.new
146
+ serve_pictures_from_here(fragment)
147
+ pair_pictures(fragment)
148
+ fold_unlinked(fragment, linked) if linked
149
+ point_links_at_the_desk(fragment, pages)
150
+ @sections = sections_of(fragment)
151
+ lay(notes)
152
+ end
153
+
154
+ # A picture was retaken since the note was drawn on it: only its name is the same.
155
+ def retaken?(note) = @retaken.include?(note.id)
156
+
157
+ private
158
+
159
+ def serve_pictures_from_here(fragment)
160
+ fragment.css("img").each do |img|
161
+ upload = img["src"].to_s.then { it.match(UPLOAD) || it.match(SERVED) } or next
162
+ link = img.parent if img.parent.name == "a" && img.parent.element_children.one? && img.parent.text.blank?
163
+ (link || img).replace(figure_of(fragment.document, img["alt"], **upload.named_captures.symbolize_keys))
164
+ end
165
+ end
166
+
167
+ def figure_of(document, name, asset:, extension:)
168
+ document.create_element("figure", "id" => "screenshot-#{name}", "class" => "picture", "data-picture" => name, "data-asset" => asset).tap do |figure|
169
+ figure.add_child(document.create_element("img", "src" => "/pictures/#{asset}.#{extension}", "alt" => name, "loading" => "lazy", "draggable" => "false"))
170
+ end
171
+ end
172
+
173
+ # A picture named NAME-before and one named NAME-after are one picture of a
174
+ # change: a figure where the first stood, both halves side by side, which
175
+ # a decision links as #screenshot-NAME. Each half keeps its name and its
176
+ # asset, so a note is drawn on the half it was made on. A half without its
177
+ # other half, or a name another picture has too, stays as it was.
178
+ def pair_pictures(fragment)
179
+ figures = fragment.css("figure.picture")
180
+ named = figures.group_by { it["data-picture"] }
181
+ figures.filter_map { it["data-picture"].to_s[PAIR, :name] }.uniq.each do |name|
182
+ before, after = named["#{name}-before"], named["#{name}-after"]
183
+ next unless before&.one? && after&.one? && !named.key?(name)
184
+
185
+ before.first.replace(pair_of(fragment.document, name, before.first, after.first))
186
+ after.first.remove
187
+ end
188
+ end
189
+
190
+ def pair_of(document, name, *halves)
191
+ document.create_element("figure", "id" => "screenshot-#{name}", "class" => "picture pair", "data-picture" => name).tap do |pair|
192
+ halves.each do |figure|
193
+ said = figure["data-picture"][PAIR, :half]
194
+ half = document.create_element("span", "class" => "half", "id" => figure["id"], "data-half" => said, "data-picture" => figure["data-picture"], "data-asset" => figure["data-asset"])
195
+ half << figure.at_css("img") << document.create_element("span", said, "class" => "said", "data-desk" => "")
196
+ pair << half
197
+ end
198
+ end
199
+ end
200
+
201
+ # Each run of pictures that nothing links, each with the caption under
202
+ # it, folds where it stood into one "More pictures (N)", which a reader
203
+ # opens like a section. A picture a line links stands, and ends a run.
204
+ def fold_unlinked(fragment, linked)
205
+ runs = [[]]
206
+ # A paragraph a picture was parsed out of stays empty, and is no block.
207
+ fragment.element_children.reject { it.name == "p" && it.children.all?(&:blank?) }.each do |node|
208
+ if pictures?(node) && !linked?(node, linked) then runs.last << node
209
+ elsif caption?(node) && pictures?(runs.last.last) then runs.last << node
210
+ elsif runs.last.any? then runs << []
211
+ end
212
+ end
213
+ runs.reject(&:empty?).each { fold(fragment.document, it) }
214
+ end
215
+
216
+ def fold(document, run)
217
+ count = run.sum { figures_of(it).size }
218
+ folded = document.create_element("details", "class" => "more-pictures", "data-more-pictures" => "")
219
+ folded << (document.create_element("summary") << document.create_element("span", "More pictures (#{count})", "data-desk" => ""))
220
+ # Everything from its first block to its last, the space between them
221
+ # too, so the words of the section read as they did.
222
+ spanned = [run.first].tap { |nodes| nodes << nodes.last.next_sibling until nodes.last == run.last }
223
+ run.first.add_previous_sibling(folded)
224
+ spanned.each { folded << it }
225
+ end
226
+
227
+ # A block that is pictures and nothing more: a figure, or a paragraph of them.
228
+ def pictures?(node) = !node.nil? && (picture?(node) || (node.at_css("figure.picture") && node.xpath(".//text()[not(ancestor::figure)]").all?(&:blank?)))
229
+
230
+ # Told by its own name and class: Nokogiri's matches? searches the whole
231
+ # document for every node it is asked about, and a description has hundreds.
232
+ def picture?(node) = node.name == "figure" && node.classes.include?("picture")
233
+
234
+ # A block's pictures: the block itself when it is one, else those in it.
235
+ def figures_of(node) = picture?(node) ? [node] : node.css("figure.picture")
236
+
237
+ # The words under a picture: a paragraph with no picture in it.
238
+ def caption?(node) = node.name == "p" && node.at_css("figure.picture").nil?
239
+
240
+ # Whether a line links one of its pictures, or a half of one of its pairs.
241
+ def linked?(node, linked)
242
+ figures_of(node).flat_map { [it["data-picture"], *it.css(".half").map { |half| half["data-picture"] }] }.any? { linked.include?(it) }
243
+ end
244
+
245
+ def point_links_at_the_desk(fragment, pages)
246
+ fragment.css("a[href]").each { |link| link["href"] = pages[link["href"]] if pages.key?(link["href"]) }
247
+ end
248
+
249
+ def sections_of(fragment)
250
+ fragment.children.slice_before { it.name == "h2" }.filter_map do |nodes|
251
+ heading = nodes.shift.text.strip if nodes.first.name == "h2"
252
+ next if heading.nil? && nodes.all?(&:blank?)
253
+
254
+ body = fragment.document.create_element("div", "data-quotable" => "", "data-section" => heading.to_s)
255
+ nodes.each { body << it }
256
+ Section.new(heading, body).tap { it.lay_out_decisions if heading == Description::DECISIONS }
257
+ end
258
+ end
259
+
260
+ def lay(notes)
261
+ active = notes.reject(&:withdrawn?)
262
+ @about = active.reject { anchored?(it) }
263
+ drawn, unplaced = active.select { anchored?(it) }.partition { draw(it) }
264
+ @lost = unplaced.select(&:live?)
265
+ @set_aside = notes - @about - drawn - @lost
266
+ @margin = sections.flat_map { it.body.css("[data-note]") }.pluck("data-note").uniq.filter_map { |id| drawn.find { it.id == id } }
267
+ end
268
+
269
+ def anchored?(note) = (note.place in Note::Quote | Note::Region)
270
+
271
+ def draw(note)
272
+ case note.place
273
+ in Note::Quote => quote then mark_quote(note, quote)
274
+ in Note::Region => region then draw_region(note, region)
275
+ end
276
+ end
277
+
278
+ # In the section it was written under; in another only when the words are
279
+ # distinctive enough to be the evidence themselves, as a short phrase
280
+ # standing elsewhere is likelier another sentence than the one moved.
281
+ def mark_quote(note, quote)
282
+ own, others = sections.partition { it.heading == quote.section }
283
+ (quote.distinctive? ? own + others : own).any? do |section|
284
+ range = quote.locate(section.text)
285
+ range && section.mark(range, note)
286
+ end
287
+ end
288
+
289
+ # On the picture it was drawn on, or on the one of the same name when
290
+ # that was retaken, if only one is.
291
+ def draw_region(note, region)
292
+ pictures = sections.flat_map { |section| section.figures.map { |figure| [section, figure] } }
293
+ holder = pictures.find { |_, figure| figure["data-asset"] == region.asset } || retaken(note, pictures.select { |_, figure| figure["data-picture"] == region.picture })
294
+ holder&.then { |section, figure| section.draw(figure, note, region.box) }
295
+ end
296
+
297
+ # The picture a note was drawn on is gone: its name says which one is
298
+ # meant only when no other picture has it.
299
+ def retaken(note, named)
300
+ named.first.tap { @retaken << note.id } if named.one?
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What agents wrote back about a hand-over: YAML files under reports/, each
5
+ # a list of entries naming a note, the commit that holds the change and a
6
+ # reply. Taking them in moves an open note to "addressed" with the newest
7
+ # entry that names it, and the owner then confirms it.
8
+ #
9
+ # A note the owner reopened carries the answer it was reopened against. The
10
+ # same answer does not address it a second time: only a different commit or
11
+ # a different reply does.
12
+ class Reports
13
+ NOTE = /\A(?<of>sitting|\d+)-\d+\z/
14
+
15
+ Entry = Data.define(:note, :commit, :reply, :report) do
16
+ def answer = {"commit" => commit, "text" => reply}.compact
17
+ end
18
+
19
+ def initialize(burst)
20
+ @burst = burst
21
+ end
22
+
23
+ # Answers the notes it moved.
24
+ def take_in = newest_for_each_note.filter_map { address(it) }
25
+
26
+ # Entries that name no note of this burst: a mistyped id, said on a page
27
+ # rather than dropped.
28
+ def unmatched = entries.reject { note_of(it) }
29
+
30
+ # Reports that could not be read as a list of entries.
31
+ def unreadable = files.reject { list_in(it) }
32
+
33
+ private
34
+
35
+ def address(entry)
36
+ note = note_of(entry)
37
+ return unless note&.open? && note.reply&.slice("commit", "text") != entry.answer
38
+
39
+ marks_of(entry).move(note.id, to: "addressed", reply: entry.answer.merge("report" => entry.report))
40
+ end
41
+
42
+ def newest_for_each_note = entries.group_by(&:note).values.map(&:last)
43
+
44
+ # The marks a note's id points into: a pull request's, or the sitting's.
45
+ def marks_of(entry)
46
+ NOTE.match(entry.note)&.[](:of)&.then { @burst.marks((it == "sitting") ? it : it.to_i) }
47
+ end
48
+
49
+ def note_of(entry) = marks_of(entry)&.note(entry.note)
50
+
51
+ # Oldest report first, and each report's entries in its own order.
52
+ def entries
53
+ files.flat_map do |file|
54
+ (list_in(file) || []).grep(Hash).map { Entry.new(note: it["note"].to_s, commit: it["commit"], reply: it["reply"], report: file.basename.to_s) }
55
+ end
56
+ end
57
+
58
+ def files = Marks.directory_of(@burst).join("reports").glob("*.yml").sort
59
+
60
+ def list_in(file)
61
+ YAML.safe_load_file(file).then { it if it.is_a?(Array) }
62
+ rescue Psych::Exception
63
+ nil
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class ResponseFiles
5
+ FORMAT = "proctolog-response/v1"
6
+ STATUSES = %w[done answered deferred needs_owner blocked].freeze
7
+
8
+ def initialize(burst) = @burst = burst
9
+
10
+ def folder = Marks.directory_of(@burst).join("responses")
11
+
12
+ def all
13
+ folder.glob("*.json").sort.filter_map do |file|
14
+ data = JSON.parse(file.read)
15
+ validate!(data)
16
+ data
17
+ rescue JSON::ParserError, Failure => e
18
+ errors << "#{file.basename}: #{e.message}"
19
+ nil
20
+ end
21
+ end
22
+
23
+ def errors = @errors ||= []
24
+
25
+ def validate!(data)
26
+ valid = data.is_a?(Hash) && data["format"] == FORMAT && data["repository"] == @burst.repository && data["review"] == @burst.slug &&
27
+ %w[id batchId summary createdAt].all? { data[it].is_a?(String) && data[it].present? } && data["results"].is_a?(Array)
28
+ raise Failure, "Not a #{FORMAT} response for this burst." unless valid
29
+ Time.iso8601(data.fetch("createdAt"))
30
+
31
+ ids = data["results"].map do |result|
32
+ raise Failure, "A result needs id, status, summary, details, evidence and verification." unless result.is_a?(Hash) &&
33
+ %w[id summary details].all? { result[it].is_a?(String) } && result["id"].present? && result["summary"].present? &&
34
+ STATUSES.include?(result["status"]) && result["evidence"].is_a?(Array) && result["verification"].is_a?(Array)
35
+
36
+ raise Failure, "Invalid evidence or verification for #{result["id"]}." unless result["evidence"].all? { it.is_a?(Hash) && it["label"].is_a?(String) } &&
37
+ result["verification"].all? { it.is_a?(Hash) && it["command"].is_a?(String) && %w[passed failed not_run].include?(it["result"]) }
38
+ raise Failure, "A deferred result needs a backlog reference or proposed task." if result["status"] == "deferred" && (!result["backlog"].is_a?(String) || result["backlog"].blank?)
39
+ raise Failure, "A result head must be a full commit hash." if result.key?("head") && !result["head"].to_s.match?(/\A\h{40}\z/)
40
+
41
+ if result["status"] == "needs_owner"
42
+ question = result["question"]
43
+ raise Failure, "A question needs prompt, recommendation and a blocking boolean." unless question.is_a?(Hash) &&
44
+ %w[prompt recommendation].all? { question[it].is_a?(String) && question[it].present? } && [true, false].include?(question["blocking"])
45
+ end
46
+ result["id"]
47
+ end
48
+ raise Failure, "The response repeats a result id." unless ids.uniq.size == ids.size
49
+ raise Failure, "Follow-ups must be a list of suggestions." if data.key?("followups") && (!data["followups"].is_a?(Array) || data["followups"].any? { !it.is_a?(String) })
50
+ data
51
+ rescue ArgumentError
52
+ raise Failure, "The response has an invalid createdAt time."
53
+ end
54
+ end
55
+ end