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,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "shellwords"
|
|
6
|
+
|
|
7
|
+
module Proctolog
|
|
8
|
+
# How the checkout's own app is run for its scenarios, in the app's own
|
|
9
|
+
# words: the commands and paths of .proctolog/app.yml (docs/manual/app.md).
|
|
10
|
+
# Every part is optional; a scenario that needs a missing one says which.
|
|
11
|
+
class App
|
|
12
|
+
# A Server::Error, so the scenario pages say it where the press was made.
|
|
13
|
+
Missing = Class.new(Server::Error)
|
|
14
|
+
|
|
15
|
+
FILE = "app.yml"
|
|
16
|
+
|
|
17
|
+
# A commit, by its short hash and its first line.
|
|
18
|
+
Arrival = Data.define(:commit, :subject)
|
|
19
|
+
|
|
20
|
+
def self.current = new(Project.current.definitions.join(FILE))
|
|
21
|
+
|
|
22
|
+
attr_reader :file
|
|
23
|
+
|
|
24
|
+
def initialize(file)
|
|
25
|
+
@file = Pathname(file)
|
|
26
|
+
@settings = file.exist? ? YAML.safe_load_file(file) || {} : {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def described? = file.exist?
|
|
30
|
+
|
|
31
|
+
# The commit that brought this file into its repository: a checkout without
|
|
32
|
+
# it is older than the commands the file names. Nil outside Git.
|
|
33
|
+
def arrival
|
|
34
|
+
return @arrival if defined?(@arrival)
|
|
35
|
+
|
|
36
|
+
output, found = Open3.capture2e("git", "-C", file.dirname.to_s, "log", "--diff-filter=A", "-1", "--format=%h%x00%s", "--", file.basename.to_s)
|
|
37
|
+
@arrival = (Arrival.new(*output.strip.split("\0", 2)) if found.success? && output.include?("\0"))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parts = @settings.keys
|
|
41
|
+
|
|
42
|
+
def serve(slug:, port:) = command(:serve, slug:, port:)
|
|
43
|
+
|
|
44
|
+
# What makes the app ready to serve, such as its database; nil when it needs nothing.
|
|
45
|
+
def prepare(slug:, port:) = (command(:prepare, slug:, port:) if @settings.key?("prepare"))
|
|
46
|
+
|
|
47
|
+
# The words of the command that makes a state, its arguments one key=value
|
|
48
|
+
# word each where %{args} stands.
|
|
49
|
+
def state(name, arguments, slug:, port:) = command(:state, slug:, port:, name:, args: arguments.map { |key, value| "#{key}=#{value}" })
|
|
50
|
+
|
|
51
|
+
# Grouse's overlay, labelled with a scenario: `overlay` opens a review
|
|
52
|
+
# session from a brief of one JSON word, and `notes` prints what was
|
|
53
|
+
# written in one. A project that names no `overlay` opens none.
|
|
54
|
+
def overlay? = @settings.key?("overlay")
|
|
55
|
+
|
|
56
|
+
def overlay(brief, slug:, port:) = command(:overlay, slug:, port:, brief: brief.to_json)
|
|
57
|
+
|
|
58
|
+
def notes(session, slug:, port:) = command(:notes, slug:, port:, session:)
|
|
59
|
+
|
|
60
|
+
def sign_in(email, to:) = path(:sign_in, email:, to:)
|
|
61
|
+
|
|
62
|
+
def sign_out(to:) = path(:sign_out, to:)
|
|
63
|
+
|
|
64
|
+
# The ports that answer once the app is up, counted from the one it is given.
|
|
65
|
+
def listens(port) = @settings.fetch("listens", [0]).map { port + it }
|
|
66
|
+
|
|
67
|
+
# Cookies a clean browser starts with, as name=value.
|
|
68
|
+
def cookies = Array(@settings["cookies"])
|
|
69
|
+
|
|
70
|
+
def size(name) = @settings.dig("sizes", name)&.symbolize_keys
|
|
71
|
+
|
|
72
|
+
def sizes = @settings.fetch("sizes", {}).keys
|
|
73
|
+
|
|
74
|
+
# The programs its commands start, as the file names them: past an `env`
|
|
75
|
+
# and the variables it sets, which only say how the program runs.
|
|
76
|
+
def programs = %w[prepare serve state overlay notes].filter_map { @settings[it]&.then { program_of(it) } }.uniq
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def program_of(template) = Shellwords.split(template).drop_while { it == "env" || it.match?(/\A\w+=/) }.first
|
|
81
|
+
|
|
82
|
+
def command(part, args: [], **values)
|
|
83
|
+
Shellwords.split(template(part)).flat_map { (it == "%{args}") ? args : format(it, **values) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def path(part, **values) = format(template(part), **values.transform_values { ERB::Util.url_encode(it) })
|
|
87
|
+
|
|
88
|
+
def template(part)
|
|
89
|
+
@settings[part.to_s] or raise Missing, "#{Project.current.from_root(file)} names no #{part}, which this scenario needs. See docs/manual/app.md."
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Where a scenario opens. `mine` is the browser that pressed the button, with
|
|
5
|
+
# its 1Password and its passkeys. `clean` is a Chrome of its own, started
|
|
6
|
+
# with a profile kept under cache/, so that it can be given a size and say it
|
|
7
|
+
# is a phone. `fresh` is that with a new, empty profile each time, for a step
|
|
8
|
+
# that wants the cookie banner up.
|
|
9
|
+
class Browser < Data.define(:name)
|
|
10
|
+
NAMES = %w[mine clean fresh].freeze
|
|
11
|
+
# Chrome will not make a window with tabs narrower than this. An app
|
|
12
|
+
# window will, which is also what an iPhone's web layer looks like.
|
|
13
|
+
NARROWEST_TABBED = 500
|
|
14
|
+
|
|
15
|
+
def self.named(name)
|
|
16
|
+
NAMES.include?(name) or raise ArgumentError, "a browser is #{NAMES.to_sentence(last_word_connector: " or ")}, not #{name.inspect}"
|
|
17
|
+
new(name:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
NAMES.each { |browser| define_method(:"#{browser}?") { name == browser } }
|
|
21
|
+
|
|
22
|
+
# Drops the profiles fresh browsers were given over a day ago, whose
|
|
23
|
+
# scenarios are long over (PruneFreshProfilesJob).
|
|
24
|
+
def self.prune_fresh_profiles = Project.current.browsers.glob("fresh-*").select { it.mtime < 1.day.ago }.each(&:rmtree)
|
|
25
|
+
|
|
26
|
+
def command(address, size:)
|
|
27
|
+
["open", "-na", "Google Chrome", "--args", "--user-data-dir=#{profile(size)}", "--window-size=#{size.width},#{size.height}",
|
|
28
|
+
*("--user-agent=#{size.user_agent}" if size.user_agent), "--no-first-run", "--no-default-browser-check",
|
|
29
|
+
(size.width < NARROWEST_TABBED) ? "--app=#{address}" : address]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def launch(address, size:)
|
|
33
|
+
system(*command(address, size:)) or raise Failure, "Chrome could not be opened (is Google Chrome installed?)."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def profile(size) = profiles.join(fresh? ? "fresh-#{SecureRandom.hex(4)}" : "clean-#{size}")
|
|
39
|
+
|
|
40
|
+
def profiles = Project.current.browsers
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# A body of agent work the desk opens as one: the pull requests a file under
|
|
5
|
+
# bursts/ names, taken together because they came out of one effort.
|
|
6
|
+
class Burst
|
|
7
|
+
Missing = Class.new(StandardError)
|
|
8
|
+
|
|
9
|
+
# Where burst files are, the first to hold a slug winning: the reviewer's
|
|
10
|
+
# own, then the repository's; reviews/ is where they were kept before
|
|
11
|
+
# bursts had their name.
|
|
12
|
+
FOLDERS = -> { Project.current.burst_folders }
|
|
13
|
+
|
|
14
|
+
# `file` is where it was read from.
|
|
15
|
+
attr_reader :slug, :name, :repository, :stacks, :tops, :file
|
|
16
|
+
|
|
17
|
+
delegate :read_at, to: :github
|
|
18
|
+
|
|
19
|
+
def self.all = files.map { from(it) }
|
|
20
|
+
|
|
21
|
+
# One file for each slug, in the order of their slugs.
|
|
22
|
+
def self.files = folders.flat_map { it.glob("*.yml") }.uniq(&:basename).sort_by(&:basename)
|
|
23
|
+
|
|
24
|
+
def self.folders = Array(FOLDERS.respond_to?(:call) ? FOLDERS.call : FOLDERS)
|
|
25
|
+
|
|
26
|
+
# A burst file as the desk reads it. A mistake in it is named with the
|
|
27
|
+
# file and the reason, as Project#settings names one in its own files.
|
|
28
|
+
def self.from(file)
|
|
29
|
+
settings = YAML.safe_load_file(file, symbolize_names: true)
|
|
30
|
+
settings.is_a?(Hash) or raise Failure, "#{file} is not a burst file: it holds no settings."
|
|
31
|
+
numbered(settings, file)
|
|
32
|
+
new(file.basename(".yml").to_s, file:, **settings)
|
|
33
|
+
rescue Psych::Exception => e
|
|
34
|
+
raise Failure, "#{file} is not valid YAML (#{e.message.lines.first&.strip})."
|
|
35
|
+
rescue ArgumentError => e
|
|
36
|
+
raise Failure, "#{file} is not a burst file as the desk reads one (#{e.message})."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Stacks and pull requests are named by their numbers, in lists; a pull
|
|
40
|
+
# request nobody has opened yet by its branch.
|
|
41
|
+
def self.numbered(settings, file)
|
|
42
|
+
%i[stacks tops pull_requests].each do |list|
|
|
43
|
+
named = settings.fetch(list, [])
|
|
44
|
+
named.is_a?(Array) or raise Failure, "#{file} names #{named.inspect} under `#{list}`, which is a list of numbers."
|
|
45
|
+
named.grep_v((list == :stacks) ? Integer : BRANCH_OR_NUMBER).first&.then { raise Failure, "#{file} names #{it.inspect} under `#{list}`: #{(list == :stacks) ? "a stack" : "a pull request"} is named by its number#{", or by its branch while it is not opened" unless list == :stacks}." }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A number, or a branch as git would take one.
|
|
50
|
+
BRANCH_OR_NUMBER = ->(named) { named.is_a?(Integer) || (named.is_a?(String) && named.match?(%r{\A(?!-)[^\s~^:?*\[\\]+\z})) }
|
|
51
|
+
private_class_method :numbered
|
|
52
|
+
|
|
53
|
+
# Every burst, the one written last first: a burst an agent has just
|
|
54
|
+
# made is the work waiting, and one reviewed a while ago sinks.
|
|
55
|
+
def self.newest_first = all.sort_by { -it.file.mtime.to_f }
|
|
56
|
+
|
|
57
|
+
# The burst the desk opens on, and the commands take when none is named:
|
|
58
|
+
# the newest one not put away.
|
|
59
|
+
def self.latest = newest_first.find { !it.put_away? }
|
|
60
|
+
|
|
61
|
+
def self.find(slug) = all.find { it.slug == slug } || raise(Missing, "no burst is named #{slug}")
|
|
62
|
+
|
|
63
|
+
SLUG = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
|
|
64
|
+
|
|
65
|
+
# Writes a burst of the reviewer's own, beside their marks, and returns its
|
|
66
|
+
# file. It is named as addresses and the route name it, `login-fixes`.
|
|
67
|
+
def self.start(slug, name: nil, stacks: [], tops: [], pull_requests: [])
|
|
68
|
+
slug.match?(SLUG) or raise Failure, "#{slug.inspect} is no burst name: lowercase words joined by hyphens, as login-fixes."
|
|
69
|
+
[stacks, tops, pull_requests].any?(&:present?) or raise Failure, "A burst needs pull requests: name some with --pr, --top or --stack."
|
|
70
|
+
taken = folders.map { it.join("#{slug}.yml") }.find(&:exist?) and raise Failure, "#{Project.current.from_root(taken)} is a burst named #{slug} already."
|
|
71
|
+
repository = Project.current.github_repository or raise Failure, "#{Project.current.root} has no GitHub repository as its origin."
|
|
72
|
+
|
|
73
|
+
settings = {name: name || slug.tr("-", " ").capitalize, repository:, stacks:, tops:, pull_requests:}.compact_blank
|
|
74
|
+
Project.current.own_bursts.tap(&:mkpath).join("#{slug}.yml").tap do |file|
|
|
75
|
+
file.write(["# Yours alone, outside the repository. docs/manual/bursts.md says what else it can hold.",
|
|
76
|
+
*settings.map { |key, value| "#{key}: #{value.is_a?(Array) ? "[#{value.map(&:to_json).join(", ")}]" : value.to_json}" }].join("\n") + "\n")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The burst of that name, or else one written as `start` writes it: what a
|
|
81
|
+
# press on a lone pull request or a stack opens, the second time as the first.
|
|
82
|
+
def self.find_or_start(slug, **picks) = all.find { it.slug == slug } || from(start(slug, **picks))
|
|
83
|
+
|
|
84
|
+
# Whether it is the reviewer's own, rather than one the repository shares.
|
|
85
|
+
def own? = file.to_s.start_with?("#{Project.current.own_bursts}/")
|
|
86
|
+
|
|
87
|
+
# `stacks` are GitHub's native stacks, by their numbers; `tops` the tops of
|
|
88
|
+
# chains the desk follows down by base branches; `pull_requests` those
|
|
89
|
+
# that stand alone. A top with nothing under it stands alone as well.
|
|
90
|
+
def initialize(slug, name:, repository:, file: nil, stacks: [], tops: [], pull_requests: [], hands_off: [], before_it_goes: {}, handover: {}, sitting: nil, waiting: [])
|
|
91
|
+
@slug, @name, @repository, @file = slug, name, repository, file
|
|
92
|
+
@stacks, @tops, @pull_requests = stacks, tops, pull_requests
|
|
93
|
+
@hands_off, @before_it_goes = hands_off, before_it_goes
|
|
94
|
+
@terms = Handover::Terms.from(handover)
|
|
95
|
+
@sitting = sitting
|
|
96
|
+
@waiting = waiting
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_param = slug
|
|
100
|
+
|
|
101
|
+
def marks(number) = Marks.of(self, number)
|
|
102
|
+
|
|
103
|
+
def choices = Choices.of(self)
|
|
104
|
+
|
|
105
|
+
def rules = Rules.new(choices, handovers: Handover.kept(self))
|
|
106
|
+
|
|
107
|
+
# How far each pull request's decisions are, by its number.
|
|
108
|
+
def progress(route) = choices.then { |kept| route.to_h { [it.number, kept.progress(it.number, it.description.decisions)] } }
|
|
109
|
+
|
|
110
|
+
# Ticking these decisions of one pull request, to show and then carry out.
|
|
111
|
+
# A description the burst file lists under `hands_off` is another
|
|
112
|
+
# session's, and the desk does not edit it.
|
|
113
|
+
def acceptance(number, decisions)
|
|
114
|
+
raise Acceptance::Refused, refusal(number) if refusal(number)
|
|
115
|
+
Acceptance.new(home: home_of(number), number:, decisions:)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Why the desk will not edit this description, when it will not.
|
|
119
|
+
def refusal(number) = ("The description of ##{number} is not the desk's to edit" if @hands_off.include?(number))
|
|
120
|
+
|
|
121
|
+
# Every decision box of the route, or of the part in focus, and what waits
|
|
122
|
+
# for the owner among them. `pages` maps a pull request's address on
|
|
123
|
+
# GitHub to the desk's page for it.
|
|
124
|
+
def decisions(pages: {}, focus: route) = Decisions.new(route: focus, marks: method(:marks), renderings: method(:rendering), named: @waiting, pages:)
|
|
125
|
+
|
|
126
|
+
# Where every stop of the route stands, in its order: each layer knows the
|
|
127
|
+
# standing of the one it sits on.
|
|
128
|
+
def standings(route)
|
|
129
|
+
progress, scenarios = progress(route), scenarios_on(route)
|
|
130
|
+
route.each_with_object({}) do |stop, standings|
|
|
131
|
+
standings[stop] = Standing.new(stop:, marks: marks(stop.number), progress: progress.fetch(stop.number), under: route.under(stop)&.then { standings[it] },
|
|
132
|
+
above: route.above(stop), scenarios: scenarios.select { it.pr == stop.number }.map(&:name), named: @before_it_goes.fetch(stop.number, []))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# What moved in a pull request since the owner's "good to go", to show
|
|
137
|
+
# when its standing says something did.
|
|
138
|
+
def since(stop) = Since.new(stop:, mark: marks(stop.number).good_to_go, repository:, history: home_of(stop.number), root: Project.current.root)
|
|
139
|
+
|
|
140
|
+
# The prompt for a fresh session, out of everything open on this route, or
|
|
141
|
+
# in the part in focus.
|
|
142
|
+
def handover(route, focus: Focus.new(route, nil), worklist: nil) = Handover.new(burst: self, route:, focus:, worklist:, terms: @terms, renderings: method(:rendering), pictures: method(:picture_file))
|
|
143
|
+
|
|
144
|
+
def reports = Reports.new(self)
|
|
145
|
+
|
|
146
|
+
# A description's picture as a file on this Mac, once the desk has shown it.
|
|
147
|
+
def picture_file(asset) = (Project.current.pictures.glob("#{asset}.*").first if Picture.asset?(asset.to_s))
|
|
148
|
+
|
|
149
|
+
# The server its scenarios open the app on: the burst file's `sitting`, or
|
|
150
|
+
# else the checkout under review as it is, on a database of the burst's own.
|
|
151
|
+
def server = @server ||= Server.from(@sitting || {checkout: Project.current.root, port: Server::PORT, slug:})
|
|
152
|
+
|
|
153
|
+
# A checkout the owner can make for one pull request's scenarios when the
|
|
154
|
+
# sitting cannot run them: where the burst keeps its worktrees, or else in
|
|
155
|
+
# the cache. Nil when the app's app.yml came with no commit to pick.
|
|
156
|
+
def fresh_sitting(stop)
|
|
157
|
+
arrival = server.app.arrival or return
|
|
158
|
+
path = @terms.worktree_of("try/#{stop.number}")&.then { Pathname(it).expand_path } || Project.current.cache.join("sittings", stop.number.to_s)
|
|
159
|
+
FreshSitting.new(repository: Project.current.root, path:, head: stop.head_oid.first(12), arrival: arrival.commit, port: server.port, slug: server.slug)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def scenarios = @scenarios ||= scenarios_on(route)
|
|
163
|
+
|
|
164
|
+
# The scenarios its file writes, and for each pull request of `route` the
|
|
165
|
+
# file names none of, those its description offers on its own.
|
|
166
|
+
def scenarios_on(route)
|
|
167
|
+
written = Scenario.of(self)
|
|
168
|
+
written + route.reject { |stop| written.any? { it.pr == stop.number } }.flat_map { Scenario.described(it) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def scenario(id) = scenarios.find { it.to_param == id } || raise(Missing, "no scenario is called #{id}")
|
|
172
|
+
|
|
173
|
+
# A stack is placed from the membership kept beside the marks when GitHub
|
|
174
|
+
# cannot say where its pull requests stand. Built once for this burst's
|
|
175
|
+
# reading of a page, which asks for it from the page, its pane and its focus.
|
|
176
|
+
def route(fresh: false)
|
|
177
|
+
return @route if @route && !fresh
|
|
178
|
+
|
|
179
|
+
open = github.open_pull_requests(fresh:)
|
|
180
|
+
unopened = Unopened.waiting(open:).map(&:pull_request)
|
|
181
|
+
among = open + unopened
|
|
182
|
+
@route = Route.new(stacks: @stacks, tops: resolved(@tops, among), loose: resolved(@pull_requests, among), among:, kept: membership.stacks).tap do |route|
|
|
183
|
+
Picture.fetch_ahead(of: [read_at, route.map(&:number), *unopened.map { [it.number, it.head_oid, it.described_at] }]) { pictures_of(route) }
|
|
184
|
+
Mentions.here&.read_ahead
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A description as GitHub renders it, for an open pull request, or as
|
|
189
|
+
# this Mac keeps it, for one nobody has opened yet.
|
|
190
|
+
def rendering(number) = home_of(number).rendering(number)
|
|
191
|
+
|
|
192
|
+
# The pull requests it holds now.
|
|
193
|
+
def numbers = route.map(&:number)
|
|
194
|
+
|
|
195
|
+
# The pull requests it held that are no longer open, merged or closed,
|
|
196
|
+
# by their numbers: those its file names and those the owner saw in it.
|
|
197
|
+
def gone = @gone ||= github.gone(held - numbers)
|
|
198
|
+
|
|
199
|
+
# Those of them that went in, the latest first.
|
|
200
|
+
def merged = gone.values.select(&:merged?).sort_by { -it.merged_at.to_f }
|
|
201
|
+
|
|
202
|
+
# Nothing of it is open or waiting unopened, and some of it was merged:
|
|
203
|
+
# the work went in, and the desk puts the burst away. A burst GitHub
|
|
204
|
+
# cannot be asked about now is not taken for finished.
|
|
205
|
+
def finished?
|
|
206
|
+
route.none? && merged.any?
|
|
207
|
+
rescue Github::Unreachable
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Put away: finished, or put away by the owner while some of it is still
|
|
212
|
+
# open. The desk no longer opens on it, and lists it only when asked.
|
|
213
|
+
def put_away? = put_away_by_hand? || finished?
|
|
214
|
+
|
|
215
|
+
# The owner's word that they are done with it, kept beside its marks, not
|
|
216
|
+
# in its file: a burst the repository shares is put away for them alone.
|
|
217
|
+
def put_away_by_hand? = put_away_mark.exist?
|
|
218
|
+
|
|
219
|
+
def put_away! = put_away_mark.tap { it.dirname.mkpath }.write("#{Time.current.iso8601}\n")
|
|
220
|
+
|
|
221
|
+
def take_out! = FileUtils.rm_f(put_away_mark)
|
|
222
|
+
|
|
223
|
+
def membership = Membership.of(self)
|
|
224
|
+
|
|
225
|
+
# What joined it, what left, and where its file and GitHub disagree. The
|
|
226
|
+
# first time the desk reads a burst, the membership it counts from is kept.
|
|
227
|
+
def notices
|
|
228
|
+
@notices ||= route.then do |route|
|
|
229
|
+
membership.start(route)
|
|
230
|
+
BurstNotices.new(burst: self, route:, membership:, open: github.open_pull_requests, choices: choices.document, others:, merged: merged.map(&:number))
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The other bursts of the repository, which may hold the same pull requests.
|
|
235
|
+
def others = Burst.all.reject { it.slug == slug }.select { it.repository == repository }
|
|
236
|
+
|
|
237
|
+
# Which of its pull requests GitHub has moved on since the desk read them,
|
|
238
|
+
# and which it closed: their numbers. Others of the repository are not its concern.
|
|
239
|
+
def moved
|
|
240
|
+
mine = route.map(&:number)
|
|
241
|
+
github.moved.slice(:changed, :closed).transform_values { it & mine }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Pieces of a description's markdown as GitHub draws them, for a page that has only the markdown.
|
|
245
|
+
def draw(pieces) = PiecesRendering.new(github).call(pieces)
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
# The pictures its descriptions show, each by its id and its kind, as GitHub's HTML addresses them.
|
|
250
|
+
def pictures_of(route) = route.flat_map { Rendering.pictures(rendering(it.number)) }
|
|
251
|
+
|
|
252
|
+
def github = @github ||= Github.new(@repository)
|
|
253
|
+
|
|
254
|
+
def put_away_mark = Marks.directory_of(self).join("put_away")
|
|
255
|
+
|
|
256
|
+
# Every pull request it was known to hold, by GitHub's number.
|
|
257
|
+
def held = [*@tops, *@pull_requests, *membership.pull_requests, *membership.handed_over].grep(Integer).uniq
|
|
258
|
+
|
|
259
|
+
# What holds a pull request's description, and answers for it: GitHub,
|
|
260
|
+
# or this Mac while nobody has opened it.
|
|
261
|
+
def home_of(number) = PullRequest.unopened?(number) ? Unopened : github
|
|
262
|
+
|
|
263
|
+
# A branch the file names stands for the pull request on it: the one
|
|
264
|
+
# open on GitHub, or else the one waiting unopened. A branch with
|
|
265
|
+
# neither stays as it is named, and is gone.
|
|
266
|
+
def resolved(named, among) = named.map { |name| (name.is_a?(String) && among.find { it.head == name }&.number) || name }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# What the burst page says above its route. How the burst moved: the pull
|
|
5
|
+
# requests that joined or left it since the owner last looked, each until
|
|
6
|
+
# the owner dismisses it, and those that left with choices or drafts not
|
|
7
|
+
# yet handed over. And where the burst's file and GitHub disagree, each with
|
|
8
|
+
# its fix, for as long as it is true.
|
|
9
|
+
class BurstNotices
|
|
10
|
+
# A pull request that joined or left, by its number and its title, when
|
|
11
|
+
# the desk still knows it; or one opened on GitHub, with the number it
|
|
12
|
+
# waited unopened as.
|
|
13
|
+
Moved = Data.define(:number, :title, :was) do
|
|
14
|
+
def initialize(was: nil, **) = super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A pull request that left with the owner's choices or drafts, drawn from
|
|
18
|
+
# what they kept of it: nothing of it is read again from GitHub.
|
|
19
|
+
Departed = Data.define(:number, :title, :entries)
|
|
20
|
+
|
|
21
|
+
# One of the owner's choices or drafts on a decision of it.
|
|
22
|
+
Entry = Data.define(:kind, :lead, :text, :draft)
|
|
23
|
+
|
|
24
|
+
# Something the file says that GitHub does not, in a sentence, with how
|
|
25
|
+
# to mend it. `cause` groups it with its kind: :off_the_burst,
|
|
26
|
+
# :empty_stack, :filed_twice or :held_elsewhere.
|
|
27
|
+
Disagreement = Data.define(:cause, :said, :fix)
|
|
28
|
+
|
|
29
|
+
# `open` is every open pull request of the repository, `others` the
|
|
30
|
+
# other bursts of the same repository, `choices` the owner's document,
|
|
31
|
+
# `merged` the numbers of those that left it by going in.
|
|
32
|
+
def initialize(burst:, route:, membership:, open:, choices:, others:, merged: [])
|
|
33
|
+
@burst, @route, @membership, @open, @choices, @others, @merged = burst, route, membership, open, choices, others, merged
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def joined = @joined ||= @membership.joined(@route).map { moved(it) }
|
|
37
|
+
|
|
38
|
+
# One that left by going in is no notice: the route lists it as merged.
|
|
39
|
+
def left = @left ||= (@membership.left(@route) - @merged).map { moved(it) }
|
|
40
|
+
|
|
41
|
+
# Opened on GitHub since it waited unopened, and its review came with it.
|
|
42
|
+
def opened = @opened ||= @membership.opened.filter_map { |number, was| Moved.new(number:, title: title_of(number), was:) if @route.at(number) }
|
|
43
|
+
|
|
44
|
+
def departed
|
|
45
|
+
@departed ||= @membership.departed(@route).filter_map do |number|
|
|
46
|
+
entries = entries_of(number)
|
|
47
|
+
Departed.new(number:, title: title_of(number), entries:) if entries.any?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A burst whose work all went in has nothing left to mend.
|
|
52
|
+
def disagreements = @disagreements ||= finished? ? [] : [*off_the_burst, *empty_stacks, *filed_twice, *held_elsewhere]
|
|
53
|
+
|
|
54
|
+
def count = [joined, left, opened, departed, disagreements].sum(&:size)
|
|
55
|
+
|
|
56
|
+
def any? = count.positive?
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def moved(number) = Moved.new(number:, title: title_of(number))
|
|
61
|
+
|
|
62
|
+
def finished? = @route.none? && @merged.any?
|
|
63
|
+
|
|
64
|
+
# From GitHub while it is open, else from a choice made on it.
|
|
65
|
+
def title_of(number) = @open.find { it.number == number }&.title || on(number).first&.dig("decision", "title")
|
|
66
|
+
|
|
67
|
+
def on(number) = %w[choices pending].flat_map { Array(@choices&.dig(it)) }.select { it.dig("decision", "pr") == number }
|
|
68
|
+
|
|
69
|
+
def entries_of(number)
|
|
70
|
+
on(number).map do |entry|
|
|
71
|
+
Entry.new(kind: entry["kind"], lead: entry.dig("decision", "lead"), text: entry["text"].to_s, draft: Array(@choices&.dig("pending")).include?(entry))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def file = @burst.file ? shown(@burst.file) : "the burst file"
|
|
76
|
+
|
|
77
|
+
# Open pull requests that sit, one on another, on a member of the burst,
|
|
78
|
+
# and are neither in it nor in another burst of the repository. Each run
|
|
79
|
+
# is named at the member it sits on.
|
|
80
|
+
def off_the_burst
|
|
81
|
+
held = numbers(@route) | @others.flat_map(&:numbers)
|
|
82
|
+
@route.filter_map do |member|
|
|
83
|
+
run = sitting_on([member.pull_request], held)
|
|
84
|
+
off(member, run) if run.any?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Every open pull request outside `held` that sits on one of `below`, or
|
|
89
|
+
# on one that does, and so on up.
|
|
90
|
+
def sitting_on(below, held)
|
|
91
|
+
above = @open.select { |pull| below.any? { pull.base == it.head } }.reject { held.include?(it.number) }
|
|
92
|
+
above.empty? ? [] : above + sitting_on(above, held + numbers(above))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def off(member, run)
|
|
96
|
+
tops = run.reject { |pull| run.any? { it.base == pull.head } }
|
|
97
|
+
fix = "List #{named(tops)} under `tops` in #{file}"
|
|
98
|
+
# GitHub appends to a stack only at its top.
|
|
99
|
+
if member.part.stack? && member.layer == member.of
|
|
100
|
+
fix += ", or link #{them(run)} to stack ##{member.part.id}: `gh stack link #{member.part.id} #{numbers(run).join(" ")}`"
|
|
101
|
+
end
|
|
102
|
+
Disagreement.new(cause: :off_the_burst, said: "#{named(run)} #{be(run)} on ##{member.number}'s branch, not in this burst.", fix: "#{fix}.")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def empty_stacks
|
|
106
|
+
return [] unless @route.stacks_known?
|
|
107
|
+
|
|
108
|
+
(@burst.stacks - @open.filter_map(&:stack)).map do |stack|
|
|
109
|
+
Disagreement.new(cause: :empty_stack, said: "Stack ##{stack} has no open pull requests.", fix: "Take it out of `stacks` in #{file}.")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A slug both under bursts/ and under reviews/, where burst files were
|
|
114
|
+
# kept before bursts had their name: the desk opens the first.
|
|
115
|
+
def filed_twice
|
|
116
|
+
opened, *also = Burst.folders.map { it.join("#{@burst.slug}.yml") }.select(&:exist?)
|
|
117
|
+
also.map do |other|
|
|
118
|
+
Disagreement.new(cause: :filed_twice, said: "#{shown(other)} #{(other.realpath == opened.realpath) ? "points to" : "names the same burst as"} #{shown(opened)}, which the desk opens.",
|
|
119
|
+
fix: "Delete #{shown(other)} once no desk from before bursts had their name is left to read it.")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def held_elsewhere
|
|
124
|
+
@others.filter_map do |other|
|
|
125
|
+
shared = @route.select { other.numbers.include?(it.number) }
|
|
126
|
+
next if shared.empty?
|
|
127
|
+
|
|
128
|
+
Disagreement.new(cause: :held_elsewhere, said: "#{named(shared)} #{be(shared)} also in #{other.name}, in #{shown(other.file)}.",
|
|
129
|
+
fix: "Each burst keeps its own choices on #{them(shared)}: take #{them(shared)} out of one of the files if only one burst should hold #{them(shared)}.")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def shown(path) = Project.current.from_root(path)
|
|
134
|
+
|
|
135
|
+
def numbers(pull_requests) = pull_requests.map(&:number)
|
|
136
|
+
|
|
137
|
+
def named(pull_requests) = pull_requests.map { "##{it.number}" }.to_sentence
|
|
138
|
+
|
|
139
|
+
def be(pull_requests) = pull_requests.one? ? "is" : "are"
|
|
140
|
+
|
|
141
|
+
def them(pull_requests) = pull_requests.one? ? "it" : "them"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Proctolog
|
|
6
|
+
# The steps of a recording and when each was on screen, as a WebVTT chapters
|
|
7
|
+
# file. The capture script writes it beside the video and the player reads
|
|
8
|
+
# it back, so the list beside the video is what the video says. Plain Ruby,
|
|
9
|
+
# with no Rails in it, because capture/film.rb loads it without the desk.
|
|
10
|
+
class Chapters
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
Chapter = Data.define(:name, :from, :to)
|
|
14
|
+
|
|
15
|
+
CUE = /^(?<from>[\d:.]+) --> (?<to>[\d:.]+)[^\n]*\n(?<name>[^\n]+)/
|
|
16
|
+
|
|
17
|
+
def self.parse(text) = new(text.scan(CUE).map { |from, to, name| Chapter.new(name: CGI.unescapeHTML(name), from: seconds(from), to: seconds(to)) })
|
|
18
|
+
|
|
19
|
+
def self.read(file) = file.exist? ? parse(file.read) : new([])
|
|
20
|
+
|
|
21
|
+
def self.seconds(stamp) = stamp.split(":").map(&:to_f).reduce { |minutes, more| (minutes * 60) + more }
|
|
22
|
+
|
|
23
|
+
def initialize(chapters) = @chapters = chapters
|
|
24
|
+
|
|
25
|
+
def each(&) = @chapters.each(&)
|
|
26
|
+
|
|
27
|
+
# The step a time falls in: the last that began at or before it, so a
|
|
28
|
+
# pause between two steps belongs to the one before.
|
|
29
|
+
def at(time) = @chapters.rfind { it.from <= time }
|
|
30
|
+
|
|
31
|
+
def to_vtt = "WEBVTT\n\n#{map { cue(it) }.join("\n\n")}\n"
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def cue(chapter) = "#{stamp(chapter.from)} --> #{stamp(chapter.to)}\n#{CGI.escapeHTML(chapter.name.split.join(" "))}"
|
|
36
|
+
|
|
37
|
+
def stamp(time)
|
|
38
|
+
ms = (time * 1000).round
|
|
39
|
+
format("%02d:%02d:%02d.%03d", ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|