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,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# Every box under "Decisions for the owner" across a burst's pull requests,
|
|
5
|
+
# for one page. A box is in one state: open, answered otherwise, accepted
|
|
6
|
+
# from the desk, or ticked elsewhere. It waits for the owner when the owner
|
|
7
|
+
# starred it or the burst file names it. Nothing is sorted away: every box
|
|
8
|
+
# is listed under "all", and the counts of the states add up to it.
|
|
9
|
+
class Decisions
|
|
10
|
+
Changed = Class.new(StandardError)
|
|
11
|
+
|
|
12
|
+
# The ways to look at the boxes, with the words the page uses for them.
|
|
13
|
+
# The four states are also filters; "waiting" cuts across them.
|
|
14
|
+
FILTERS = {"waiting" => "Waiting for you", "open" => "Open", "answered" => "Answered otherwise", "accepted" => "Accepted",
|
|
15
|
+
"elsewhere" => "Ticked elsewhere", "all" => "All"}.freeze
|
|
16
|
+
|
|
17
|
+
# What the burst file says waits for the owner: a box, by the first words
|
|
18
|
+
# of its lead, or a question that has no box; and who said so.
|
|
19
|
+
Named = Data.define(:pr, :words, :question, :by) do
|
|
20
|
+
def initialize(words: nil, question: nil, by: nil, **) = super
|
|
21
|
+
|
|
22
|
+
def question? = question.present?
|
|
23
|
+
|
|
24
|
+
def names?(stop, decision) = pr == stop.number && words.present? && decision.plain_lead.start_with?(words)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# One box as GitHub renders it, and where it stands. `quote` is its lead
|
|
28
|
+
# as a note holds it, `answers` the owner's live changes to it, `asked`
|
|
29
|
+
# the live questions and remarks on it, which leave it open, `accepted`
|
|
30
|
+
# whether the owner ticked it from the desk, `named` the entries of the
|
|
31
|
+
# burst file that name it.
|
|
32
|
+
Box = Data.define(:stop, :decision, :html, :quote, :answers, :asked, :starred, :accepted, :named) do
|
|
33
|
+
delegate :position, :lead, :plain_lead, :alternative, to: :decision
|
|
34
|
+
|
|
35
|
+
# The owner's live notes on it, the newest last: notes are numbered as they are written.
|
|
36
|
+
def notes = (answers + asked).sort_by { [it.written_at, it.id[/\d+\z/].to_i] }
|
|
37
|
+
|
|
38
|
+
def state
|
|
39
|
+
if decision.ticked then accepted ? :accepted : :elsewhere
|
|
40
|
+
elsif answers.any? then :answered
|
|
41
|
+
else :open
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def waiting? = decision.open? && (starred || named.any?)
|
|
46
|
+
|
|
47
|
+
# Open and waiting for no one: what one gesture may accept.
|
|
48
|
+
def routine? = state == :open && !waiting?
|
|
49
|
+
|
|
50
|
+
def to_param = "#{stop.number}-#{position}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What one pull request shows under a filter: its boxes, its questions
|
|
54
|
+
# that have no box, and the leads accepted from the desk that its
|
|
55
|
+
# description no longer holds.
|
|
56
|
+
Part = Data.define(:stop, :boxes, :questions, :rewritten) do
|
|
57
|
+
# Boxes in runs, each under the line the description leads it in with.
|
|
58
|
+
def groups(of = boxes) = of.slice_when { |one, other| one.decision.under != other.decision.under }.map { [it.first.decision.under, it] }
|
|
59
|
+
|
|
60
|
+
def blocking = boxes.select { it.decision.blocking? }
|
|
61
|
+
|
|
62
|
+
def non_blocking = boxes.reject { it.decision.blocking? }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `marks` answers a pull request's number with the owner's marks on it,
|
|
66
|
+
# `renderings` with GitHub's HTML of its description. `pages` maps the
|
|
67
|
+
# address of a pull request on GitHub to the desk's page for it.
|
|
68
|
+
def initialize(route:, marks:, renderings:, named: [], pages: {})
|
|
69
|
+
@route, @marks, @renderings, @pages = route, marks, renderings, pages
|
|
70
|
+
@named = named.map { Named.new(**it) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def boxes = @boxes ||= @route.flat_map { boxes_of(it) }
|
|
74
|
+
|
|
75
|
+
# The boxes a filter lists, and the one a page was just saved from
|
|
76
|
+
# (`keeping`, a box's `to_param`) wherever it now falls: it stays where the
|
|
77
|
+
# owner is, reading its new state, and goes from the list at the next visit.
|
|
78
|
+
def only(filter, keeping: nil) = boxes.select { shown?(it, filter) || it.to_param == keeping }
|
|
79
|
+
|
|
80
|
+
# What a filter lists: boxes, and under "waiting" the questions as well.
|
|
81
|
+
def count(filter) = only(filter).size + ((filter == "waiting") ? questions.size : 0)
|
|
82
|
+
|
|
83
|
+
# A filter the page was asked for, or the one worth opening on.
|
|
84
|
+
def filter_for(name)
|
|
85
|
+
return name if FILTERS.key?(name)
|
|
86
|
+
|
|
87
|
+
count("waiting").positive? ? "waiting" : "open"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The boxes of one theme across the burst, in the route's order.
|
|
91
|
+
ThemeGroup = Data.define(:theme, :boxes)
|
|
92
|
+
|
|
93
|
+
# The boxes a filter lists, by the theme each names: the project's themes
|
|
94
|
+
# in its own order, then "other", where a box naming none or one the
|
|
95
|
+
# project does not list goes.
|
|
96
|
+
def by_theme(filter, keeping: nil)
|
|
97
|
+
themes = Theme.all
|
|
98
|
+
only(filter, keeping:).group_by { Theme.named(it.decision.theme_name, themes) || Theme::OTHER }
|
|
99
|
+
.sort_by { |theme, _| themes.index(theme) || themes.size }
|
|
100
|
+
.map { |theme, boxes| ThemeGroup.new(theme:, boxes:) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parts(filter, keeping: nil)
|
|
104
|
+
shown = only(filter, keeping:).group_by { it.stop.number }
|
|
105
|
+
@route.filter_map do |stop|
|
|
106
|
+
part = Part.new(stop:, boxes: shown.fetch(stop.number, []), questions: questions_of(stop, filter), rewritten: rewritten_of(stop, filter))
|
|
107
|
+
part if [part.boxes, part.questions, part.rewritten].any?(&:any?)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Questions the burst file names that belong to no box, for the pull
|
|
112
|
+
# requests of the route: those are the ones a page lists.
|
|
113
|
+
def questions = @named.select(&:question?).select { @route.at(it.pr) }
|
|
114
|
+
|
|
115
|
+
# Entries of the burst file that name a box, and no box begins with their words.
|
|
116
|
+
def unmatched = @named.reject(&:question?).reject { |entry| boxes.any? { entry.names?(it.stop, it.decision) } }
|
|
117
|
+
|
|
118
|
+
# How many boxes of a pull request are in each state.
|
|
119
|
+
def tally(stop) = boxes.select { it.stop.number == stop.number }.map(&:state).tally
|
|
120
|
+
|
|
121
|
+
# The open boxes of a pull request that wait for no one.
|
|
122
|
+
def routine(stop) = boxes.select { it.stop.number == stop.number && it.routine? }
|
|
123
|
+
|
|
124
|
+
# The box a page named: it must still stand there, with that lead.
|
|
125
|
+
def box(number, position, lead)
|
|
126
|
+
boxes.find { it.stop.number == number && it.position == position && it.lead == lead } or raise Changed, gone(number)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The unticked boxes of one pull request at these places, to tick.
|
|
130
|
+
def to_accept(number, positions)
|
|
131
|
+
chosen = boxes.select { it.stop.number == number && positions.include?(it.position) && it.decision.open? }
|
|
132
|
+
chosen.size == positions.uniq.size && chosen.any? or raise Changed, gone(number)
|
|
133
|
+
chosen
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The boxes of one pull request alone, which its own page shows.
|
|
137
|
+
def boxes_of(stop)
|
|
138
|
+
decisions = stop.description.decisions
|
|
139
|
+
marks = @marks.call(stop.number)
|
|
140
|
+
section = Rendering.new(@renderings.call(stop.number), pages: @pages).sections.find { it.heading == Description::DECISIONS }
|
|
141
|
+
items = section ? section.boxes : []
|
|
142
|
+
items = [] unless items.size == decisions.size # shortcut: if GitHub's list and the Markdown's disagree, show the Markdown; upgrade when one does
|
|
143
|
+
on_boxes = notes_on_boxes(marks.notes, stop.description)
|
|
144
|
+
|
|
145
|
+
decisions.zip(items).map do |decision, item|
|
|
146
|
+
answers, asked = on_boxes.fetch(decision, []).partition(&:change?)
|
|
147
|
+
Box.new(stop:, decision:, html: html_of(decision, item), quote: quote_of(decision, item, section), answers:, asked:,
|
|
148
|
+
starred: marks.starred.include?(decision.lead), accepted: marks.accepted.include?(decision.lead),
|
|
149
|
+
named: @named.select { it.names?(stop, decision) })
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def shown?(box, filter)
|
|
156
|
+
case filter
|
|
157
|
+
when "all" then true
|
|
158
|
+
when "waiting" then box.waiting?
|
|
159
|
+
else box.state.to_s == filter
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def gone(number) = "The boxes of ##{number} are not as the page showed them: read it again."
|
|
164
|
+
|
|
165
|
+
def questions_of(stop, filter) = (filter == "waiting") ? questions.select { it.pr == stop.number } : []
|
|
166
|
+
|
|
167
|
+
# Leads the owner accepted from the desk that no box of the description has now.
|
|
168
|
+
def rewritten_of(stop, filter)
|
|
169
|
+
return [] unless %w[accepted all].include?(filter)
|
|
170
|
+
|
|
171
|
+
@marks.call(stop.number).accepted - stop.description.decisions.map(&:lead)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def html_of(decision, item)
|
|
175
|
+
return ERB::Util.html_escape(decision.text) unless item
|
|
176
|
+
|
|
177
|
+
item.css("input.task-list-item-checkbox").each(&:remove)
|
|
178
|
+
item.inner_html.strip
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The lead as the page has it, with the words around it from the section's
|
|
182
|
+
# text, so that a note finds this box and not another that repeats the words.
|
|
183
|
+
def quote_of(decision, item, section)
|
|
184
|
+
quote = Note::Quote.new(exact: decision.plain_lead, before: nil, after: nil, section: Description::DECISIONS)
|
|
185
|
+
spot = item && quote.locate(item.text) or return quote
|
|
186
|
+
|
|
187
|
+
start = item.xpath("preceding::text()").sum { it.content.length } + spot.begin
|
|
188
|
+
finish = start + spot.size
|
|
189
|
+
text = section.text
|
|
190
|
+
quote.with(exact: item.text[spot], before: text[[start - Note::NEAR, 0].max...start], after: text[finish, Note::NEAR])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The owner's live notes on words of a box, by the decision they fall in.
|
|
194
|
+
def notes_on_boxes(notes, description)
|
|
195
|
+
on_boxes = notes.select { it.live? && (it.place in Note::Quote[section: Description::DECISIONS]) }
|
|
196
|
+
on_boxes.group_by { description.decision_quoted_by(it.place) }.except(nil)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# A pull request's description, read for what the desk shows of it: the task
|
|
5
|
+
# boxes under "Decisions for the owner", the pictures it shows, its length.
|
|
6
|
+
class Description
|
|
7
|
+
DECISIONS = "Decisions for the owner"
|
|
8
|
+
NON_BLOCKING = /^###\s+Non-blocking\s*$/i
|
|
9
|
+
BOX = /^- \[([ xX])\] (.+)$/
|
|
10
|
+
PICTURE = %r{<img\b[^>]*\bsrc="(https://github\.com/user-attachments/assets/[^"]+)"}
|
|
11
|
+
# A picture shown, as an <img> or as Markdown's , and a
|
|
12
|
+
# decision's link to one by its alt text.
|
|
13
|
+
IMAGE = %r{<img\b[^>]*>|!\[[^\]]*\]\(https://github\.com/user-attachments/assets/[^)\s]+\)}
|
|
14
|
+
LINK = /\(#screenshot-([^)\s]+)\)/
|
|
15
|
+
|
|
16
|
+
# A picture the description shows: its alt text, which a decision links
|
|
17
|
+
# it by, and its asset on GitHub.
|
|
18
|
+
Shot = Data.define(:name, :asset)
|
|
19
|
+
|
|
20
|
+
# One box under "Decisions for the owner": its place among them, its
|
|
21
|
+
# words, whether it is ticked, the line the description leads its group
|
|
22
|
+
# in with ("Still open"), when it has one, and its grade: :blocking, or
|
|
23
|
+
# :non_blocking under a "### Non-blocking" heading. A description that
|
|
24
|
+
# grades none has every decision blocking. `row` is its line in the
|
|
25
|
+
# description, which a check names it by; `pictures` the shots it rests on.
|
|
26
|
+
Decision = Data.define(:position, :text, :ticked, :under, :grade, :row, :pictures) do
|
|
27
|
+
def initialize(grade: :blocking, row: nil, pictures: [], **) = super
|
|
28
|
+
|
|
29
|
+
def open? = !ticked
|
|
30
|
+
|
|
31
|
+
def blocking? = grade == :blocking
|
|
32
|
+
|
|
33
|
+
# The theme its hidden marker names, as the marker spells it; nil
|
|
34
|
+
# without one. Theme.named says which theme of the project that is.
|
|
35
|
+
def theme_name = text[Theme::MARKER, 1]
|
|
36
|
+
|
|
37
|
+
# Its words without theme markers: what its key is made from and what a
|
|
38
|
+
# choice compares, so naming or mending a theme never reopens a choice.
|
|
39
|
+
def bare_text = Theme.unmarked(text)
|
|
40
|
+
|
|
41
|
+
def key = text[/<!-- proctolog:decision ([\w.-]+) -->/, 1] || Digest::SHA256.hexdigest(bare_text)[0, 16]
|
|
42
|
+
|
|
43
|
+
# What was built, as the description puts it: the bold sentence, or the
|
|
44
|
+
# first one. It is how a decision is known again after an edit, so a
|
|
45
|
+
# colon does not end it: "Still open: after a refused send, ..." opens
|
|
46
|
+
# five boxes of one description.
|
|
47
|
+
def lead = bare_text[/\A\*\*(.+?)\*\*/, 1] || bare_text[/\A.+?[.?!](?=\s|\z)/] || bare_text
|
|
48
|
+
|
|
49
|
+
# The lead as the page reads it, with no Markdown around the words.
|
|
50
|
+
def plain_lead = plain(lead)
|
|
51
|
+
|
|
52
|
+
# Whether the box holds the words a quote is of.
|
|
53
|
+
def holds?(quote) = plain(text).include?(plain(quote.exact))
|
|
54
|
+
|
|
55
|
+
# Whether a quote could be about this decision: it quotes words of the
|
|
56
|
+
# box, or it quotes more than the lead and so takes in the whole lead.
|
|
57
|
+
# Other boxes may hold the same words: Description#decision_quoted_by tells.
|
|
58
|
+
def quoted_by?(quote) = holds?(quote) || plain(quote.exact).include?(plain_lead)
|
|
59
|
+
|
|
60
|
+
# Whether the quote is exactly this decision's lead.
|
|
61
|
+
def led_by?(quote) = plain_lead == plain(quote.exact)
|
|
62
|
+
|
|
63
|
+
# How many characters of the quote's surroundings stand round its words
|
|
64
|
+
# within this box; 0 when its words are not in the box.
|
|
65
|
+
def fit(quote)
|
|
66
|
+
around = Note::Quote.new(exact: plain(quote.exact), before: flat(quote.before), after: flat(quote.after), section: nil)
|
|
67
|
+
around.fit_in(flat(text)).to_i
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def alternative = visible(text)[/Alternative: (.+)\z/, 1]
|
|
71
|
+
|
|
72
|
+
# The line as it stands in the description.
|
|
73
|
+
def line = "- [#{ticked ? "x" : " "}] #{text}"
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def visible(words) = words.to_s.gsub(/<!--.*?-->/, "").strip
|
|
78
|
+
def plain(words) = visible(words).delete("*`").squish
|
|
79
|
+
|
|
80
|
+
# Like `plain`, for words with other text beside them: the spaces at the ends are part of what is compared.
|
|
81
|
+
def flat(words) = words.to_s.gsub(/<!--.*?-->/, "").delete("*`").gsub(/\s+/, " ")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize(markdown)
|
|
85
|
+
@markdown = markdown.to_s.gsub("\r\n", "\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_s = @markdown
|
|
89
|
+
|
|
90
|
+
def words = @markdown.split.size
|
|
91
|
+
|
|
92
|
+
def pictures = @markdown.scan(PICTURE).flatten
|
|
93
|
+
|
|
94
|
+
# The pull requests it names, as "#1234".
|
|
95
|
+
def mentions = @markdown.scan(/#([1-9]\d{2,4})\b/).flatten.map(&:to_i).uniq
|
|
96
|
+
|
|
97
|
+
def digest = "sha256:#{Digest::SHA256.hexdigest(@markdown)}"
|
|
98
|
+
|
|
99
|
+
# The boxes after "### Non-blocking" are non-blocking, and the line that
|
|
100
|
+
# leads a group in starts again under it.
|
|
101
|
+
def decisions
|
|
102
|
+
under, grade = nil, :blocking
|
|
103
|
+
rows(DECISIONS).each_with_object([]) do |(line, row), found|
|
|
104
|
+
case line
|
|
105
|
+
when BOX then found << Decision.new(position: found.size, text: $2, ticked: $1 != " ", under:, grade:, row:, pictures: pictures_in($2))
|
|
106
|
+
when NON_BLOCKING then under, grade = nil, :non_blocking
|
|
107
|
+
when /^\S/ then under = line.strip.delete_suffix(":").delete("*")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Every picture it shows, in its order.
|
|
113
|
+
def shots
|
|
114
|
+
@shots ||= @markdown.scan(IMAGE).filter_map do |shown|
|
|
115
|
+
asset = shown[%r{user-attachments/assets/(#{Picture::ASSET})}, 1] or next
|
|
116
|
+
Shot.new(name: shown[/\balt="([^"]*)"/, 1] || shown[/\A!\[([^\]]*)\]/, 1], asset:)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The names of the pictures some line of it links: a decision, or a step
|
|
121
|
+
# of a scenario.
|
|
122
|
+
def linked = @markdown.scan(LINK).flatten.to_set
|
|
123
|
+
|
|
124
|
+
# The pictures a decision's words rest on: those its links name, a pair
|
|
125
|
+
# as both its halves, and those shown in its own line, each once.
|
|
126
|
+
def pictures_in(text) = (text.scan(LINK).flatten.flat_map { named(it) } + Description.new(text).shots).uniq
|
|
127
|
+
|
|
128
|
+
# A picture by its alt text, or a pair's halves when no picture has the
|
|
129
|
+
# name itself, as the desk's page pairs them.
|
|
130
|
+
def named(name)
|
|
131
|
+
halves = %w[before after].filter_map { |half| shots.find { it.name == "#{name}-#{half}" } }
|
|
132
|
+
shots.select { it.name == name }.presence || ((halves.size == 2) ? halves : [])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The lines of a section under its heading, each with its line number in
|
|
136
|
+
# the description; none when it has no such section.
|
|
137
|
+
def rows(heading)
|
|
138
|
+
first = @markdown.lines.index { it[/\A## (.+)$/, 1]&.strip == heading } or return []
|
|
139
|
+
section(heading).lines.drop(1).each.with_index(first + 2).to_a
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The decision a quote is about: the box whose lead it is, else the only
|
|
143
|
+
# box that holds its words (or, when none does, whose lead it takes in).
|
|
144
|
+
# Of several, the one whose surroundings are those the quote saw, and none
|
|
145
|
+
# when they cannot tell the boxes apart, since "Alternative:" is in every box.
|
|
146
|
+
def decision_quoted_by(quote)
|
|
147
|
+
holding = decisions.select { it.holds?(quote) }.presence || decisions.select { it.quoted_by?(quote) }
|
|
148
|
+
candidates = holding.select { it.led_by?(quote) }.presence || holding
|
|
149
|
+
candidates.one? ? candidates.first : apart(candidates, quote)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def section(heading) = sections.fetch(heading, "")
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# The box among several whose surroundings plainly are the quote's own.
|
|
157
|
+
def apart(candidates, quote)
|
|
158
|
+
fits = candidates.index_with { it.fit(quote) }
|
|
159
|
+
best, next_best = fits.values.max(2)
|
|
160
|
+
fits.key(best) if best.to_i >= Note::CONFIRMING && best > next_best
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def sections = @sections ||= @markdown.split(/^(?=## )/).index_by { it[/\A## (.+)$/, 1]&.strip }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Proctolog
|
|
8
|
+
# Whether two pictures show the same pixels, and where they do not: the
|
|
9
|
+
# boxes of the changed regions, a mask of every changed pixel, and that
|
|
10
|
+
# mask's pixel signature, by which the same change is known in two pairs.
|
|
11
|
+
# Pictures of different sizes are compared from the top left, and say so.
|
|
12
|
+
# Plain Ruby over ImageMagick, with no Rails in it, because the capture
|
|
13
|
+
# kit's compare.rb loads it without the desk. Each answer is kept in the
|
|
14
|
+
# cache root it is given, under the two pictures' pixel signatures.
|
|
15
|
+
class Difference
|
|
16
|
+
# A changed region, in pixels from the top left.
|
|
17
|
+
Box = Data.define(:left, :top, :width, :height) do
|
|
18
|
+
def right = left + width
|
|
19
|
+
def bottom = top + height
|
|
20
|
+
|
|
21
|
+
def near?(other, gap) = left - gap <= other.right && other.left - gap <= right && top - gap <= other.bottom && other.top - gap <= bottom
|
|
22
|
+
|
|
23
|
+
def +(other)
|
|
24
|
+
left, top = [self.left, other.left].min, [self.top, other.top].min
|
|
25
|
+
Box.new(left:, top:, width: [right, other.right].max - left, height: [bottom, other.bottom].max - top)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# What a comparison found. One that could not be made, for want of
|
|
30
|
+
# ImageMagick or of a picture it can read, is not compared.
|
|
31
|
+
Result = Data.define(:compared, :same, :boxes, :mask, :mask_signature, :sizes) do
|
|
32
|
+
def self.from(stored) = new(**stored, boxes: stored.fetch(:boxes).map { Box.new(**it) }, mask: stored[:mask]&.then { Pathname(it) })
|
|
33
|
+
|
|
34
|
+
def compared? = compared
|
|
35
|
+
def same? = same
|
|
36
|
+
def different_sizes? = sizes.uniq.size > 1
|
|
37
|
+
|
|
38
|
+
def to_h = super.merge(boxes: boxes.map(&:to_h), mask: mask&.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
NOT_COMPARED = Result.new(compared: false, same: nil, boxes: [], mask: nil, mask_signature: nil, sizes: [])
|
|
42
|
+
|
|
43
|
+
# Changes nearer than this, in pixels, are one region: the letters of a
|
|
44
|
+
# changed word are one place to look, not one each.
|
|
45
|
+
GAP = 12
|
|
46
|
+
|
|
47
|
+
def self.between(before, after, cache:) = new(before, after, cache:).result
|
|
48
|
+
|
|
49
|
+
# A picture's pixel signature: a re-encoding or a change of metadata
|
|
50
|
+
# leaves it as it was. Kept by the file's bytes, so it is asked once.
|
|
51
|
+
def self.signature(file, cache:)
|
|
52
|
+
kept = Pathname(cache).join("signatures", Digest::SHA256.file(file).hexdigest)
|
|
53
|
+
return kept.read if kept.exist?
|
|
54
|
+
|
|
55
|
+
identify(file, "%#").tap { |signature| write(kept, signature) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.identify(file, format)
|
|
59
|
+
output, status = Open3.capture2("magick", "identify", "-format", format, "#{file}[0]", err: File::NULL)
|
|
60
|
+
status.success? ? output.strip : raise(Unreadable, file.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Beside the file and moved over it, so a reader never meets half of one.
|
|
64
|
+
def self.write(file, text)
|
|
65
|
+
file.dirname.mkpath
|
|
66
|
+
file.sub_ext("#{file.extname}.part").tap { it.write(text) }.rename(file)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Unreadable = Class.new(StandardError)
|
|
70
|
+
|
|
71
|
+
def initialize(before, after, cache:)
|
|
72
|
+
@before, @after, @cache = Pathname(before), Pathname(after), Pathname(cache).join("differences")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def result
|
|
76
|
+
signatures = [@before, @after].map { self.class.signature(it, cache: @cache.dirname) }
|
|
77
|
+
kept = @cache.join("#{signatures.join("-")}.json")
|
|
78
|
+
return Result.from(JSON.parse(kept.read, symbolize_names: true)) if kept.exist?
|
|
79
|
+
|
|
80
|
+
compare(signatures, mask: kept.sub_ext(".png")).tap { self.class.write(kept, it.to_h.to_json) }
|
|
81
|
+
rescue Errno::ENOENT, Unreadable
|
|
82
|
+
NOT_COMPARED
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def compare(signatures, mask:)
|
|
88
|
+
sizes = [@before, @after].map { self.class.identify(it, "%w %h").split.map(&:to_i) }
|
|
89
|
+
return Result.new(compared: true, same: true, boxes: [], mask: nil, mask_signature: nil, sizes:) if signatures.uniq.size == 1 && sizes.uniq.size == 1
|
|
90
|
+
|
|
91
|
+
draw_mask(sizes, mask)
|
|
92
|
+
boxes = regions(mask)
|
|
93
|
+
Result.new(compared: true, same: boxes.empty?, boxes:, mask:, mask_signature: self.class.identify(mask, "%#"), sizes:)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# White where any channel differs, black elsewhere, on a canvas as large
|
|
97
|
+
# as the larger picture: what one picture has and the other lacks counts.
|
|
98
|
+
def draw_mask(sizes, mask)
|
|
99
|
+
canvas = sizes.transpose.map(&:max).join("x")
|
|
100
|
+
part = mask.sub_ext(".png.part")
|
|
101
|
+
mask.dirname.mkpath
|
|
102
|
+
run("magick", *laid(@before, canvas), *laid(@after, canvas), "-compose", "difference", "-composite",
|
|
103
|
+
"-separate", "-evaluate-sequence", "max", "-threshold", "0", "-depth", "8", "PNG8:#{part}")
|
|
104
|
+
part.rename(mask)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def laid(file, canvas) = ["(", "#{file}[0]", "-alpha", "off", "-background", "black", "-gravity", "NorthWest", "-extent", canvas, ")"]
|
|
108
|
+
|
|
109
|
+
# A run of changed pixels as ImageMagick lists it: its box, and white,
|
|
110
|
+
# which a palette names in sRGB and a grey mask in grey.
|
|
111
|
+
CHANGED = /^\s*\d+: (\d+)x(\d+)\+(\d+)\+(\d+) \S+ \d+ (?:gray\(255\)|srgb\(255,255,255\)|white)\s*$/
|
|
112
|
+
|
|
113
|
+
# The changed regions, each a run of changed pixels with those near it.
|
|
114
|
+
# ImageMagick lists the runs on one stream or the other, by its build.
|
|
115
|
+
def regions(mask)
|
|
116
|
+
listed, status = Open3.capture2e("magick", mask.to_s, "-define", "connected-components:verbose=true", "-connected-components", "8", "null:")
|
|
117
|
+
status.success? or raise Unreadable, mask.to_s
|
|
118
|
+
found = listed.scan(CHANGED)
|
|
119
|
+
.map { |width, height, left, top| Box.new(left: left.to_i, top: top.to_i, width: width.to_i, height: height.to_i) }
|
|
120
|
+
merged(found).sort_by { [it.top, it.left] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def merged(boxes)
|
|
124
|
+
boxes.each_with_object([]) do |box, regions|
|
|
125
|
+
while (near = regions.find { it.near?(box, GAP) })
|
|
126
|
+
regions.delete(near)
|
|
127
|
+
box += near
|
|
128
|
+
end
|
|
129
|
+
regions << box
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def run(*command)
|
|
134
|
+
output, status = Open3.capture2(*command, err: File::NULL)
|
|
135
|
+
status.success? ? output : raise(Unreadable, command.join(" "))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Proctolog
|
|
7
|
+
# What this Mac and this checkout give the desk, a line each with the fix.
|
|
8
|
+
# A need that is missing keeps the desk from working; a nicety that is
|
|
9
|
+
# missing only turns its feature off. `proctolog doctor` prints it.
|
|
10
|
+
class Doctor
|
|
11
|
+
MARKS = {fine: "ok", missing: "!!", off: "--"}.freeze
|
|
12
|
+
|
|
13
|
+
Finding = Data.define(:state, :line) do
|
|
14
|
+
def missing? = state == :missing
|
|
15
|
+
def to_s = "#{MARKS.fetch(state)} #{line}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
NEEDS = %i[ruby gh launcher checkout repository bursts].freeze
|
|
19
|
+
NICETIES = %i[release skills chrome diffle imagemagick ffmpeg app port].freeze
|
|
20
|
+
|
|
21
|
+
# project is nil when the directory is no Git checkout.
|
|
22
|
+
def initialize(project, home: Dir.home, path: ENV.fetch("PATH", ""))
|
|
23
|
+
@project, @home, @path = project, Pathname(home), path.split(File::PATH_SEPARATOR)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def findings = @findings ||= (NEEDS + NICETIES).filter_map { send(it) }
|
|
27
|
+
|
|
28
|
+
def fine? = findings.none?(&:missing?)
|
|
29
|
+
|
|
30
|
+
def to_s = findings.join("\n")
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def ruby = fine("Ruby #{RUBY_VERSION}")
|
|
35
|
+
|
|
36
|
+
def gh
|
|
37
|
+
return missing("gh is not on the PATH: brew install gh, then gh auth login") unless command?("gh")
|
|
38
|
+
|
|
39
|
+
succeeds?("gh", "auth", "status") ? fine("gh, signed in") : missing("gh is not signed in: gh auth login")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# RubyGems' launcher for a gem, or the one bin/install leaves in ~/.local/bin for a checkout.
|
|
43
|
+
def launcher
|
|
44
|
+
if (folder = @path.find { File.executable?(File.join(it, "proctolog")) }) then fine("proctolog in #{folder}")
|
|
45
|
+
elsif (bin = @home.join(".local/bin")).join("proctolog").exist? then missing("#{bin} is not on the PATH: add it in ~/.zshrc")
|
|
46
|
+
else missing("PRoctolog is not installed: gem install proctolog, or ruby bin/install in a checkout of it")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def checkout = @project ? fine("Reviewing #{@project.root}") : missing("Not in a Git checkout: cd into the one you review, or pass --project")
|
|
51
|
+
|
|
52
|
+
# Asked only of a gh that is there: a missing one has its own line.
|
|
53
|
+
def repository
|
|
54
|
+
return unless @project && command?("gh")
|
|
55
|
+
return missing("#{@project.root}'s origin is not on GitHub") unless (name = @project.github_repository)
|
|
56
|
+
|
|
57
|
+
succeeds?("gh", "repo", "view", name, "--json", "name") ? fine("GitHub answers for #{name}") : missing("gh cannot read #{name}: ask for access to it")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def bursts
|
|
61
|
+
return unless @project
|
|
62
|
+
|
|
63
|
+
files = Burst.files
|
|
64
|
+
return missing("No bursts to open: start one with proctolog burst new NAME --pr NUMBER") if files.empty?
|
|
65
|
+
|
|
66
|
+
fine("Bursts: #{files.map { it.basename(".yml") }.join(", ")}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The skills an agent needs, by what the line calls them.
|
|
70
|
+
SKILLS = {"proctolog-pr-writing" => "PR-writing", "proctolog-handover" => "handover"}.freeze
|
|
71
|
+
|
|
72
|
+
def skills
|
|
73
|
+
folders = %w[.claude/skills .agents/skills].select { |folder| SKILLS.keys.all? { @home.join(folder, it).exist? } }
|
|
74
|
+
missing = SKILLS.reject { |skill, _| %w[.claude/skills .agents/skills].any? { @home.join(it, skill).exist? } }.values
|
|
75
|
+
if missing.any? then off("The #{missing.to_sentence} #{"skill".pluralize(missing.size)} #{missing.one? ? "is" : "are"} not linked: proctolog install")
|
|
76
|
+
else fine("The #{SKILLS.values.to_sentence} skills, in ~/#{folders.join(" and ~/")}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# As the desk last asked RubyGems: a doctor waits on no network for it.
|
|
81
|
+
def release = (newer = Release.newer) ? off(newer.news) : fine("PRoctolog #{Release.running}")
|
|
82
|
+
|
|
83
|
+
def chrome
|
|
84
|
+
require "ferrum"
|
|
85
|
+
Ferrum::Browser::Options::Chrome.options.detect_path ? fine("Chrome, for browser tests and scenarios") : off("No Chrome: browser tests skip and scenarios cannot open")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def diffle = command?("diffle") ? fine("Diffle, for d on a pull request") : off("No Diffle, so d opens no code: brew install diffle")
|
|
89
|
+
|
|
90
|
+
def imagemagick = command?("magick") ? fine("ImageMagick, for comparing pictures") : off("No ImageMagick, so pictures are not compared: brew install imagemagick")
|
|
91
|
+
|
|
92
|
+
def ffmpeg = command?("ffmpeg") ? fine("ffmpeg, for the stills of films") : off("No ffmpeg, so films have no stills: brew install ffmpeg")
|
|
93
|
+
|
|
94
|
+
# Each command app.yml names runs from a program the checkout or the PATH holds.
|
|
95
|
+
def app
|
|
96
|
+
return unless @project
|
|
97
|
+
|
|
98
|
+
app = App.new(@project.definitions.join(App::FILE))
|
|
99
|
+
return off("No .proctolog/app.yml, so scenario buttons cannot run the app: see docs/manual/app.md") unless app.described?
|
|
100
|
+
|
|
101
|
+
lacking = app.programs.reject { runnable?(it) }
|
|
102
|
+
return missing("app.yml runs #{lacking.to_sentence}, which neither the checkout nor the PATH holds") if lacking.any?
|
|
103
|
+
|
|
104
|
+
fine("app.yml names #{app.parts.to_sentence}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def port = answers?(4040) ? off("Port 4040 answers already: a desk may be open there, or serve --port 4041") : fine("Port 4040 is free")
|
|
108
|
+
|
|
109
|
+
def fine(line) = Finding.new(:fine, line)
|
|
110
|
+
def missing(line) = Finding.new(:missing, line)
|
|
111
|
+
def off(line) = Finding.new(:off, line)
|
|
112
|
+
|
|
113
|
+
def command?(name) = @path.any? { File.executable?(File.join(it, name)) }
|
|
114
|
+
|
|
115
|
+
# A program app.yml names: a path from the checkout's root, or a command on the PATH.
|
|
116
|
+
def runnable?(program) = program.include?("/") ? @project.root.join(program).executable? : command?(program)
|
|
117
|
+
|
|
118
|
+
def succeeds?(*command) = Open3.capture2e({"PATH" => @path.join(File::PATH_SEPARATOR)}, *command).last.success?
|
|
119
|
+
|
|
120
|
+
def answers?(port)
|
|
121
|
+
Socket.tcp("localhost", port, connect_timeout: 1, &:close)
|
|
122
|
+
true
|
|
123
|
+
rescue SystemCallError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# A picture an agent's answer rests on: a file on this Mac that the answer's
|
|
5
|
+
# evidence names, from the root or from the checkout under review. The desk
|
|
6
|
+
# gives out a file only when an answer of the burst names it, and only a
|
|
7
|
+
# picture, so its address reads nothing else the Mac holds.
|
|
8
|
+
class Evidence
|
|
9
|
+
KINDS = %w[png jpg jpeg gif webp].freeze
|
|
10
|
+
|
|
11
|
+
def self.picture?(path) = KINDS.include?(File.extname(path.to_s).delete(".").downcase)
|
|
12
|
+
|
|
13
|
+
def initialize(burst) = @burst = burst
|
|
14
|
+
|
|
15
|
+
# The file named, or nil when no answer names it, it is no picture, or it is not on this Mac.
|
|
16
|
+
def picture(path)
|
|
17
|
+
return unless self.class.picture?(path) && named.include?(path)
|
|
18
|
+
|
|
19
|
+
Project.current.root.join(path).then { it if it.file? }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def named = answers.flat_map { it["results"] }.flat_map { Array(it["evidence"]) }.filter_map { it["path"] }.to_set
|
|
25
|
+
|
|
26
|
+
# Those the desk keeps with the choices, and those waiting in the folder of responses.
|
|
27
|
+
def answers = [*Array(@burst.choices.document&.dig("responses")), *ResponseFiles.new(@burst).all]
|
|
28
|
+
end
|
|
29
|
+
end
|