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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # A state of the app that one click opens, from the registry in
5
+ # scenarios/<burst>.yml. It is a step of the sitting (`sitting`), or a group
6
+ # of steps in a pull request's "How to test locally" (`pr`, with the bold
7
+ # heading over them as `steps`): those steps are read from the description
8
+ # each time and never copied here.
9
+ #
10
+ # `recipe` and `arguments` (`with` in the file) name the state the app's
11
+ # own `state` command makes; `opens` is the page, a path or the key of what
12
+ # it printed (`link`, `lands_on`). The member it printed is signed in on
13
+ # arrival, nobody is when `signed_out`, and with neither, whoever is signed
14
+ # in stays.
15
+ class Scenario < Data.define(:pr, :sitting, :group, :name, :expect, :steps, :recipe, :arguments, :opens, :size, :browser, :signed_out)
16
+ Incomplete = Class.new(StandardError)
17
+
18
+ FILES = -> { Project.current.scenario_folders }
19
+
20
+ STEPS_IN = "How to test locally"
21
+ # What the owner can say of a scenario, by the words on the buttons.
22
+ RESULTS = {"As expected" => "ok", "Not as expected" => "not_ok", "Skip" => "skipped"}.freeze
23
+
24
+ Step = Data.define(:number, :text)
25
+
26
+ def self.of(burst)
27
+ file = folders.map { it.join("#{burst.slug}.yml") }.find(&:exist?) or return []
28
+
29
+ YAML.safe_load_file(file, symbolize_names: true).map { from(it) }.tap { check_names(it) }
30
+ end
31
+
32
+ def self.folders = Array(FILES.respond_to?(:call) ? FILES.call : FILES)
33
+
34
+ # The scenarios a pull request's description offers on its own: each bold
35
+ # heading of its "How to test locally" with numbered steps under it, a
36
+ # label such as "**What is needed.**" being none, opening the app's home
37
+ # page as whoever is signed in. A section that numbers its steps under no
38
+ # such heading is one scenario. A file that names the pull request says
39
+ # more, and wins.
40
+ def self.described(stop)
41
+ section = stop.description.section(STEPS_IN)
42
+ headings = section.split(/^(?=\*\*[^*\n]+\*\*)/).filter_map { it[/\A\*\*([^*\n]+)\*\*/, 1] if it.match?(/^\d+\.\s/) }
43
+ headings = [STEPS_IN] if headings.empty? && section.match?(/^\d+\.\s/)
44
+ headings.map { new(pr: stop.number, steps: it, opens: "/") }
45
+ end
46
+
47
+ def self.from(entry)
48
+ new(**entry.transform_keys { (it == :with) ? :arguments : it }).tap do |scenario|
49
+ scenario.problems.any? and raise Incomplete, "The scenario #{scenario.name.inspect} #{scenario.problems.to_sentence}"
50
+ end
51
+ end
52
+
53
+ def self.check_names(scenarios)
54
+ twice = scenarios.map(&:to_param).tally.select { _2 > 1 }.keys
55
+ twice.none? or raise Incomplete, "Two scenarios are called #{twice.to_sentence}"
56
+ end
57
+
58
+ def initialize(pr: nil, sitting: nil, group: nil, steps: nil, name: steps&.chomp("."), expect: nil, recipe: nil, arguments: {},
59
+ opens: ("lands_on" if recipe), size: "desktop", browser: "clean", signed_out: false)
60
+ super(pr:, sitting:, group:, name:, expect:, steps:, recipe:, arguments:, opens:, size:, browser: Browser.named(browser), signed_out:)
61
+ end
62
+
63
+ # What is missing or wrong, in words that follow "The scenario “…” ".
64
+ def problems
65
+ [("has no name" unless name),
66
+ ("is neither a step of the sitting nor a pull request's" unless pr.nil? ^ sitting.nil?),
67
+ ("is a step of the sitting with no group" if sitting && !group),
68
+ ("is a step of the sitting with nothing to expect" if sitting && !expect),
69
+ ("names no bold heading over its steps" if pr && !steps),
70
+ ("opens no page" unless opens),
71
+ ("opens a key of what a recipe printed, and has no recipe" if opens && !opens.start_with?("/") && !recipe),
72
+ ("has a size that is neither a name nor a width and a height" unless Size.valid?(size))].compact
73
+ end
74
+
75
+ def to_param = sitting ? "step-#{sitting}" : "#{pr}-#{name.to_s.parameterize}"
76
+
77
+ # Whose marks keep what the owner saw of it.
78
+ def marked_on = pr || "sitting"
79
+
80
+ def window = Size.of(size)
81
+
82
+ # The notes of a marks file that are pinned to this scenario.
83
+ def noted_in(marks) = marks.notes.select { it.place in Note::Scenario[name: ^(name)] }
84
+
85
+ # Makes the state and works out where the browser goes, through the app's
86
+ # own sign-in or sign-out on the way, and what cookies it is given first.
87
+ # With an `overlay` brief, Grouse's overlay is opened once the member is
88
+ # known, and its cookie goes last, as the app printed it, to any browser.
89
+ # Raises a Server::Error when the app cannot be entered; a refused overlay
90
+ # does not stop it.
91
+ def visit(server, overlay: nil)
92
+ server.require_up
93
+ account = recipe ? server.enter(recipe, **arguments) : {}
94
+ opened = overlay && Overlay.open(server, overlay.without(account["email"]))
95
+ Visit.new(to: server.address(entrance(account, server.app)), overlay: opened,
96
+ cookies: [*(server.app.cookies.map { Visit.cookie(it) } if browser.clean?), *opened&.cookie])
97
+ end
98
+
99
+ # Its numbered steps as the description has them now: under the bold
100
+ # heading `steps` of "How to test locally", the first line of each. Code
101
+ # blocks and sub-bullets stay on GitHub. A description that numbers its
102
+ # steps under no bold heading has one scenario, whose `steps` is the name
103
+ # of the section itself.
104
+ def steps_in(description)
105
+ section = description.section(STEPS_IN)
106
+ group = (steps == STEPS_IN) ? section : section.split(/^(?=\*\*[^*\n]+\*\*)/).find { it[/\A\*\*([^*\n]+)\*\*/, 1].to_s.chomp(".") == steps.chomp(".") }
107
+ group.to_s.scan(/^(\d+)\.\s+(.+)$/).map { |number, text| Step.new(number: number.to_i, text:) }
108
+ end
109
+
110
+ private
111
+
112
+ def landing(account)
113
+ return opens if opens.start_with?("/")
114
+
115
+ account.fetch(opens) { raise Failure, "The recipe #{recipe} printed no #{opens.inspect} for the scenario #{name.inspect} to open. It printed #{account.keys.to_sentence.presence || "nothing"}." }
116
+ end
117
+
118
+ def entrance(account, app)
119
+ page = landing(account)
120
+ if signed_out then app.sign_out(to: page)
121
+ elsif (email = account["email"]) then app.sign_in(email, to: page)
122
+ else page
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "socket"
6
+
7
+ module Proctolog
8
+ # The app a scenario opens: a checkout of the sitting's branch, served on a
9
+ # port of its own. The desk starts and stops it, and makes the state a
10
+ # scenario needs, with the commands the app names for both (App).
11
+ class Server
12
+ Error = Class.new(StandardError)
13
+ Down = Class.new(Error)
14
+ NotStartedHere = Class.new(Error)
15
+ RecipeFailed = Class.new(Error)
16
+ TookTooLong = Class.new(Error)
17
+ Unanswered = Class.new(Error)
18
+ Unready = Class.new(Error)
19
+
20
+ # How long a recipe gets, in seconds. Booting the app is most of it.
21
+ PATIENCE = 90
22
+ # Where a burst that names no sitting serves its app.
23
+ PORT = 3100
24
+
25
+ attr_reader :checkout, :branch, :port, :slug, :app
26
+
27
+ # PROCTOLOG_SITTING, a YAML mapping such as `{port: 3400, slug: deskb}`,
28
+ # overrides what the burst file says, so a trial never touches the
29
+ # owner's server.
30
+ def self.from(settings) = new(**settings.merge(YAML.safe_load(ENV.fetch("PROCTOLOG_SITTING", "{}"), symbolize_names: true)))
31
+
32
+ # With no branch, the checkout is served as it is.
33
+ def initialize(checkout:, port:, slug:, branch: nil, app: App.current)
34
+ @checkout, @branch, @port, @slug, @app = Pathname(checkout).expand_path, branch, port, slug, app
35
+ end
36
+
37
+ # Every port the app listens on answers.
38
+ def up? = app.listens(port).all? { listening?(it) }
39
+
40
+ def state
41
+ return :up if up?
42
+
43
+ started_here? ? :starting : :down
44
+ end
45
+
46
+ def address(path = "/") = "http://localhost:#{port}#{path}"
47
+
48
+ def log = logs.join("#{slug}.log")
49
+
50
+ def head
51
+ output, status = git("rev-parse", "HEAD")
52
+ output.strip if status.success?
53
+ end
54
+
55
+ # When the commit the checkout stands on was made.
56
+ def head_at
57
+ output, found = git("log", "-1", "--format=%cI")
58
+ Time.iso8601(output.strip) if found.success?
59
+ end
60
+
61
+ # The first line of the commit the checkout stands on, which says what it is.
62
+ def head_subject
63
+ output, found = git("log", "-1", "--format=%s")
64
+ output.strip.presence if found.success?
65
+ end
66
+
67
+ # Where the checkout stands: the branch it is on, or the sitting's branch
68
+ # when it is detached at that branch's tip, or else the commit.
69
+ def serving
70
+ name, on_a_branch = git("symbolic-ref", "--quiet", "--short", "HEAD")
71
+ return name.strip if on_a_branch.success?
72
+
73
+ at = head or return
74
+ (branch && tip(branch) == at) ? branch : at.first(8)
75
+ end
76
+
77
+ # Whether a commit is in the history the checkout stands on, so that a
78
+ # pull request's scenario opened here opens that pull request's work.
79
+ def holds?(commit) = commit.present? && git("merge-base", "--is-ancestor", commit, "HEAD").last.success?
80
+
81
+ # Whether the checkout's files differ from the commit it is at.
82
+ def changed?
83
+ output, status = git("status", "--porcelain", "--untracked-files=no")
84
+ status.success? && output.present?
85
+ end
86
+
87
+ # Whether the checkout keeps an app.yml of its own. One that has none is
88
+ # older than the commands the desk runs the app by, which may not do there
89
+ # what they say: open the app's own development database, for one.
90
+ def runnable? = checkout.join(Project::CONFIGURATION, App::FILE).exist?
91
+
92
+ def require_runnable
93
+ runnable? or raise Unready, "#{checkout.to_s.sub(Dir.home, "~")} has no #{Project::CONFIGURATION}/#{App::FILE} of its own, so it is older than the commands that run the app here, and they may not do there what they say. Bring it up to date, or name another checkout as the sitting."
94
+ end
95
+
96
+ # Whether the desk started the server that runs now: only that one it can stop.
97
+ def started_here? = leader.present?
98
+
99
+ # A server the desk started that has stopped since, not by its Stop, with
100
+ # its log to read.
101
+ def crashed? = pidfile.exist? && !started_here? && !up?
102
+
103
+ # The end of its log as words, without the colours a terminal would draw.
104
+ def last_lines(count = 8) = log.exist? ? log.readlines.last(count).join.gsub(/\e\[[\d;]*m/, "") : ""
105
+
106
+ def serve_command = app.serve(slug:, port:)
107
+
108
+ # The app's `prepare` and then its `serve`, in one shell that becomes the
109
+ # server, so a slow first preparation happens in the log, not the page.
110
+ def start_command
111
+ prepare = app.prepare(slug:, port:) or return serve_command
112
+ ["sh", "-c", "#{Shellwords.join(prepare)} && exec #{Shellwords.join(serve_command)}"]
113
+ end
114
+
115
+ def checkout_command = ["git", "-C", checkout.to_s, "checkout", "--detach", branch]
116
+
117
+ def enter_command(recipe, **arguments) = app.state(recipe, arguments, slug:, port:)
118
+
119
+ # Starts the app detached, its output in a log, and says what it did about
120
+ # the branch. The checkout is moved onto the branch only when it is not
121
+ # there already and has no changes of its own.
122
+ def start
123
+ return "The server is up already." if up? || started_here?
124
+
125
+ sentence = align
126
+ require_runnable
127
+ logs.mkpath
128
+ spawn_detached(start_command)
129
+ sentence
130
+ rescue SystemCallError => e
131
+ raise Error, "The server could not be started: #{e.message}"
132
+ end
133
+
134
+ def stop
135
+ pid = leader or raise NotStartedHere, "The desk did not start this server, so it cannot stop it. Stop it where it runs."
136
+ signal_group("TERM", pid)
137
+ within(10) { !alive?(pid) } or signal_group("KILL", pid)
138
+ pidfile.delete
139
+ end
140
+
141
+ # Makes the state a recipe names and returns what it printed last: the
142
+ # member to sign in as, and the places a scenario may open.
143
+ def enter(recipe, **arguments)
144
+ require_up
145
+ require_runnable
146
+ output, status = run(enter_command(recipe, **arguments)) ||
147
+ raise(TookTooLong, "The recipe #{recipe} took longer than #{PATIENCE} seconds and was stopped.")
148
+ account = parse(output) if status.success?
149
+ account || raise(RecipeFailed, "The recipe #{recipe} failed. Its last lines, without the backtrace:\n#{output.lines.grep_v(/^\s+from /).last(4).join}")
150
+ end
151
+
152
+ # Opens a review session for Grouse's overlay and returns what the app
153
+ # printed last: the session and its cookie, or the problem it refused for.
154
+ def open_overlay(brief) = answer(app.overlay(brief, slug:, port:), "The overlay")
155
+
156
+ # The notes written in a review session, as the app printed them.
157
+ def overlay_notes(session) = answer(app.notes(session, slug:, port:), "Reading the overlay's notes")
158
+
159
+ def require_up
160
+ up? or raise Down, "The server is down: nothing answers on #{app.listens(port).map { ":#{it}" }.to_sentence}. Start it first."
161
+ end
162
+
163
+ # Puts the checkout on the branch, detached, when it can, and says what it
164
+ # did or why it did not.
165
+ def align
166
+ return "Serving the checkout as it is." unless branch
167
+
168
+ at = tip(branch) or return "There is no branch #{branch} here, so the checkout stays as it is."
169
+ return "The checkout is at #{branch} already." if at == head
170
+ return "The checkout is not at #{branch} and has changes of its own, so it was left as it is." unless clean?
171
+
172
+ output, status = Open3.capture2e(*checkout_command)
173
+ status.success? ? "Checked out #{branch}, detached." : "#{branch} could not be checked out: #{output.lines.first&.strip}"
174
+ end
175
+
176
+ private
177
+
178
+ def pidfile = logs.join("#{slug}.pid")
179
+
180
+ def logs = Project.current.cache.join("servers")
181
+
182
+ def listening?(port)
183
+ Socket.tcp("localhost", port, connect_timeout: 0.5) { true }
184
+ rescue SystemCallError
185
+ false
186
+ end
187
+
188
+ def git(*) = Open3.capture2e("git", "-C", checkout.to_s, *)
189
+
190
+ def tip(name)
191
+ output, found = git("rev-parse", "--verify", "--quiet", "#{name}^{commit}")
192
+ output.strip if found.success?
193
+ end
194
+
195
+ def clean? = git("status", "--porcelain", "--untracked-files=no").first.empty?
196
+
197
+ # The output and exit status of a command run in the checkout; nil when it
198
+ # was stopped for taking too long.
199
+ def run(command)
200
+ Outside.run do |environment|
201
+ Open3.popen2e(environment, *command, chdir: checkout.to_s) do |input, output, process|
202
+ input.close
203
+ reading = Thread.new { output.read }
204
+ if process.join(PATIENCE)
205
+ [reading.value, process.value]
206
+ else
207
+ Process.kill("TERM", process.pid)
208
+ reading.kill
209
+ nil
210
+ end
211
+ end
212
+ end
213
+ end
214
+
215
+ # The JSON a command of the app's printed last, whether it succeeded or
216
+ # refused in words; raises when it printed none, or took too long.
217
+ def answer(command, what)
218
+ require_runnable
219
+ output, = run(command) || raise(TookTooLong, "#{what} took longer than #{PATIENCE} seconds and was stopped.")
220
+ parse(output) || raise(Unanswered, "#{what} printed no JSON. Its last lines, without the backtrace:\n#{output.lines.grep_v(/^\s+from /).last(4).join}")
221
+ end
222
+
223
+ def parse(output)
224
+ account = JSON.parse(output.lines.last.to_s)
225
+ account if account.is_a?(Hash)
226
+ rescue JSON::ParserError
227
+ nil
228
+ end
229
+
230
+ # The log and the pidfile are found before the app's environment stands
231
+ # in for the desk's, which may not say where the cache is.
232
+ def spawn_detached(command)
233
+ output = log.to_s
234
+ pid = Outside.run { Process.spawn(it, *command, chdir: checkout.to_s, pgroup: true, in: File::NULL, out: [output, "w"], err: %i[child out]) }
235
+ Process.detach(pid)
236
+ pidfile.write("#{pid} #{started_at(pid)}")
237
+ end
238
+
239
+ # The process group the desk started, while it still runs. A pid alone may
240
+ # have been given to another process since, so it is kept with its start time.
241
+ def leader
242
+ return unless pidfile.exist?
243
+
244
+ pid, started = pidfile.read.split(" ", 2)
245
+ pid.to_i if started && started_at(pid) == started
246
+ end
247
+
248
+ def started_at(pid) = Open3.capture2("ps", "-o", "lstart=", "-p", pid.to_s).first.strip
249
+
250
+ def signal_group(name, pid)
251
+ Process.kill(name, -pid)
252
+ rescue Errno::ESRCH
253
+ nil
254
+ end
255
+
256
+ def alive?(pid)
257
+ Process.kill(0, pid)
258
+ true
259
+ rescue Errno::ESRCH
260
+ false
261
+ end
262
+
263
+ def within(seconds)
264
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
265
+ until yield
266
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
267
+
268
+ sleep 0.2
269
+ end
270
+ true
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Proctolog
6
+ # What moved in a pull request since the owner's mark: its code, between the
7
+ # head then and the head now, and its description, between the revision that
8
+ # stood then and the text now. Each is worked out when asked, from the
9
+ # repository on this Mac and from GitHub's edit history; nothing is copied.
10
+ class Since
11
+ FileChange = Data.define(:path, :added, :removed) do
12
+ # The lines of each file as `git diff --numstat` counts them.
13
+ def self.parse(numstat)
14
+ numstat.each_line(chomp: true).map do |line|
15
+ line.split("\t", 3).then { |added, removed, path| new(path:, added: Integer(added, exception: false), removed: Integer(removed, exception: false)) }
16
+ end
17
+ end
18
+
19
+ # Git counts no lines in a binary file.
20
+ def binary? = added.nil?
21
+ end
22
+
23
+ # The files that differ between two heads, among those the pull request
24
+ # changed at either: `dropped` are those it changed at the first and no
25
+ # longer does. A commit or a base branch this Mac does not hold is named in
26
+ # `absent`, and no file is listed then.
27
+ Code = Data.define(:from, :to, :files, :absent, :dropped, :compare_url) do
28
+ def dropped?(file) = dropped.include?(file.path)
29
+ end
30
+
31
+ # What a description lost and gained, as paragraphs, since the revision
32
+ # that stood at `at`.
33
+ Paragraphs = Data.define(:at, :removed, :added) do
34
+ # A blank line inside a code fence splits it in two: the comparison does not read Markdown.
35
+ def self.between(before, after, at:)
36
+ [before, after].map { split(it) }.then { |was, is| new(at:, removed: was - is, added: is - was) }
37
+ end
38
+
39
+ def self.split(text) = text.gsub("\r\n", "\n").split(/\n[ \t]*\n/).map(&:strip).compact_blank
40
+
41
+ def unchanged? = removed.empty? && added.empty?
42
+ end
43
+
44
+ # The revision that stood at `at` is no longer in GitHub's history.
45
+ Forgotten = Data.define(:at)
46
+
47
+ # `history` answers `description_revisions(number)`, as Github does.
48
+ def initialize(stop:, mark:, repository:, history:, root: Rails.root)
49
+ @stop, @mark, @repository, @history, @root = stop, mark, repository, history, root
50
+ end
51
+
52
+ # Nothing while the head is the one marked.
53
+ def code
54
+ return if mark.head == stop.head_oid
55
+
56
+ absent = [mark.head, stop.head_oid, base].reject { held?(it) }
57
+ was, is = absent.empty? ? [mark.head, stop.head_oid].map { own_files(it) } : [[], []]
58
+ Code.new(from: mark.head, to: stop.head_oid, files: differing(was | is), absent:, dropped: was - is,
59
+ compare_url: ("https://github.com/#{@repository}/compare/#{mark.head}...#{stop.head_oid}" if stop.pull_request.opened?))
60
+ end
61
+
62
+ # Nothing while the description is the one marked. The revision that stood
63
+ # then is found in the history by the time of the edit the mark recorded.
64
+ def description
65
+ return if mark.description == stop.description.digest
66
+
67
+ at = Time.iso8601(mark.described_at.to_s)
68
+ revision = @history.description_revisions(stop.number).find { it.at == at }
69
+ revision ? Paragraphs.between(revision.text, stop.description.to_s, at:) : Forgotten.new(at:)
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :stop, :mark
75
+
76
+ # The branch the pull request sits on, as this Mac last fetched it; one
77
+ # nobody has opened yet sits on a branch of this Mac.
78
+ def base = stop.pull_request.opened? ? "origin/#{stop.base}" : stop.base
79
+
80
+ def held?(commit) = git("cat-file", "-e", "#{commit}^{commit}").last.success?
81
+
82
+ # The files a pull request changes at one head: those of its own commits.
83
+ # Its base may have been rebuilt since that head was pushed (a restack) and
84
+ # still holds the lower layers' commits as the same patches, which
85
+ # `--cherry-pick` leaves out; against the base's tip alone, the diff of an
86
+ # old head would count every file of every layer under it.
87
+ def own_files(head)
88
+ git!("log", "--cherry-pick", "--right-only", "--no-merges", "--no-renames", "--name-only", "--format=", "#{base}...#{head}").lines(chomp: true).compact_blank.uniq
89
+ end
90
+
91
+ # The files among these that differ between the two heads. `:(top,literal)`
92
+ # takes each path from the top of the repository as it is written, wherever
93
+ # the desk is run from.
94
+ # shortcut: a layer under this one that changed a shared file between the heads shows it as this pull request's; git cannot tell without the base the old head stood on, so upgrade when a mark keeps it.
95
+ def differing(paths)
96
+ return [] if paths.empty? # with no path, git would list the whole diff
97
+
98
+ FileChange.parse(git!("diff", "--no-renames", "--numstat", mark.head, stop.head_oid, "--", *paths.map { ":(top,literal)#{it}" }))
99
+ end
100
+
101
+ def git!(*args)
102
+ output, status = git(*args)
103
+ status.success? ? output : raise(Failure, "git could not compare the heads of ##{stop.number}: #{output.strip}")
104
+ end
105
+
106
+ def git(*) = Open3.capture2e("git", "-C", @root.to_s, *)
107
+ end
108
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # How big a window to open, and what it says it is: the desk's desktop or
5
+ # phone, a size the app names in its app.yml, or a width and a height.
6
+ class Size < Data.define(:name, :width, :height, :user_agent)
7
+ IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) " \
8
+ "Version/18.6 Mobile/15E148 Safari/604.1"
9
+
10
+ OWN = {
11
+ "desktop" => {width: 1440, height: 900, user_agent: nil},
12
+ "phone" => {width: 390, height: 844, user_agent: IPHONE}
13
+ }.freeze
14
+
15
+ # A name or a width and a height. Whether the app names that size is asked
16
+ # when the window opens, so a registry reads before its app.yml does.
17
+ def self.valid?(spec) = spec in String | [Integer, Integer]
18
+
19
+ def self.of(spec, app: App.current)
20
+ case spec
21
+ in [Integer => width, Integer => height] then new(name: "#{width}x#{height}", width:, height:, user_agent: nil)
22
+ in String
23
+ named = OWN[spec] || app.size(spec) || raise(App::Missing, "No size is named #{spec}: it is not the desk's desktop or phone, and app.yml's sizes do not name it.")
24
+ new(name: spec, user_agent: nil, **named)
25
+ end
26
+ end
27
+
28
+ def to_s = name
29
+ end
30
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Where one pull request stands, worked out from facts: the owner's marks and
5
+ # what GitHub says now. Nothing is set by hand but "good to go" and "waiting
6
+ # on"; everything else follows.
7
+ class Standing
8
+ # Something that should hold before the pull request goes. `met` is nil for
9
+ # a condition only the owner can judge. `about` names the part of the page
10
+ # it is about, where the page can take the owner there.
11
+ Condition = Data.define(:name, :met, :detail, :about) do
12
+ def initialize(about: nil, **) = super
13
+ end
14
+
15
+ attr_reader :stop, :marks, :progress
16
+
17
+ # `under` is the standing of the layer this one sits on. `above` holds the
18
+ # layers over it: a suite run at the top of a stack vouches for what is
19
+ # under it. `scenarios` are the names of this pull request's scenarios, and
20
+ # `named` the preconditions the burst file names for it. `progress` is how
21
+ # far the owner's choices on its decisions are; without one, only the boxes
22
+ # ticked on GitHub count as reviewed.
23
+ def initialize(stop:, marks:, progress: nil, under: nil, above: [], scenarios: [], named: [])
24
+ @stop, @marks, @under, @above, @scenarios, @named = stop, marks, under, above, scenarios, named
25
+ @progress = progress || Choices::Progress.of(decisions.index_with(&:ticked))
26
+ end
27
+
28
+ # Whose move it is: :waiting on something outside, the :agent's, the
29
+ # :owner's, or nobody's because it is :settled.
30
+ def turn
31
+ if marks.waiting_on then :waiting
32
+ elsif for_the_agent.any? then :agent
33
+ elsif good_to_go? && !changed? && for_the_owner.empty? then :settled
34
+ else :owner
35
+ end
36
+ end
37
+
38
+ def settled? = turn == :settled
39
+
40
+ def good_to_go? = marks.good_to_go.present?
41
+
42
+ # What moved since "good to go" was given: the code, the description, both
43
+ # or neither.
44
+ def changed
45
+ return [] unless good_to_go?
46
+
47
+ [("code" if marks.good_to_go.head != stop.head_oid), ("description" if marks.good_to_go.description != stop.description.digest)].compact
48
+ end
49
+
50
+ def changed? = changed.any?
51
+
52
+ # What is open, in a few words each: the reasons behind the turn.
53
+ def open = [*("changed since your mark: #{changed.to_sentence}" if changed?), *otherwise_open]
54
+
55
+ # What is open besides a change since the mark, which the route says in a column of its own.
56
+ def otherwise_open = [*for_the_agent, *for_the_owner, *to_review, *("not yet marked" unless good_to_go?)]
57
+
58
+ # What is left of its decisions, as one of the reasons: "22 of 24 decisions
59
+ # to review". A page that shows the count beside them leaves it out.
60
+ def to_review
61
+ return [] if progress.done?
62
+
63
+ ["#{progress.reviewed.zero? ? progress.left : "#{progress.left} of #{progress.total}"} #{"decision".pluralize(progress.total)} to review"]
64
+ end
65
+
66
+ def conditions
67
+ [decisions_reviewed, notes_confirmed, verification_current, layers_under_settled,
68
+ *@named.map { Condition.new(name: it, met: nil, detail: "named in the burst file; yours to judge") }]
69
+ end
70
+
71
+ # The conditions that facts decide, which "2 of 4 met" counts.
72
+ def judged = conditions.reject { it.met.nil? }
73
+
74
+ # Whether its scenarios were tried on this head, when it has any. Said beside
75
+ # the conditions, never one of them: a pull request can go without them.
76
+ def scenarios_seen
77
+ return if @scenarios.empty?
78
+
79
+ seen = @scenarios.select { marks.scenarios.dig(it, "head") == stop.head_oid }
80
+ failed = seen.select { marks.scenarios.dig(it, "result") == "not_ok" }
81
+ Condition.new(name: "Its scenarios are tried on this head", met: seen.size == @scenarios.size && failed.empty?,
82
+ detail: ["#{seen.size - failed.size} of #{@scenarios.size}", ("#{failed.size} not as expected" if failed.any?)].compact.join(", "))
83
+ end
84
+
85
+ private
86
+
87
+ def decisions = stop.description.decisions
88
+
89
+ def for_the_agent
90
+ [*("#{count(notes(:open).size, "note")} open" if notes(:open).any?),
91
+ *("#{count(stop.open_threads, "review thread")} unresolved" if stop.open_threads.positive?)]
92
+ end
93
+
94
+ def for_the_owner = notes(:addressed).any? ? ["#{count(notes(:addressed).size, "note")} addressed, to confirm"] : []
95
+
96
+ def notes(state) = marks.notes.select { it.state == state.to_s }
97
+
98
+ def count(number, noun) = "#{number} #{noun.pluralize(number)}"
99
+
100
+ def decisions_reviewed
101
+ detail = ["#{progress.reviewed} of #{progress.total} reviewed", ("#{progress.ticked} ticked on GitHub" if progress.ticked.positive?)].compact.join(", ")
102
+ Condition.new(name: "Its decisions are reviewed", met: progress.done?, detail: progress.any? ? detail : "it has none", about: :decisions)
103
+ end
104
+
105
+ def notes_confirmed
106
+ live = marks.notes.select(&:live?)
107
+ Condition.new(name: "Your notes are confirmed", met: live.empty?,
108
+ detail: live.empty? ? "none waiting" : live.group_by(&:state).map { |state, notes| "#{notes.size} #{state}" }.to_sentence)
109
+ end
110
+
111
+ # A commit the Verification section names that is still the head of this
112
+ # pull request, or of a layer above it.
113
+ def verification_current
114
+ named = stop.description.section("Verification").scan(/`(\h{7,40})`/).flatten
115
+ vouching = [stop, *@above].find { |layer| named.any? { layer.head_oid.start_with?(it) } }
116
+ Condition.new(name: "Verification is of the present head", met: vouching.present?,
117
+ detail: vouching ? "it names `#{vouching.head_oid[0, 8]}`, the head of ##{vouching.number}" : "it names no commit that is a head now")
118
+ end
119
+
120
+ def layers_under_settled
121
+ Condition.new(name: "The layers under it are settled", met: @under.nil? || @under.settled_through?,
122
+ detail: @under.nil? ? "it sits on nothing in this burst" : (@under.unsettled_below || "all settled"))
123
+ end
124
+
125
+ protected
126
+
127
+ def settled_through? = settled? && (@under.nil? || @under.settled_through?)
128
+
129
+ # The highest layer at or below this one that is not settled, named.
130
+ def unsettled_below = settled? ? @under&.unsettled_below : "##{stop.number} is not settled"
131
+ end
132
+ end