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