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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The repository's open pull requests, to pick what to review: where the
5
+ # desk opens while it has no burst. A press makes the burst for the pick
6
+ # among the reviewer's own, so nobody writes one to review a pull request.
7
+ class OpenPullRequestsController < ApplicationController
8
+ before_action do
9
+ @repository = Project.current.github_repository or raise Failure, "#{Project.current.root} has no GitHub repository as its origin."
10
+ @github = Github.new(@repository)
11
+ end
12
+
13
+ # `fresh` reads GitHub again, then comes back to an address a reload can repeat.
14
+ def index
15
+ @pick = Pick.new(@github.open_pull_requests(fresh: params.key?(:fresh)), viewer: Github.viewer, unopened: unopened)
16
+ return redirect_to(open_pull_requests_path) if params.key?(:fresh)
17
+
18
+ @bursts, @put_away = Burst.newest_first.partition { !it.put_away? }
19
+ @put_away_shown = params.key?(:put_away)
20
+ @read_at = @github.read_at
21
+ end
22
+
23
+ # One pull request, reviewed alone: one open on GitHub, or one nobody
24
+ # has opened yet, which its burst names by its branch.
25
+ def alone
26
+ pull_request = held(params.expect(:id))
27
+ slug = pull_request.opened? ? "pr-#{pull_request.number}" : "branch-#{pull_request.head.parameterize}"
28
+ burst = Burst.find_or_start(slug, name: "##{pull_request.number} #{pull_request.title}", pull_requests: [pull_request.listed_as])
29
+ redirect_to burst_pull_request_path(burst.slug, pull_request.number)
30
+ end
31
+
32
+ # A pull request nobody has opened yet, with the chain under it,
33
+ # followed down by base branches.
34
+ def chain
35
+ top = held(params.expect(:id))
36
+ burst = Burst.find_or_start("chain-#{top.head.parameterize}", name: "Up to #{top.head}: #{top.title}", tops: [top.listed_as])
37
+ redirect_to burst_path(burst.slug)
38
+ end
39
+
40
+ # One of GitHub's stacks, whole.
41
+ def stack
42
+ number = params.expect(:id).to_i
43
+ layers = @github.open_pull_requests.select { it.stack == number }.sort_by(&:position)
44
+ layers.any? or raise Failure, "No open pull request is in stack #{number}."
45
+ burst = Burst.find_or_start("stack-#{number}", name: "Stack #{number}: #{layers.last.title}", stacks: [number])
46
+ redirect_to burst_path(burst.slug)
47
+ end
48
+
49
+ private
50
+
51
+ def held(id)
52
+ number = PullRequest.number(id)
53
+ [*@github.open_pull_requests, *unopened].find { it.number == number } || raise(Failure, "##{id} is neither open nor waiting unopened.")
54
+ end
55
+
56
+ # Those nobody has opened yet: on a branch with no open pull request.
57
+ def unopened = Unopened.waiting(open: @github.open_pull_requests).map(&:pull_request)
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class PicturesController < ApplicationController
5
+ rescue_from(Picture::Unavailable) { render plain: it.message, status: :bad_gateway }
6
+
7
+ # A picture never changes under its asset id, so the browser keeps it.
8
+ def show
9
+ extension = params[:format]
10
+ return head(:not_found) unless Picture::EXTENSIONS.include?(extension)
11
+
12
+ file = Picture.new(params.expect(:asset), extension).file
13
+ expires_in 1.year, public: true
14
+ send_file file, type: Mime::Type.lookup_by_extension(extension).to_s, disposition: :inline
15
+ end
16
+
17
+ # Where the halves of a pair differ, for the viewer to outline and go
18
+ # through, and whether they could be compared at all.
19
+ def compare
20
+ pictures = params.expect(:before, :after).map { Picture.new(*it.split(".", 2)) }
21
+ return head(:not_found) unless pictures.all? { Picture::EXTENSIONS.include?(it.extension) }
22
+
23
+ result = Difference.between(*pictures.map(&:file), cache: Project.current.cache)
24
+ render json: result.to_h.slice(:compared, :same, :sizes, :boxes)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The prompt for a fresh session: what would be handed over now; and the
5
+ # handover `e` asks for, written as a folder where the desk keeps
6
+ # handovers, with the address of a new session that reads it.
7
+ class PromptsController < ApplicationController
8
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
9
+
10
+ def show
11
+ @taken_in = @burst.reports.take_in
12
+ @handover = @burst.handover(@burst.route, focus:)
13
+ end
14
+
15
+ # The pass is the desk's own, from the choices the page saved before it
16
+ # asked. `again` takes back what an agent took and never answered.
17
+ def create
18
+ route = @burst.route
19
+ worklist = Rounds.new(@burst, route:, focus:).worklist(batch_id: SecureRandom.uuid, again: params[:again].present?)
20
+ file = @burst.handover(route, focus:, worklist:).write
21
+ render json: {file: file.to_s, session: helpers.new_session_address(file), prompt: helpers.session_prompt(file),
22
+ requests: worklist.items.size, withAgent: worklist.with_agent.size}
23
+ end
24
+
25
+ # What `create` would write, file by file, and writes nothing.
26
+ def preview = render(plain: @burst.handover(@burst.route, focus:).to_s)
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # For a controller nested under a pull request of a burst: finds the
5
+ # burst and the pull request's place in its route, and answers by going
6
+ # back to the pull request's page.
7
+ module PullRequestScoped
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
12
+ before_action { @stop = @burst.route.at(PullRequest.number(params.expect(:pull_request_number))) || render(plain: "##{params[:pull_request_number]} is not part of #{@burst.name}.", status: :not_found) }
13
+ end
14
+
15
+ private
16
+
17
+ def marks = @marks ||= @burst.marks(@stop.number)
18
+
19
+ def back_to_the_page(anchor:) = redirect_to(burst_pull_request_path(@burst, @stop.number, anchor:))
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class PullRequestsController < ApplicationController
5
+ # One pull request of a burst: where it stands and what moved since the
6
+ # owner's mark, then its description as GitHub renders it, with the
7
+ # owner's notes drawn on it and listed beside it. `fresh` reads GitHub
8
+ # again, then comes back to an address that a reload can repeat.
9
+ def show
10
+ @burst = Burst.find(params.expect(:burst_id))
11
+ @route = @burst.route(fresh: params.key?(:fresh))
12
+ return redirect_to(burst_pull_request_path(@burst, params.expect(:number))) if params.key?(:fresh)
13
+
14
+ @burst.reports.take_in
15
+ @stop = @route.at(PullRequest.number(params.expect(:number))) or return render(plain: "##{params[:number]} is not part of #{@burst.name}.", status: :not_found)
16
+ standings = @burst.standings(@route)
17
+ @standing = standings.fetch(@stop)
18
+ # Next to review is the next with blocking decisions left: the non-blocking
19
+ # wait for one gesture, and do not hold up deciding.
20
+ @next_to_review = focus.round_from(@stop).find { standings.fetch(it).progress.blocking_left.positive? }
21
+ @since = @burst.since(@stop) if @standing.changed?
22
+ @scenarios = @burst.scenarios.select { it.pr == @stop.number }
23
+ @rendering = Rendering.new(@burst.rendering(@stop.number), notes: @standing.marks.notes, pages: pages, linked: @stop.description.linked)
24
+ @decisions = @burst.decisions(pages:)
25
+ @boxes = @decisions.boxes_of(@stop)
26
+ @code = CodeReview.new(@burst, @stop.number).tally
27
+ end
28
+
29
+ private
30
+
31
+ # Where each pull request of the burst is, as GitHub says and as the desk says.
32
+ def pages = @route.to_h { [it.url, burst_pull_request_path(@burst, it.number)] }
33
+ end
34
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The owner putting a burst away while some of it is still open, as a
5
+ # finished one is put away, or taking it out again.
6
+ class PutAwayController < ApplicationController
7
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
8
+
9
+ def create
10
+ @burst.put_away!
11
+ redirect_to burst_path(@burst), notice: "Put away."
12
+ end
13
+
14
+ def destroy
15
+ @burst.take_out!
16
+ redirect_to burst_path(@burst), notice: "Out again."
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class RecordingsController < ApplicationController
5
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
6
+
7
+ # Every recording of the burst, a folder at a time: the owner's sitting
8
+ # first, then the pull requests in number order.
9
+ def index
10
+ @folders = Recording.of(@burst).group_by(&:folder).sort_by { |folder, _| [(folder == "sitting") ? 0 : 1, folder.to_i, folder] }
11
+ end
12
+
13
+ # One recording to play, with the owner's notes pinned to its moments.
14
+ def show
15
+ @recording = Recording.find(@burst, params.expect(:path)) or return render(plain: "There is no recording at #{params[:path]}.", status: :not_found)
16
+ @notes = @recording.noted_in(@burst.marks(@recording.folder))
17
+ @changes = @recording.changes(Recording::Rounds.new(@burst).before(@recording)) if @recording.chapters.any?
18
+ end
19
+
20
+ # The picture a step of a film opens on, for the strip beside it; none
21
+ # when it cannot be had, and the step's name stands alone.
22
+ def still
23
+ recording = Recording.find(@burst, params.expect(:path)) or return head(:not_found)
24
+ file = recording.still(params.expect(:index).to_i) or return head(:not_found)
25
+ send_file file, type: "image/png", disposition: :inline
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Draws pieces of a description, which the page has only as markdown.
5
+ class RenderedPiecesController < ApplicationController
6
+ def create
7
+ burst = Burst.find(params.expect(:burst_id))
8
+ render json: {pieces: burst.draw(params.expect(markdown: []))}
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What the pages' script needs of the burst's review rounds, built from the
5
+ # whole burst; with `pr`, that pull request's descriptions too.
6
+ class ReviewDataController < ApplicationController
7
+ def show
8
+ @burst = Burst.find(params.expect(:burst_id))
9
+ render json: helpers.round_data(@burst, pr: params[:pr]&.then { PullRequest.number(it) })
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A rule the owner made of an answer, edited or withdrawn from the burst
5
+ # page. The pages' script makes rules; these change the kept choices the
6
+ # same way, and an open page catches up with them.
7
+ class RulesController < ApplicationController
8
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
9
+
10
+ def update
11
+ @burst.rules.edit(params.expect(:id), params.expect(:text))
12
+ redirect_to burst_path(@burst, anchor: "rules"), notice: "Rule saved."
13
+ end
14
+
15
+ def destroy
16
+ @burst.rules.withdraw(params.expect(:id))
17
+ redirect_to burst_path(@burst, anchor: "rules"), notice: "Rule withdrawn."
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A note on a scenario, and moving it along. It is kept with the marks of
5
+ # the scenario's pull request, or of the sitting for a step of it.
6
+ class ScenarioNotesController < ApplicationController
7
+ include ScenarioScoped
8
+ include NoteParams
9
+
10
+ before_action { @scenario = @burst.scenario(params.expect(:scenario_id)) }
11
+
12
+ def create
13
+ marks.pin(kind: one_of(Note::KINDS, params.expect(:kind)), text: params.expect(:text).strip, place: Note::Scenario.new(name: @scenario.name))
14
+ back_to_the_page(notice: "Note saved.")
15
+ end
16
+
17
+ def update
18
+ note = marks.move(params.expect(:id), to: one_of(Note::STATES, params.expect(:state))) or return head(:not_found)
19
+ back_to_the_page(notice: "The note is #{note.state}.")
20
+ end
21
+
22
+ private
23
+
24
+ def marks = @burst.marks(@scenario.marked_on)
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # For a controller that works on a scenario of a burst and answers by going
5
+ # back to the page it was asked from, at the scenario: the scenarios page or
6
+ # a pull request's.
7
+ module ScenarioScoped
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
12
+ end
13
+
14
+ private
15
+
16
+ def back_to_the_page(**flash)
17
+ page = url_from(request.referer&.sub(/#.*/, "")) || burst_scenarios_path(@burst)
18
+ redirect_to "#{page}##{@scenario&.to_param}", **flash
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class ScenariosController < ApplicationController
5
+ include ScenarioScoped
6
+
7
+ before_action(only: %i[open saw pull]) { @scenario = @burst.scenario(params[:id]) }
8
+ rescue_from(Server::Error) { back_to_the_page(alert: it.message.truncate(1200)) }
9
+
10
+ def index
11
+ @server = @burst.server
12
+ @state = @server.state
13
+ @route = @burst.route
14
+ end
15
+
16
+ # Opens the app in the scenario's state. Every browser comes by an address
17
+ # of the desk that is good once and sets the cookies, so the session never
18
+ # shows in an address: your own browser follows it at once, and the other
19
+ # kinds are started at it.
20
+ #
21
+ # Where the app names an `overlay`, the press opens Grouse's overlay there
22
+ # too, labelled with the scenario, and keeps its session beside it.
23
+ def open
24
+ visit = @scenario.visit(@burst.server, overlay: (brief if @burst.server.app.overlay?))
25
+ keep(visit.overlay)
26
+ address = entry_url(visit.keep)
27
+ flash[:alert] = visit.overlay&.missing if visit.overlay&.missing
28
+ return redirect_to(address, status: :see_other) if @scenario.browser.mine?
29
+
30
+ @scenario.browser.launch(address, size: @scenario.window)
31
+ back_to_the_page(notice: "Opened in a #{@scenario.browser.name} Chrome.")
32
+ end
33
+
34
+ # Brings back what the owner wrote in the scenario's overlays, each note
35
+ # pinned on the scenario once.
36
+ def pull
37
+ back_to_the_page(notice: Overlay.pull(@burst.server, @burst.marks(@scenario.marked_on), @scenario.name).sentence)
38
+ end
39
+
40
+ # What the owner saw, kept with the commit it was seen on.
41
+ def saw
42
+ result = params.expect(:result)
43
+ return head(:unprocessable_content) unless Scenario::RESULTS.value?(result)
44
+
45
+ @burst.marks(@scenario.marked_on).saw(@scenario.name, head:, result:)
46
+ back_to_the_page
47
+ end
48
+
49
+ private
50
+
51
+ # The commit the scenario is seen on: its pull request's head, or the sitting's.
52
+ def head = @scenario.pr ? @burst.route.at(@scenario.pr)&.head_oid : @burst.server.head
53
+
54
+ # The scenario as the overlay's strip shows it, with the way back to the
55
+ # page the press was made on.
56
+ def brief
57
+ stop = @scenario.pr && @burst.route.at(@scenario.pr)
58
+ page = url_from(request.referer&.sub(/#.*/, "")) || burst_scenarios_url(@burst)
59
+ Overlay::Brief.for(@scenario, burst: @burst, steps: stop ? @scenario.steps_in(stop.description).map(&:text) : [], head:,
60
+ desk_url: "#{page}##{@scenario.to_param}")
61
+ end
62
+
63
+ def keep(overlay)
64
+ @burst.marks(@scenario.marked_on).keep_overlay(@scenario.name, Overlay.opening(overlay.session, head:)) if overlay&.session
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The server a burst's scenarios open the app on: asked after, started,
5
+ # stopped. A press goes back to the page it was made on, the Scenarios page
6
+ # or a pull request's; asked for JSON, it answers where the server stands,
7
+ # so that page can stay where it is.
8
+ class SittingServersController < ApplicationController
9
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
10
+ rescue_from(Server::Error) { answer(alert: it.message.truncate(1200)) }
11
+
12
+ def show = render(json: {state: @burst.server.state})
13
+
14
+ def create = answer(notice: @burst.server.start)
15
+
16
+ def destroy
17
+ @burst.server.stop
18
+ answer(notice: "The server was stopped.")
19
+ end
20
+
21
+ private
22
+
23
+ def answer(**flash)
24
+ respond_to do |format|
25
+ format.html { redirect_back_or_to burst_scenarios_path(@burst), **flash }
26
+ format.json { render json: {state: @burst.server.state, **flash}, status: flash.key?(:alert) ? :unprocessable_content : :ok }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What a pull request waits on, outside the burst, in a sentence. An empty
5
+ # sentence says it waits on nothing.
6
+ class WaitingController < ApplicationController
7
+ include PullRequestScoped
8
+
9
+ def update
10
+ marks.wait_on(params[:what].to_s.strip)
11
+ back_to_the_page(anchor: "standing")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What every page that works on the boxes of "Decisions for the owner"
5
+ # starts from: the burst, and its decisions as GitHub holds them now. A
6
+ # page that names a box GitHub no longer holds as it was shown goes back to
7
+ # the list with a sentence.
8
+ module WithDecisions
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ before_action { @burst = Burst.find(params.expect(:burst_id)) }
13
+ rescue_from(Decisions::Changed) { redirect_to(decisions_path_for(params[:pull_request_number]), alert: it.message) }
14
+ end
15
+
16
+ private
17
+
18
+ def decisions = @decisions ||= @burst.decisions(pages:)
19
+
20
+ # Where each pull request of the burst is, as GitHub says and as the desk says.
21
+ def pages = @burst.route.to_h { [it.url, burst_pull_request_path(@burst, it.number)] }
22
+
23
+ def number = PullRequest.number(params.expect(:pull_request_number))
24
+
25
+ # The list as the page that sent the request had it, at a pull request.
26
+ def decisions_path_for(number) = burst_decisions_path(@burst, show: params[:show], anchor: ("pr-#{number}" if number.present?))
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module BurstsHelper
5
+ # Which of the two a mark will later be compared against moved last.
6
+ def last_change(pull_request)
7
+ what, at = {"description" => pull_request.described_at, "code" => pull_request.committed_at}.max_by(&:last)
8
+ tag.time("#{what}, #{time_ago_in_words(at)} ago", datetime: at.iso8601, title: at.localtime.strftime("%-d %b %H:%M"))
9
+ end
10
+
11
+ def open_threads(pull_request)
12
+ " · #{pluralize(pull_request.open_threads, "review thread")} open" if pull_request.open_threads.positive?
13
+ end
14
+
15
+ # "Stack #1055, 21 from the bottom up", "A chain of 2 on #1062, from the
16
+ # bottom up", "Standing alone". A stack placed from the membership kept
17
+ # beside the marks, when GitHub could not say, is said to be as last read.
18
+ def part_heading(part, route)
19
+ stops = route.stops_of(part)
20
+ if part.alone? then "Standing alone"
21
+ elsif part.stack? then "Stack ##{part.id}#{" as last read" unless route.stacks_known?}, #{stops.size} from the bottom up"
22
+ else "A chain of #{stops.size}#{" on ##{stops.first.on}" if stops.first.on}, from the bottom up"
23
+ end
24
+ end
25
+
26
+ # The heading over each kind of disagreement between a burst's file and GitHub.
27
+ DISAGREEMENTS = {off_the_burst: "On a member's branch, not in this burst", empty_stack: "Listed stacks with nothing open",
28
+ filed_twice: "Filed twice", held_elsewhere: "Also in another burst"}.freeze
29
+
30
+ def disagreement_heading(cause) = DISAGREEMENTS.fetch(cause)
31
+
32
+ # What the owner chose on a decision of a pull request that left.
33
+ CHOSEN = {"leave" => "Left as is", "question" => "Question", "change" => "Change", "later" => "Left for later"}.freeze
34
+
35
+ def entry_line(entry)
36
+ said = entry.draft ? "Draft" : CHOSEN.fetch(entry.kind, entry.kind.to_s)
37
+ safe_join([tag.strong("#{said}:"), entry.lead, (tag.span("— #{entry.text}", class: "text") if entry.text.present?)].compact, " ")
38
+ end
39
+
40
+ # What a rule's state means for the next handover.
41
+ RULE_STATES = {written: "goes in the next handover", sent: "handed over; the agent's answer is awaited",
42
+ answered: "answered: yours to confirm under Returned work", confirmed: "recorded, and confirmed", withdrawn: "withdrawn"}.freeze
43
+
44
+ # A rule's line once the cursor is on it: what it was made from, what its
45
+ # state means, and the line it replaces until the replacement is handed over.
46
+ def rule_facts(rule)
47
+ [("made from #{rule.from.map { "##{it.pr} “#{it.lead}”" }.to_sentence}" if rule.from.any?),
48
+ RULE_STATES.fetch(rule.state),
49
+ ("replaces “#{rule.replaces}”" if rule.replaces && rule.state == :written)].compact.join(" · ")
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ module DecisionsHelper
5
+ # Every decision of the burst as the pages' script takes it, in JSON. It
6
+ # holds what GitHub says and nothing of the owner's marks, so it is built
7
+ # once for a reading: building it reads every description again. It is of
8
+ # the whole burst whatever a page is focused on, so a choice outside the
9
+ # focus is never taken for one whose decision is gone.
10
+ # What the inventory holds changed with the desk: a page built from an
11
+ # older one would read its decisions wrong.
12
+ INVENTORY = 3
13
+
14
+ # The pictures' files and pixel signatures are looked up at each page,
15
+ # not kept with the rest: a picture fetched since the reading is known.
16
+ def decision_inventory(burst)
17
+ Rails.cache.fetch([:inventory, INVENTORY, burst.slug, burst.read_at.to_f]) { burst.decisions.boxes.map { draft_decision(it) } }
18
+ .map { |decision| decision.merge(pictures: decision[:pictures].map { picture_now(it) }) }.to_json
19
+ end
20
+
21
+ def draft_decision(box)
22
+ {id: box.to_param, pr: box.stop.number, title: box.stop.title, url: box.stop.url, branch: box.stop.pull_request.head,
23
+ head: box.stop.head_oid, description: box.stop.description.digest, position: box.position, lead: box.lead,
24
+ text: box.decision.text, key: box.decision.key, ticked: box.decision.ticked, protected: @burst.refusal(box.stop.number).present?,
25
+ grade: box.decision.grade, theme: box.decision.theme_name, pictures: box.decision.pictures.map(&:to_h)}
26
+ end
27
+
28
+ # A picture a decision rests on, with its file on this Mac and its pixels'
29
+ # signature; both nil while it is not here, its signature while
30
+ # ImageMagick cannot read it.
31
+ def picture_now(picture) = picture.merge(file: Picture.on_disk(picture[:asset])&.basename&.to_s, signature: Picture.signature(picture[:asset]))
32
+
33
+ # "3 blocking · 12 non-blocking". More blocking decisions than one owner
34
+ # should be asked at once says so in words: "8 blocking · over five".
35
+ def grades_of(decisions)
36
+ blocking = decisions.count(&:blocking?)
37
+ [("#{blocking} blocking" if blocking.positive?), ("over five" if blocking > Check::MOST_BLOCKING),
38
+ ("#{decisions.size - blocking} non-blocking" if decisions.size > blocking)].compact.join(" · ")
39
+ end
40
+
41
+ # A grade as the end of a pull request's decisions heads it, and what it
42
+ # asks of the owner, in a sentence under its name.
43
+ Grade = Data.define(:name, :meaning)
44
+ GRADES = {
45
+ blocking: Grade.new("Blocking", "Each needs your answer before the work goes on."),
46
+ non_blocking: Grade.new("Non-blocking", "Each has a sensible default: left as is, what the agent built stands.")
47
+ }.freeze
48
+
49
+ def grade_named(grade) = GRADES.fetch(grade)
50
+
51
+ # Why leaving every non-blocking decision at once passes over this one,
52
+ # as the desk knows it: the page adds a draft of its own.
53
+ def held_by(box)
54
+ [("starred" if box.starred), ("named in the burst file" if box.named.any?), ("answered" if box.answers.any?), ("asked" if box.asked.any?)].compact
55
+ end
56
+
57
+ def decision_html(box, burst)
58
+ Nokogiri::HTML5.fragment(box.html).tap do |html|
59
+ html.css('a[href^="#screenshot-"]').each do |link|
60
+ link["href"] = burst_pull_request_path(burst, box.stop.number) + link["href"]
61
+ end
62
+ end.to_html
63
+ end
64
+
65
+ # The ways to look at the boxes, each with how many it lists.
66
+ def filter_links(burst, decisions, current)
67
+ safe_join(Decisions::FILTERS.map do |filter, name|
68
+ link_to(safe_join([name, tag.span(number_with_delimiter(decisions.count(filter)), class: "count")], " "),
69
+ burst_decisions_path(burst, show: filter), data: {filter:}, aria: {current: ("page" if filter == current)})
70
+ end)
71
+ end
72
+
73
+ def filter_name(filter) = Decisions::FILTERS.fetch(filter.to_s)
74
+
75
+ # "22 open, 2 ticked elsewhere": what a pull request's boxes are in, all of them.
76
+ def tally_of(decisions, stop)
77
+ decisions.tally(stop).map { |state, count| "#{number_with_delimiter(count)} #{filter_name(state).downcase}" }.to_sentence
78
+ end
79
+
80
+ # What a box says of itself beyond being open: where it stands, and what
81
+ # else says it needs the owner. Nothing, for a box that is simply open.
82
+ def status_of(box)
83
+ parts = [("Starred by you" if box.starred), ("Named by #{named_by(box.named)}" if box.named.any?)]
84
+ parts.unshift(tag.span(filter_name(box.state), class: "state")) unless box.state == :open
85
+ safe_join(parts.compact, " · ")
86
+ end
87
+
88
+ def named_by(entries) = entries.filter_map(&:by).uniq.to_sentence.presence || "the burst file"
89
+
90
+ # The box's place on its pull request's page, which numbers its boxes.
91
+ def box_on_its_page(burst, box) = burst_pull_request_path(burst, box.stop.number, anchor: "decision-#{box.position}")
92
+
93
+ # The star, which comes back to the page it was pressed on, at `back`.
94
+ def star_button(burst, box, back)
95
+ button_to(box.starred ? "★ Starred" : "☆ Star", burst_pull_request_decision_path(burst, box.stop.number, box.position),
96
+ method: :patch, params: {lead: box.lead, starred: box.starred ? 0 : 1, return: back}, class: "quiet", form_class: "star", aria: {pressed: box.starred.to_s})
97
+ end
98
+
99
+ # What a button under a box asks: the kind of note it writes, as a word of the owner's.
100
+ def respond_button(label, kind) = tag.button(label, type: "button", class: "quiet", data: {respond: kind})
101
+ end
102
+ end