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
data/bin/proctolog ADDED
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/proctolog/installation"
5
+
6
+ # A checkout runs in its own bundle, whatever the calling project's. A gem
7
+ # runs on what RubyGems installed with it, which its launcher has loaded.
8
+ if Proctolog::Installation.here.checkout? && ENV["PROCTOLOG_BUNDLED"] != "1"
9
+ require "bundler"
10
+ require "rbconfig"
11
+
12
+ Bundler.with_unbundled_env do
13
+ exec({"BUNDLE_FROZEN" => "true", "BUNDLE_GEMFILE" => File.expand_path("../Gemfile", __dir__), "PROCTOLOG_BUNDLED" => "1"}, RbConfig.ruby, Gem.bin_path("bundler", "bundle"), "exec", RbConfig.ruby, __FILE__, *ARGV)
14
+ end
15
+ end
16
+
17
+ require_relative "../config/application"
18
+ require_relative "../app/models/proctolog/project"
19
+ require "rbconfig"
20
+ require "thor"
21
+
22
+ module Proctolog
23
+ # Opens the checkout --project names, or the one the command runs in.
24
+ module Booting
25
+ private
26
+
27
+ # A command prints what it was asked for and nothing else, and asks
28
+ # 1Password for no keys. The desk's log, with what Grouse says at boot it
29
+ # is missing, and the keys are for `serve`, which says so in the block.
30
+ def boot!
31
+ Project.current = Project.discover(options[:project] || Dir.pwd)
32
+ Desk.config.log_level = :error
33
+ Desk.config.x.reads_keys = false
34
+ yield Desk.config if block_given?
35
+ Desk.initialize! unless Desk.initialized?
36
+ end
37
+
38
+ # A pull request as the owner names one: an open one by its number, and
39
+ # one nobody has opened yet by its branch, or by its number, 01. Nil for
40
+ # an open one; the unopened one otherwise, or a sentence that says why not.
41
+ def unopened_by(named)
42
+ if named.match?(/\A[1-9]\d*\z/) then nil
43
+ elsif PullRequest.unopened?(named) then Unopened.find(named) || abort("No pull request waits unopened as #{named}: `proctolog unopened` lists those that do.")
44
+ else Unopened.on(named) || abort("Nothing is written for #{named}. Write its pull_request.md in #{Unopened.folder_of(named)}.")
45
+ end
46
+ end
47
+ end
48
+
49
+ class BurstCommands < Thor
50
+ include Booting
51
+
52
+ def self.exit_on_failure? = true
53
+
54
+ class_option :project, type: :string, aliases: "-C", desc: "The Git checkout whose bursts to open"
55
+
56
+ desc "new NAME", "Start a burst of your own, kept outside the repository"
57
+ long_desc <<~TEXT
58
+ Writes NAME.yml among your own bursts, beside your marks under ~/.local/share/proctolog, where every
59
+ checkout of this repository on this Mac finds it and Git never does. NAME is lowercase words joined by
60
+ hyphens; it is what `route` and the desk's addresses use. Name its pull requests with --pr, --top and
61
+ --stack, each as many times as needed: an open pull request by its number, one nobody has opened yet by
62
+ its branch, so that the burst still holds it once it is opened.
63
+ TEXT
64
+ option :pr, type: :string, repeatable: true, desc: "A pull request that stands alone, by its number, or its branch while not opened"
65
+ option :top, type: :string, repeatable: true, desc: "The top of a chain, followed down by base branches"
66
+ option :stack, type: :numeric, repeatable: true, desc: "A stack GitHub holds, by its number"
67
+ option :title, type: :string, desc: "Its name on the desk (from NAME when left out)"
68
+ map "new" => :start
69
+ def start(slug)
70
+ boot!
71
+ file = Burst.start(slug, name: options[:title], stacks: options.fetch(:stack, []), tops: listed(options.fetch(:top, [])), pull_requests: listed(options.fetch(:pr, [])))
72
+ puts "Wrote #{Project.current.from_root(file)}. Open it with: proctolog route #{slug}"
73
+ rescue Project::Missing, Failure => e
74
+ abort e.message
75
+ end
76
+
77
+ # Thor gives every group its own `tree`; the top level's already shows this one's.
78
+ desc "tree", "Print a tree of all available commands", hide: true
79
+ def tree = super
80
+
81
+ no_commands do
82
+ # As a burst file lists them: by number, or by branch while not opened.
83
+ def listed(named) = named.map { it.match?(/\A[1-9]\d*\z/) ? Integer(it) : (PullRequest.unopened?(it) ? unopened_by(it).branch : it) }
84
+ end
85
+
86
+ desc "list", "List the bursts this checkout opens, and where each is kept"
87
+ def list
88
+ boot!
89
+ Burst.all.each { puts format("%-24s %-5s %s", it.slug, it.own? ? "yours" : "repo", Project.current.from_root(it.file)) }
90
+ rescue Project::Missing, Failure => e
91
+ abort e.message
92
+ end
93
+ end
94
+
95
+ class UnopenedCommands < Thor
96
+ include Booting
97
+
98
+ def self.exit_on_failure? = true
99
+
100
+ class_option :project, type: :string, aliases: "-C", desc: "The Git checkout whose pull requests to read"
101
+ default_command :list
102
+
103
+ desc "list", "List the pull requests nobody has opened yet: number, branch, base and title"
104
+ def list
105
+ boot!
106
+ waiting = Unopened.all
107
+ puts "Nothing waits unopened. One is written as #{Unopened.folder}/BRANCH/#{Unopened::FILE}." if waiting.empty?
108
+ waiting.each { puts format("#%-4s %-32s into %-24s %s", it.number, it.branch, it.base, it.title) }
109
+ rescue Project::Missing, Failure => e
110
+ abort e.message
111
+ end
112
+
113
+ desc "folder BRANCH", "Print the folder a pull request nobody has opened yet is written in"
114
+ long_desc <<~TEXT
115
+ Prints where the pull request of BRANCH is written while nobody has opened it, whether anything is there
116
+ yet or not. Write pull_request.md in it: a title and a base as front matter, then the description, with
117
+ its pictures beside it, linked by their file names. The desk reads it from there under a number with a
118
+ leading zero, 01, and once the branch is opened on GitHub, the pull request is GitHub's.
119
+ TEXT
120
+ def folder(branch)
121
+ boot!
122
+ puts Unopened.folder_of(branch)
123
+ rescue Project::Missing => e
124
+ abort e.message
125
+ end
126
+
127
+ desc "tree", "Print a tree of all available commands", hide: true
128
+ def tree = super
129
+ end
130
+
131
+ class CLI < Thor
132
+ include Booting
133
+
134
+ def self.exit_on_failure? = true
135
+
136
+ class_option :project, type: :string, aliases: "-C", desc: "The Git checkout whose bursts to open"
137
+
138
+ desc "burst SUBCOMMAND", "Start a burst of your own, or list the bursts there are"
139
+ subcommand "burst", BurstCommands
140
+
141
+ desc "unopened SUBCOMMAND", "List the pull requests nobody has opened yet, or say where one is written"
142
+ subcommand "unopened", UnopenedCommands
143
+
144
+ desc "serve", "Open the desk on http://localhost:4040"
145
+ option :port, type: :numeric, default: 4040, aliases: "-p", desc: "The port to listen on"
146
+ def serve
147
+ boot! do |config|
148
+ config.log_level = :warn
149
+ config.x.reads_keys = true
150
+ end
151
+ require "rackup"
152
+ Release.newer&.then { puts it.news }
153
+ Housekeeping.start
154
+ Rackup::Handler.get("puma").run(Desk, Host: "localhost", Port: options[:port])
155
+ rescue Errno::EADDRINUSE
156
+ abort "Port #{options[:port]} is taken. If that is the desk, it is open at http://localhost:#{options[:port]}; " \
157
+ "for a second one: proctolog serve --port #{options[:port] + 1}"
158
+ rescue Project::Missing => e
159
+ abort e.message
160
+ end
161
+
162
+ desc "route [BURST]", "Print the order to take a burst in"
163
+ def route(burst = nil)
164
+ boot!
165
+ burst ||= Burst.latest&.slug
166
+ Burst.find(burst).route.each do |stop|
167
+ puts format("%-6s #%-5s %s", stop.stacked? ? "#{stop.layer}/#{stop.of}" : "alone", stop.number, stop.title)
168
+ end
169
+ rescue Project::Missing => e
170
+ abort e.message
171
+ rescue Burst::Missing, Github::Unreachable, Failure => e
172
+ abort e.message
173
+ end
174
+
175
+ desc "handover [BURST]", "Hand what the owner decided so far to the agent that runs it"
176
+ long_desc <<~TEXT
177
+ Takes a pass as `e` on the desk does, with no page open: the owner's open notes, their choices on the
178
+ decisions, the code comments nobody resolved and their replies to answers that came back. Writes it as a
179
+ handover folder where the desk keeps them, and says where its brief is: read it, carry it out, and answer
180
+ in the one file it names. The desk takes that answer in, for the owner to confirm.
181
+
182
+ The owner may be halfway through: what they have not reviewed yet stays out, and the brief asks that it
183
+ be left as it is. What an earlier pass handed over and nobody answered is held back, so that two agents
184
+ never do the same work; --again takes it into this pass too. --part narrows the pass to one part of the
185
+ burst. With nothing to hand over, it writes nothing.
186
+ TEXT
187
+ option :part, type: :string, desc: "One part of the burst: its stack's or top's number, or alone"
188
+ option :again, type: :boolean, default: false, desc: "Take again what an earlier pass handed over and nobody answered"
189
+ def handover(burst = nil)
190
+ boot!
191
+ burst = Burst.find(burst || Burst.latest&.slug || abort("#{Project.current.root} has no burst. Start one: proctolog burst new NAME"))
192
+ route = burst.route
193
+ focus = Focus.on(route, options[:part])
194
+ abort "#{burst.name} has no part #{options[:part]}. Its parts: #{route.parts.map(&:to_param).join(", ")}" if options[:part] && focus.whole?
195
+ worklist = Rounds.new(burst, route:, focus:).worklist(batch_id: SecureRandom.uuid, again: options[:again])
196
+ handover = burst.handover(route, focus:, worklist:)
197
+ puts Handover::Taken.new(burst:, worklist:, brief: (handover.write if worklist.items.any?), answer: handover.answer_file)
198
+ rescue Project::Missing, Burst::Missing, Github::Unreachable, Failure => e
199
+ abort e.message
200
+ end
201
+
202
+ desc "check [PULL_REQUEST]", "Check a pull request's description as the desk reads it, or a file's with --file"
203
+ long_desc <<~TEXT
204
+ Reads a description with the desk's own parser and says, line by line, what would keep the desk from
205
+ reading it well (a fault) and what it only advises (a warning). PULL_REQUEST checks an open pull
206
+ request's description as GitHub holds it now, by its number, or one nobody has opened yet by its branch
207
+ or its number (01), with its pictures; --file checks a local Markdown file before it is published.
208
+ GitHub renders each, and keeps nothing of it.
209
+
210
+ Exits 0 with no faults, 1 with faults, and 2 when GitHub could not be asked.
211
+ TEXT
212
+ option :file, type: :string, aliases: "-f", desc: "A local Markdown file to check before it is published"
213
+ def check(named = nil)
214
+ boot!
215
+ github = Github.new(Project.current.github_repository || abort("#{Project.current.root} has no GitHub repository as its origin."))
216
+ named || options[:file] || abort("Name a pull request by its number or its branch, or a file with --file.")
217
+ markdown, rendering = if options[:file]
218
+ File.read(options[:file]).then { [it, github.rendered(it)] }
219
+ elsif (waiting = unopened_by(named))
220
+ [waiting.description.to_s, waiting.rendering]
221
+ else
222
+ github.described(Integer(named))
223
+ end
224
+ Check.new(markdown, rendering:).tap { puts it }.then { exit(it.passed? ? 0 : 1) }
225
+ rescue Github::Unreachable => e
226
+ warn e.message
227
+ exit 2
228
+ rescue Project::Missing, Failure, SystemCallError, ArgumentError => e
229
+ abort e.message
230
+ end
231
+
232
+ desc "themes", "Print the themes a decision may name, with what each means"
233
+ def themes
234
+ boot!
235
+ Theme.all.each { puts format("%-10s %s", it.name, it.meaning) }
236
+ rescue Project::Missing, Failure => e
237
+ abort e.message
238
+ end
239
+
240
+ desc "version", "Print which PRoctolog this is"
241
+ map %w[-v --version] => :version
242
+ def version = puts("PRoctolog #{VERSION}")
243
+
244
+ desc "install", "Link zsh's completion and the agent skills to this PRoctolog"
245
+ long_desc <<~TEXT
246
+ Links zsh's completion into ~/.zsh/completions and the agent skills into ~/.claude/skills and
247
+ ~/.agents/skills, from wherever this PRoctolog lives: the gem, or the checkout, which also gets its
248
+ launcher in ~/.local/bin. Leaves anything of the same name that is not PRoctolog's as it is.
249
+ `update` runs it for the version it installs.
250
+ TEXT
251
+ def install
252
+ puts Installation.here.install
253
+ rescue Installation::Refused => e
254
+ abort e.message
255
+ end
256
+
257
+ desc "update", "Install the latest PRoctolog, and link to it"
258
+ long_desc <<~TEXT
259
+ Asks RubyGems for the latest PRoctolog and, when it is newer than this one, installs it the way this one
260
+ was: with `rv tool install` for one rvx runs, with `gem install` otherwise. A checkout is pulled
261
+ forward instead, when it is clean and on origin's own branch, and its bundle installed. Then runs the
262
+ new one's `install`. A desk open now runs on as it was: restart it to run the new one.
263
+ TEXT
264
+ def update
265
+ installation = Installation.here
266
+ return take_with_git(installation) if installation.checkout?
267
+
268
+ Desk.initialize! unless Desk.initialized?
269
+ latest = Release.ask || abort("RubyGems named no PRoctolog: it did not answer, or holds none yet. Try again later.")
270
+ return puts("PRoctolog #{Release.running} is the latest.") unless latest > Release.running
271
+
272
+ installation.rv_tool? ? take_with_rv(latest) : take_with_gem(latest)
273
+ rescue Installation::Refused, RuntimeError => e
274
+ abort e.message
275
+ end
276
+
277
+ no_commands do
278
+ # rv runs as the shell would, not on the Ruby and gems it gave this one.
279
+ def take_with_rv(latest)
280
+ Outside.run do |environment|
281
+ system(environment, "rv", "tool", "install", "proctolog@#{latest}", "--gem-server", "#{Release.server}/", exception: true)
282
+ puts taken(latest)
283
+ # rvx runs the newest it holds, which is now this one; rv 0.7 runs no proctolog@VERSION.
284
+ exec(environment, "rvx", "--no-install", "proctolog", "install")
285
+ end
286
+ end
287
+
288
+ # With this Ruby's own gem command, into the gems this one is among.
289
+ def take_with_gem(latest)
290
+ system(RbConfig.ruby, File.join(RbConfig::CONFIG["bindir"], "gem"), "install", "proctolog", "--version", latest.version, "--clear-sources", "--source", Release.server, exception: true)
291
+ puts taken(latest)
292
+ # RubyGems' launcher takes the version to run as its first word.
293
+ exec(RbConfig.ruby, File.join(Gem.bindir, "proctolog"), "_#{latest}_", "install")
294
+ end
295
+
296
+ # The checkout's own bundle, on this Ruby, as bin/proctolog runs it.
297
+ def take_with_git(installation)
298
+ puts installation.pull
299
+ Bundler.with_unbundled_env do
300
+ system(RbConfig.ruby, Gem.bin_path("bundler", "bundle"), "install", "--quiet", chdir: installation.root.to_s, exception: true)
301
+ end
302
+ puts "Pulled #{installation.root} forward. Restart the desk to run it."
303
+ exec(RbConfig.ruby, installation.root.join("bin/install").to_s)
304
+ end
305
+
306
+ def taken(latest) = "Installed PRoctolog #{latest}. Restart the desk to run it: one open now still runs #{Release.running}."
307
+ end
308
+
309
+ desc "doctor", "Say what this Mac and this checkout lack, a line each with the fix"
310
+ long_desc <<~TEXT
311
+ Checks what the desk needs (gh signed in, the launcher on the PATH, a GitHub checkout gh can read, a burst
312
+ file) and what it would like (the PR-writing skill, Chrome, Diffle, ImageMagick, ffmpeg, an app.yml whose
313
+ programs are there, a free port 4040). Lines start ok, !! for a need that is missing, or -- for a nicety
314
+ that is off.
315
+
316
+ Exits 0 when every need is met, 1 when one is not.
317
+ TEXT
318
+ def doctor
319
+ project = begin
320
+ Project.current = Project.discover(options[:project] || Dir.pwd)
321
+ rescue Project::Missing
322
+ nil
323
+ end
324
+ Desk.initialize! unless Desk.initialized?
325
+ Doctor.new(project).tap { puts it }.then { exit(it.fine? ? 0 : 1) }
326
+ end
327
+ end
328
+ end
329
+
330
+ Proctolog::CLI.start
@@ -0,0 +1,143 @@
1
+ #compdef proctolog
2
+
3
+ # Completes proctolog: its commands, options and a checkout's burst names,
4
+ # your own ones first, then the repository's from bursts/ and from reviews/,
5
+ # where burst files were kept before; for check and burst new, the open pull
6
+ # requests of the desk's last reading, with their titles, and the branches
7
+ # whose pull requests nobody has opened yet; for unopened folder, every
8
+ # branch of the checkout.
9
+
10
+ # "owner/name" of the checkout's GitHub origin.
11
+ _proctolog_repository() {
12
+ local remote
13
+ remote=$(git -C "$1" remote get-url origin 2>/dev/null)
14
+ print -r -- ${${remote#*github.com[:/]}%.git}
15
+ }
16
+
17
+ # The open pull requests the desk last read for this checkout, as
18
+ # "number:title", read from the reading it keeps; none when it keeps none.
19
+ _proctolog_pull_requests() {
20
+ local repository reading
21
+ repository=$(_proctolog_repository "$1")
22
+ reading=${PROCTOLOG_CACHE:-$HOME/.cache/proctolog}/$repository/readings/${repository/\//-}.json
23
+ [[ -r $reading ]] || return
24
+ ruby -rjson -e 'JSON.parse(File.read(ARGV[0])).fetch("nodes").each { |node| puts "#{node["number"]}:#{node["title"].tr(":", " ")}" }' "$reading" 2>/dev/null
25
+ }
26
+
27
+ # The pull requests nobody has opened yet, as "branch:title", read from
28
+ # their folders in the store; the title is the front matter's.
29
+ _proctolog_unopened() {
30
+ local folder
31
+ folder=${PROCTOLOG_DATA:-$HOME/.local/share/proctolog}/$(_proctolog_repository "$1")/unopened
32
+ [[ -d $folder ]] || return
33
+ ruby -ryaml -e 'Dir.glob("**/pull_request.md", base: ARGV[0]).sort.each { |file| title = File.read(File.join(ARGV[0], file))[/\A---\n(.*?)^---/m, 1]&.then { YAML.safe_load(it)&.fetch("title", nil) rescue nil }; puts "#{File.dirname(file)}:#{title.to_s.tr(":", " ")}" }' "$folder" 2>/dev/null
34
+ }
35
+
36
+ # The checkout's own branches.
37
+ _proctolog_branches() {
38
+ git -C "$1" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null
39
+ }
40
+
41
+ # A burst's parts, as `handover --part` takes them: its stacks and tops by
42
+ # number, and alone; read from the first of the burst's files it is given.
43
+ _proctolog_parts() {
44
+ [[ -r $1 ]] || return
45
+ ruby -ryaml -e 'burst = YAML.safe_load_file(ARGV[0]) || {}; puts [*burst["stacks"], *burst["tops"], "alone"]' "$1" 2>/dev/null
46
+ }
47
+
48
+ _proctolog() {
49
+ local root own named
50
+ local -a commands burst_commands unopened_commands bursts pull_requests unopened branches parts project files
51
+ commands=(
52
+ 'serve:Open the desk on http://localhost:4040'
53
+ 'route:Print the order to take a burst in'
54
+ 'handover:Hand what the owner decided so far to the agent that runs it'
55
+ 'burst:Start a burst of your own, or list the bursts there are'
56
+ 'unopened:List the pull requests nobody has opened yet, or say where one is written'
57
+ 'check:Check a description as the desk reads it, of a pull request or a file'
58
+ 'themes:Print the themes a decision may name, with what each means'
59
+ 'doctor:Say what this Mac and this checkout lack, a line each with the fix'
60
+ 'version:Print which PRoctolog this is'
61
+ 'install:Link zsh'"'"'s completion and the agent skills to this PRoctolog'
62
+ 'update:Install the latest PRoctolog, and link to it'
63
+ 'tree:Print a tree of all available commands'
64
+ 'help:Describe the commands, or one of them'
65
+ )
66
+ burst_commands=(
67
+ 'new:Start a burst of your own, kept outside the repository'
68
+ 'list:List the bursts this checkout opens, and where each is kept'
69
+ 'help:Describe the burst commands, or one of them'
70
+ )
71
+ unopened_commands=(
72
+ 'list:List the pull requests nobody has opened yet'
73
+ 'folder:Print the folder a pull request nobody has opened yet is written in'
74
+ 'help:Describe the unopened commands, or one of them'
75
+ )
76
+ project=('(-C --project)'{-C,--project}'[Git checkout]:directory:_directories')
77
+ root=$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null)
78
+ own=${PROCTOLOG_DATA:-$HOME/.local/share/proctolog}/$(_proctolog_repository "$root")/bursts
79
+ bursts=($own/*.yml(N:t:r) $root/.proctolog/{bursts,reviews}/*.yml(N:t:r))
80
+ bursts=(${(u)bursts})
81
+
82
+ _arguments -C '1: :->command' '*:: :->argument'
83
+
84
+ case $state in
85
+ command) _describe -t commands 'command' commands ;;
86
+ argument)
87
+ case $words[1] in
88
+ serve) _arguments '(-p --port)'{-p,--port}'[The port to listen on]:port:' $project ;;
89
+ route) _arguments $project "1:burst:(${bursts})" ;;
90
+ handover)
91
+ named=${${words[2,-1]:#-*}[1]}
92
+ [[ -n $named ]] || named=${${(f)"$(ls -t $own/*.yml(N) $root/.proctolog/{bursts,reviews}/*.yml(N) 2>/dev/null)"}[1]:t:r}
93
+ files=($own/$named.yml(N) $root/.proctolog/{bursts,reviews}/$named.yml(N))
94
+ parts=(${(f)"$(_proctolog_parts "$files[1]")"})
95
+ _arguments $project \
96
+ "--part[One part of the burst, by its stack or top, or alone]:part:(${parts})" \
97
+ '--again[Take again what an earlier pass handed over and nobody answered]' \
98
+ "1:burst:(${bursts})" ;;
99
+ burst)
100
+ if (( CURRENT == 2 )); then
101
+ _describe -t commands 'burst command' burst_commands
102
+ else
103
+ case $words[2] in
104
+ new)
105
+ pull_requests=(${(f)"$(_proctolog_pull_requests "$root")"})
106
+ unopened=(${(f)"$(_proctolog_unopened "$root")"})
107
+ _arguments $project \
108
+ '*--pr[A pull request that stands alone]:pull request:{_describe -t pull-requests "open pull request" pull_requests; _describe -t unopened "not opened yet" unopened}' \
109
+ '*--top[The top of a chain, followed down by base branches]:pull request:{_describe -t pull-requests "open pull request" pull_requests; _describe -t unopened "not opened yet" unopened}' \
110
+ '*--stack[A stack GitHub holds, by its number]:stack number:' \
111
+ '--title[Its name on the desk]:title:' \
112
+ '2:burst name (lowercase words joined by hyphens):' ;;
113
+ list) _arguments $project ;;
114
+ help) _describe -t commands 'burst command' burst_commands ;;
115
+ esac
116
+ fi
117
+ ;;
118
+ unopened)
119
+ if (( CURRENT == 2 )); then
120
+ _describe -t commands 'unopened command' unopened_commands
121
+ else
122
+ case $words[2] in
123
+ list) _arguments $project ;;
124
+ folder)
125
+ branches=(${(f)"$(_proctolog_branches "$root")"})
126
+ _arguments $project "2:branch:(${branches})" ;;
127
+ help) _describe -t commands 'unopened command' unopened_commands ;;
128
+ esac
129
+ fi
130
+ ;;
131
+ check)
132
+ pull_requests=(${(f)"$(_proctolog_pull_requests "$root")"})
133
+ unopened=(${(f)"$(_proctolog_unopened "$root")"})
134
+ _arguments $project '(-f --file)'{-f,--file}'[A local Markdown file to check before it is published]:file:_files' \
135
+ "1:pull request:{_describe -t pull-requests 'open pull request' pull_requests; _describe -t unopened 'not opened yet' unopened}" ;;
136
+ themes|doctor|version|install|update) _arguments $project ;;
137
+ help) _describe -t commands 'command' commands ;;
138
+ esac
139
+ ;;
140
+ esac
141
+ }
142
+
143
+ _proctolog "$@"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The PRoctolog desk: a local page over a burst's pull requests.
4
+ # From a checkout it has its own bundle; installed as a gem, the gems RubyGems
5
+ # installed with it. Either way it reads only the selected project's files.
6
+
7
+ require "bundler/setup" if File.exist?(File.expand_path("../Gemfile", __dir__))
8
+ require_relative "../lib/proctolog/version"
9
+ require_relative "../lib/proctolog/installation"
10
+ require "ipaddr"
11
+ require "tmpdir"
12
+ require "action_controller/railtie"
13
+ # Grouse, the overlay the owner writes notes on the desk with, keeps them in a
14
+ # database, records their voice notes and screenshots, and reads them later.
15
+ require "active_record/railtie"
16
+ require "active_storage/engine"
17
+ require "active_job/railtie"
18
+ begin
19
+ require "grouse-rails"
20
+ rescue LoadError
21
+ # Not bundled: the Gemfile's :grouse group is optional, as the gem is private.
22
+ end
23
+
24
+ # The agent shell has no LANG, and descriptions hold dashes and arrows.
25
+ Encoding.default_external = Encoding.default_internal = Encoding::UTF_8
26
+
27
+ module Proctolog
28
+ class Desk < Rails::Application
29
+ # What is the owner's beyond any one repository: the secret below, and
30
+ # beside it a folder per repository (Project).
31
+ def self.data = Pathname(ENV.fetch("PROCTOLOG_DATA") { File.expand_path("~/.local/share/proctolog") })
32
+
33
+ # What the desk read and can read again: GitHub's answers, pictures, and
34
+ # which PRoctolog is the latest (Release).
35
+ def self.cache = Pathname(ENV.fetch("PROCTOLOG_CACHE") { File.expand_path("~/.cache/proctolog") })
36
+
37
+ # Grouse's notes on the desk itself, with the files they keep
38
+ # (config/database.yml, config/storage.yml): one store for every checkout
39
+ # the desk reviews, as the notes are about the desk. A test process keeps
40
+ # one of its own and drops it when it ends.
41
+ def self.grouse? = defined?(Grouse::Engine).present?
42
+
43
+ def self.grouse_notes
44
+ (Rails.env.test? ? scratch : data).join("grouse_notes").tap(&:mkpath)
45
+ end
46
+
47
+ # A value made once and kept in the data folder, outside the installed app
48
+ # and Git, for this user alone.
49
+ def self.kept(name)
50
+ file = data.join(name)
51
+ file.exist? ? file.read.strip : yield.tap { file.dirname.mkpath && file.write(it, perm: 0o600) }
52
+ end
53
+
54
+ def self.scratch = @scratch ||= Pathname(Dir.mktmpdir("proctolog")).tap { |made| at_exit { made.rmtree } }
55
+
56
+ config.root = File.expand_path("..", __dir__)
57
+ config.load_defaults 8.1
58
+ config.eager_load = false
59
+ config.enable_reloading = true
60
+ config.consider_all_requests_local = true
61
+ # Kept outside the installed app so an update does not sign out an open desk.
62
+ config.secret_key_base = kept("secret_key_base") { SecureRandom.hex(64) }
63
+ # Whatever the environment: a page of another site that points a name of
64
+ # its own at this Mac is turned away.
65
+ config.hosts = ["localhost", IPAddr.new("127.0.0.0/8"), IPAddr.new("::1")]
66
+ config.logger = ActiveSupport::Logger.new($stdout)
67
+ # Grouse says at boot what is not set up yet, which its tests need not hear.
68
+ config.log_level = Rails.env.test? ? :error : :warn
69
+ config.cache_store = :memory_store
70
+ # A desk at work keeps what it read of GitHub for its next start. Its tests
71
+ # read a made-up GitHub and must leave no trace of it beside the real one.
72
+ config.x.keeps_readings = !Rails.env.test?
73
+ config.public_file_server.enabled = true
74
+ config.public_file_server.headers = {"cache-control" => "no-cache"}
75
+
76
+ # Jobs run in the desk's own process: the pictures and pull requests it
77
+ # reads ahead of the pages that show them, and Grouse's write-up of a note
78
+ # a moment after it is sent. Its tests queue them and run none they do
79
+ # not ask for.
80
+ config.active_job.queue_adapter = Rails.env.test? ? :test : :async
81
+ # Whether Grouse is given the owner's keys from 1Password (config/keys.yml):
82
+ # when the desk serves, and for Rails' own commands such as grouse:doctor.
83
+ # A `proctolog` command that only prints asks 1Password for nothing.
84
+ config.x.reads_keys = Rails.env.development?
85
+ config.active_storage.service = :local
86
+ config.active_storage.variant_processor = :disabled
87
+ # The migrations are the record; the desk brings its database up to them
88
+ # whenever it boots (config/initializers/grouse_notes.rb).
89
+ config.active_record.dump_schema_after_migration = false
90
+ # A note says which templates drew what the owner pointed at. Only where
91
+ # the desk runs for its owner: its tests read pages without them.
92
+ config.action_view.annotate_rendered_view_with_filenames = Rails.env.development?
93
+ end
94
+ end
@@ -0,0 +1,16 @@
1
+ # The desk's one database: Grouse's notes on the desk itself, their review
2
+ # sessions and the files Active Storage keeps for them (config/application.rb).
3
+ default: &default
4
+ adapter: sqlite3
5
+ database: <%= Proctolog::Desk.grouse_notes.join("notes.sqlite3") %>
6
+ # The desk's own, wherever it is started: `serve` runs in the checkout under
7
+ # review, whose db/migrate is that app's.
8
+ migrations_paths: <%= Rails.root.join("db/migrate") %>
9
+ max_connections: 5
10
+ timeout: 5000
11
+
12
+ development:
13
+ <<: *default
14
+
15
+ test:
16
+ <<: *default
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What bin/rails loads before a task that needs the desk booted.
4
+ require_relative "application"
5
+
6
+ Rails.application.initialize!