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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # What the owner last saw of a burst's membership, in one YAML file beside
5
+ # its marks: its pull requests, each listed stack's members from the bottom
6
+ # up, its pull requests as the last handover found them, and those opened
7
+ # on GitHub since they waited unopened, each with the number it had.
8
+ #
9
+ # A reading alone never moves it: the owner does, by dismissing what joined
10
+ # or left, or by writing a handover. Nor does a reading that could not say
11
+ # where GitHub's stacks stand, which says nothing of who joined or left.
12
+ class Membership
13
+ attr_reader :pull_requests, :stacks, :handed_over, :opened
14
+
15
+ def self.of(burst) = new(Marks.directory_of(burst).join("membership.yml"))
16
+
17
+ # A file that is not as the desk reads it is left for the owner to mend,
18
+ # as a marks file is.
19
+ def initialize(path)
20
+ @path = path
21
+ stored = (path.exist? && YAML.safe_load_file(path)) || {}
22
+ @pull_requests = stored.fetch("pull_requests", []).map { number(it) }
23
+ @stacks = stored.fetch("stacks", {}).to_h { |stack, members| [Integer(stack), members.map { number(it) }] }
24
+ @handed_over = stored.fetch("handed_over", @pull_requests).map { number(it) }
25
+ @opened = stored.fetch("opened", {}).to_h { |now, was| [number(now), was.to_s] }
26
+ rescue Psych::Exception, ArgumentError, TypeError, NoMethodError => e
27
+ raise Failure, "The membership file #{path} is not as the desk reads it (#{e.message.lines.first&.strip}). The desk left it as it is: mend it, or move it aside to start the membership again."
28
+ end
29
+
30
+ def kept? = @path.exist?
31
+
32
+ # The pull requests that joined the burst, and those that left it, since
33
+ # the owner last looked.
34
+ def joined(route) = counting?(route) ? numbers(route) - pull_requests : []
35
+
36
+ def left(route) = counting?(route) ? pull_requests - numbers(route) : []
37
+
38
+ # Those that left since the last handover: their choices are not yet handed over.
39
+ def departed(route) = kept? ? handed_over - numbers(route) : []
40
+
41
+ # The first reading of a burst names nothing: it is where joining and
42
+ # leaving count from.
43
+ def start(route) = (keep(route, members: numbers(route), handed_over: numbers(route)) if !kept? && route.stacks_known?)
44
+
45
+ # The owner has seen that this pull request joined, that it left, or
46
+ # that it was opened.
47
+ def acknowledge(number, route)
48
+ return seen_opened(number) if opened.key?(number)
49
+ return unless route.stacks_known?
50
+
51
+ keep(route, members: numbers(route).include?(number) ? pull_requests | [number] : pull_requests - [number], handed_over:)
52
+ end
53
+
54
+ # It waited unopened as `was`, and is open as `number` now: said until
55
+ # the owner has seen it.
56
+ def note_opened(number, was:)
57
+ @opened[number] = was
58
+ write
59
+ end
60
+
61
+ # A handover takes in what the burst holds now.
62
+ def hand_over(route) = (keep(route, members: numbers(route), handed_over: numbers(route)) if route.stacks_known?)
63
+
64
+ private
65
+
66
+ def number(written) = PullRequest.number(written) || raise(ArgumentError, "#{written.inspect} is no pull request's number")
67
+
68
+ def seen_opened(number)
69
+ @opened.delete(number)
70
+ write
71
+ end
72
+
73
+ def counting?(route) = kept? && route.stacks_known?
74
+
75
+ def numbers(route) = route.map(&:number)
76
+
77
+ def keep(route, members:, handed_over:)
78
+ @pull_requests, @handed_over = members, handed_over
79
+ @stacks = route.parts.select(&:stack?).to_h { [it.id, route.stops_of(it).map(&:number)] }
80
+ write
81
+ end
82
+
83
+ # Written beside the file and moved over it, so a reader never meets half a file.
84
+ def write
85
+ @path.dirname.mkpath
86
+ draft = @path.sub_ext(".yml.part")
87
+ draft.write({"pull_requests" => pull_requests, "stacks" => stacks, "handed_over" => handed_over, "opened" => opened.presence}.compact.to_yaml(line_width: -1))
88
+ draft.rename(@path)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The pull requests a # in the owner's words can name: every one of the
5
+ # repository, open, merged or closed. GitHub is read in full once and the
6
+ # answer kept for the next start of the desk; after that it is asked only
7
+ # for what changed since the last change kept, and no more than once a
8
+ # minute.
9
+ class Mentions
10
+ # A pull request, as a # names it.
11
+ Mention = Data.define(:number, :title, :state, :url) do
12
+ def self.from(node) = new(number: node.fetch("number"), title: node.fetch("title"), state: node.fetch("state").downcase, url: node.fetch("url"))
13
+
14
+ def digits = number.to_s
15
+
16
+ def open? = state == "open"
17
+ end
18
+
19
+ # A # in the owner's words: one that begins a word, and its digits end one.
20
+ WRITTEN = /(?<![\w&#])#\d+(?!\w)/
21
+
22
+ # How many a # offers at once.
23
+ OFFERED = 8
24
+
25
+ # How long what GitHub said is taken for what it says now.
26
+ GOOD_FOR = 1.minute
27
+
28
+ # A desk serves several pages at once: one asks GitHub, the others wait
29
+ # for its answer instead of asking again.
30
+ ASKING = Mutex.new
31
+
32
+ # Those of the checkout's own repository; none for a checkout that has
33
+ # no repository on GitHub.
34
+ def self.here = Project.current.github_repository&.then { new(Github.new(it)) }
35
+
36
+ def initialize(github) = @github = github
37
+
38
+ # A repository the desk has never read is read behind the owner's back
39
+ # (ReadMentionsAheadJob).
40
+ def read_ahead
41
+ ReadMentionsAheadJob.perform_later(@github.repository) unless Rails.cache.exist?(cached) || kept&.file?
42
+ end
43
+
44
+ # Those a # with these digits may mean: the pull request with that very
45
+ # number first, then those whose number begins so, the newest first.
46
+ def matching(digits)
47
+ begun = every.select { it.digits.start_with?(digits) }
48
+ exact, longer = begun.partition { it.digits == digits }
49
+ [*exact, *longer.max_by(OFFERED, &:number)].first(OFFERED)
50
+ end
51
+
52
+ # The pull request a number names, of those already kept: a page that
53
+ # shows the owner's words asks GitHub nothing.
54
+ def [](number) = in_hand&.dig(:nodes, number)&.then { Mention.from(it) }
55
+
56
+ # What GitHub holds, asked again no more than once a minute. What is in
57
+ # hand is read again once the way is clear: the page that asked meanwhile
58
+ # may have brought it up to date.
59
+ def up_to_date
60
+ ASKING.synchronize do
61
+ @in_hand = nil
62
+ fresh? ? in_hand : brought_up_to_date
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def every = up_to_date.fetch(:nodes).values.map { Mention.from(it) }
69
+
70
+ def in_hand = @in_hand ||= Rails.cache.read(cached) || from_disk
71
+
72
+ def fresh? = in_hand&.fetch(:checked)&.after?(GOOD_FOR.ago)
73
+
74
+ # The first reading asks for every pull request; the next only for those
75
+ # that changed at the last change it kept, or since. When GitHub cannot
76
+ # be asked, what is kept is good enough.
77
+ def brought_up_to_date
78
+ nodes = in_hand&.fetch(:nodes) || {}
79
+ since = nodes.values.pluck("updatedAt").max&.then { Time.iso8601(it) }
80
+ changed = @github.pull_requests_changed(since:).index_by { it.fetch("number") }
81
+ keep(checked: Time.current, nodes: nodes.merge(changed))
82
+ rescue Github::Unreachable
83
+ in_hand or raise
84
+ end
85
+
86
+ def keep(reading)
87
+ Rails.cache.write(cached, reading)
88
+ to_disk(reading)
89
+ @in_hand = reading
90
+ end
91
+
92
+ def cached = [:mentions, @github.repository]
93
+
94
+ # Where the reading waits for the next start of the desk. A desk that is
95
+ # only being tried out keeps none.
96
+ def kept = (Project.current.cache.join("mentions", "#{@github.repository.tr("/", "-")}.json") if Desk.config.x.keeps_readings)
97
+
98
+ # A file that cannot be read is as good as none: GitHub is asked again.
99
+ def from_disk
100
+ return unless kept&.file?
101
+
102
+ JSON.parse(kept.read).then { |read| {checked: Time.iso8601(read.fetch("checked")), nodes: read.fetch("nodes").index_by { it.fetch("number") }} }
103
+ rescue JSON::ParserError, KeyError, ArgumentError
104
+ nil
105
+ end
106
+
107
+ # Written beside the file and moved over it, so a start never meets half a reading.
108
+ def to_disk(reading)
109
+ return unless kept
110
+
111
+ kept.dirname.mkpath
112
+ draft = kept.sub_ext(".json.part")
113
+ draft.write(JSON.generate(checked: reading.fetch(:checked).iso8601(6), nodes: reading.fetch(:nodes).values))
114
+ draft.rename(kept)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Something the owner wrote about a pull request, pinned to the place it is
5
+ # about: words of the description, a region of a picture, a moment of a
6
+ # recording, a scenario. A note with no place is about the whole pull request.
7
+ class Note < Data.define(:id, :kind, :state, :text, :written_at, :place, :reply)
8
+ KINDS = %w[change question remark].freeze
9
+ STATES = %w[open addressed confirmed withdrawn].freeze
10
+
11
+ # How far around a quote its surroundings are compared, in characters.
12
+ NEAR = 64
13
+
14
+ # How many characters of what stood around a quote must still stand there
15
+ # for a place to be its place: a word's worth, or all of it when less was kept.
16
+ CONFIRMING = 8
17
+
18
+ # Words this long are their own evidence and are followed wherever they
19
+ # moved: about seven words do not turn up by chance in a place they were not
20
+ # written. Shorter ones ("Expected: 0 failures.") recur through a description.
21
+ DISTINCTIVE = 40
22
+
23
+ # Words of a description, kept with a little of what stands before and
24
+ # after them, so they are found again wherever an edit moves them.
25
+ Quote = Data.define(:exact, :before, :after, :section) do
26
+ # A form sends a line break as two characters and a description has one,
27
+ # so the words are kept as the description has them.
28
+ def initialize(exact:, before:, after:, section:) = super(exact: exact.delete("\r"), before: before&.delete("\r"), after: after&.delete("\r"), section:)
29
+
30
+ # Where the words stand in a text now, as a range of it; nil when they
31
+ # are no longer there. Of several places, the one whose surroundings fit,
32
+ # and that one only while enough of its surroundings do or the words are
33
+ # distinctive: a phrase standing somewhere it was not written is not the note's.
34
+ def locate(text)
35
+ spot = spots(text).max_by { fit(text, it) } or return
36
+ spot if distinctive? || fit(text, spot) >= [CONFIRMING, before.to_s.length + after.to_s.length].min
37
+ end
38
+
39
+ # How many characters of the surroundings stand round the words where
40
+ # they stand best in a text; nil when the words are not in it.
41
+ def fit_in(text) = spots(text).map { fit(text, it) }.max
42
+
43
+ def distinctive? = exact.squish.length >= DISTINCTIVE
44
+
45
+ private
46
+
47
+ def spots(text) = text.enum_for(:scan, pattern).map { Regexp.last_match.begin(0)...Regexp.last_match.end(0) }
48
+
49
+ # The words, with any run of white space standing for any other.
50
+ def pattern = Regexp.new(exact.split.map { Regexp.escape(it) }.join('\s+'))
51
+
52
+ def fit(text, spot)
53
+ shared(text[[spot.begin - NEAR, 0].max...spot.begin].reverse, before.to_s.reverse) + shared(text[spot.end, NEAR], after.to_s)
54
+ end
55
+
56
+ def shared(one, other) = one.each_char.zip(other.each_char).take_while { |a, b| a == b }.size
57
+ end
58
+
59
+ # A rectangle of a picture: `box` is x, y, width and height as fractions of
60
+ # the picture, so it holds at any size. `picture` is the alt text, `asset`
61
+ # the uploaded file it was drawn on.
62
+ Region = Data.define(:picture, :asset, :box)
63
+
64
+ # A moment of a recording: seconds from its start, the step it falls in,
65
+ # that frame saved as a picture, and a rectangle on it when one was drawn.
66
+ Moment = Data.define(:recording, :time, :step, :frame, :box)
67
+
68
+ Scenario = Data.define(:name)
69
+
70
+ PLACES = {"quote" => Quote, "picture" => Region, "recording" => Moment, "scenario" => Scenario}.freeze
71
+
72
+ STATES.each { |state| define_method(:"#{state}?") { self.state == state } }
73
+ KINDS.each { |kind| define_method(:"#{kind}?") { self.kind == kind } }
74
+
75
+ def self.restored(stored)
76
+ name, place = PLACES.find { |key, _| stored.key?(key) }
77
+ new(**%w[id kind state text reply].index_with { stored[it] }.symbolize_keys,
78
+ written_at: Time.iso8601(stored.fetch("written_at")),
79
+ place: place&.new(**place.members.index_with { stored[name][it.to_s] }))
80
+ end
81
+
82
+ # Still needs someone: the agent while it is open, the owner once addressed.
83
+ def live? = open? || addressed?
84
+
85
+ # The decision of a description that this note answers otherwise: a change
86
+ # asked for on words of a box. A question or a remark leaves the box open.
87
+ def answers_in(description) = (description.decision_quoted_by(place) if change? && (place in Quote[section: Description::DECISIONS]))
88
+
89
+ def stored
90
+ {"id" => id, "kind" => kind, "state" => state, "text" => text, "written_at" => written_at.iso8601,
91
+ PLACES.key(place.class) => place&.to_h&.stringify_keys&.compact, "reply" => reply}.compact
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The environment a program that is not the desk's runs in when the desk
5
+ # starts it: the reviewed app's commands, Diffle. Whatever put the desk on
6
+ # its own Ruby and gems stays with the desk, so that the app finds its own:
7
+ #
8
+ # - from a checkout, Bundler's variables;
9
+ # - as an rv tool, the gem home rv made for it, the variables that name its
10
+ # Ruby, and the folders rv put first on the PATH;
11
+ # - and a version manager's Ruby of the desk's (asdf, for one), which leaves
12
+ # the PATH while another ruby is still on it.
13
+ module Outside
14
+ # rv sets these for the tool it runs; the shell it was run from had none.
15
+ RV = %w[GEM_HOME GEM_PATH RUBY_ROOT RUBY_ENGINE RUBY_VERSION].freeze
16
+
17
+ # Yields what to hand Process.spawn, with Bundler's variables gone from
18
+ # ENV while the block runs.
19
+ def self.run(&) = defined?(Bundler) ? Bundler.with_unbundled_env { yield environment } : yield(environment)
20
+
21
+ def self.environment(env = ENV, installation: Installation.here, desks: RbConfig::CONFIG["bindir"])
22
+ path = env.fetch("PATH", "")
23
+ return {"BUNDLE_GEMFILE" => nil, "PATH" => path_for_the_app(path, desks:)} unless installation.rv_tool?
24
+
25
+ RV.index_with(nil).merge("BUNDLE_GEMFILE" => nil, "PATH" => path_for_the_app(before_rv(path, env), desks:))
26
+ end
27
+
28
+ # The PATH as the shell had it: rv puts its Ruby's folders, and its gems',
29
+ # in front.
30
+ def self.before_rv(path, env)
31
+ own = [env["RUBY_ROOT"], env["GEM_HOME"], *env.fetch("GEM_PATH", "").split(File::PATH_SEPARATOR)].compact_blank
32
+ path.split(File::PATH_SEPARATOR).drop_while { |entry| own.any? { entry.start_with?("#{it}/") } }.join(File::PATH_SEPARATOR)
33
+ end
34
+
35
+ # The app's bin/ scripts would run under the desk's own Ruby, put first on
36
+ # the PATH by a version manager, rather than the Ruby the app names. It is
37
+ # left out whenever another ruby is still on the PATH.
38
+ def self.path_for_the_app(path, desks: RbConfig::CONFIG["bindir"])
39
+ entries = path.split(File::PATH_SEPARATOR)
40
+ rest = entries - [desks]
41
+ another = rest.any? { File.executable?(File.join(it, "ruby")) }
42
+ (another ? rest : entries).join(File::PATH_SEPARATOR)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class Overlay
5
+ # What the desk tells Grouse of a scenario when it opens its overlay: the
6
+ # strip's label, who narrates, what to expect and the steps, and the way
7
+ # back to the desk. Grouse checks every field as the session opens, so the
8
+ # brief keeps within its limits; the rest of `metadata` is the desk's own,
9
+ # and no page shows it.
10
+ class Brief < Data.define(:label, :reviewer_label, :external_ref, :metadata)
11
+ SOURCE = "proctolog"
12
+ LABEL = 200
13
+ EXPECT = 1_000
14
+ STEP = 500
15
+ STEPS = 50
16
+ METADATA = 16_000
17
+
18
+ # "grouse-rails #7 · Send a note", or "sartoria · Step 2 · Sign in" for a
19
+ # step of the sitting. `steps` are the scenario's, one line each.
20
+ def self.for(scenario, burst:, steps:, head:, desk_url:)
21
+ label = [[Project.current.name, ("##{scenario.pr}" if scenario.pr)].compact.join(" "), ("Step #{scenario.sitting}" if scenario.sitting), scenario.name]
22
+ new(label: line(label.compact.join(" · "), LABEL), reviewer_label: "#{Owner.current.first_name} (PRoctolog)",
23
+ external_ref: line("#{SOURCE}:#{burst.slug}/#{scenario.to_param}#{"@#{head}" if head}", LABEL),
24
+ metadata: {expect: scenario.expect&.truncate(EXPECT), steps: steps.map { line(plain(it), STEP) }.compact_blank.first(STEPS), desk_url:,
25
+ burst: burst.slug, pr: scenario.pr, scenario: scenario.to_param, recipe: scenario.recipe, head:}.compact).fitted
26
+ end
27
+
28
+ # A step as GitHub sets it, read as words: no code ticks, bold or links.
29
+ def self.plain(text) = text.gsub(/\[([^\]]+)\]\([^)\s]+\)/, '\1').delete("`").gsub("**", "")
30
+
31
+ def self.line(text, most) = text.squish.truncate(most)
32
+
33
+ # The member's email never reaches the strip: wherever the label, what to
34
+ # expect or a step names it, it says "the member".
35
+ def without(email)
36
+ return self if email.blank?
37
+
38
+ unnamed = ->(text) { text&.gsub(email, "the member") }
39
+ with(label: unnamed.call(label), metadata: metadata.merge({expect: unnamed.call(metadata[:expect]), steps: metadata[:steps]&.map(&unnamed)}.compact))
40
+ end
41
+
42
+ # Within Grouse's limit for all of `metadata`, losing steps from the end.
43
+ def fitted
44
+ return self if metadata.to_json.bytesize <= METADATA || metadata[:steps].blank?
45
+
46
+ with(metadata: metadata.merge(steps: metadata[:steps][...-1])).fitted
47
+ end
48
+
49
+ def as_json(*) = {source: SOURCE, label:, reviewer_label:, external_ref:, metadata:}
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ class Overlay
5
+ # A note the owner wrote in the overlay, as its app's `notes` printed it
6
+ # (Grouse's export at its standard detail), and as the desk pins it on the
7
+ # scenario: their words, then where Grouse keeps the note.
8
+ class Written < Data.define(:id, :intent, :body, :transcript, :transcription, :url, :route, :templates, :created_at)
9
+ # Grouse's intents as the desk's kinds of note; approving, or saying
10
+ # nothing of what is wanted, is a remark.
11
+ KINDS = {"fix" => "change", "change" => "change", "question" => "question"}.freeze
12
+ TEMPLATES = 3
13
+
14
+ def self.from(note, app: nil)
15
+ new(id: note.fetch("public_id"), intent: note["intent"], body: note["body"], transcript: note["transcript"],
16
+ transcription: note["transcription_state"], url: (app && note["url"]&.start_with?("/")) ? URI.join(app, note["url"]).to_s : note["url"], route: note["route"],
17
+ templates: Array(note["templates"]), created_at: note["created_at"].to_s)
18
+ end
19
+
20
+ # A voice note waits for its words while they are being written out;
21
+ # once done, failed or not to be, it is pinned as it stands.
22
+ def ready? = transcription != "pending"
23
+
24
+ def kind = KINDS.fetch(intent.to_s, "remark")
25
+
26
+ def text
27
+ words = [body, transcript].map { it.to_s.strip }.compact_blank
28
+ words = ["A voice note with no words (#{transcription.to_s.tr("_", " ")})."] if words.empty?
29
+ [*words, [url, route, *templates.first(TEMPLATES)].compact_blank.join(" · ")].join("\n\n")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # Grouse's overlay in the app under review, labelled with a scenario: a
5
+ # review session of the app's, which one press of the scenario's button
6
+ # opened through app.yml's `overlay` (docs/manual/app.md). It is kept in the
7
+ # marks beside its scenario, with the ids of the Grouse notes pinned from it,
8
+ # so that a pull pins each note once. A second press opens a second one.
9
+ class Overlay < Data.define(:session, :opened, :head, :pinned)
10
+ # The session a press opened, and the cookie that shows the overlay to the
11
+ # browser it opens, a whole Set-Cookie line as the app printed it.
12
+ Opened = Data.define(:session, :cookie) do
13
+ def missing = nil
14
+ end
15
+
16
+ # Why a press opened the app without the overlay, in words that follow
17
+ # "Grouse's overlay did not open: ".
18
+ Refused = Data.define(:reason) do
19
+ def cookie = nil
20
+
21
+ def session = nil
22
+
23
+ def missing = "Grouse's overlay did not open: #{reason}"
24
+ end
25
+
26
+ # Opens a session for the brief. A refusal is said, and never stops the
27
+ # entering: the member is signed in all the same.
28
+ def self.open(server, brief)
29
+ case server.open_overlay(brief).symbolize_keys
30
+ in {public_id: String => session, cookie: String => cookie} then Opened.new(session:, cookie:)
31
+ in {problem: problem, **said} then Refused.new(reason: refusal(problem, said[:errors]))
32
+ else Refused.new(reason: "the app printed no session.")
33
+ end
34
+ rescue Server::Error => e
35
+ Refused.new(reason: e.message)
36
+ end
37
+
38
+ # "the app said invalid (metadata.steps: too_long)." Grouse names the
39
+ # fields of a refusal, never their values.
40
+ def self.refusal(problem, errors)
41
+ fields = errors.to_h.map { |field, codes| "#{field}: #{Array(codes).join(", ")}" }
42
+ "the app said #{problem}#{" (#{fields.join("; ")})" if fields.any?}."
43
+ end
44
+
45
+ # What a pull brought: notes pinned, and notes still waiting on their words.
46
+ Pulled = Data.define(:pinned, :waiting) do
47
+ def sentence
48
+ found = pinned.zero? ? "No new notes in Grouse's overlay." : "Pinned #{pinned} #{"note".pluralize(pinned)} from Grouse's overlay."
49
+ later = " #{waiting} #{(waiting == 1) ? "waits" : "wait"} for #{(waiting == 1) ? "its" : "their"} words: pull again in a moment." if waiting.positive?
50
+ "#{found}#{later}"
51
+ end
52
+ end
53
+
54
+ # Pins on a scenario every note written in its overlays that is not pinned
55
+ # yet. Each overlay's `notes` boots the app, so it is run when asked for.
56
+ def self.pull(server, marks, scenario)
57
+ counts = marks.overlays_of(scenario).map do |overlay|
58
+ answer = server.overlay_notes(overlay.session)
59
+ fresh = overlay.unpinned(answer, app: server.address).each { marks.pin_written(scenario, overlay:, written: it) }
60
+ [fresh.size, overlay.waiting(answer)]
61
+ end
62
+ pinned, waiting = counts.transpose.map(&:sum)
63
+ Pulled.new(pinned: pinned.to_i, waiting: waiting.to_i)
64
+ end
65
+
66
+ def self.restored(stored) = new(session: stored.fetch("session"), opened: stored["opened"], head: stored["head"], pinned: stored.fetch("pinned", []))
67
+
68
+ def self.opening(session, head:) = new(session:, opened: Time.current.iso8601, head:, pinned: [])
69
+
70
+ # The notes of what its app's `notes` printed that are ready to pin and are
71
+ # not pinned yet, the oldest first.
72
+ # A link Grouse printed as a path, as a runner has no host to give it, is
73
+ # made whole on `app`, the address the sitting serves the app on.
74
+ def unpinned(answer, app: nil) = written(answer, app:).select(&:ready?).reject { pinned.include?(it.id) }
75
+
76
+ # The notes still waiting on their words.
77
+ def waiting(answer) = written(answer).count { !it.ready? }
78
+
79
+ def pinning(id) = with(pinned: pinned | [id])
80
+
81
+ def stored = {"session" => session, "opened" => opened, "head" => head, "pinned" => pinned.presence}.compact
82
+
83
+ private
84
+
85
+ def written(answer, app: nil) = answer.fetch("notes", []).map { Written.from(it, app:) }.sort_by(&:created_at)
86
+ end
87
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Proctolog
6
+ # Whom the desk serves: one person, on this Mac, named as Git names them
7
+ # (`user.name`), else by their login. Grouse's notes on the desk are theirs,
8
+ # and so are those written in the overlay of a scenario they opened.
9
+ class Owner < Data.define(:name)
10
+ def self.current = @current ||= new(name: git_name.presence || ENV.fetch("USER", "the owner"))
11
+
12
+ def self.git_name = Open3.capture2("git", "config", "--get", "user.name").first.strip
13
+
14
+ def first_name = name.split.first
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # The repository's open pull requests as a reviewer picks a burst from them:
5
+ # GitHub's stacks whole, from the bottom up, then the rest, the reviewer's
6
+ # own first, then those written for PRoctolog, then the newest.
7
+ class Pick
8
+ Stack = Data.define(:number, :pull_requests) do
9
+ def top = pull_requests.last
10
+ end
11
+
12
+ attr_reader :viewer, :unopened
13
+
14
+ # `unopened` are the pull requests nobody has opened yet, in the order of
15
+ # their numbers.
16
+ def initialize(pull_requests, viewer:, unopened: [])
17
+ @pull_requests, @viewer, @unopened = pull_requests, viewer, unopened
18
+ end
19
+
20
+ def stacks
21
+ @pull_requests.select(&:stack).group_by(&:stack).sort.map do |number, layers|
22
+ Stack.new(number:, pull_requests: layers.sort_by(&:position))
23
+ end
24
+ end
25
+
26
+ def loose = @pull_requests.reject(&:stack).sort_by { [yours?(it) ? 0 : 1, written?(it) ? 0 : 1, -it.number] }
27
+
28
+ def yours?(pull_request) = viewer.present? && pull_request.author == viewer
29
+
30
+ # Whether its description asks the reviewer anything the way PRoctolog reads it.
31
+ def written?(pull_request) = pull_request.description.decisions.any?
32
+
33
+ def empty? = @pull_requests.empty? && unopened.empty?
34
+
35
+ # Whether a pull request nobody has opened yet sits on another pull
36
+ # request's branch, so that it tops a chain.
37
+ def chained?(pull_request) = [*@pull_requests, *unopened].any? { it.head == pull_request.base }
38
+
39
+ # A name for a burst of these pull requests, from the branch of the first:
40
+ # what `burst new` would be told, and free among the bursts there are.
41
+ def self.slug_for(pull_requests)
42
+ base = pull_requests.first&.head.to_s.parameterize.first(40).delete_suffix("-").presence || "burst"
43
+ taken = Burst.files.map { it.basename(".yml").to_s }
44
+ (1..).lazy.map { (it == 1) ? base : "#{base}-#{it}" }.find { !taken.include?(it) }
45
+ end
46
+ end
47
+ end