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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +178 -0
- data/app/controllers/proctolog/acceptances_controller.rb +29 -0
- data/app/controllers/proctolog/application_controller.rb +32 -0
- data/app/controllers/proctolog/bursts_controller.rb +34 -0
- data/app/controllers/proctolog/choices_controller.rb +30 -0
- data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
- data/app/controllers/proctolog/decisions_controller.rb +33 -0
- data/app/controllers/proctolog/entries_controller.rb +14 -0
- data/app/controllers/proctolog/evidence_controller.rb +23 -0
- data/app/controllers/proctolog/freshness_controller.rb +27 -0
- data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
- data/app/controllers/proctolog/memberships_controller.rb +20 -0
- data/app/controllers/proctolog/mentions_controller.rb +16 -0
- data/app/controllers/proctolog/moments_controller.rb +26 -0
- data/app/controllers/proctolog/note_params.rb +24 -0
- data/app/controllers/proctolog/notes_controller.rb +56 -0
- data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
- data/app/controllers/proctolog/pictures_controller.rb +27 -0
- data/app/controllers/proctolog/prompts_controller.rb +28 -0
- data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
- data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
- data/app/controllers/proctolog/put_away_controller.rb +19 -0
- data/app/controllers/proctolog/recordings_controller.rb +28 -0
- data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
- data/app/controllers/proctolog/review_data_controller.rb +12 -0
- data/app/controllers/proctolog/rules_controller.rb +20 -0
- data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
- data/app/controllers/proctolog/scenario_scoped.rb +21 -0
- data/app/controllers/proctolog/scenarios_controller.rb +67 -0
- data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
- data/app/controllers/proctolog/waiting_controller.rb +14 -0
- data/app/controllers/proctolog/with_decisions.rb +28 -0
- data/app/helpers/proctolog/bursts_helper.rb +52 -0
- data/app/helpers/proctolog/decisions_helper.rb +102 -0
- data/app/helpers/proctolog/desk_helper.rb +61 -0
- data/app/helpers/proctolog/mentions_helper.rb +27 -0
- data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
- data/app/helpers/proctolog/prompts_helper.rb +21 -0
- data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
- data/app/helpers/proctolog/recordings_helper.rb +30 -0
- data/app/helpers/proctolog/rounds_helper.rb +27 -0
- data/app/helpers/proctolog/scenarios_helper.rb +123 -0
- data/app/helpers/proctolog/standings_helper.rb +80 -0
- data/app/jobs/proctolog/application_job.rb +8 -0
- data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
- data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
- data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
- data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
- data/app/models/proctolog/acceptance.rb +47 -0
- data/app/models/proctolog/app.rb +92 -0
- data/app/models/proctolog/browser.rb +42 -0
- data/app/models/proctolog/burst.rb +268 -0
- data/app/models/proctolog/burst_notices.rb +143 -0
- data/app/models/proctolog/chapters.rb +42 -0
- data/app/models/proctolog/check.rb +122 -0
- data/app/models/proctolog/choices.rb +112 -0
- data/app/models/proctolog/code_review.rb +251 -0
- data/app/models/proctolog/decision_record.rb +12 -0
- data/app/models/proctolog/decisions.rb +199 -0
- data/app/models/proctolog/description.rb +165 -0
- data/app/models/proctolog/difference.rb +138 -0
- data/app/models/proctolog/doctor.rb +127 -0
- data/app/models/proctolog/evidence.rb +29 -0
- data/app/models/proctolog/failure.rb +7 -0
- data/app/models/proctolog/focus.rb +48 -0
- data/app/models/proctolog/fresh_sitting.rb +35 -0
- data/app/models/proctolog/github.rb +353 -0
- data/app/models/proctolog/handover/brief.rb +138 -0
- data/app/models/proctolog/handover/layers.rb +128 -0
- data/app/models/proctolog/handover/places.rb +98 -0
- data/app/models/proctolog/handover/taken.rb +41 -0
- data/app/models/proctolog/handover/worklist.rb +35 -0
- data/app/models/proctolog/handover.rb +123 -0
- data/app/models/proctolog/housekeeping.rb +23 -0
- data/app/models/proctolog/kit.rb +36 -0
- data/app/models/proctolog/marks.rb +130 -0
- data/app/models/proctolog/membership.rb +91 -0
- data/app/models/proctolog/mentions.rb +117 -0
- data/app/models/proctolog/note.rb +94 -0
- data/app/models/proctolog/outside.rb +45 -0
- data/app/models/proctolog/overlay/brief.rb +52 -0
- data/app/models/proctolog/overlay/written.rb +33 -0
- data/app/models/proctolog/overlay.rb +87 -0
- data/app/models/proctolog/owner.rb +16 -0
- data/app/models/proctolog/pick.rb +47 -0
- data/app/models/proctolog/picture.rb +138 -0
- data/app/models/proctolog/pieces_rendering.rb +40 -0
- data/app/models/proctolog/project.rb +118 -0
- data/app/models/proctolog/pull_request.rb +89 -0
- data/app/models/proctolog/recording.rb +194 -0
- data/app/models/proctolog/release.rb +59 -0
- data/app/models/proctolog/rendering.rb +303 -0
- data/app/models/proctolog/reports.rb +66 -0
- data/app/models/proctolog/response_files.rb +55 -0
- data/app/models/proctolog/rounds.rb +305 -0
- data/app/models/proctolog/route.rb +151 -0
- data/app/models/proctolog/rules.rb +121 -0
- data/app/models/proctolog/scenario.rb +126 -0
- data/app/models/proctolog/server.rb +273 -0
- data/app/models/proctolog/since.rb +108 -0
- data/app/models/proctolog/size.rb +30 -0
- data/app/models/proctolog/standing.rb +132 -0
- data/app/models/proctolog/theme.rb +40 -0
- data/app/models/proctolog/unopened/numbers.rb +48 -0
- data/app/models/proctolog/unopened/opening.rb +88 -0
- data/app/models/proctolog/unopened.rb +203 -0
- data/app/models/proctolog/visit.rb +26 -0
- data/app/views/layouts/proctolog/application.html.erb +58 -0
- data/app/views/proctolog/acceptances/new.html.erb +34 -0
- data/app/views/proctolog/bursts/_line.html.erb +28 -0
- data/app/views/proctolog/bursts/_merged.html.erb +23 -0
- data/app/views/proctolog/bursts/_moved.html.erb +16 -0
- data/app/views/proctolog/bursts/_notices.html.erb +39 -0
- data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
- data/app/views/proctolog/bursts/_rules.html.erb +27 -0
- data/app/views/proctolog/bursts/_side.html.erb +49 -0
- data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
- data/app/views/proctolog/bursts/show.html.erb +54 -0
- data/app/views/proctolog/code_reviews/show.html.erb +33 -0
- data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
- data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
- data/app/views/proctolog/decisions/_box.html.erb +27 -0
- data/app/views/proctolog/decisions/_choices.html.erb +9 -0
- data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
- data/app/views/proctolog/decisions/_part.html.erb +61 -0
- data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
- data/app/views/proctolog/decisions/_theme.html.erb +13 -0
- data/app/views/proctolog/decisions/index.html.erb +75 -0
- data/app/views/proctolog/notes/_card.html.erb +19 -0
- data/app/views/proctolog/notes/_template.html.erb +41 -0
- data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
- data/app/views/proctolog/prompts/show.html.erb +41 -0
- data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
- data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
- data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
- data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
- data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
- data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
- data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
- data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
- data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
- data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
- data/app/views/proctolog/pull_requests/show.html.erb +145 -0
- data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
- data/app/views/proctolog/recordings/_list.html.erb +8 -0
- data/app/views/proctolog/recordings/_note.html.erb +22 -0
- data/app/views/proctolog/recordings/index.html.erb +33 -0
- data/app/views/proctolog/recordings/show.html.erb +118 -0
- data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
- data/app/views/proctolog/scenarios/_note.html.erb +18 -0
- data/app/views/proctolog/scenarios/_open.html.erb +9 -0
- data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
- data/app/views/proctolog/scenarios/_server.html.erb +20 -0
- data/app/views/proctolog/scenarios/index.html.erb +62 -0
- data/bin/proctolog +330 -0
- data/completions/_proctolog +143 -0
- data/config/application.rb +94 -0
- data/config/database.yml +16 -0
- data/config/environment.rb +6 -0
- data/config/initializers/grouse.rb +113 -0
- data/config/initializers/grouse_notes.rb +9 -0
- data/config/keys.yml +6 -0
- data/config/routes.rb +71 -0
- data/config/storage.yml +4 -0
- data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
- data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
- data/docs/manual/app.md +157 -0
- data/docs/manual/bursts.md +129 -0
- data/docs/manual/deciding.md +89 -0
- data/docs/manual/desk.png +0 -0
- data/docs/manual/films.md +20 -0
- data/docs/manual/handover.md +93 -0
- data/docs/manual/pr-format.md +75 -0
- data/docs/manual/reading.md +233 -0
- data/docs/manual/unopened.md +80 -0
- data/docs/manual/updates.md +55 -0
- data/lib/proctolog/installation.rb +125 -0
- data/lib/proctolog/version.rb +7 -0
- data/public/asking.js +50 -0
- data/public/code-review.js +40 -0
- data/public/cursor.js +292 -0
- data/public/decision-screenshots.js +97 -0
- data/public/decisions.css +949 -0
- data/public/decisions.js +1289 -0
- data/public/desk.css +582 -0
- data/public/desk.js +126 -0
- data/public/favicon.png +0 -0
- data/public/keys.js +228 -0
- data/public/mentions.js +389 -0
- data/public/messages.js +103 -0
- data/public/notes.js +731 -0
- data/public/pick.css +99 -0
- data/public/plaque.png +0 -0
- data/public/pr-number.js +11 -0
- data/public/prompt.css +46 -0
- data/public/prompt.js +16 -0
- data/public/pull_request.css +805 -0
- data/public/recording.css +379 -0
- data/public/recording.js +394 -0
- data/public/release.js +25 -0
- data/public/respond.css +260 -0
- data/public/respond.js +95 -0
- data/public/review-drafts.js +949 -0
- data/public/review-rounds.js +1447 -0
- data/public/scenarios.css +253 -0
- data/public/scenarios.js +36 -0
- data/public/schemas/proctolog-response-v1.json +101 -0
- data/public/side.js +133 -0
- data/public/standing.css +478 -0
- data/public/terminal-apl.svg +6 -0
- data/public/terminal-cuneiform.svg +3 -0
- data/public/terminal-grain-dots.svg +6 -0
- data/public/terminal-grain-weave.svg +6 -0
- data/public/terminal-grain-wedges.svg +6 -0
- data/public/terminal-shades.svg +3 -0
- data/public/terminal.css +4426 -0
- data/public/toast.js +26 -0
- data/public/try.js +124 -0
- data/public/viewer.js +808 -0
- data/public/words.js +81 -0
- data/skills/proctolog-handover/SKILL.md +77 -0
- data/skills/proctolog-pr-writing/SKILL.md +228 -0
- data/skills/proctolog-pr-writing/references/example-description.md +33 -0
- metadata +366 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# What every page of the desk shares: its top line, how far the decisions
|
|
5
|
+
# are, and the list of keys that ? opens.
|
|
6
|
+
module DeskHelper
|
|
7
|
+
# A page of the desk in its top line, marked when it is the one shown.
|
|
8
|
+
def desk_link(name, path, on:) = link_to(name, path, aria: {current: ("page" if controller_name == on)})
|
|
9
|
+
|
|
10
|
+
# When GitHub was read, on this Mac's clock: "at 15:45" today, "on 12 Oct at 15:45" before.
|
|
11
|
+
def read_at_in_words(time)
|
|
12
|
+
time = time.localtime
|
|
13
|
+
time.strftime((time.to_date == Date.current) ? "at %H:%M" : "on %-d %b at %H:%M")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# How far one pull request is, as the side pane marks its row: every
|
|
17
|
+
# decision reviewed, some, none yet (which takes no mark), or none to
|
|
18
|
+
# review at all. The page's script writes the same marks as choices are made.
|
|
19
|
+
def side_mark(progress)
|
|
20
|
+
if !progress.any? then "-"
|
|
21
|
+
elsif progress.done? then "x"
|
|
22
|
+
elsif progress.reviewed.positive? then "~"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def side_fraction(progress) = ("#{progress.reviewed}/#{progress.total}" if progress.any?)
|
|
27
|
+
|
|
28
|
+
# The whole burst in one line: "6 of 439 decisions reviewed" over a bar.
|
|
29
|
+
# Focused on a part, what is left in it against the burst: "120 / 380
|
|
30
|
+
# decisions left, part / burst", over the part's bar.
|
|
31
|
+
def side_gauge(burst, part: nil)
|
|
32
|
+
said = part ? "#{number_with_delimiter(part.left)} / #{number_with_delimiter(burst.left)} decisions left, part / burst" : "#{number_with_delimiter(burst.reviewed)} of #{number_with_delimiter(burst.total)} decisions reviewed"
|
|
33
|
+
shown = part || burst
|
|
34
|
+
safe_join([tag.span(said), tag.meter(value: shown.reviewed, max: [shown.total, 1].max)])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# "12 of 21 decisions reviewed, 2 of them ticked on GitHub, 1 draft unfinished".
|
|
38
|
+
def progress_in_words(progress)
|
|
39
|
+
["#{number_with_delimiter(progress.reviewed)} of #{pluralize(number_with_delimiter(progress.total), "decision")} reviewed",
|
|
40
|
+
("#{progress.ticked} of them ticked on GitHub" if progress.ticked.positive?),
|
|
41
|
+
("#{pluralize(progress.drafts, "draft")} unfinished" if progress.drafts.positive?)].compact.join(", ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One group of keys in the list: `keys` maps the keys, a space between
|
|
45
|
+
# those that go together, to what they do.
|
|
46
|
+
def keys_group(title, keys)
|
|
47
|
+
tag.section do
|
|
48
|
+
safe_join([tag.h3(title), tag.dl { safe_join(keys.map { |pressed, does| tag.div { tag.dt(keycaps(pressed)) + tag.dd(does) } }) }])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The line the bar at the foot of a page starts with: its name, then pairs like the list's.
|
|
53
|
+
def mode_line(name, hints)
|
|
54
|
+
safe_join([tag.strong(name), *hints.map { |pressed, does| tag.span { keycaps(pressed) + " #{does}" } }])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def keycaps(pressed) = safe_join(pressed.split.map { tag.kbd(it) })
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Words the owner or the agent wrote, as a page shows them: each # that
|
|
5
|
+
# names a pull request of the repository goes to it, its title beside. One
|
|
6
|
+
# of the burst's own goes to its page on the desk, any other to GitHub. A #
|
|
7
|
+
# the desk has not read of yet, or that names no pull request, stays as
|
|
8
|
+
# written.
|
|
9
|
+
module MentionsHelper
|
|
10
|
+
# Words cut on each # they hold, which stands apart among the pieces.
|
|
11
|
+
EACH_MENTION = /(#{Mentions::WRITTEN})/
|
|
12
|
+
|
|
13
|
+
def with_mentions(text, burst: nil) = safe_join(text.to_s.split(EACH_MENTION).map { mention_of(it, burst) || it })
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def mention_of(piece, burst)
|
|
18
|
+
mention = piece[/\A#(\d+)\z/, 1]&.then { mentions&.[](it.to_i) } or return
|
|
19
|
+
address = burst&.route&.at(mention.number) ? burst_pull_request_path(burst, mention.number) : mention.url
|
|
20
|
+
tag.span(class: "mention", data: {state: mention.state}) do
|
|
21
|
+
safe_join([link_to(piece, address), tag.span(mention.title, class: "mention-title"), (tag.span("· #{mention.state}", class: "mention-state") unless mention.open?)].compact, " ")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mentions = defined?(@mentions) ? @mentions : (@mentions = Mentions.here)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module OpenPullRequestsHelper
|
|
5
|
+
# What a pull request on the pick says of itself, after its title: whose it
|
|
6
|
+
# is, what it sits on when that is not main, and whether it asks anything.
|
|
7
|
+
def pick_facts(pick, pull_request)
|
|
8
|
+
decisions = pull_request.description.decisions
|
|
9
|
+
[
|
|
10
|
+
(pick.yours?(pull_request) ? "yours" : pull_request.author&.then { "by @#{it}" }),
|
|
11
|
+
("on #{pull_request.base}" unless pull_request.base.in?(%w[main master])),
|
|
12
|
+
decisions.any? ? pluralize(decisions.size, "decision") : "not written for PRoctolog yet"
|
|
13
|
+
].compact.join(" · ")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Why a burst is put away, after its other facts: its work went in, or
|
|
17
|
+
# the owner was done with it; nothing while it is out.
|
|
18
|
+
def put_away_in_words(burst)
|
|
19
|
+
if burst.put_away_by_hand? then " · put away by you"
|
|
20
|
+
elsif burst.finished? then " · finished, #{pluralize(burst.merged.size, "pull request")} merged"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# What one nobody has opened yet says of itself: its branch, what it
|
|
25
|
+
# would go into, and whether it asks anything.
|
|
26
|
+
def unopened_facts(pull_request)
|
|
27
|
+
decisions = pull_request.description.decisions
|
|
28
|
+
safe_join([tag.code(pull_request.head), "into #{pull_request.base}", (decisions.any? ? pluralize(decisions.size, "decision") : "asks nothing")], " · ")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module PromptsHelper
|
|
5
|
+
# A link that opens a new Claude Code session in the Claude app, in the
|
|
6
|
+
# checkout under review, with this typed in. Nothing is sent until the
|
|
7
|
+
# owner sends it there. Spaces are written %20: a plus is not read as a
|
|
8
|
+
# space by every handler.
|
|
9
|
+
def new_session_address(file) = "claude://code/new?folder=#{ERB::Util.url_encode(Project.current.root.to_s)}&q=#{ERB::Util.url_encode(session_prompt(file))}"
|
|
10
|
+
|
|
11
|
+
# What that session is asked, also put on the clipboard for a session started by hand.
|
|
12
|
+
def session_prompt(file) = "Read #{file} and carry it out. It is the brief of my own hand-over from the review desk, and the rest of its folder holds the work: follow it as if I had typed it."
|
|
13
|
+
|
|
14
|
+
def handed_over(handover)
|
|
15
|
+
threads = handover.stops.sum { it.pull_request.threads.size }
|
|
16
|
+
asked = handover.stops.count { handover.worklist.of(it.number).items.any? || it.pull_request.threads.any? }
|
|
17
|
+
[pluralize(handover.worklist.items.size, "request"), ("#{pluralize(threads, "unresolved review thread")} on GitHub" if threads.positive?)]
|
|
18
|
+
.compact.to_sentence + " on #{pluralize(asked, "pull request")}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module PullRequestsHelper
|
|
5
|
+
# "Layer 19 of 23" or "Stands alone".
|
|
6
|
+
def position_of(stop) = stop.stacked? ? "Layer #{stop.layer} of #{stop.of}" : "Stands alone"
|
|
7
|
+
|
|
8
|
+
# "not opened, on recovery/emails" for a pull request nobody has opened
|
|
9
|
+
# yet; nil for one open on GitHub.
|
|
10
|
+
def not_opened(stop) = (safe_join(["not opened, on ", tag.code(stop.pull_request.head)]) if stop.pull_request.unopened?)
|
|
11
|
+
|
|
12
|
+
# "#1060 Title", the number being the desk's page for it.
|
|
13
|
+
def pull_request_link(burst, stop)
|
|
14
|
+
safe_join([link_to("##{stop.number}", burst_pull_request_path(burst, stop.number), class: "number"), stop.title], " ")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A section as GitHub rendered it, to be shown as it is. Under "Decisions for the owner" each box
|
|
18
|
+
# has what stands under it on the decisions page, the same partial; where
|
|
19
|
+
# GitHub's list and the Markdown's disagree, the boxes are left as they are.
|
|
20
|
+
def section_html(section, burst:, boxes:)
|
|
21
|
+
if section.heading == Description::DECISIONS && section.boxes.size == boxes.size
|
|
22
|
+
section.furnish_boxes { |position| render("proctolog/decisions/beneath", box: boxes[position], burst:, back: box_on_its_page(burst, boxes[position])) }
|
|
23
|
+
end
|
|
24
|
+
section.html
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# "1 of 4 met", of the conditions that can be judged, beside the Standing section's heading.
|
|
28
|
+
def conditions_met(standing) = "#{standing.judged.count(&:met)} of #{standing.judged.size} met"
|
|
29
|
+
|
|
30
|
+
# One line of the outline: a section by name, and its count when it has one.
|
|
31
|
+
# `waits` says the count is of something the owner still has to do, which the
|
|
32
|
+
# outline sets in full relief; `waits_for` leaves the counting to the script;
|
|
33
|
+
# `apart` begins the sections that follow the description. A line with
|
|
34
|
+
# somewhere to go `to` leaves the page instead, and is no section.
|
|
35
|
+
def outline_line(name, count: nil, waits: false, waits_for: nil, apart: false, to: nil, **options)
|
|
36
|
+
said = safe_join([tag.span(name, class: "name"), tag.span(count, class: "count", data: {waits: (waits if count), waits_for:})])
|
|
37
|
+
to ? link_to(said, to, class: ("apart" if apart), **options) : tag.button(said, type: "button", class: ("apart" if apart), data: {fold: ""})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# What a section is worth a glance at, said quietly beside its heading.
|
|
41
|
+
def count_of(section)
|
|
42
|
+
case section.heading
|
|
43
|
+
when Description::DECISIONS then ((open = section.open_decisions).zero? ? "none open" : "#{open} open")
|
|
44
|
+
when "Screenshots" then pluralize(section.figures.size, "picture")
|
|
45
|
+
else "#{number_with_delimiter(section.words)} words"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Open when it is the summary, or when it holds a note someone still has to act on.
|
|
50
|
+
def unfolded?(section) = section.heading == "Summary" || section.noted?
|
|
51
|
+
|
|
52
|
+
# The same files as another pull request, longest overlap first.
|
|
53
|
+
def by_overlap(shared) = shared.sort_by { |stop, files| [-files.size, stop.number] }
|
|
54
|
+
|
|
55
|
+
# A note's own place in words: what it quotes, or which picture it is on.
|
|
56
|
+
def place_of(note, rendering)
|
|
57
|
+
return unless note.place
|
|
58
|
+
|
|
59
|
+
case note.place
|
|
60
|
+
in Note::Quote => quote then tag.blockquote(quote.exact, class: "words")
|
|
61
|
+
in Note::Region => region then tag.p(safe_join(["On the picture ", tag.code(region.picture), (" (retaken since)" if rendering.retaken?(note))]), class: "where")
|
|
62
|
+
in Note::Scenario => scenario then tag.p("On the scenario #{scenario.name}", class: "where")
|
|
63
|
+
in Note::Moment => moment then tag.p(moment_place(note, moment), class: "where")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module RecordingsHelper
|
|
5
|
+
# 83.4 as "1:23", and 3725 as "1:02:05".
|
|
6
|
+
def timecode(seconds)
|
|
7
|
+
hours, rest = seconds.floor.divmod(3600)
|
|
8
|
+
minutes, whole = rest.divmod(60)
|
|
9
|
+
hours.positive? ? format("%d:%02d:%02d", hours, minutes, whole) : format("%d:%02d", minutes, whole)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# A recording's name as a title: "first-run" is "First run".
|
|
13
|
+
def recording_title(recording) = recording.name.tr("-_", " ").upcase_first
|
|
14
|
+
|
|
15
|
+
# Where a folder of recordings belongs: the owner's sitting, or a pull request.
|
|
16
|
+
def recording_folder(folder) = (folder == "sitting") ? "My sitting" : "##{folder}"
|
|
17
|
+
|
|
18
|
+
# How far along a film a time is, for a mark on its timeline.
|
|
19
|
+
def film_position(time, recording) = "#{(100 * time / recording.length).clamp(0, 100).round(3)}%"
|
|
20
|
+
|
|
21
|
+
# Where a note on a moment is: a link to that moment while the recording is still there.
|
|
22
|
+
def moment_place(note, moment)
|
|
23
|
+
text = "In #{File.basename(moment.recording, ".mp4")} at #{timecode(moment.time)}"
|
|
24
|
+
recording = Recording.at(moment.recording)
|
|
25
|
+
recording ? link_to(text, burst_recording_path(recording.burst_slug, recording.to_param, anchor: "note-#{note.id}")) : text
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def player_link(burst, recording, text = recording_title(recording)) = link_to(text, burst_recording_path(burst, recording.to_param))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module RoundsHelper
|
|
5
|
+
# What a page's script needs of the rounds: the requests that stand, the
|
|
6
|
+
# heads read, the answers that came back, and the batches the desk wrote,
|
|
7
|
+
# which a page takes in as its own so that it knows the answer to a pass
|
|
8
|
+
# an agent took itself. On a pull request's page, its description now
|
|
9
|
+
# and as each handover the desk kept found it, by batch, so the page can
|
|
10
|
+
# say what changed since.
|
|
11
|
+
def round_data(burst, pr: nil)
|
|
12
|
+
route = burst.route
|
|
13
|
+
rounds = Rounds.new(burst, route:)
|
|
14
|
+
responses = ResponseFiles.new(burst)
|
|
15
|
+
{requests: rounds.requests, batches: Handover.kept(burst).map(&:batch), reports: responses.all, errors: responses.errors,
|
|
16
|
+
responseFolder: responses.folder.to_s, heads: rounds.heads,
|
|
17
|
+
**(route.at(pr)&.then { |stop| {descriptions: descriptions_of(burst, stop)} } || {})}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def descriptions_of(burst, stop)
|
|
23
|
+
handed_over = Handover.kept(burst).filter_map { |kept| kept.description_of(stop.number)&.then { [kept.batch_id, it] } }.to_h
|
|
24
|
+
{now: stop.description.to_s, handedOver: handed_over}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module ScenariosHelper
|
|
5
|
+
# "up at http://localhost:3300", "starting", "down".
|
|
6
|
+
def server_status(server)
|
|
7
|
+
case server.state
|
|
8
|
+
when :up then safe_join(["The server is up at ", link_to(server.address, server.address), "."])
|
|
9
|
+
when :starting then "The server is starting. This takes a minute or two."
|
|
10
|
+
else server.crashed? ? "The server has stopped." : "The server is down."
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Where a scenario opens, in a few words.
|
|
15
|
+
def where_it_opens(scenario)
|
|
16
|
+
size = scenario.size.is_a?(Array) ? scenario.size.join("x") : scenario.size
|
|
17
|
+
case scenario.browser.name
|
|
18
|
+
when "mine" then "In your own Chrome. It replaces whoever is signed in on this app, on every port of localhost, in that browser."
|
|
19
|
+
when "clean" then "In a Chrome of its own, #{size}"
|
|
20
|
+
else "In a new Chrome, #{size}, cookie banner up"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A step of a description, with its `code` set in code.
|
|
25
|
+
def with_code(text)
|
|
26
|
+
safe_join(text.split(/`([^`]*)`/).each_with_index.map { |part, index| index.odd? ? tag.code(part) : part })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# What a step's Markdown sets apart from its words: code, bold and links.
|
|
30
|
+
MARKED = /(`[^`]*`|\*\*[^*\n]+\*\*|\[[^\]]+\]\([^)\s]+\))/
|
|
31
|
+
LINK = /\A\[(?<words>[^\]]+)\]\((?<to>[^)\s]+)\)\z/
|
|
32
|
+
|
|
33
|
+
# A step of a description as GitHub sets it. A link to one of the
|
|
34
|
+
# description's pictures stays on the page, where a press opens the
|
|
35
|
+
# picture to look at closely; any other leaves the desk.
|
|
36
|
+
def step_words(text) = safe_join(text.split(MARKED).map { marked(it) })
|
|
37
|
+
|
|
38
|
+
BROWSERS = {"mine" => "your own Chrome", "clean" => "a Chrome of its own", "fresh" => "a new Chrome"}.freeze
|
|
39
|
+
|
|
40
|
+
# How a scenario starts, in a line under its button: whom it signs in, the
|
|
41
|
+
# page it opens at, and its size and browser where those are its own.
|
|
42
|
+
def how_it_starts(scenario, browser: true)
|
|
43
|
+
who = if scenario.recipe then "as #{with_article(scenario.recipe.humanize(capitalize: false))}#{", signed out" if scenario.signed_out}"
|
|
44
|
+
elsif scenario.signed_out then "signed out"
|
|
45
|
+
else "as whoever is signed in"
|
|
46
|
+
end
|
|
47
|
+
size = Array(scenario.size).join("x") unless scenario.size == "desktop" || scenario.browser.mine?
|
|
48
|
+
safe_join([who, ", at ", page_it_opens(scenario), *(", #{size}" if size), *(", in #{BROWSERS.fetch(scenario.browser.name)}" if browser), "."])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The line over the scenarios' buttons: what pressing one does, and the
|
|
52
|
+
# browser they share, while they can be pressed; when they wait, for what.
|
|
53
|
+
def tries_lede(scenario, ready:, shared:)
|
|
54
|
+
browser = " in #{BROWSERS.fetch(scenario.browser.name)}" if shared
|
|
55
|
+
ready ? "Each sets its state up and opens#{browser} where its steps begin." : "Each opens#{browser} once the server is up."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A path as the owner would type it: from the checkout's root, or from ~.
|
|
59
|
+
def shown_path(path) = Project.current.from_root(path).to_s
|
|
60
|
+
|
|
61
|
+
# A checkout by its last two folders, which tell it apart; the whole path
|
|
62
|
+
# on hover.
|
|
63
|
+
def folder(path) = tag.code(["…", *Pathname(path).each_filename.to_a.last(2)].join("/"), title: shown_path(path))
|
|
64
|
+
|
|
65
|
+
# The commit that brought the app its app.yml, by its hash and its words.
|
|
66
|
+
def arrival_of(app)
|
|
67
|
+
app.arrival&.then { safe_join([tag.code(it.commit), ", “", it.subject.truncate(44), "”"]) } || "the commit that added it"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Words to copy as they are, with the button that copies them.
|
|
71
|
+
def copyable(text)
|
|
72
|
+
tag.div(class: "copyable") { tag.pre(tag.code(text)) + tag.button("copy", type: "button", class: "quiet", data: {copy: ""}) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def day_of(time) = time&.localtime&.strftime("%-d %b") || "an unknown day"
|
|
76
|
+
|
|
77
|
+
# The end of a server's log, each line cut to a width that reads.
|
|
78
|
+
def log_tail(server) = server.last_lines(16).lines.map { it.chomp.truncate(220) }.join("\n")
|
|
79
|
+
|
|
80
|
+
# What was last seen of a scenario, and whether that was on the commit it
|
|
81
|
+
# is on now.
|
|
82
|
+
# "Grouse's overlay opened twice, last at 20 Oct 14:32. 3 notes pinned from it."
|
|
83
|
+
def overlays_line(overlays)
|
|
84
|
+
at = Time.iso8601(overlays.last.opened).localtime.strftime("%-d %b %H:%M")
|
|
85
|
+
times = {1 => "once", 2 => "twice"}.fetch(overlays.size) { "#{overlays.size} times" }
|
|
86
|
+
"Grouse's overlay opened #{times}, last at #{at}. #{pluralize(overlays.sum { it.pinned.size }, "note")} pinned from it."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def seen_line(seen, head)
|
|
90
|
+
return "Not seen yet." unless seen
|
|
91
|
+
|
|
92
|
+
at = Time.iso8601(seen["seen"]).localtime.strftime("%-d %b %H:%M")
|
|
93
|
+
moved = " On an earlier commit than the present one." if head && seen["head"] != head
|
|
94
|
+
"#{Scenario::RESULTS.key(seen["result"])}, #{at}.#{moved}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The pull requests that have scenarios, in the order of the route.
|
|
98
|
+
def scenarios_by_pull_request(scenarios, route)
|
|
99
|
+
order = route.map(&:number)
|
|
100
|
+
scenarios.select(&:pr).group_by(&:pr).sort_by { |number, _| order.index(number) || order.size }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def marked(piece)
|
|
106
|
+
if (code = piece[/\A`(.*)`\z/, 1]) then tag.code(code)
|
|
107
|
+
elsif (bold = piece[/\A\*\*(.+)\*\*\z/, 1]) then tag.strong(bold)
|
|
108
|
+
elsif (link = piece.match(LINK)) then link_to(link[:words], link[:to], **(link[:to].start_with?("#") ? {} : {target: "_blank", rel: "noopener"}))
|
|
109
|
+
else piece
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def page_it_opens(scenario)
|
|
114
|
+
case scenario.opens
|
|
115
|
+
when %r{\A/} then tag.code(scenario.opens)
|
|
116
|
+
when "lands_on" then "where its member lands"
|
|
117
|
+
else "the #{scenario.opens.tr("_", " ")} its state makes"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def with_article(words) = "#{words.match?(/\A[aeiou]/i) ? "an" : "a"} #{words}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
module StandingsHelper
|
|
5
|
+
# The four things a pull request can be, in the order the owner reads them.
|
|
6
|
+
TURNS = {owner: "Your turn", agent: "The agent's turn", settled: "Settled", waiting: "Waiting on"}.freeze
|
|
7
|
+
|
|
8
|
+
VERDICTS = {true => "Met", false => "Not met", nil => "Yours to judge"}.freeze
|
|
9
|
+
|
|
10
|
+
# "Your turn", or "Waiting on" and what, in the standing's own words.
|
|
11
|
+
def turn_of(standing) = [TURNS.fetch(standing.turn), standing.marks.waiting_on].compact.join(" ")
|
|
12
|
+
|
|
13
|
+
def turn_tag(standing) = tag.span(turn_of(standing), class: "turn", data: {turn: standing.turn})
|
|
14
|
+
|
|
15
|
+
def reasons_of(standing) = line_of(standing, beside_the_count(standing, standing.open))
|
|
16
|
+
|
|
17
|
+
# On the route a change since the mark has a column of its own.
|
|
18
|
+
def route_reasons_of(standing) = line_of(standing, beside_the_count(standing, standing.otherwise_open))
|
|
19
|
+
|
|
20
|
+
# How many pull requests are at each turn, the turns with none included.
|
|
21
|
+
def turn_counts(standings)
|
|
22
|
+
counts = standings.each_value.map(&:turn).tally
|
|
23
|
+
TURNS.keys.index_with { counts.fetch(it, 0) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whose the pull requests of a burst are, as the route's first line says it.
|
|
27
|
+
IN_A_SENTENCE = {owner: "yours to review", agent: "with the agent", settled: "settled", waiting: "waiting on something"}.freeze
|
|
28
|
+
|
|
29
|
+
# "25 are yours to review, 2 are with the agent." The owner's share stands out.
|
|
30
|
+
def turns_in_words(standings)
|
|
31
|
+
parts = turn_counts(standings).select { |_, count| count.positive? }.map do |turn, count|
|
|
32
|
+
tag.span("#{count} #{(count == 1) ? "is" : "are"} #{IN_A_SENTENCE.fetch(turn)}", class: ("yours" if turn == :owner))
|
|
33
|
+
end
|
|
34
|
+
safe_join([safe_join(parts, ", "), "."])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# What a line of the route says at its right: whose the pull request is when
|
|
38
|
+
# it is not the owner's, then how far its decisions are.
|
|
39
|
+
def beside_a_line(standing)
|
|
40
|
+
whose = {agent: "with the agent", settled: "settled", waiting: "waiting"}[standing.turn]
|
|
41
|
+
safe_join([whose, (tag.span(side_fraction(standing.progress), class: "fraction") if standing.progress.any?)].compact, " · ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def verdict_of(condition) = VERDICTS.fetch(condition.met)
|
|
45
|
+
|
|
46
|
+
# What the drawer of a pull request's page holds, said beside its heading.
|
|
47
|
+
def drawer_count(standing, scenarios)
|
|
48
|
+
judged = standing.conditions.reject { it.met.nil? }
|
|
49
|
+
["#{judged.count(&:met)} of #{pluralize(judged.size, "condition")} met", (pluralize(scenarios.size, "scenario") if scenarios.any?)].compact.join(" · ")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The route's last column: once there is a mark, what moved since it.
|
|
53
|
+
def changed_since_mark(standing, pull_request)
|
|
54
|
+
return last_change(pull_request) unless standing.good_to_go?
|
|
55
|
+
|
|
56
|
+
standing.changed? ? tag.span("changed since your mark: #{standing.changed.to_sentence}", class: "moved") : "unchanged since your mark"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# "12 Oct 09:40" on this Mac's clock, for a time or the ISO 8601 text of one.
|
|
60
|
+
def clock(time) = time.to_time.localtime.strftime("%-d %b %H:%M")
|
|
61
|
+
|
|
62
|
+
# A commit by its first eight characters; the name of a branch whole.
|
|
63
|
+
def short(commit) = tag.code(commit.to_s.match?(/\A\h{40}\z/) ? commit[0, 8] : commit)
|
|
64
|
+
|
|
65
|
+
def lines_of(file) = file.binary? ? "binary" : safe_join([tag.span("+#{file.added}", class: "added"), tag.span("−#{file.removed}", class: "removed")], " ")
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# The pages that give these reasons show how many decisions are reviewed
|
|
70
|
+
# right beside them, so the decisions are named only when nothing else is.
|
|
71
|
+
def beside_the_count(standing, reasons) = (reasons - standing.to_review).presence || reasons
|
|
72
|
+
|
|
73
|
+
# What is open, as a line. Under a mark nothing but decisions can be, and they are said as what the mark left.
|
|
74
|
+
def line_of(standing, reasons)
|
|
75
|
+
return "nothing is open" if reasons.empty?
|
|
76
|
+
|
|
77
|
+
[("with" if standing.settled?), reasons.join(" · ")].compact.join(" ")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Takes a reading's pictures ahead of the pages that show them, so a page
|
|
5
|
+
# that shows forty waits neither on forty requests nor on forty runs of
|
|
6
|
+
# ImageMagick: those on this Mac without a signature are signed, and those
|
|
7
|
+
# not on it yet are fetched, and signed as they land. One that cannot be
|
|
8
|
+
# fetched now is fetched when a page asks for it, and says why there.
|
|
9
|
+
class FetchPicturesAheadJob < ApplicationJob
|
|
10
|
+
# How many pictures are fetched, or signed, at a time.
|
|
11
|
+
ABREAST = 6
|
|
12
|
+
|
|
13
|
+
# gh has no token now: a page that asks for a picture says so. Those on
|
|
14
|
+
# this Mac need none, and are signed first.
|
|
15
|
+
discard_on Picture::Unavailable
|
|
16
|
+
|
|
17
|
+
# Each picture as a pair of its id and its kind.
|
|
18
|
+
def perform(pictures)
|
|
19
|
+
missing, held = pictures.map { Picture.new(*it) }.partition { !it.path.exist? }
|
|
20
|
+
abreast(held) { Picture.sign(it.asset) }
|
|
21
|
+
return if missing.empty?
|
|
22
|
+
|
|
23
|
+
token = Picture.token
|
|
24
|
+
abreast(missing) { it.fetch(token) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Each picture handed to the block, ABREAST at a time, while the job
|
|
30
|
+
# holds the app's executor.
|
|
31
|
+
def abreast(pictures, &each)
|
|
32
|
+
waiting = Queue.new(pictures).tap(&:close)
|
|
33
|
+
Array.new([ABREAST, pictures.size].min) { Thread.new { while (picture = waiting.pop) do each.call(picture) end } }.each(&:join)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The desk's own prune, once a day (Housekeeping): the Chrome profiles of
|
|
5
|
+
# fresh browsers, one for every scenario opened in one.
|
|
6
|
+
class PruneFreshProfilesJob < ApplicationJob
|
|
7
|
+
def perform = Browser.prune_fresh_profiles
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Reads the pull requests a # can name in a repository the desk has never
|
|
5
|
+
# read, so that the first # the owner types finds them there.
|
|
6
|
+
class ReadMentionsAheadJob < ApplicationJob
|
|
7
|
+
# The first # asks again, and says what GitHub answered.
|
|
8
|
+
discard_on Github::Unreachable
|
|
9
|
+
|
|
10
|
+
def perform(repository) = Mentions.new(Github.new(repository)).up_to_date
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Ticking decision boxes of one description: the one way the desk writes to
|
|
5
|
+
# GitHub, or to the file of a pull request nobody has opened yet. It is
|
|
6
|
+
# made to be shown before it is carried out: the boxes it will tick, and
|
|
7
|
+
# the command. Carried out, it reads the description again at that moment,
|
|
8
|
+
# ticks only lines that stand in it exactly once, changes nothing else, and
|
|
9
|
+
# checks that its home then holds what it sent.
|
|
10
|
+
class Acceptance
|
|
11
|
+
Refused = Class.new(StandardError)
|
|
12
|
+
|
|
13
|
+
attr_reader :number, :decisions
|
|
14
|
+
|
|
15
|
+
# `home` holds the description: Github, or Unopened.
|
|
16
|
+
def initialize(home:, number:, decisions:)
|
|
17
|
+
@home, @number, @decisions = home, number, decisions
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
FILE = "<the description, with these boxes ticked>"
|
|
21
|
+
|
|
22
|
+
# The command as the owner would type it, for showing first. The file is a
|
|
23
|
+
# temporary one, written at the moment it runs.
|
|
24
|
+
def command = @home.rewriting(number, "FILE").shelljoin.sub("FILE", FILE)
|
|
25
|
+
|
|
26
|
+
# `seen` is the digest of the description the owner was shown the command
|
|
27
|
+
# for: an edit since then, by anyone, stops it.
|
|
28
|
+
def carry_out(seen: nil)
|
|
29
|
+
before = @home.description_now(number)
|
|
30
|
+
seen.nil? || Description.new(before).digest == seen or raise Refused, "The description of ##{number} changed since you saw the command. Look at it again."
|
|
31
|
+
after = self.class.ticked(before, decisions.map(&:line))
|
|
32
|
+
@home.rewrite(number, after)
|
|
33
|
+
@home.description_now(number).strip == after.strip or raise Refused, "##{number} does not hold what was sent: read it before anything else"
|
|
34
|
+
decisions.map(&:lead)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The text with each of these unticked lines ticked. Line endings are kept
|
|
38
|
+
# as they are; a line that is missing, or stands more than once, stops it.
|
|
39
|
+
def self.ticked(text, lines)
|
|
40
|
+
lines.reduce(text) do |ticking, line|
|
|
41
|
+
box = /^#{Regexp.escape(line)}(?=\r?$)/
|
|
42
|
+
ticking.scan(box).one? or raise Refused, "#{ticking.scan(box).size} lines, not one, read: #{line.truncate(90)}"
|
|
43
|
+
ticking.sub(box) { line.sub("- [ ]", "- [x]") }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|