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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A description checked as the desk reads it, before it is published or
5
+ # after: faults, which keep the desk from reading it well, and warnings,
6
+ # which only advise, each on its line. It reads with the desk's own parser
7
+ # and the desk's own picture names, so the check and the desk agree.
8
+ class Check
9
+ # What was found, on which line of the description; `fault` is false for
10
+ # a warning.
11
+ Finding = Data.define(:row, :said, :fault)
12
+
13
+ # More blocking decisions than this ask too much of one owner at once.
14
+ MOST_BLOCKING = 5
15
+
16
+ # `rendering` is GitHub's HTML of the same Markdown.
17
+ def initialize(markdown, rendering:, themes: Theme.all)
18
+ @markdown = markdown.to_s.gsub("\r\n", "\n")
19
+ @description = Description.new(@markdown)
20
+ @themes = themes
21
+ @pictures = Rendering.new(rendering).sections.flat_map(&:figures).map { it["data-picture"] }.tally
22
+ end
23
+
24
+ def findings
25
+ @findings ||= if @description.rows(Description::DECISIONS).empty?
26
+ [fault(nil, "There is no “## #{Description::DECISIONS}” section.")]
27
+ else
28
+ [*decisions.flat_map { of(it) }, *runs_on, *repeated_keys, *halves, *too_many].sort_by { it.row.to_i }
29
+ end
30
+ end
31
+
32
+ def faults = findings.select(&:fault)
33
+
34
+ def warnings = findings.reject(&:fault)
35
+
36
+ def passed? = faults.empty?
37
+
38
+ def to_s
39
+ blocking = decisions.count(&:blocking?)
40
+ [("#{count(decisions.size, "decision")}: #{blocking} blocking, #{decisions.size - blocking} non-blocking." if decisions.any?),
41
+ *findings.map { [("line #{it.row}" if it.row), (it.fault ? "fault" : "warning"), it.said].compact.join(": ") },
42
+ how_to_name_a_theme, "#{count(faults.size, "fault")}, #{count(warnings.size, "warning")}."].compact.join("\n")
43
+ end
44
+
45
+ private
46
+
47
+ def decisions = @description.decisions
48
+
49
+ def fault(row, said) = Finding.new(row:, said:, fault: true)
50
+
51
+ def warning(row, said) = Finding.new(row:, said:, fault: false)
52
+
53
+ def count(number, noun) = "#{number} #{noun.pluralize(number)}"
54
+
55
+ # What one decision lacks: a bold lead, its alternative, a theme the
56
+ # project lists, and pictures that its links name once each.
57
+ def of(decision)
58
+ row = decision.row
59
+ [(fault(row, "It has no bold lead: open it with what was built, in **bold**.") unless decision.bare_text.match?(/\A\*\*.+?\*\*/)),
60
+ (fault(row, "It names no alternative: end it with “Alternative: …”, before its theme.") unless decision.alternative),
61
+ theme_of(decision), *pictures_of(decision)].compact
62
+ end
63
+
64
+ def theme_of(decision)
65
+ return fault(decision.row, "It names no theme.") unless decision.theme_name
66
+
67
+ fault(decision.row, "It names the theme “#{decision.theme_name}”, which the project does not list.") if Theme.named(decision.theme_name, @themes).other?
68
+ end
69
+
70
+ # Said once under the findings, when any of them is about a theme.
71
+ def how_to_name_a_theme
72
+ "A decision names its theme at its end, <!-- proctolog:theme NAME -->, NAME one of #{@themes.map(&:name).join(", ")}: `proctolog themes` says what each means." if findings.any? { it.said.include?("theme") }
73
+ end
74
+
75
+ # A link names a picture by its alt text, which must be one picture's
76
+ # alone, or a pair's: NAME-before beside NAME-after.
77
+ def pictures_of(decision)
78
+ decision.text.scan(Description::LINK).flatten.uniq.filter_map do |name|
79
+ shown = @pictures.fetch(name, 0) + (paired?(name) ? 1 : 0)
80
+ if shown.zero? then fault(decision.row, "It links the picture “#{name}”, which no picture of the description has as its alt text.")
81
+ elsif shown > 1 then fault(decision.row, "It links the picture “#{name}”, which #{shown} pictures' alt texts name: give each picture an alt text of its own.")
82
+ end
83
+ end
84
+ end
85
+
86
+ def paired?(name) = @pictures.key?("#{name}-before") && @pictures.key?("#{name}-after")
87
+
88
+ # A decision is one line: what is indented under it runs onto the next.
89
+ def runs_on
90
+ @description.rows(Description::DECISIONS).each_cons(2).filter_map do |(line, row), (following, _)|
91
+ fault(row, "It runs onto the next line: keep a decision to one line.") if line.match?(Description::BOX) && following.match?(/\A\s+\S/)
92
+ end
93
+ end
94
+
95
+ # A reference kept twice, or two decisions in the same words, would make
96
+ # a choice on one stand for the other.
97
+ def repeated_keys
98
+ decisions.group_by(&:key).values.select(&:many?).flat_map do |same|
99
+ same.drop(1).map { fault(it.row, "It has the same reference as the decision on line #{same.first.row}: give it one of its own, or drop the copy.") }
100
+ end
101
+ end
102
+
103
+ # One half of a pair with no other half: a picture named NAME-before
104
+ # with no NAME-after, or the other way round.
105
+ def halves
106
+ @pictures.keys.filter_map do |name|
107
+ stem, half = name.match(/\A(.+)-(before|after)\z/)&.captures
108
+ other = (half == "before") ? "after" : "before"
109
+ fault(row_of_picture(name), "The picture “#{name}” has no “#{stem}-#{other}” to pair with.") if stem && !@pictures.key?("#{stem}-#{other}")
110
+ end
111
+ end
112
+
113
+ def row_of_picture(name) = @markdown.lines.index { it.include?(%(alt="#{name}")) }&.succ
114
+
115
+ def too_many
116
+ blocking = decisions.select(&:blocking?)
117
+ return [] if blocking.size <= MOST_BLOCKING
118
+
119
+ [warning(blocking[MOST_BLOCKING].row, "#{blocking.size} decisions are blocking: more than #{MOST_BLOCKING} ask a lot of one owner. Grade the ones a default can settle non-blocking.")]
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The owner's choices on the decisions of a burst, with the drafts, batches
5
+ # and responses that go with them: one JSON document beside the marks, which
6
+ # the pages' script writes whole and an agent can read. The script numbers
7
+ # each writing, so a page that was left open cannot write over a newer one.
8
+ class Choices
9
+ # A writing older than the one kept: another page moved on since.
10
+ Stale = Class.new(StandardError)
11
+
12
+ # How far one pull request's decisions are: a decision is reviewed once
13
+ # the owner chose on its present words, or it is ticked on GitHub. Its
14
+ # blocking decisions are counted apart: those left of them are what
15
+ # deciding goes through, while every decision counts toward its standing.
16
+ Progress = Data.define(:total, :chosen, :ticked, :drafts, :blocking, :blocking_left) do
17
+ def initialize(total:, chosen:, ticked:, drafts:, blocking: total, blocking_left: total - chosen - ticked) = super
18
+
19
+ # Out of each decision and whether it is reviewed.
20
+ def self.of(reviewed, drafts: 0)
21
+ blocking = reviewed.select { |decision, _| decision.blocking? }
22
+ new(total: reviewed.size, ticked: reviewed.count { |decision, _| decision.ticked }, chosen: reviewed.count { |decision, done| done && !decision.ticked },
23
+ drafts:, blocking: blocking.size, blocking_left: blocking.count { |_, done| !done })
24
+ end
25
+
26
+ def reviewed = chosen + ticked
27
+ def left = total - reviewed
28
+ def done? = left.zero?
29
+ def any? = total.positive?
30
+
31
+ def non_blocking = total - blocking
32
+ def non_blocking_left = left - blocking_left
33
+
34
+ def +(other) = with(**members.to_h { [it, public_send(it) + other.public_send(it)] })
35
+ end
36
+
37
+ NONE = Progress.new(total: 0, chosen: 0, ticked: 0, drafts: 0)
38
+
39
+ def self.of(burst) = new(Marks.directory_of(burst).join("choices.json"))
40
+
41
+ def initialize(path) = @path = path
42
+
43
+ # Nil until a page has written one.
44
+ def document
45
+ @document ||= JSON.parse(@path.read) if @path.exist?
46
+ rescue JSON::ParserError => e
47
+ raise Failure, "The choices file #{@path} is not JSON (#{e.message.lines.first&.strip}). The desk left it as it is: mend it, or move it aside to start the choices again."
48
+ end
49
+
50
+ def revision = document&.fetch("revision", 0).to_i
51
+
52
+ # Keeps a newer writing, beside the file and moved over it, so a reader
53
+ # never meets half a file.
54
+ def keep(incoming)
55
+ incoming.fetch("revision", 0).to_i > revision or raise Stale, "These choices were saved from another page since this one read them."
56
+
57
+ @path.dirname.mkpath
58
+ draft = @path.sub_ext(".json.part")
59
+ draft.write(JSON.pretty_generate(incoming))
60
+ draft.rename(@path)
61
+ @document = incoming
62
+ end
63
+
64
+ def progress(number, decisions)
65
+ chosen = entries("choices").select { it.dig("decision", "pr") == number }
66
+ Progress.of(decisions.index_with { |decision| decision.ticked || chosen.any? { stands?(it, decision) } },
67
+ drafts: entries("pending").count { it.dig("decision", "pr") == number && it["text"].to_s.strip.present? })
68
+ end
69
+
70
+ # A choice still stands while its decision reads as it did and rests on
71
+ # the pictures it was made on. The page's script, reopened() in
72
+ # review-drafts.js, follows the same rules.
73
+ def stands?(entry, decision) = reads_as_it_did?(entry, decision) && !pictures_changed?(entry, decision)
74
+
75
+ private
76
+
77
+ def entries(field) = Array(document&.dig(field))
78
+
79
+ # Its words, theme markers aside. One the owner made by itself that kept
80
+ # its reference through a rewrite stands when nothing was asked of the
81
+ # agent, or when the owner confirmed what came back. One made by leaving
82
+ # every non-blocking decision at once was made on less attention: it
83
+ # stands only while its words are the same and its decision is still
84
+ # non-blocking.
85
+ def reads_as_it_did?(entry, decision)
86
+ was = entry.fetch("decision", {})
87
+ same = [was["lead"], Theme.unmarked(was["text"])] == [decision.lead, decision.bare_text]
88
+ return same && !promoted?(entry, decision) if left_in_bulk?(entry)
89
+ return true if same
90
+
91
+ was["key"].present? && was["key"] == decision.key && (entry["kind"] == "leave" || confirmed?(entry))
92
+ end
93
+
94
+ # A bulk leave records the grade each decision had.
95
+ def left_in_bulk?(entry) = entry["batch"].present? && entry["grade"].present?
96
+
97
+ def promoted?(entry, decision) = entry["grade"] == "non_blocking" && decision.blocking?
98
+
99
+ def confirmed?(entry) = document.dig("feedback", entry["requestId"], "resolution") == "confirmed"
100
+
101
+ # Its pictures, matched by name: one added or taken away, or one whose
102
+ # pixels are no longer those recorded. A picture whose pixels are not
103
+ # known, then or now, changes nothing: the page says it is unverified. A
104
+ # choice made before pictures were recorded has none to hold it to.
105
+ def pictures_changed?(entry, decision)
106
+ was = entry.dig("decision", "pictures") or return false
107
+ seen = was.to_h { [it["name"], it["signature"]] }
108
+ now = decision.pictures.to_h { [it.name, Picture.signature(it.asset)] }
109
+ seen.keys.to_set != now.keys.to_set || now.any? { |name, signature| signature && seen[name] && signature != seen[name] }
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "open3"
5
+
6
+ module Proctolog
7
+ class CodeReview
8
+ BASE = "refs/heads/proctolog-base"
9
+ HEAD = "refs/heads/proctolog-head"
10
+
11
+ # What Diffle holds of the pull request at a glance: the comments nobody
12
+ # resolved, which join the next handover, and how many of the files of
13
+ # the code last opened are marked viewed. `files` is nil until it opens.
14
+ Tally = Data.define(:open, :viewed, :files)
15
+
16
+ def initialize(burst, number)
17
+ @burst, @number = burst, PullRequest.number(number) || raise(ArgumentError, "#{number.inspect} is no pull request's number")
18
+ end
19
+
20
+ def folder = Marks.directory_of(@burst).join("code", @number.to_s)
21
+ def checkout = folder.join("checkout")
22
+
23
+ def snapshot
24
+ file = folder.join("snapshot.json")
25
+ file.file? ? JSON.parse(file.read) : nil
26
+ rescue JSON::ParserError => e
27
+ raise Failure, "The code-review snapshot #{file} could not be read: #{e.message}"
28
+ end
29
+
30
+ def threads
31
+ file = checkout.join(".git/diffle/comments.json")
32
+ return [] unless file.file?
33
+
34
+ data = JSON.parse(file.read)
35
+ raise Failure, "Diffle's comments in #{file} use an unsupported format." unless data["version"] == 3 && data["sets"].is_a?(Hash)
36
+
37
+ data["sets"].flat_map { |key, set| Array(set["threads"]).map { it.merge("mode" => key) } }.uniq { it.fetch("id") }
38
+ rescue JSON::ParserError, KeyError, NoMethodError => e
39
+ raise Failure, "Diffle's comments in #{file} could not be read: #{e.message}"
40
+ end
41
+
42
+ def requests
43
+ captured = snapshot
44
+ return [] unless captured
45
+
46
+ readings = captured.fetch("readings", {})
47
+ threads.reject { it["resolved"] }.map do |thread|
48
+ focused = thread.fetch("mode")[/\Acommit:(\h{40})\z/, 1]
49
+ reading = readings.find { |asked, kept| set_of(kept, asked) == thread.fetch("mode") }&.last || captured
50
+ {id: "code:#{@number}:#{thread.fetch("id")}", pr: @number, kind: "remark",
51
+ text: thread.fetch("messages").map { it.fetch("body") }.join("\n\n"),
52
+ source: {kind: "code", head: focused || reading.fetch("head"), base: focused ? "#{focused}^" : reading.fetch("oldSha"),
53
+ anchor: thread.fetch("anchor"), mode: thread.fetch("mode"), stale: thread.fetch("stale"),
54
+ updatedAt: thread.fetch("messages").map { it.fetch("updatedAt") }.max}}
55
+ end
56
+ end
57
+
58
+ def viewed
59
+ file = checkout.join(".git/diffle/comments.json")
60
+ captured = snapshot
61
+ return [] unless file.file? && captured
62
+
63
+ Array(JSON.parse(file.read).dig("sets", set_of(captured), "viewed"))
64
+ end
65
+
66
+ # The files of the code last opened that are marked viewed as they are
67
+ # there: a mark on another version of a file does not count.
68
+ def files_viewed
69
+ marked = viewed.select { it["viewed"] }
70
+ Array(snapshot&.fetch("files")).count { |file| marked.any? { it.values_at("path", "blob") == file.values_at("path", "blob") } }
71
+ end
72
+
73
+ def tally = Tally.new(open: threads.count { !it["resolved"] }, viewed: files_viewed, files: snapshot&.fetch("files")&.size)
74
+
75
+ def start(stop, head: nil, base: nil)
76
+ folder.mkpath
77
+ File.open(folder.join("start.lock"), "a") do |lock|
78
+ lock.flock(File::LOCK_EX)
79
+ pair = revisions(stop).dup
80
+ pair["prHead"] = pair.fetch("headRefOid")
81
+ if head
82
+ raise Failure, "The returned head must be a full available commit hash." unless held?(head)
83
+ pair["headRefOid"] = head
84
+ end
85
+ if base == "main"
86
+ pair["baseRefOid"] = main_head
87
+ elsif base
88
+ raise Failure, "The reviewed base must be a full available commit hash, or main." unless held?(base)
89
+ pair["baseRefOid"] = base
90
+ end
91
+ pair["comparison"] = if base == "main"
92
+ "main"
93
+ elsif base
94
+ "changes"
95
+ else
96
+ "layer"
97
+ end
98
+ separator = (base && base != "main") ? ".." : "..."
99
+ pair["range"] = "#{pair.fetch("baseRefOid")}#{separator}#{pair.fetch("headRefOid")}"
100
+ captured = snapshot
101
+ return captured.fetch("url") if captured && captured["mode"] == "range:#{pair.fetch("range")}" && live?(captured)
102
+
103
+ stop_server if captured
104
+ prepare(pair)
105
+ launch(pair)
106
+ end
107
+ end
108
+
109
+ def live?(captured = snapshot)
110
+ return false unless captured
111
+
112
+ answer = get(captured.fetch("url"), "/api/snapshot")
113
+ answer["root"] == checkout.realpath.to_s
114
+ rescue Failure, KeyError
115
+ false
116
+ end
117
+
118
+ def stop_server
119
+ captured = snapshot
120
+ return unless live?(captured)
121
+
122
+ Process.kill("TERM", captured.fetch("pid"))
123
+ rescue Errno::ESRCH
124
+ nil
125
+ end
126
+
127
+ private
128
+
129
+ # The comparison a snapshot opened, as the desk asked for it. The first
130
+ # snapshots compared the pinned refs and did not say so.
131
+ def mode_of(captured) = captured.fetch("mode", "range:#{BASE}...#{HEAD}")
132
+
133
+ # The set Diffle keeps a comparison's comments and viewed marks in. Diffle
134
+ # names it for what it compares: for three dots, the merge base, not the
135
+ # base it was given. It says the name as it opens, and the desk keeps it;
136
+ # a reading kept before then has the name made again from its revisions.
137
+ def set_of(reading, asked = mode_of(reading)) = reading["set"] || asked.sub(/\Arange:\h{40}/) { "range:#{reading.fetch("oldSha")}" }
138
+
139
+ def held?(hash)
140
+ hash.is_a?(String) && hash.match?(/\A[a-f0-9]{40}\z/) && [Project.current.root, checkout].any? do |root|
141
+ root.directory? && Open3.capture2e("git", "-C", root.to_s, "cat-file", "-e", "#{hash}^{commit}").last.success?
142
+ end
143
+ end
144
+
145
+ def main_head
146
+ output, status = Open3.capture2e("gh", "api", "repos/#{@burst.repository}/commits/main", "--jq", ".sha")
147
+ hash = output.strip
148
+ raise Failure, "GitHub could not name main's revision: #{output.strip}" unless status.success? && hash.match?(/\A[a-f0-9]{40}\z/)
149
+ hash
150
+ end
151
+
152
+ def revisions(stop) = revisions_now.tap { read_again_for(it.fetch("headRefOid")) unless it["headRefOid"] == stop.head_oid }
153
+
154
+ # Its base and head as GitHub names them this moment, or as this Mac
155
+ # holds them while nobody has opened it.
156
+ def revisions_now
157
+ return Unopened.find(@number)&.heads || raise(Failure, "##{@number} is no longer waiting unopened.") if PullRequest.unopened?(@number)
158
+
159
+ output, status = Open3.capture2e("gh", "pr", "view", @number.to_s, "--repo", @burst.repository, "--json", "baseRefOid,headRefOid")
160
+ raise Failure, "GitHub could not name this PR's revisions: #{output.strip}" unless status.success?
161
+
162
+ JSON.parse(output).tap do |pair|
163
+ raise Failure, "GitHub returned invalid revision hashes." unless %w[baseRefOid headRefOid].all? { pair[it].to_s.match?(/\A\h{40}\z/) }
164
+ end
165
+ end
166
+
167
+ # A head that moved since the desk read GitHub has the desk read it
168
+ # again, as `r` would, so the pull request's page and Diffle show the
169
+ # same code. GitHub moving once more in those seconds is said.
170
+ def read_again_for(head)
171
+ now = @burst.route(fresh: true).at(@number) or raise Failure, "##{@number} is no longer part of #{@burst.name}."
172
+ now.head_oid == head or raise Failure, "##{@number} moved on GitHub while the desk read it again. Open its code once more."
173
+ end
174
+
175
+ def prepare(pair)
176
+ git!("clone", "--quiet", "--local", "--no-checkout", Project.current.root.to_s, checkout.to_s) unless checkout.join(".git").directory?
177
+ hashes = pair.values_at("baseRefOid", "headRefOid")
178
+ absent = hashes.reject { Open3.capture2e("git", "-C", checkout.to_s, "cat-file", "-e", "#{it}^{commit}").last.success? }
179
+ local = absent.select { Open3.capture2e("git", "-C", Project.current.root.to_s, "cat-file", "-e", "#{it}^{commit}").last.success? }
180
+ git!("-C", checkout.to_s, "fetch", "--quiet", "--no-tags", Project.current.root.to_s, *local) if local.any?
181
+ absent -= local
182
+ unless absent.empty?
183
+ raise Failure, "Invalid GitHub repository name." unless @burst.repository.match?(%r{\A[\w.-]+/[\w.-]+\z})
184
+ git!("-C", checkout.to_s, "fetch", "--quiet", "--no-tags", "https://github.com/#{@burst.repository}.git", *absent)
185
+ end
186
+ git!("-C", checkout.to_s, "update-ref", BASE, hashes.first)
187
+ git!("-C", checkout.to_s, "update-ref", HEAD, hashes.last)
188
+ end
189
+
190
+ def launch(pair)
191
+ log = folder.join("server.log")
192
+ output = folder.join("export.md")
193
+ command = ENV.fetch("PROCTOLOG_DIFFLE", "diffle")
194
+ arguments = [command, "-C", checkout.to_s, pair.fetch("range"), "--no-open", "--no-lsp", "--no-watch", "--keep-alive", "--host", "127.0.0.1", "--port", "0"]
195
+ pid = Outside.run do |environment|
196
+ Process.spawn(environment.merge("NO_COLOR" => "1"), *arguments, out: output.to_s, err: log.to_s)
197
+ end
198
+ process = Process.detach(pid)
199
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
200
+ loop do
201
+ url = log.file? && log.read[%r{http://127\.0\.0\.1:\d+/}]
202
+ if url
203
+ answer = get(url, "/api/snapshot")
204
+ raise Failure, "Diffle opened a different checkout or head." unless answer["root"] == checkout.realpath.to_s && answer["newSha"] == pair["headRefOid"]
205
+
206
+ previous = snapshot
207
+ readings = previous&.fetch("readings", {}) || {}
208
+ readings[mode_of(previous)] ||= previous.slice("head", "base", "oldSha", "set") if previous
209
+ mode = "range:#{pair.fetch("range")}"
210
+ set = answer.dig("mode", "commentKey")
211
+ readings[mode] = {"head" => pair.fetch("headRefOid"), "base" => pair.fetch("baseRefOid"), "oldSha" => answer.fetch("oldSha"), "set" => set}.compact
212
+ captured = {"pid" => pid, "url" => url, "mode" => mode, "set" => set, "readings" => readings, "comparison" => pair.fetch("comparison"), "head" => pair.fetch("headRefOid"), "prHead" => pair.fetch("prHead"), "base" => pair.fetch("baseRefOid"),
213
+ "oldSha" => answer.fetch("oldSha"), "files" => answer.fetch("changed"), "openedAt" => Time.current.iso8601}.compact
214
+ file = folder.join("snapshot.json")
215
+ file.sub_ext(".part").write(JSON.pretty_generate(captured))
216
+ file.sub_ext(".part").rename(file)
217
+ return url
218
+ end
219
+ raise Failure, "Diffle did not start. See #{log}.\n#{log.file? ? log.read.last(2000) : "No log was written."}" if !process.alive? || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
220
+ sleep 0.1
221
+ end
222
+ rescue Errno::ENOENT
223
+ raise Failure, "Diffle is not on PATH. Install it or set PROCTOLOG_DIFFLE to its executable."
224
+ rescue Failure
225
+ begin
226
+ Process.kill("TERM", pid) if pid
227
+ rescue Errno::ESRCH
228
+ nil
229
+ end
230
+ raise
231
+ end
232
+
233
+ def get(url, path)
234
+ origin = URI.parse(url)
235
+ raise Failure, "The saved Diffle address is not a loopback address." unless origin.scheme == "http" && origin.host == "127.0.0.1" && origin.port.positive?
236
+
237
+ response = Net::HTTP.start(origin.host, origin.port, nil, open_timeout: 2, read_timeout: 15) { it.get(path) }
238
+ raise Failure, "Diffle did not answer (#{response.code})." unless response.is_a?(Net::HTTPSuccess)
239
+
240
+ JSON.parse(response.body)
241
+ rescue IOError, SystemCallError, Timeout::Error, JSON::ParserError, URI::InvalidURIError => e
242
+ raise Failure, "Diffle could not be reached: #{e.message}"
243
+ end
244
+
245
+ def git!(*arguments)
246
+ output, status = Open3.capture2e("git", *arguments)
247
+ raise Failure, "The private code-review checkout could not be prepared: #{output.strip}" unless status.success?
248
+ output
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A decision record of the repository, under docs/adr/: where a decision is
5
+ # recorded once it is more than a ticked box. Read from disk, never copied.
6
+ class DecisionRecord < Data.define(:file, :title)
7
+ def self.all(directory = nil) = Array(directory || Project.current.decision_records).flat_map { it.glob("*.md") }.sort.map { from(it) }
8
+
9
+ # Its first heading names it; a file with none is named by its file.
10
+ def self.from(path) = new(file: path.basename.to_s, title: path.each_line.find { it.start_with?("# ") }&.delete_prefix("# ")&.strip || path.basename(".md").to_s)
11
+ end
12
+ end