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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The kind of question a decision asks, so that one kind can be answered
5
+ # across a burst at once. A project lists its themes, each with what it
6
+ # means, under `themes` in .proctolog/project.yml; without that, the desk's
7
+ # own list stands. A decision names its theme in a hidden marker,
8
+ # `<!-- proctolog:theme copy -->`, which is no part of its words: a name
9
+ # the list does not hold is "other", and a decision with no marker has none.
10
+ class Theme < Data.define(:name, :meaning)
11
+ MARKER = /<!-- proctolog:theme ([\w-]+) -->/
12
+
13
+ DEFAULTS = {
14
+ "copy" => "What the words on a screen say, and how",
15
+ "fit" => "How a screen holds what is on it: wrapping, folds, sizes",
16
+ "errors" => "What happens when something fails, and what the member is told",
17
+ "accounts" => "Who a member is: signing up, signing in, their account",
18
+ "data" => "What is kept, moved or deleted, and who may see it",
19
+ "plan" => "What was built against what was planned",
20
+ "build" => "How the code is made: its parts, its dependencies, its tools",
21
+ "limits" => "Bounds: sizes, counts, rates and time"
22
+ }.freeze
23
+
24
+ OTHER = new(name: "other", meaning: "A theme the project does not list")
25
+
26
+ # The project's themes, in its own order.
27
+ def self.all = (Project.current.setting(:themes).presence || DEFAULTS).map { |name, meaning| new(name: name.to_s, meaning: meaning.to_s) }
28
+
29
+ # The theme a marker's name stands for among `themes`: "other" for one
30
+ # they do not hold, and none for no name.
31
+ def self.named(name, themes = all) = name && (themes.find { it.name == name } || OTHER)
32
+
33
+ # Words with their theme markers taken out.
34
+ def self.unmarked(text) = text.to_s.gsub(/\s*#{MARKER}/o, "")
35
+
36
+ def other? = self == OTHER
37
+
38
+ def to_s = name
39
+ end
40
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class Unopened
5
+ # The numbers the desk gave the pull requests nobody has opened yet, in
6
+ # one YAML file of the store: a branch keeps its number across bursts and
7
+ # restarts, and once it is opened says which pull request it became.
8
+ #
9
+ # "01": {branch: recovery/fields}
10
+ # "02": {branch: recovery/emails, opened_as: 1140}
11
+ class Numbers
12
+ # Two pages may meet a new branch at once; only one numbers it.
13
+ WRITING = Mutex.new
14
+
15
+ def initialize(path) = @path = path
16
+
17
+ def read = (@path.exist? && YAML.safe_load_file(@path)) || {}
18
+
19
+ # The branch's number, a new one when it has none; nil once it is opened.
20
+ def of(branch)
21
+ WRITING.synchronize do
22
+ kept = read
23
+ number, entry = kept.find { |_, said| said["branch"] == branch }
24
+ return (number unless entry["opened_as"]) if entry
25
+
26
+ "0#{kept.keys.map(&:to_i).max.to_i + 1}".tap { write(kept.merge(it => {"branch" => branch})) }
27
+ end
28
+ end
29
+
30
+ def branch(number) = read.dig(number, "branch")
31
+
32
+ # The pull request it was opened as, once it was.
33
+ def opened_as(number) = read.dig(number, "opened_as")
34
+
35
+ def opened(number, as:) = WRITING.synchronize { write(read.tap { it.fetch(number)["opened_as"] = as }) }
36
+
37
+ private
38
+
39
+ # Written beside the file and moved over it, so a reader never meets half a file.
40
+ def write(numbers)
41
+ @path.dirname.mkpath
42
+ draft = @path.sub_ext(".yml.part")
43
+ draft.write(numbers.to_yaml(line_width: -1))
44
+ draft.rename(@path)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class Unopened
5
+ # A pull request that waited unopened and is open on GitHub now. What
6
+ # the owner kept of it under its number with a leading zero moves, in
7
+ # every burst and once, to the number GitHub gave it: its marks and
8
+ # notes, choices and drafts, what handovers asked and agents answered,
9
+ # its code comments and its films. Its route says so until the owner has
10
+ # seen it. GitHub is the truth about it from then on.
11
+ #
12
+ # A trial desk carries its own marks over, every time it meets the
13
+ # branch open, and never says in the register that it was opened: the
14
+ # owner's desk would then never carry the owner's marks.
15
+ class Opening
16
+ # Two pages may meet the opened branch at once; only one carries it.
17
+ CARRYING = Mutex.new
18
+
19
+ # The keys under which a pull request is named by its number alone.
20
+ NUMBERED = %w[pr pull_requests handed_over].freeze
21
+
22
+ def initialize(waiting, as:)
23
+ @from, @to = waiting.number, as
24
+ # An id that begins with the number: a note's 01-2, a choice's
25
+ # choice:01-0:…, a film's 01/sign-in.
26
+ @begun = /(\A|:)#{Regexp.escape(@from)}(?=-\d|\/)/
27
+ end
28
+
29
+ def carry_over
30
+ CARRYING.synchronize do
31
+ next if Unopened.numbers.opened_as(@from)
32
+
33
+ Marks.root.then { it.directory? ? it.children.select(&:directory?) : [] }.each { carry(it) }
34
+ Recording.directory.then { it.directory? ? it.children.select(&:directory?) : [] }.each { move(it.join(@from), it.join(@to.to_s)) }
35
+ Unopened.numbers.opened(@from, as: @to) unless Marks.trial?
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # A burst that held anything of it says so on its route.
42
+ def carry(burst)
43
+ carried = [move(burst.join("#{@from}.yml"), burst.join("#{@to}.yml")), move(burst.join("code", @from), burst.join("code", @to.to_s)),
44
+ *burst.glob("**/*.{json,yml}").map { rewrite(it) }]
45
+ Membership.new(burst.join("membership.yml")).then { it.note_opened(@to, was: @from) if carried.any? && it.pull_requests.include?(@to) }
46
+ end
47
+
48
+ # What was kept under the new number already stays, and the old waits
49
+ # beside it. True when it moved.
50
+ def move(from, to) = (from.rename(to).zero? if from.exist? && !to.exist?)
51
+
52
+ # Rewritten only when it named the old number, beside the file and
53
+ # moved over it; true then. A file the desk cannot read is left for
54
+ # the owner.
55
+ def rewrite(file)
56
+ json = file.extname == ".json"
57
+ before = json ? JSON.parse(file.read) : YAML.safe_load_file(file)
58
+ after = renumbered(before)
59
+ return false if after == before
60
+
61
+ draft = file.sub_ext("#{file.extname}.part")
62
+ draft.write(json ? JSON.pretty_generate(after) : after.to_yaml(line_width: -1))
63
+ draft.rename(file).zero?
64
+ rescue JSON::ParserError, Psych::Exception
65
+ false
66
+ end
67
+
68
+ def renumbered(value, under = nil)
69
+ case value
70
+ when Hash then value.to_h { |key, inner| [renamed(key), renumbered(inner, key)] }
71
+ when Array then value.map { renumbered(it, under) }
72
+ when @from then NUMBERED.include?(under) ? @to : value
73
+ when String then begun_anew(value)
74
+ else value
75
+ end
76
+ end
77
+
78
+ # A key by the pull request, as a batch keeps its heads, or by one of its films.
79
+ def renamed(key)
80
+ return @to.to_s if key == @from
81
+
82
+ key.is_a?(String) ? begun_anew(key) : key
83
+ end
84
+
85
+ def begun_anew(text) = text.sub(@begun) { "#{$1}#{@to}" }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Proctolog
6
+ # A pull request nobody has opened yet: a branch on this Mac, and a folder
7
+ # of the owner's store named after it, which holds what its agent wrote as
8
+ # `gh pr create` would be told it. A title and a base as front matter,
9
+ # then the description, with its pictures beside it:
10
+ #
11
+ # ~/.local/share/proctolog/<owner>/<repo>/unopened/recovery/emails/
12
+ # pull_request.md
13
+ # reset-mail-after.png
14
+ #
15
+ # The desk reviews it as it reviews an open one, under a number GitHub
16
+ # never gives, 01, which the branch keeps (Numbers). Once the branch is
17
+ # opened, the pull request is GitHub's and the folder is left alone.
18
+ #
19
+ # The class answers for these numbers what Github answers for its own: a
20
+ # rendering, the description as it stands, a rewrite and its history.
21
+ class Unopened
22
+ FILE = "pull_request.md"
23
+
24
+ # What `gh pr create` would be told, above the description.
25
+ FRONT_MATTER = /\A---\n(?<settings>.*?)^---[ \t]*\n/m
26
+
27
+ # A branch of this Mac, or of origin as this Mac last fetched it: where
28
+ # it is now, and when it was last committed to.
29
+ Branch = Data.define(:head, :committed_at)
30
+
31
+ class << self
32
+ # Every one whose branch is on this Mac, in the order of their numbers;
33
+ # read again only when a branch moves or a folder changes.
34
+ def all
35
+ state = [folder.glob("**/#{FILE}").map { [it, it.mtime] }, branches, numbers.read]
36
+ return @all if @read == state
37
+
38
+ @read = state
39
+ @all = state.first.filter_map { |file, _| from(file) }.sort_by { it.number.to_i }
40
+ end
41
+
42
+ # Those still waiting, now that `open` are open on GitHub. One whose
43
+ # branch has an open pull request is that pull request from now on: its
44
+ # review is carried over to it first.
45
+ def waiting(open:)
46
+ all.reject do |waiting|
47
+ opened = open.find { it.head == waiting.branch } or next false
48
+ Opening.new(waiting, as: opened.number).carry_over
49
+ true
50
+ end
51
+ end
52
+
53
+ def find(number) = all.find { it.number == number.to_s }
54
+
55
+ def on(branch) = all.find { it.branch == branch }
56
+
57
+ def folder = Project.current.unopened
58
+
59
+ # The folder a branch's pull request is written in, whether it holds
60
+ # one yet or not.
61
+ def folder_of(branch) = folder.join(branch)
62
+
63
+ def numbers = Numbers.new(folder.join("numbers.yml"))
64
+
65
+ # As Github answers them, for one of these numbers.
66
+ def rendering(number) = find(number)&.rendering
67
+
68
+ def description_now(number) = held(number).description.to_s
69
+
70
+ def description_revisions(number) = find(number)&.revisions || []
71
+
72
+ def rewrite(number, text) = held(number).rewrite(text)
73
+
74
+ # The desk writes the file itself: this only shows where.
75
+ def rewriting(number, file) = ["cp", file, held(number).file.to_s]
76
+
77
+ # Every branch of this Mac and of origin, asked of git at once.
78
+ def branches
79
+ output, status = Open3.capture2e("git", "-C", Project.current.root.to_s, "for-each-ref", "--format=%(refname)%09%(objectname)%09%(committerdate:iso-strict)", "refs/heads/", "refs/remotes/origin/")
80
+ return {} unless status.success?
81
+
82
+ output.lines(chomp: true).to_h do |line|
83
+ name, head, at = line.split("\t")
84
+ [name.delete_prefix("refs/heads/").sub(%r{\Arefs/remotes/}, ""), Branch.new(head:, committed_at: Time.iso8601(at))]
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def held(number) = find(number) || raise(Failure, "No pull request waits unopened as #{number}.")
91
+
92
+ # One whose branch is gone, or which was opened already, is not read.
93
+ def from(file)
94
+ branch = file.dirname.relative_path_from(folder).to_s
95
+ here = branches[branch] or return
96
+ number = numbers.of(branch) or return
97
+ new(branch:, number:, file:, at: here)
98
+ end
99
+ end
100
+
101
+ attr_reader :branch, :number, :file
102
+
103
+ def initialize(branch:, number:, file:, at:)
104
+ @branch, @number, @file, @at = branch, number, file, at
105
+ # Its words and the time of their edit, read together.
106
+ @text, @described_at = file.read.gsub("\r\n", "\n"), file.mtime.floor
107
+ @settings = @text[FRONT_MATTER, :settings]&.then { YAML.safe_load(it) } || {}
108
+ @settings.is_a?(Hash) or raise Failure, "#{Project.current.from_root(file)} opens with front matter that is not a title and a base."
109
+ rescue Psych::Exception => e
110
+ raise Failure, "#{Project.current.from_root(file)} opens with front matter that is not valid YAML (#{e.message.lines.first&.strip})."
111
+ end
112
+
113
+ def folder = file.dirname
114
+
115
+ def title = @settings["title"].presence || branch
116
+
117
+ def base = @settings["base"].presence || Project.current.default_branch
118
+
119
+ def description = Description.new(@text.sub(FRONT_MATTER, ""))
120
+
121
+ # Edited to the second, as a mark keeps the time.
122
+ attr_reader :described_at
123
+
124
+ def pull_request
125
+ @pull_request ||= PullRequest.new(number:, title:, url: nil, author: nil, base:, head: branch, head_oid: @at.head, description:, described_at:,
126
+ committed_at: @at.committed_at, files:, threads: [], stack_entry: nil)
127
+ end
128
+
129
+ # The files its branch changes since it left its base, as GitHub would
130
+ # list them; none while the base is on neither this Mac nor origin.
131
+ def files
132
+ under = base_head or return []
133
+
134
+ Rails.cache.fetch([:unopened_files, under, @at.head]) do
135
+ output, status = Open3.capture2e("git", "-C", Project.current.root.to_s, "diff", "--name-only", "--no-renames", "#{under}...#{@at.head}")
136
+ status.success? ? output.lines(chomp: true) : []
137
+ end
138
+ end
139
+
140
+ # Where its base is now: on this Mac, or else on origin as last fetched.
141
+ def base_head = self.class.branches.then { it[base] || it["origin/#{base}"] }&.head
142
+
143
+ # Its base and head, as GitHub names a pull request's for its code.
144
+ def heads
145
+ base_head or raise Failure, "#{base}, the base of #{branch}, is neither a branch of this Mac nor of origin."
146
+ {"baseRefOid" => base_head, "headRefOid" => @at.head}
147
+ end
148
+
149
+ # GitHub's HTML of the description, which GitHub keeps nothing of, with
150
+ # each picture beside it served from this Mac.
151
+ def rendering
152
+ repository = Project.current.github_repository or raise Failure, "#{Project.current.root} has no GitHub repository to render descriptions with."
153
+ fragment = Nokogiri::HTML5.fragment(Github.new(repository).rendered(description.to_s))
154
+ fragment.css("img").each do |img|
155
+ picture = picture(img["src"]) or next
156
+ address = "/pictures/#{picture.asset}.#{picture.extension}"
157
+ img["src"] = address
158
+ img.parent["href"] = address if img.parent.name == "a"
159
+ end
160
+ keep_revision
161
+ fragment.to_html
162
+ end
163
+
164
+ # Its text with new words, its front matter as it was. Written beside
165
+ # the file and moved over it, so a reader never meets half of one.
166
+ def rewrite(text)
167
+ draft = file.sub_ext(".md.part")
168
+ draft.write("#{@text[FRONT_MATTER]}#{text}")
169
+ draft.rename(file)
170
+ end
171
+
172
+ # Each text the desk read, newest first, as GitHub keeps a description's
173
+ # edits: what "since good to go" compares with.
174
+ def revisions
175
+ keep_revision
176
+ revisions_folder.glob("*.md").map { Github::Revision.new(at: Time.strptime(it.basename(".md").to_s, REVISION), text: it.read) }.sort_by(&:at).reverse
177
+ end
178
+
179
+ private
180
+
181
+ # A revision's file is named for the moment of its edit, in UTC.
182
+ REVISION = "%Y%m%dT%H%M%S%z"
183
+
184
+ def revisions_folder = folder.join(".revisions")
185
+
186
+ def keep_revision
187
+ revision = revisions_folder.join("#{described_at.utc.strftime(REVISION)}.md")
188
+ return if revision.exist?
189
+
190
+ revisions_folder.mkpath
191
+ revision.write(description.to_s)
192
+ end
193
+
194
+ # A picture beside the description, named by its file; nothing outside
195
+ # its folder, and nothing on the web.
196
+ def picture(source)
197
+ return if source.blank? || source.match?(%r{\A([a-z][a-z0-9+.-]*:|/)}i)
198
+
199
+ path = folder.join(CGI.unescape(source)).expand_path
200
+ Picture.import(path) if path.to_s.start_with?("#{folder.expand_path}/") && path.file?
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Where pressing a scenario's button sends the browser, and the cookies it is
5
+ # given on the way. Cookies on localhost are shared by every port, so a
6
+ # response from the desk can set the app's own. `overlay` is what became of
7
+ # Grouse's overlay on the way (Overlay::Opened or Refused), when the app has one.
8
+ class Visit < Data.define(:to, :cookies, :overlay)
9
+ GOOD_FOR = 2.minutes
10
+
11
+ def initialize(to:, cookies:, overlay: nil) = super
12
+
13
+ # One of the app's cookies (name=value), kept for as long as a browser's profile.
14
+ def self.cookie(pair) = "#{pair}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{12.months.to_i}"
15
+
16
+ # The visit that an address from #keep stands for, once; nil after that.
17
+ def self.take(token)
18
+ key = [:visit, token]
19
+ Rails.cache.read(key)&.then { new(**it) if Rails.cache.delete(key) }
20
+ end
21
+
22
+ # An address of the desk that is good once, so where it goes and what it
23
+ # sets stay out of the browser's history.
24
+ def keep = SecureRandom.urlsafe_base64(16).tap { Rails.cache.write([:visit, it], {to:, cookies:}, expires_in: GOOD_FOR) }
25
+ end
26
+ end
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for(:title) || "PRoctolog" %></title>
7
+ <%= csrf_meta_tags %>
8
+ <link rel="icon" type="image/png" href="/favicon.png">
9
+ <link rel="stylesheet" href="/desk.css">
10
+ <script src="/desk.js" defer></script>
11
+ <script type="module" src="/keys.js"></script>
12
+ <%# Before the page's own scripts: a # in any box answers its keys first. %>
13
+ <script type="module" src="/mentions.js"></script>
14
+ <% if @burst %>
15
+ <script type="module" src="/side.js"></script>
16
+ <% end %>
17
+ <%= yield :head %>
18
+ <% if (newer = Proctolog::Release.newer) %>
19
+ <%= tag.meta name: "proctolog-newer", content: newer, data: {news: newer.news} %>
20
+ <script type="module" src="/release.js"></script>
21
+ <% end %>
22
+ <%# Last, so that every page wears the same look: its panes, its type and its colours. %>
23
+ <link rel="stylesheet" href="/terminal.css">
24
+ </head>
25
+ <%= tag.body class: ("shell" if @burst || content_for?(:side)), data: {page: controller_name} do %>
26
+ <% if @burst %>
27
+ <%= render "proctolog/bursts/side", burst: @burst, at: @stop&.number %>
28
+ <% else %>
29
+ <%= yield :side %>
30
+ <% end %>
31
+ <div class="main">
32
+ <% flash.each do |kind, sentence| %>
33
+ <p class="flash <%= kind %>" role="status"><%= sentence %></p>
34
+ <% end %>
35
+ <%= yield %>
36
+ </div>
37
+ <% if content_for?(:keys) %>
38
+ <%# The keys of this page. The bar says the few that matter where the owner is; ? docks the list of them all above it, where it stays while the owner works, the keys of the block they are on first. %>
39
+ <footer class="foot" data-desk>
40
+ <section class="keys-help" id="keys-help" aria-label="Keys on this page" hidden>
41
+ <section class="this-block" data-this-block hidden>
42
+ <h3>This block</h3>
43
+ <dl></dl>
44
+ </section>
45
+ <%= yield :keys %>
46
+ <%= keys_group "Every page", "m" => "messages the desk showed" %>
47
+ </section>
48
+ <div class="modebar">
49
+ <p class="mode" data-mode><%= yield :mode %></p>
50
+ <button type="button" class="all-keys" data-show-keys aria-controls="keys-help" aria-expanded="false"><kbd>?</kbd> <span data-keys-word>keep the keys open</span></button>
51
+ <button type="button" class="all-keys" data-show-messages aria-controls="messages" aria-expanded="false"><kbd>m</kbd> messages</button>
52
+ </div>
53
+ </footer>
54
+ <% end %>
55
+ <%# Grouse's overlay, for a note on the desk itself. Last, as it lists the templates the page rendered. %>
56
+ <%= grouse_widget if Proctolog::Desk.grouse? %>
57
+ <% end %>
58
+ </html>
@@ -0,0 +1,34 @@
1
+ <% content_for :title, "Accept on ##{@stop.number}" %>
2
+ <% content_for :head do %>
3
+ <link rel="stylesheet" href="/decisions.css">
4
+ <% end %>
5
+
6
+ <% back = burst_decisions_path(@burst, show: params[:show], anchor: "pr-#{@stop.number}") %>
7
+ <% home = @stop.pull_request.opened? ? "the description on GitHub" : "its file on this Mac, as nobody has opened it yet" %>
8
+
9
+ <header class="burst">
10
+ <h1>Accept <%= pluralize(@boxes.size, "box") %> of #<%= @stop.number %></h1>
11
+ <p class="tally title"><%= @stop.title %></p>
12
+ <p class="tally">They are ticked in <%= home %>, and nowhere else. Nothing is ticked until you press Run it.</p>
13
+ </header>
14
+
15
+ <main class="acceptance">
16
+ <ol class="chosen">
17
+ <% @boxes.each do |box| %>
18
+ <li><%= box.plain_lead %></li>
19
+ <% end %>
20
+ </ol>
21
+
22
+ <h2>The command</h2>
23
+ <pre><code><%= @acceptance.command %></code></pre>
24
+ <p class="how">
25
+ Run it reads <%= home %> again, ticks exactly these lines and changes nothing else,
26
+ then reads it once more to check that it holds what was sent.
27
+ </p>
28
+
29
+ <div class="buttons">
30
+ <%= button_to "Run it", burst_pull_request_acceptance_path(@burst, @stop.number, show: params[:show]),
31
+ params: {positions: @boxes.map(&:position), digest: @stop.description.digest}, class: "primary" %>
32
+ <%= link_to "Back", back %>
33
+ </div>
34
+ </main>
@@ -0,0 +1,28 @@
1
+ <%# locals: (burst:, stop:, standing:) %>
2
+ <%# One pull request of the route, in a line: its number, its title, and at the right whose it is when it is not the owner's, and how far its decisions are. The line the cursor is on opens out: its title in full, what else there is to know of it, and its keys beside it. %>
3
+ <%= tag.li id: "pr-#{stop.number}", data: {stop: "", blocking_left: standing.progress.blocking_left, turn: standing.turn} do %>
4
+ <p class="what">
5
+ <%= link_to "##{stop.number}", burst_pull_request_path(burst, stop.number), class: "number" %>
6
+ <%= link_to stop.title, burst_pull_request_path(burst, stop.number), class: "title" %>
7
+ <span class="stands"><%= beside_a_line(standing) %></span>
8
+ </p>
9
+ <p class="facts">
10
+ <% if (waiting = not_opened(stop)) %><%= waiting %> ·<% end %>
11
+ <%= stop.stacked? ? "layer #{stop.layer}" : "stands alone" %> · <%= turn_of(standing).downcase_first %> ·
12
+ <% if (grades = grades_of(stop.description.decisions)).present? %><%= grades %> ·<% end %>
13
+ <% if (open = route_reasons_of(standing)) != "nothing is open" %><span class="reasons"><%= open %></span> ·<% end %>
14
+ <%= pluralize(stop.description.pictures.size.nonzero? || "no", "picture") %> ·
15
+ <%= number_with_delimiter(stop.description.words) %> words ·
16
+ <%= changed_since_mark(standing, stop.pull_request) %><%= open_threads(stop.pull_request) %>
17
+ </p>
18
+ <div class="beside" data-desk>
19
+ <%= link_to burst_pull_request_path(burst, stop.number) do %><kbd>Enter</kbd> open<% end %>
20
+ <% unless focus.part == stop.part %>
21
+ <%= link_to burst_path(burst, part: stop.part.to_param), data: {key: "p"} do %><kbd>p</kbd> this part only<% end %>
22
+ <% end %>
23
+ <%= button_to burst_pull_request_code_review_path(burst, stop.number), form: {target: "_blank", data: {code: ""}} do %><kbd>d</kbd> code in Diffle<% end %>
24
+ <% if stop.url %>
25
+ <%= link_to stop.url, target: "_blank", rel: "noopener", data: {key: "G"} do %><kbd>G</kbd> on GitHub<% end %>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%# locals: (burst:, merged:, shown:) %>
2
+ <%# The pull requests the burst held that went in, the latest first: put away under their heading until the owner asks for them, and shown at once when nothing else of the burst is left. Each says when it went in and how far its review got. %>
3
+ <section class="merged" id="merged" aria-labelledby="merged-heading">
4
+ <h2 id="merged-heading">Merged, <%= merged.size %></h2>
5
+ <% if shown %>
6
+ <ol class="lines">
7
+ <% merged.each do |pull_request| %>
8
+ <li>
9
+ <p class="what">
10
+ <%= link_to "##{pull_request.number}", pull_request.url, class: "number", target: "_blank", rel: "noopener" %>
11
+ <span class="title"><%= pull_request.title %></span>
12
+ </p>
13
+ <p class="facts">merged <%= pull_request.merged_at.localtime.strftime("%-d %b") %> · <%= progress_in_words(burst.choices.progress(pull_request.number, pull_request.description.decisions)) %></p>
14
+ </li>
15
+ <% end %>
16
+ </ol>
17
+ <% unless burst.finished? %>
18
+ <p class="hint"><%= link_to "Put them away", burst_path(burst, **request.query_parameters.symbolize_keys.except(:merged), anchor: "merged") %></p>
19
+ <% end %>
20
+ <% else %>
21
+ <p class="hint">Put away. <%= link_to "Show them", burst_path(burst, **request.query_parameters.symbolize_keys, merged: 1, anchor: "merged") %></p>
22
+ <% end %>
23
+ </section>
@@ -0,0 +1,16 @@
1
+ <%# locals: (burst:, pull_request:, way:) %>
2
+ <%# A pull request that joined the burst, left it, or was opened on GitHub since it waited unopened, `way` saying which, as a line of the route: one still in it opens on Enter, and each goes once x dismisses it. %>
3
+ <% joined = way != :left %>
4
+ <%= tag.li data: {stop: "", notice: way} do %>
5
+ <p class="what">
6
+ <%= joined ? link_to("##{pull_request.number}", burst_pull_request_path(burst, pull_request.number), class: "number") : tag.span("##{pull_request.number}", class: "number") %>
7
+ <span class="title"><%= pull_request.title || "no longer open" %></span>
8
+ <span class="stands"><%= pull_request.was ? "opened, was ##{pull_request.was}" : way %></span>
9
+ </p>
10
+ <div class="beside" data-desk>
11
+ <% if joined %>
12
+ <%= link_to burst_pull_request_path(burst, pull_request.number) do %><kbd>Enter</kbd> open<% end %>
13
+ <% end %>
14
+ <%= button_to burst_membership_path(burst), method: :patch, params: {number: pull_request.number}, form: {data: {dismiss: ""}} do %><kbd>x</kbd> dismiss<% end %>
15
+ </div>
16
+ <% end %>
@@ -0,0 +1,39 @@
1
+ <%# locals: (burst:, notices:) %>
2
+ <%# At the head of the route: how the burst moved since the owner last looked, and where its file and GitHub disagree, a line each, grouped by cause. What joined or left is a line of the route's own, which j and k rest on and x dismisses once seen; a disagreement stays while it is true, with its fix beside it. %>
3
+ <section class="notices" id="notices" aria-label="Notices">
4
+ <% if notices.joined.any? %>
5
+ <h2>Joined the burst</h2>
6
+ <ol class="lines"><%= render partial: "moved", collection: notices.joined, as: :pull_request, locals: {burst:, way: :joined} %></ol>
7
+ <% end %>
8
+ <% if notices.left.any? %>
9
+ <h2>Left the burst</h2>
10
+ <ol class="lines"><%= render partial: "moved", collection: notices.left, as: :pull_request, locals: {burst:, way: :left} %></ol>
11
+ <% end %>
12
+ <% if notices.opened.any? %>
13
+ <h2>Opened on GitHub, with their review</h2>
14
+ <ol class="lines"><%= render partial: "moved", collection: notices.opened, as: :pull_request, locals: {burst:, way: :opened} %></ol>
15
+ <% end %>
16
+
17
+ <% if notices.departed.any? %>
18
+ <h2>Left with your choices, until handed over</h2>
19
+ <% notices.departed.each do |departed| %>
20
+ <div class="departed">
21
+ <p class="what"><span class="number">#<%= departed.number %></span> <span class="title"><%= departed.title || "no longer open" %></span></p>
22
+ <ul>
23
+ <% departed.entries.each do |entry| %>
24
+ <li><%= entry_line(entry) %></li>
25
+ <% end %>
26
+ </ul>
27
+ </div>
28
+ <% end %>
29
+ <% end %>
30
+
31
+ <% notices.disagreements.group_by(&:cause).each do |cause, disagreements| %>
32
+ <h2><%= disagreement_heading(cause) %></h2>
33
+ <ul class="disagreements">
34
+ <% disagreements.each do |disagreement| %>
35
+ <li><%= disagreement.said %> <span class="fix"><%= disagreement.fix %></span></li>
36
+ <% end %>
37
+ </ul>
38
+ <% end %>
39
+ </section>
@@ -0,0 +1,11 @@
1
+ <%# locals: (burst:) %>
2
+ <%# The owner's word that they are done with a burst some of which is still open, and the way back. %>
3
+ <div class="put-away" data-desk>
4
+ <% if burst.put_away_by_hand? %>
5
+ <p>Put away by you: the desk no longer opens on it.</p>
6
+ <%= button_to "Take it out", burst_put_away_path(burst), method: :delete, class: "quiet" %>
7
+ <% else %>
8
+ <p>Done with it, open pull requests and all?</p>
9
+ <%= button_to "Put it away", burst_put_away_path(burst), class: "quiet" %>
10
+ <% end %>
11
+ </div>
@@ -0,0 +1,27 @@
1
+ <%# locals: (burst:, rules:) %>
2
+ <%# The rules the owner made of answers, each a line of the route that j and k rest on: c edits it, x withdraws it. Before the agent has a rule either changes it where it stands; after, the next handover carries the replacement or the withdrawal. %>
3
+ <section class="rules" id="rules" aria-label="Rules made in this burst">
4
+ <h2>Rules made in this burst</h2>
5
+ <ol class="lines">
6
+ <% rules.each do |rule| %>
7
+ <%= tag.li id: "rule-#{rule.id.delete_prefix("rule:")}", data: {stop: ("" unless rule.withdrawn?), rule: rule.id, state: rule.state} do %>
8
+ <p class="what">
9
+ <span class="number">rule</span>
10
+ <span class="title"><%= with_mentions(rule.text, burst:) %></span>
11
+ <span class="stands"><%= rule.state %></span>
12
+ </p>
13
+ <p class="facts"><%= rule_facts(rule) %></p>
14
+ <% unless rule.withdrawn? %>
15
+ <%= form_with url: burst_rule_path(burst, rule), method: :patch, class: "edit", data: {edit_rule: ""}, html: {hidden: true} do |form| %>
16
+ <%= form.text_field :text, value: rule.text, required: true, aria: {label: "The rule, in one line"} %>
17
+ <span class="said"><kbd>Enter</kbd> saves · <kbd>Esc</kbd> closes</span>
18
+ <% end %>
19
+ <div class="beside" data-desk>
20
+ <button type="button" data-edit><kbd>c</kbd> edit</button>
21
+ <%= button_to burst_rule_path(burst, rule), method: :delete, form: {data: {dismiss: ""}} do %><kbd>x</kbd> withdraw<% end %>
22
+ </div>
23
+ <% end %>
24
+ <% end %>
25
+ <% end %>
26
+ </ol>
27
+ </section>