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,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The rounds of a burst's review: every request the owner made, where it
5
+ # stands between them and the agent, and what the next pass hands over.
6
+ # All of it is read from what the desk keeps: the open notes and the code
7
+ # comments nobody resolved, the choices the pages write, the batch each
8
+ # handover carried and the answers that came back. So a pass can be taken
9
+ # with no page open, by the agent itself, halfway through a review. The
10
+ # pages' script shows the same rounds by the same rules, in
11
+ # review-rounds.js.
12
+ class Rounds
13
+ # A decision in its place in the burst, under the id the pages give it.
14
+ Placed = Data.define(:stop, :decision) do
15
+ def id = "#{pr}-#{decision.position}"
16
+
17
+ def pr = stop.number
18
+ end
19
+
20
+ # A choice or a draft a page kept, with the id its request goes by and
21
+ # the decision it was made on, while the burst still has it.
22
+ Choice = Data.define(:entry, :request_id, :at) do
23
+ def pr = entry.dig("decision", "pr")
24
+
25
+ def kind = entry["kind"]
26
+
27
+ def text = entry["text"].presence || entry.dig("decision", "lead")
28
+
29
+ # The decision as the choice was made on it, under the id it goes by
30
+ # now: a "missing:" one once the burst no longer has it.
31
+ def decision = entry["decision"].deep_symbolize_keys.merge(at ? {id: at.id, position: at.decision.position, key: at.decision.key} : {id: "missing:#{request_id}"})
32
+
33
+ # Left with every other non-blocking decision at once.
34
+ def in_bulk? = entry["batch"].present? && entry["grade"].present?
35
+
36
+ # The one answer given to several decisions it shares.
37
+ def together = entry["batch"].to_s.start_with?("together:") ? entry["batch"] : nil
38
+ end
39
+
40
+ # An answer to a request: the result, the response it came in, and the
41
+ # batch and request it answers, in the order the batches were written.
42
+ Answer = Data.define(:response, :result, :batch, :request, :order) do
43
+ # How the owner's reply names the answer it replies to.
44
+ def key = "#{response[:id]}:#{response[:createdAt]}"
45
+
46
+ def newness = [order, Rounds.moment(response[:createdAt])]
47
+ end
48
+
49
+ # A time as a page or an agent wrote it; one that cannot be read is
50
+ # taken for the oldest.
51
+ def self.moment(text)
52
+ Time.iso8601(text.to_s)
53
+ rescue ArgumentError
54
+ Time.at(0)
55
+ end
56
+
57
+ def initialize(burst, route:, focus: Focus.new(route, nil))
58
+ @burst, @route, @focus = burst, route, focus
59
+ @kept = burst.choices
60
+ @document = @kept.document || {}
61
+ end
62
+
63
+ # The owner's open notes and the code comments nobody resolved, across
64
+ # the whole burst, each as a request.
65
+ def requests
66
+ @requests ||= [*@route.flat_map { notes_of(it.number, pr: it.number) }, *notes_of("sitting", pr: nil),
67
+ *@route.flat_map { CodeReview.new(@burst, it.number).requests }]
68
+ end
69
+
70
+ # What the next pass asks of the agent, as a batch of its own, with what
71
+ # it should know beside. What an agent took and has not answered yet is
72
+ # held back, so that two never do the same work, unless taken `again`.
73
+ def worklist(batch_id: "preview", again: false, at: Time.current)
74
+ Handover::Worklist.new(batch_id:, created_at: at.utc.iso8601(3), heads:, items: work(again:),
75
+ with_agent: again ? [] : with_agent, awaiting_owner: ids_in(:owner, :question), closed: ids_in(:closed),
76
+ left_at_once:, answered_together:, unfinished_drafts:, unreviewed:)
77
+ end
78
+
79
+ # Each pull request of the burst at the head the desk read.
80
+ def heads = @heads ||= @route.to_h { [it.number, {head: it.head_oid, title: it.title, branch: it.pull_request.head, description: it.description.digest}] }
81
+
82
+ # For the next pass; out with an agent that has not answered; answered
83
+ # and the owner's to confirm, or to answer; closed; or kept from an
84
+ # earlier review for its context.
85
+ def state_of(item)
86
+ latest = latest(item)
87
+ reply = reply_to(item) if latest
88
+ if archived?(item) then :archived
89
+ elsif item[:kind] == "leave" then :closed
90
+ elsif reply&.dig(:replyTo) == latest&.key && reply&.dig(:resolution) == "confirmed" then :closed
91
+ elsif latest.nil? || reply&.dig(:replyTo) == latest.key then out?(item) ? :out : :agent
92
+ else (latest.result[:status] == "needs_owner") ? :question : :owner
93
+ end
94
+ end
95
+
96
+ # What an agent took and has not answered, with the handover it went in.
97
+ def with_agent
98
+ current.select { in_focus?(it[:pr]) && state_of(it) == :out }.map do |item|
99
+ batch = carrier(item)
100
+ {**item.slice(:id, :pr, :kind, :text), batchId: batch[:id], handedOverAt: batch[:createdAt]}
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def work(again:)
107
+ current.select { in_focus?(it[:pr]) && [:agent, *(:out if again)].include?(state_of(it)) }.map do |item|
108
+ latest = latest(item)
109
+ sent_back = {ownerReply: reopened(item)&.slice(:kind, :text), previousResponse: (latest&.result if reopened(item))}.compact
110
+ {**item, source: {**item[:source], reviewedHead: reviewed_head(item, latest)}, **sent_back}
111
+ end
112
+ end
113
+
114
+ # The head the request was last looked at on: the one its last answer
115
+ # names, or the one the owner reviewed.
116
+ def reviewed_head(item, latest)
117
+ source = item[:source]
118
+ latest&.result&.dig(:head).presence || source[:reviewedHead].presence || source.dig(:decision, :head).presence || source[:head]
119
+ end
120
+
121
+ # The owner's reply that sent an answered request back to the agent.
122
+ def reopened(item) = reply_to(item)&.then { it if it[:resolution] == "reopened" }
123
+
124
+ def ids_in(*states) = everything.select { in_focus?(it[:pr]) && states.include?(state_of(it)) }.pluck(:id)
125
+
126
+ # The requests that stand now: notes and code comments, the owner's
127
+ # choices, and what they asked beside the decisions.
128
+ def current = @current ||= [*requests, *choices.map { choice_item(it) }, *asked_beside.reject { it[:withdrawn] }]
129
+
130
+ # Those, and what an earlier batch carried that came back answered.
131
+ def everything
132
+ @everything ||= current + batches.flat_map { it[:items] }.select { latest(it) }.uniq { it[:id] }.reject { |item| current.any? { it[:id] == item[:id] } }
133
+ end
134
+
135
+ def notes_of(marked, pr:)
136
+ @burst.marks(marked).notes.select(&:open?).map do |note|
137
+ {id: "note:#{note.id}", pr:, kind: note.kind, text: note.text,
138
+ source: {kind: "note", noteId: note.id, anchor: note.place&.to_h, head: @route.at(pr)&.head_oid}}
139
+ end
140
+ end
141
+
142
+ def choice_item(choice)
143
+ {id: choice.request_id, pr: choice.pr, kind: choice.kind, text: choice.text,
144
+ source: {kind: "decision", decision: choice.decision, needsReview: stale?(choice)}}
145
+ end
146
+
147
+ def asked_beside = @asked_beside ||= Array(@document["requests"]).map(&:deep_symbolize_keys)
148
+
149
+ def archived?(item)
150
+ (item[:pr] && !heads.key?(item[:pr])) || asked_beside.any? { it[:id] == item[:id] && it[:withdrawn] } ||
151
+ (item.dig(:source, :kind) == "decision" && choices.none? { it.request_id == item[:id] }) ||
152
+ (item[:kind] == "leave" && item.dig(:source, :decision, :id).to_s.start_with?("missing:"))
153
+ end
154
+
155
+ # The batch that last carried a request as it stands, with the owner's
156
+ # last reply to it when they reopened it.
157
+ def carrier(item)
158
+ reply = reopened(item)
159
+ batches.rfind do |batch|
160
+ batch[:items].any? { it[:id] == item[:id] && signature(it) == signature(item) && (!reply || it[:ownerReply]&.values_at(:kind, :text) == reply.values_at(:kind, :text)) }
161
+ end
162
+ end
163
+
164
+ # Taken by a pass that no answer came back to yet.
165
+ def out?(item)
166
+ batch = carrier(item)
167
+ batch && responses.none? { it[:batchId] == batch[:id] }
168
+ end
169
+
170
+ # The answer that stands for a request: the newest, or the one the
171
+ # owner confirmed, or, once they replied, the one that came after.
172
+ def latest(item)
173
+ (@latest ||= {}).fetch([item[:id], signature(item)]) do |key|
174
+ answers = answers_to(item)
175
+ reply = reply_to(item)
176
+ replied_to = answers.find { it.key == reply[:replyTo] } if reply
177
+ @latest[key] = case reply&.dig(:resolution)
178
+ when "confirmed" then replied_to || answers.max_by(&:newness)
179
+ when "reopened" then answers.select { after?(it, reply) }.max_by(&:newness) || replied_to || answers.max_by(&:newness)
180
+ else answers.max_by(&:newness)
181
+ end
182
+ end
183
+ end
184
+
185
+ def answers_to(item)
186
+ responses.filter_map do |response|
187
+ batch = batches.find { it[:id] == response[:batchId] }
188
+ request = batch[:items].find { it[:id] == item[:id] }
189
+ result = response[:results].find { it[:id] == item[:id] }
190
+ Answer.new(response:, result:, batch:, request:, order: batches.index(batch)) if request && result && signature(request) == signature(item)
191
+ end
192
+ end
193
+
194
+ def after?(answer, reply)
195
+ Rounds.moment(answer.batch[:createdAt]) >= Rounds.moment(reply[:at]) && answer.request[:ownerReply]&.values_at(:kind, :text) == reply.values_at(:kind, :text)
196
+ end
197
+
198
+ # The owner's last word on a request, while it is about the request as
199
+ # it stands: one about earlier words is no longer theirs to it.
200
+ def reply_to(item)
201
+ reply = @document.dig("feedback", item[:id])&.deep_symbolize_keys
202
+ reply if reply && intent_of(reply) == signature(item)
203
+ end
204
+
205
+ def intent_of(reply)
206
+ JSON.parse(reply[:intent].to_s)
207
+ rescue JSON::ParserError
208
+ end
209
+
210
+ def signature(item) = [item[:kind], item[:text]]
211
+
212
+ # Every batch a handover carried, the oldest first: those the pages keep,
213
+ # and those the desk wrote that no page has taken in yet.
214
+ def batches
215
+ @batches ||= [*Array(@document["batches"]), *Handover.kept(@burst).map(&:batch)].map(&:deep_symbolize_keys)
216
+ .uniq { it[:id] }.sort_by { Rounds.moment(it[:createdAt]) }
217
+ end
218
+
219
+ # Every answer that came back to a batch the desk knows, as the page
220
+ # takes them in: a response naming a request its batch did not carry is
221
+ # refused whole. One written again under a new time is a newer version
222
+ # of it, and both are kept.
223
+ def responses
224
+ @responses ||= [*Array(@document["responses"]), *ResponseFiles.new(@burst).all].map(&:deep_symbolize_keys).uniq { it.values_at(:id, :createdAt) }.select do |response|
225
+ asked = batches.find { it[:id] == response[:batchId] }&.dig(:items)&.pluck(:id)
226
+ ids = response[:results].pluck(:id)
227
+ asked && ids.uniq.size == ids.size && (ids - asked).empty?
228
+ end
229
+ end
230
+
231
+ # Every decision of the burst as it reads now.
232
+ def placed = @placed ||= @route.flat_map { |stop| stop.description.decisions.map { Placed.new(stop:, decision: it) } }
233
+
234
+ def choices = @choices ||= kept("choices", "choice")
235
+
236
+ def drafts = @drafts ||= kept("pending", "draft")
237
+
238
+ # Each entry with the decision it was made on, as the page's reconcile()
239
+ # finds it: by its key, or by its words where it has none, and never one
240
+ # decision for two entries.
241
+ def kept(field, kind)
242
+ claimed = Set.new
243
+ Array(@document[field]).map do |entry|
244
+ was = entry["decision"]
245
+ matches = placed.select { it.pr == was["pr"] && (was["key"] ? it.decision.key == was["key"] : [it.decision.lead, it.decision.text] == was.values_at("lead", "text")) }
246
+ at = matches.first if matches.one? && claimed.add?(matches.first.id)
247
+ Choice.new(entry:, request_id: entry["requestId"] || "#{kind}:#{was["id"]}:#{entry["savedAt"] || "legacy"}", at:)
248
+ end
249
+ end
250
+
251
+ # A choice no longer stands once its decision changed under it, unless
252
+ # the owner confirmed what came back of it, or its pull request left.
253
+ def stale?(choice)
254
+ return false if confirmed?(choice) || !heads.key?(choice.pr)
255
+
256
+ decision = choice.at&.decision || reading_as(choice)
257
+ !(decision && @kept.stands?(choice.entry, decision))
258
+ end
259
+
260
+ # The one decision of its pull request that reads as the choice's did.
261
+ def reading_as(choice)
262
+ was = choice.entry["decision"]
263
+ placed.select { it.pr == choice.pr && [it.decision.lead, it.decision.bare_text] == [was["lead"], Theme.unmarked(was["text"])] }.then { it.first.decision if it.one? }
264
+ end
265
+
266
+ def confirmed?(choice)
267
+ item = {id: choice.request_id, kind: choice.kind, text: choice.text}
268
+ reply_to(item)&.then { it[:resolution] == "confirmed" && latest(item)&.key == it[:replyTo] }
269
+ end
270
+
271
+ def in_focus?(pr) = @focus.whole? || (!pr.nil? && @focus.at(pr).present?)
272
+
273
+ # The choices a handover carries, in the burst's order.
274
+ def in_order(kept) = kept.select { in_focus?(it.pr) }.sort_by { |choice| placed.index { it.id == choice.decision[:id] } || placed.size }
275
+
276
+ def left_at_once
277
+ in_order(choices).select { it.in_bulk? && !stale?(it) }.group_by(&:pr)
278
+ .map { |pr, left| {pr:, decisions: left.map { {id: it.decision[:id], lead: it.decision[:lead]} }} }
279
+ end
280
+
281
+ # One answer given to several decisions, as a copy for each part it
282
+ # touches, carried out once at its earliest pull request there.
283
+ def answered_together
284
+ choices.select { it.together && !stale?(it) }.group_by(&:together).flat_map do |batch, answered|
285
+ copies = answered.group_by { @route.at(it.pr)&.part&.to_param || it.pr }.values.map do |here|
286
+ sorted = here.sort_by { |choice| @route.find_index { it.number == choice.pr } || 0 }
287
+ {words: @route.at(sorted.first.pr)&.part&.in_words || "##{sorted.first.pr}", at: sorted.first.pr,
288
+ decisions: sorted.map { {id: it.decision[:id], pr: it.pr, lead: it.decision[:lead]} }}
289
+ end
290
+ copies.select { in_focus?(it[:at]) }.map { |copy| {batch:, kind: answered.first.kind, text: answered.first.entry["text"], **copy, elsewhere: copies - [copy]} }
291
+ end
292
+ end
293
+
294
+ def unfinished_drafts = drafts.select { in_focus?(it.pr) }.map { {**it.entry.deep_symbolize_keys, requestId: it.request_id, decision: it.decision} }
295
+
296
+ # What the owner has not reviewed yet, or not since it changed under
297
+ # their choice: no instruction, only what they have not reached.
298
+ def unreviewed
299
+ placed.select { in_focus?(it.pr) }.filter_map do |decision|
300
+ choice = choices.find { it.decision[:id] == decision.id }
301
+ {id: decision.id, pr: decision.pr, lead: decision.decision.lead, **(choice ? {changed: true} : {})} if choice.nil? || stale?(choice)
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The order to take a burst in, part by part: each of GitHub's native stacks
5
+ # the burst names, from the bottom up by GitHub's own positions; then each
6
+ # chain found from a top by following base branches down; then the pull
7
+ # requests that stand alone.
8
+ #
9
+ # A pull request shows once: a stack holds it before a chain does, and a
10
+ # chain before it stands alone. A chain that meets a pull request placed
11
+ # before it stops there and sits on it, so a change below is still carried
12
+ # up through it: #1085 sits on #1062, the top of stack #1055.
13
+ class Route
14
+ include Enumerable
15
+
16
+ # A piece of a burst the owner can take on its own. Its id is its stack's
17
+ # number, its top's number, or "alone": GitHub never gives a stack and a
18
+ # pull request the same number. A chain that sits on another part names
19
+ # that part's id as `above`.
20
+ Part = Data.define(:id, :kind, :above) do
21
+ def self.stack(number) = new(id: number, kind: :stack, above: nil)
22
+
23
+ def self.chain(top, above: nil) = new(id: top, kind: :chain, above:)
24
+
25
+ def self.alone = new(id: "alone", kind: :alone, above: nil)
26
+
27
+ def stack? = kind == :stack
28
+
29
+ def alone? = kind == :alone
30
+
31
+ # As a sentence names it: "stack #1055", "the chain up to #1086".
32
+ def in_words
33
+ if alone? then "the pull requests that stand alone"
34
+ elsif stack? then "stack ##{id}"
35
+ else "the chain up to ##{id}"
36
+ end
37
+ end
38
+
39
+ def to_param = id.to_s
40
+ end
41
+
42
+ # A pull request in its place: its layer of `of` within its part, and the
43
+ # number of the pull request it sits on, nil at the bottom of a stack, of
44
+ # a chain on nothing, and for one that stands alone.
45
+ Stop = Data.define(:pull_request, :layer, :of, :part, :on) do
46
+ delegate :number, :title, :url, :base, :description, :files, :head_oid, :described_at, :committed_at, :open_threads, to: :pull_request
47
+
48
+ def initialize(part: Part.alone, on: nil, **) = super
49
+
50
+ def stacked? = of > 1 || !on.nil?
51
+ end
52
+
53
+ # `stacks` are the native stacks the burst names, `tops` the tops of the
54
+ # chains it follows, `loose` the pull requests it names to stand alone.
55
+ # `kept` holds each stack's members, bottom first, as they were last
56
+ # read: a stack is taken from it when GitHub could not say where its pull
57
+ # requests stand.
58
+ def initialize(among:, tops: [], stacks: [], loose: [], kept: {})
59
+ @stacks, @tops, @loose, @kept = stacks, tops, loose, kept
60
+ @pull_requests = among.index_by(&:number)
61
+ @by_head = among.index_by(&:head)
62
+ end
63
+
64
+ def each(&) = stops.each(&)
65
+
66
+ def parts = stops.map(&:part).uniq
67
+
68
+ def stops_of(part) = select { it.part == part }
69
+
70
+ def part(id) = parts.find { it.to_param == id.to_s }
71
+
72
+ def at(number) = stops_by_number[number]
73
+
74
+ # Whether GitHub said where its pull requests stand in its stacks: when it
75
+ # did not, a stack's members are those kept from its last reading.
76
+ def stacks_known? = @pull_requests.each_value.all?(&:stack_known?)
77
+
78
+ # The stops on either side of one, in the order of the route.
79
+ def before(stop) = each_cons(2).find { |_, later| later == stop }&.first
80
+
81
+ def after(stop) = each_cons(2).find { |earlier, _| earlier == stop }&.last
82
+
83
+ # What a layer sits on, in its own part or one before it, and the first
84
+ # that sits on it.
85
+ def under(stop) = stop.on&.then { at(it) }
86
+
87
+ def over(stop) = find { it.on == stop.number }
88
+
89
+ # Every layer that sits on it, however far up and in whichever part,
90
+ # nearest first.
91
+ def above(stop) = select { |other| below(other).include?(stop) }
92
+
93
+ # The other stops that change files this one changes, each with those files.
94
+ def sharing_files_with(stop) = others_than(stop).index_with { it.files & stop.files }.compact_blank
95
+
96
+ # The other stops its description names.
97
+ def named_by(stop) = others_than(stop).select { stop.description.mentions.include?(it.number) }
98
+
99
+ # Pull requests the burst names that are no longer open: merged, or closed.
100
+ def gone = (@tops + @loose) - @pull_requests.keys
101
+
102
+ private
103
+
104
+ def others_than(stop) = reject { it == stop }
105
+
106
+ def stops_by_number = @stops_by_number ||= index_by(&:number)
107
+
108
+ # What a stop stands on, down to the bottom it rests on.
109
+ def below(stop) = Enumerator.produce(stop) { under(it) || raise(StopIteration) }.drop(1)
110
+
111
+ def stops
112
+ @stops ||= {}.then do |placed|
113
+ [*@stacks.map { native(it, placed) }, *@tops.map { chain(it, placed) }, alone(placed)].flatten.compact
114
+ end
115
+ end
116
+
117
+ # A native stack's members by their positions, or as they were last read.
118
+ def native(number, placed)
119
+ members = stacks_known? ? @pull_requests.each_value.select { it.stack == number }.sort_by(&:position) : @pull_requests.values_at(*@kept.fetch(number, [])).compact
120
+ layers(members.reject { placed.key?(it.number) }, Part.stack(number), placed)
121
+ end
122
+
123
+ # Followed down from its top until a base is not the branch of an open
124
+ # pull request, or is that of one placed already, which it then sits on.
125
+ # A top with nothing under it and nothing to sit on stands alone.
126
+ def chain(top, placed)
127
+ pull_request = @pull_requests[top]
128
+ return if pull_request.nil? || placed.key?(top)
129
+
130
+ followed = Enumerator.produce(pull_request) { @by_head.fetch(it.base) { raise StopIteration } }.take_while { !placed.key?(it.number) }
131
+ on = @by_head[followed.last.base]&.then { placed[it.number] }
132
+ return if followed.one? && on.nil?
133
+
134
+ layers(followed.reverse, Part.chain(top, above: on&.part&.id), placed, on: on&.number)
135
+ end
136
+
137
+ def alone(placed)
138
+ singles = (@tops + @loose).filter_map { @pull_requests[it] }.uniq.reject { placed.key?(it.number) }
139
+ singles.map { place(Stop.new(pull_request: it, layer: 1, of: 1, part: Part.alone, on: nil), placed) }
140
+ end
141
+
142
+ # Each layer sits on the one under it, and the first on `on`.
143
+ def layers(pull_requests, part, placed, on: nil)
144
+ pull_requests.each.with_index(1).map do |pull_request, layer|
145
+ place(Stop.new(pull_request:, layer:, of: pull_requests.size, part:, on: (layer == 1) ? on : pull_requests[layer - 2].number), placed)
146
+ end
147
+ end
148
+
149
+ def place(stop, placed) = (placed[stop.number] = stop)
150
+ end
151
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The rules the owner made of answers in a burst. Each is a request of its
5
+ # own in the choices document, made from a choice and attached to it, so
6
+ # the agent's answer to a rule never answers the choice. The pages' script
7
+ # makes a rule and hands it over; the burst page lists them, and edits or
8
+ # withdraws one here. A rule is as far as its request: written, sent,
9
+ # answered, confirmed, followed as review-rounds.js follows every request.
10
+ class Rules
11
+ include Enumerable
12
+
13
+ Missing = Class.new(Failure)
14
+ Blank = Class.new(Failure)
15
+
16
+ # A decision a rule was made from, as the choice named it.
17
+ From = Data.define(:pr, :lead)
18
+
19
+ # A rule as the burst page shows it. `replaces` is the line the agent
20
+ # recorded before an edit, until the replacement is handed over.
21
+ Rule = Data.define(:id, :pr, :text, :from, :state, :replaces) do
22
+ def to_param = id
23
+
24
+ def withdrawn? = state == :withdrawn
25
+
26
+ def within?(focus) = focus.whole? || from.any? { focus.at(it.pr) }
27
+ end
28
+
29
+ # `handovers` are those the desk kept: a pass an agent took itself is
30
+ # in no page's choices until a page takes its batch in.
31
+ def initialize(choices, handovers: [])
32
+ @choices, @handovers = choices, handovers
33
+ end
34
+
35
+ def each(&) = requests.map { rule(it) }.each(&)
36
+
37
+ def find(id) = detect { it.id == id } || raise(Missing, "This burst has no rule #{id}.")
38
+
39
+ # A rule the agent does not have yet changes where it stands. One it has
40
+ # changes too, and says which line it replaces until that is handed over.
41
+ def edit(id, text)
42
+ text = text.to_s.squish.presence or raise Blank, "A rule is one line: write it, or withdraw the rule."
43
+
44
+ write(id) do |rule, _|
45
+ rule["text"] = text
46
+ sent = last_sent(id)&.fetch("text")
47
+ (sent && sent != text) ? rule["replaces"] = sent : rule.delete("replaces")
48
+ end
49
+ end
50
+
51
+ # A rule the agent does not have yet goes. One it has stays, withdrawn,
52
+ # and an ordinary request asks the agent to take it out again.
53
+ def withdraw(id)
54
+ write(id) do |rule, requests|
55
+ next requests.delete(rule) unless (sent = last_sent(id))
56
+
57
+ rule["withdrawn"] = true
58
+ requests << {"id" => "request:#{SecureRandom.uuid}", "pr" => rule["pr"], "kind" => "change",
59
+ "text" => "Withdraw the rule \"#{sent["text"]}\" (#{id}): take it out of where you recorded it, and name that file as evidence.",
60
+ "source" => {"kind" => "rule_withdrawn", "rule" => id}}
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def document = @choices.document || {}
67
+
68
+ def requests = Array(document["requests"]).select { it["kind"] == "rule" }
69
+
70
+ def batches = [*Array(document["batches"]), *@handovers.map(&:batch)].uniq { it["id"] }.sort_by { Rounds.moment(it["createdAt"]) }
71
+
72
+ def rule(request)
73
+ Rule.new(id: request["id"], pr: request["pr"], text: request["text"], replaces: request["replaces"], state: state_of(request),
74
+ from: Array(request.dig("source", "decisions")).map { From.new(pr: it["pr"], lead: it["lead"]) })
75
+ end
76
+
77
+ # Written until a handover holds it in these words; answered once the
78
+ # agent's response to that handover does; confirmed once the owner said
79
+ # so of these words. The owner sending it back makes it written again.
80
+ def state_of(request)
81
+ return :withdrawn if request["withdrawn"]
82
+
83
+ said = said_of(request)
84
+ return :confirmed if said&.dig("resolution") == "confirmed"
85
+
86
+ since = (Time.iso8601(said["at"]) if said&.dig("resolution") == "reopened")
87
+ held = handed_over(request).select { since.nil? || Time.iso8601(it["createdAt"]) >= since }
88
+ return :answered if held.any? { answered_in?(it, request) }
89
+
90
+ held.any? ? :sent : :written
91
+ end
92
+
93
+ # The owner's last word on what came back, while it is on these words.
94
+ def said_of(request)
95
+ said = document.dig("feedback", request["id"]) or return
96
+ said if intent_of(said) == [request["kind"], request["text"]]
97
+ end
98
+
99
+ def intent_of(said)
100
+ JSON.parse(said["intent"].to_s)
101
+ rescue JSON::ParserError
102
+ end
103
+
104
+ def handed_over(request) = batches.select { |batch| batch["items"].any? { it["id"] == request["id"] && it["text"] == request["text"] } }
105
+
106
+ def answered_in?(batch, request) = Array(document["responses"]).any? { |report| report["batchId"] == batch["id"] && report["results"].any? { it["id"] == request["id"] } }
107
+
108
+ # The rule as the agent last had it.
109
+ def last_sent(id) = batches.flat_map { it["items"] }.rfind { it["id"] == id }
110
+
111
+ # Changes the kept document, numbered past the writing it changes so a
112
+ # page left open catches up with it rather than writing over it.
113
+ def write(id)
114
+ changed = document.deep_dup
115
+ requests = changed["requests"] ||= []
116
+ rule = requests.find { it["id"] == id && it["kind"] == "rule" } or raise Missing, "This burst has no rule #{id}."
117
+ yield rule, requests
118
+ @choices.keep(changed.merge("revision" => @choices.revision + 1))
119
+ end
120
+ end
121
+ end