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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# A description checked as the desk reads it, before it is published or
|
|
5
|
+
# after: faults, which keep the desk from reading it well, and warnings,
|
|
6
|
+
# which only advise, each on its line. It reads with the desk's own parser
|
|
7
|
+
# and the desk's own picture names, so the check and the desk agree.
|
|
8
|
+
class Check
|
|
9
|
+
# What was found, on which line of the description; `fault` is false for
|
|
10
|
+
# a warning.
|
|
11
|
+
Finding = Data.define(:row, :said, :fault)
|
|
12
|
+
|
|
13
|
+
# More blocking decisions than this ask too much of one owner at once.
|
|
14
|
+
MOST_BLOCKING = 5
|
|
15
|
+
|
|
16
|
+
# `rendering` is GitHub's HTML of the same Markdown.
|
|
17
|
+
def initialize(markdown, rendering:, themes: Theme.all)
|
|
18
|
+
@markdown = markdown.to_s.gsub("\r\n", "\n")
|
|
19
|
+
@description = Description.new(@markdown)
|
|
20
|
+
@themes = themes
|
|
21
|
+
@pictures = Rendering.new(rendering).sections.flat_map(&:figures).map { it["data-picture"] }.tally
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def findings
|
|
25
|
+
@findings ||= if @description.rows(Description::DECISIONS).empty?
|
|
26
|
+
[fault(nil, "There is no “## #{Description::DECISIONS}” section.")]
|
|
27
|
+
else
|
|
28
|
+
[*decisions.flat_map { of(it) }, *runs_on, *repeated_keys, *halves, *too_many].sort_by { it.row.to_i }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def faults = findings.select(&:fault)
|
|
33
|
+
|
|
34
|
+
def warnings = findings.reject(&:fault)
|
|
35
|
+
|
|
36
|
+
def passed? = faults.empty?
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
blocking = decisions.count(&:blocking?)
|
|
40
|
+
[("#{count(decisions.size, "decision")}: #{blocking} blocking, #{decisions.size - blocking} non-blocking." if decisions.any?),
|
|
41
|
+
*findings.map { [("line #{it.row}" if it.row), (it.fault ? "fault" : "warning"), it.said].compact.join(": ") },
|
|
42
|
+
how_to_name_a_theme, "#{count(faults.size, "fault")}, #{count(warnings.size, "warning")}."].compact.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def decisions = @description.decisions
|
|
48
|
+
|
|
49
|
+
def fault(row, said) = Finding.new(row:, said:, fault: true)
|
|
50
|
+
|
|
51
|
+
def warning(row, said) = Finding.new(row:, said:, fault: false)
|
|
52
|
+
|
|
53
|
+
def count(number, noun) = "#{number} #{noun.pluralize(number)}"
|
|
54
|
+
|
|
55
|
+
# What one decision lacks: a bold lead, its alternative, a theme the
|
|
56
|
+
# project lists, and pictures that its links name once each.
|
|
57
|
+
def of(decision)
|
|
58
|
+
row = decision.row
|
|
59
|
+
[(fault(row, "It has no bold lead: open it with what was built, in **bold**.") unless decision.bare_text.match?(/\A\*\*.+?\*\*/)),
|
|
60
|
+
(fault(row, "It names no alternative: end it with “Alternative: …”, before its theme.") unless decision.alternative),
|
|
61
|
+
theme_of(decision), *pictures_of(decision)].compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def theme_of(decision)
|
|
65
|
+
return fault(decision.row, "It names no theme.") unless decision.theme_name
|
|
66
|
+
|
|
67
|
+
fault(decision.row, "It names the theme “#{decision.theme_name}”, which the project does not list.") if Theme.named(decision.theme_name, @themes).other?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Said once under the findings, when any of them is about a theme.
|
|
71
|
+
def how_to_name_a_theme
|
|
72
|
+
"A decision names its theme at its end, <!-- proctolog:theme NAME -->, NAME one of #{@themes.map(&:name).join(", ")}: `proctolog themes` says what each means." if findings.any? { it.said.include?("theme") }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A link names a picture by its alt text, which must be one picture's
|
|
76
|
+
# alone, or a pair's: NAME-before beside NAME-after.
|
|
77
|
+
def pictures_of(decision)
|
|
78
|
+
decision.text.scan(Description::LINK).flatten.uniq.filter_map do |name|
|
|
79
|
+
shown = @pictures.fetch(name, 0) + (paired?(name) ? 1 : 0)
|
|
80
|
+
if shown.zero? then fault(decision.row, "It links the picture “#{name}”, which no picture of the description has as its alt text.")
|
|
81
|
+
elsif shown > 1 then fault(decision.row, "It links the picture “#{name}”, which #{shown} pictures' alt texts name: give each picture an alt text of its own.")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def paired?(name) = @pictures.key?("#{name}-before") && @pictures.key?("#{name}-after")
|
|
87
|
+
|
|
88
|
+
# A decision is one line: what is indented under it runs onto the next.
|
|
89
|
+
def runs_on
|
|
90
|
+
@description.rows(Description::DECISIONS).each_cons(2).filter_map do |(line, row), (following, _)|
|
|
91
|
+
fault(row, "It runs onto the next line: keep a decision to one line.") if line.match?(Description::BOX) && following.match?(/\A\s+\S/)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A reference kept twice, or two decisions in the same words, would make
|
|
96
|
+
# a choice on one stand for the other.
|
|
97
|
+
def repeated_keys
|
|
98
|
+
decisions.group_by(&:key).values.select(&:many?).flat_map do |same|
|
|
99
|
+
same.drop(1).map { fault(it.row, "It has the same reference as the decision on line #{same.first.row}: give it one of its own, or drop the copy.") }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# One half of a pair with no other half: a picture named NAME-before
|
|
104
|
+
# with no NAME-after, or the other way round.
|
|
105
|
+
def halves
|
|
106
|
+
@pictures.keys.filter_map do |name|
|
|
107
|
+
stem, half = name.match(/\A(.+)-(before|after)\z/)&.captures
|
|
108
|
+
other = (half == "before") ? "after" : "before"
|
|
109
|
+
fault(row_of_picture(name), "The picture “#{name}” has no “#{stem}-#{other}” to pair with.") if stem && !@pictures.key?("#{stem}-#{other}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def row_of_picture(name) = @markdown.lines.index { it.include?(%(alt="#{name}")) }&.succ
|
|
114
|
+
|
|
115
|
+
def too_many
|
|
116
|
+
blocking = decisions.select(&:blocking?)
|
|
117
|
+
return [] if blocking.size <= MOST_BLOCKING
|
|
118
|
+
|
|
119
|
+
[warning(blocking[MOST_BLOCKING].row, "#{blocking.size} decisions are blocking: more than #{MOST_BLOCKING} ask a lot of one owner. Grade the ones a default can settle non-blocking.")]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The owner's choices on the decisions of a burst, with the drafts, batches
|
|
5
|
+
# and responses that go with them: one JSON document beside the marks, which
|
|
6
|
+
# the pages' script writes whole and an agent can read. The script numbers
|
|
7
|
+
# each writing, so a page that was left open cannot write over a newer one.
|
|
8
|
+
class Choices
|
|
9
|
+
# A writing older than the one kept: another page moved on since.
|
|
10
|
+
Stale = Class.new(StandardError)
|
|
11
|
+
|
|
12
|
+
# How far one pull request's decisions are: a decision is reviewed once
|
|
13
|
+
# the owner chose on its present words, or it is ticked on GitHub. Its
|
|
14
|
+
# blocking decisions are counted apart: those left of them are what
|
|
15
|
+
# deciding goes through, while every decision counts toward its standing.
|
|
16
|
+
Progress = Data.define(:total, :chosen, :ticked, :drafts, :blocking, :blocking_left) do
|
|
17
|
+
def initialize(total:, chosen:, ticked:, drafts:, blocking: total, blocking_left: total - chosen - ticked) = super
|
|
18
|
+
|
|
19
|
+
# Out of each decision and whether it is reviewed.
|
|
20
|
+
def self.of(reviewed, drafts: 0)
|
|
21
|
+
blocking = reviewed.select { |decision, _| decision.blocking? }
|
|
22
|
+
new(total: reviewed.size, ticked: reviewed.count { |decision, _| decision.ticked }, chosen: reviewed.count { |decision, done| done && !decision.ticked },
|
|
23
|
+
drafts:, blocking: blocking.size, blocking_left: blocking.count { |_, done| !done })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reviewed = chosen + ticked
|
|
27
|
+
def left = total - reviewed
|
|
28
|
+
def done? = left.zero?
|
|
29
|
+
def any? = total.positive?
|
|
30
|
+
|
|
31
|
+
def non_blocking = total - blocking
|
|
32
|
+
def non_blocking_left = left - blocking_left
|
|
33
|
+
|
|
34
|
+
def +(other) = with(**members.to_h { [it, public_send(it) + other.public_send(it)] })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
NONE = Progress.new(total: 0, chosen: 0, ticked: 0, drafts: 0)
|
|
38
|
+
|
|
39
|
+
def self.of(burst) = new(Marks.directory_of(burst).join("choices.json"))
|
|
40
|
+
|
|
41
|
+
def initialize(path) = @path = path
|
|
42
|
+
|
|
43
|
+
# Nil until a page has written one.
|
|
44
|
+
def document
|
|
45
|
+
@document ||= JSON.parse(@path.read) if @path.exist?
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise Failure, "The choices file #{@path} is not JSON (#{e.message.lines.first&.strip}). The desk left it as it is: mend it, or move it aside to start the choices again."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def revision = document&.fetch("revision", 0).to_i
|
|
51
|
+
|
|
52
|
+
# Keeps a newer writing, beside the file and moved over it, so a reader
|
|
53
|
+
# never meets half a file.
|
|
54
|
+
def keep(incoming)
|
|
55
|
+
incoming.fetch("revision", 0).to_i > revision or raise Stale, "These choices were saved from another page since this one read them."
|
|
56
|
+
|
|
57
|
+
@path.dirname.mkpath
|
|
58
|
+
draft = @path.sub_ext(".json.part")
|
|
59
|
+
draft.write(JSON.pretty_generate(incoming))
|
|
60
|
+
draft.rename(@path)
|
|
61
|
+
@document = incoming
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def progress(number, decisions)
|
|
65
|
+
chosen = entries("choices").select { it.dig("decision", "pr") == number }
|
|
66
|
+
Progress.of(decisions.index_with { |decision| decision.ticked || chosen.any? { stands?(it, decision) } },
|
|
67
|
+
drafts: entries("pending").count { it.dig("decision", "pr") == number && it["text"].to_s.strip.present? })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A choice still stands while its decision reads as it did and rests on
|
|
71
|
+
# the pictures it was made on. The page's script, reopened() in
|
|
72
|
+
# review-drafts.js, follows the same rules.
|
|
73
|
+
def stands?(entry, decision) = reads_as_it_did?(entry, decision) && !pictures_changed?(entry, decision)
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def entries(field) = Array(document&.dig(field))
|
|
78
|
+
|
|
79
|
+
# Its words, theme markers aside. One the owner made by itself that kept
|
|
80
|
+
# its reference through a rewrite stands when nothing was asked of the
|
|
81
|
+
# agent, or when the owner confirmed what came back. One made by leaving
|
|
82
|
+
# every non-blocking decision at once was made on less attention: it
|
|
83
|
+
# stands only while its words are the same and its decision is still
|
|
84
|
+
# non-blocking.
|
|
85
|
+
def reads_as_it_did?(entry, decision)
|
|
86
|
+
was = entry.fetch("decision", {})
|
|
87
|
+
same = [was["lead"], Theme.unmarked(was["text"])] == [decision.lead, decision.bare_text]
|
|
88
|
+
return same && !promoted?(entry, decision) if left_in_bulk?(entry)
|
|
89
|
+
return true if same
|
|
90
|
+
|
|
91
|
+
was["key"].present? && was["key"] == decision.key && (entry["kind"] == "leave" || confirmed?(entry))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A bulk leave records the grade each decision had.
|
|
95
|
+
def left_in_bulk?(entry) = entry["batch"].present? && entry["grade"].present?
|
|
96
|
+
|
|
97
|
+
def promoted?(entry, decision) = entry["grade"] == "non_blocking" && decision.blocking?
|
|
98
|
+
|
|
99
|
+
def confirmed?(entry) = document.dig("feedback", entry["requestId"], "resolution") == "confirmed"
|
|
100
|
+
|
|
101
|
+
# Its pictures, matched by name: one added or taken away, or one whose
|
|
102
|
+
# pixels are no longer those recorded. A picture whose pixels are not
|
|
103
|
+
# known, then or now, changes nothing: the page says it is unverified. A
|
|
104
|
+
# choice made before pictures were recorded has none to hold it to.
|
|
105
|
+
def pictures_changed?(entry, decision)
|
|
106
|
+
was = entry.dig("decision", "pictures") or return false
|
|
107
|
+
seen = was.to_h { [it["name"], it["signature"]] }
|
|
108
|
+
now = decision.pictures.to_h { [it.name, Picture.signature(it.asset)] }
|
|
109
|
+
seen.keys.to_set != now.keys.to_set || now.any? { |name, signature| signature && seen[name] && signature != seen[name] }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Proctolog
|
|
7
|
+
class CodeReview
|
|
8
|
+
BASE = "refs/heads/proctolog-base"
|
|
9
|
+
HEAD = "refs/heads/proctolog-head"
|
|
10
|
+
|
|
11
|
+
# What Diffle holds of the pull request at a glance: the comments nobody
|
|
12
|
+
# resolved, which join the next handover, and how many of the files of
|
|
13
|
+
# the code last opened are marked viewed. `files` is nil until it opens.
|
|
14
|
+
Tally = Data.define(:open, :viewed, :files)
|
|
15
|
+
|
|
16
|
+
def initialize(burst, number)
|
|
17
|
+
@burst, @number = burst, PullRequest.number(number) || raise(ArgumentError, "#{number.inspect} is no pull request's number")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def folder = Marks.directory_of(@burst).join("code", @number.to_s)
|
|
21
|
+
def checkout = folder.join("checkout")
|
|
22
|
+
|
|
23
|
+
def snapshot
|
|
24
|
+
file = folder.join("snapshot.json")
|
|
25
|
+
file.file? ? JSON.parse(file.read) : nil
|
|
26
|
+
rescue JSON::ParserError => e
|
|
27
|
+
raise Failure, "The code-review snapshot #{file} could not be read: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def threads
|
|
31
|
+
file = checkout.join(".git/diffle/comments.json")
|
|
32
|
+
return [] unless file.file?
|
|
33
|
+
|
|
34
|
+
data = JSON.parse(file.read)
|
|
35
|
+
raise Failure, "Diffle's comments in #{file} use an unsupported format." unless data["version"] == 3 && data["sets"].is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
data["sets"].flat_map { |key, set| Array(set["threads"]).map { it.merge("mode" => key) } }.uniq { it.fetch("id") }
|
|
38
|
+
rescue JSON::ParserError, KeyError, NoMethodError => e
|
|
39
|
+
raise Failure, "Diffle's comments in #{file} could not be read: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def requests
|
|
43
|
+
captured = snapshot
|
|
44
|
+
return [] unless captured
|
|
45
|
+
|
|
46
|
+
readings = captured.fetch("readings", {})
|
|
47
|
+
threads.reject { it["resolved"] }.map do |thread|
|
|
48
|
+
focused = thread.fetch("mode")[/\Acommit:(\h{40})\z/, 1]
|
|
49
|
+
reading = readings.find { |asked, kept| set_of(kept, asked) == thread.fetch("mode") }&.last || captured
|
|
50
|
+
{id: "code:#{@number}:#{thread.fetch("id")}", pr: @number, kind: "remark",
|
|
51
|
+
text: thread.fetch("messages").map { it.fetch("body") }.join("\n\n"),
|
|
52
|
+
source: {kind: "code", head: focused || reading.fetch("head"), base: focused ? "#{focused}^" : reading.fetch("oldSha"),
|
|
53
|
+
anchor: thread.fetch("anchor"), mode: thread.fetch("mode"), stale: thread.fetch("stale"),
|
|
54
|
+
updatedAt: thread.fetch("messages").map { it.fetch("updatedAt") }.max}}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def viewed
|
|
59
|
+
file = checkout.join(".git/diffle/comments.json")
|
|
60
|
+
captured = snapshot
|
|
61
|
+
return [] unless file.file? && captured
|
|
62
|
+
|
|
63
|
+
Array(JSON.parse(file.read).dig("sets", set_of(captured), "viewed"))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The files of the code last opened that are marked viewed as they are
|
|
67
|
+
# there: a mark on another version of a file does not count.
|
|
68
|
+
def files_viewed
|
|
69
|
+
marked = viewed.select { it["viewed"] }
|
|
70
|
+
Array(snapshot&.fetch("files")).count { |file| marked.any? { it.values_at("path", "blob") == file.values_at("path", "blob") } }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tally = Tally.new(open: threads.count { !it["resolved"] }, viewed: files_viewed, files: snapshot&.fetch("files")&.size)
|
|
74
|
+
|
|
75
|
+
def start(stop, head: nil, base: nil)
|
|
76
|
+
folder.mkpath
|
|
77
|
+
File.open(folder.join("start.lock"), "a") do |lock|
|
|
78
|
+
lock.flock(File::LOCK_EX)
|
|
79
|
+
pair = revisions(stop).dup
|
|
80
|
+
pair["prHead"] = pair.fetch("headRefOid")
|
|
81
|
+
if head
|
|
82
|
+
raise Failure, "The returned head must be a full available commit hash." unless held?(head)
|
|
83
|
+
pair["headRefOid"] = head
|
|
84
|
+
end
|
|
85
|
+
if base == "main"
|
|
86
|
+
pair["baseRefOid"] = main_head
|
|
87
|
+
elsif base
|
|
88
|
+
raise Failure, "The reviewed base must be a full available commit hash, or main." unless held?(base)
|
|
89
|
+
pair["baseRefOid"] = base
|
|
90
|
+
end
|
|
91
|
+
pair["comparison"] = if base == "main"
|
|
92
|
+
"main"
|
|
93
|
+
elsif base
|
|
94
|
+
"changes"
|
|
95
|
+
else
|
|
96
|
+
"layer"
|
|
97
|
+
end
|
|
98
|
+
separator = (base && base != "main") ? ".." : "..."
|
|
99
|
+
pair["range"] = "#{pair.fetch("baseRefOid")}#{separator}#{pair.fetch("headRefOid")}"
|
|
100
|
+
captured = snapshot
|
|
101
|
+
return captured.fetch("url") if captured && captured["mode"] == "range:#{pair.fetch("range")}" && live?(captured)
|
|
102
|
+
|
|
103
|
+
stop_server if captured
|
|
104
|
+
prepare(pair)
|
|
105
|
+
launch(pair)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def live?(captured = snapshot)
|
|
110
|
+
return false unless captured
|
|
111
|
+
|
|
112
|
+
answer = get(captured.fetch("url"), "/api/snapshot")
|
|
113
|
+
answer["root"] == checkout.realpath.to_s
|
|
114
|
+
rescue Failure, KeyError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def stop_server
|
|
119
|
+
captured = snapshot
|
|
120
|
+
return unless live?(captured)
|
|
121
|
+
|
|
122
|
+
Process.kill("TERM", captured.fetch("pid"))
|
|
123
|
+
rescue Errno::ESRCH
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# The comparison a snapshot opened, as the desk asked for it. The first
|
|
130
|
+
# snapshots compared the pinned refs and did not say so.
|
|
131
|
+
def mode_of(captured) = captured.fetch("mode", "range:#{BASE}...#{HEAD}")
|
|
132
|
+
|
|
133
|
+
# The set Diffle keeps a comparison's comments and viewed marks in. Diffle
|
|
134
|
+
# names it for what it compares: for three dots, the merge base, not the
|
|
135
|
+
# base it was given. It says the name as it opens, and the desk keeps it;
|
|
136
|
+
# a reading kept before then has the name made again from its revisions.
|
|
137
|
+
def set_of(reading, asked = mode_of(reading)) = reading["set"] || asked.sub(/\Arange:\h{40}/) { "range:#{reading.fetch("oldSha")}" }
|
|
138
|
+
|
|
139
|
+
def held?(hash)
|
|
140
|
+
hash.is_a?(String) && hash.match?(/\A[a-f0-9]{40}\z/) && [Project.current.root, checkout].any? do |root|
|
|
141
|
+
root.directory? && Open3.capture2e("git", "-C", root.to_s, "cat-file", "-e", "#{hash}^{commit}").last.success?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def main_head
|
|
146
|
+
output, status = Open3.capture2e("gh", "api", "repos/#{@burst.repository}/commits/main", "--jq", ".sha")
|
|
147
|
+
hash = output.strip
|
|
148
|
+
raise Failure, "GitHub could not name main's revision: #{output.strip}" unless status.success? && hash.match?(/\A[a-f0-9]{40}\z/)
|
|
149
|
+
hash
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def revisions(stop) = revisions_now.tap { read_again_for(it.fetch("headRefOid")) unless it["headRefOid"] == stop.head_oid }
|
|
153
|
+
|
|
154
|
+
# Its base and head as GitHub names them this moment, or as this Mac
|
|
155
|
+
# holds them while nobody has opened it.
|
|
156
|
+
def revisions_now
|
|
157
|
+
return Unopened.find(@number)&.heads || raise(Failure, "##{@number} is no longer waiting unopened.") if PullRequest.unopened?(@number)
|
|
158
|
+
|
|
159
|
+
output, status = Open3.capture2e("gh", "pr", "view", @number.to_s, "--repo", @burst.repository, "--json", "baseRefOid,headRefOid")
|
|
160
|
+
raise Failure, "GitHub could not name this PR's revisions: #{output.strip}" unless status.success?
|
|
161
|
+
|
|
162
|
+
JSON.parse(output).tap do |pair|
|
|
163
|
+
raise Failure, "GitHub returned invalid revision hashes." unless %w[baseRefOid headRefOid].all? { pair[it].to_s.match?(/\A\h{40}\z/) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A head that moved since the desk read GitHub has the desk read it
|
|
168
|
+
# again, as `r` would, so the pull request's page and Diffle show the
|
|
169
|
+
# same code. GitHub moving once more in those seconds is said.
|
|
170
|
+
def read_again_for(head)
|
|
171
|
+
now = @burst.route(fresh: true).at(@number) or raise Failure, "##{@number} is no longer part of #{@burst.name}."
|
|
172
|
+
now.head_oid == head or raise Failure, "##{@number} moved on GitHub while the desk read it again. Open its code once more."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def prepare(pair)
|
|
176
|
+
git!("clone", "--quiet", "--local", "--no-checkout", Project.current.root.to_s, checkout.to_s) unless checkout.join(".git").directory?
|
|
177
|
+
hashes = pair.values_at("baseRefOid", "headRefOid")
|
|
178
|
+
absent = hashes.reject { Open3.capture2e("git", "-C", checkout.to_s, "cat-file", "-e", "#{it}^{commit}").last.success? }
|
|
179
|
+
local = absent.select { Open3.capture2e("git", "-C", Project.current.root.to_s, "cat-file", "-e", "#{it}^{commit}").last.success? }
|
|
180
|
+
git!("-C", checkout.to_s, "fetch", "--quiet", "--no-tags", Project.current.root.to_s, *local) if local.any?
|
|
181
|
+
absent -= local
|
|
182
|
+
unless absent.empty?
|
|
183
|
+
raise Failure, "Invalid GitHub repository name." unless @burst.repository.match?(%r{\A[\w.-]+/[\w.-]+\z})
|
|
184
|
+
git!("-C", checkout.to_s, "fetch", "--quiet", "--no-tags", "https://github.com/#{@burst.repository}.git", *absent)
|
|
185
|
+
end
|
|
186
|
+
git!("-C", checkout.to_s, "update-ref", BASE, hashes.first)
|
|
187
|
+
git!("-C", checkout.to_s, "update-ref", HEAD, hashes.last)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def launch(pair)
|
|
191
|
+
log = folder.join("server.log")
|
|
192
|
+
output = folder.join("export.md")
|
|
193
|
+
command = ENV.fetch("PROCTOLOG_DIFFLE", "diffle")
|
|
194
|
+
arguments = [command, "-C", checkout.to_s, pair.fetch("range"), "--no-open", "--no-lsp", "--no-watch", "--keep-alive", "--host", "127.0.0.1", "--port", "0"]
|
|
195
|
+
pid = Outside.run do |environment|
|
|
196
|
+
Process.spawn(environment.merge("NO_COLOR" => "1"), *arguments, out: output.to_s, err: log.to_s)
|
|
197
|
+
end
|
|
198
|
+
process = Process.detach(pid)
|
|
199
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
|
|
200
|
+
loop do
|
|
201
|
+
url = log.file? && log.read[%r{http://127\.0\.0\.1:\d+/}]
|
|
202
|
+
if url
|
|
203
|
+
answer = get(url, "/api/snapshot")
|
|
204
|
+
raise Failure, "Diffle opened a different checkout or head." unless answer["root"] == checkout.realpath.to_s && answer["newSha"] == pair["headRefOid"]
|
|
205
|
+
|
|
206
|
+
previous = snapshot
|
|
207
|
+
readings = previous&.fetch("readings", {}) || {}
|
|
208
|
+
readings[mode_of(previous)] ||= previous.slice("head", "base", "oldSha", "set") if previous
|
|
209
|
+
mode = "range:#{pair.fetch("range")}"
|
|
210
|
+
set = answer.dig("mode", "commentKey")
|
|
211
|
+
readings[mode] = {"head" => pair.fetch("headRefOid"), "base" => pair.fetch("baseRefOid"), "oldSha" => answer.fetch("oldSha"), "set" => set}.compact
|
|
212
|
+
captured = {"pid" => pid, "url" => url, "mode" => mode, "set" => set, "readings" => readings, "comparison" => pair.fetch("comparison"), "head" => pair.fetch("headRefOid"), "prHead" => pair.fetch("prHead"), "base" => pair.fetch("baseRefOid"),
|
|
213
|
+
"oldSha" => answer.fetch("oldSha"), "files" => answer.fetch("changed"), "openedAt" => Time.current.iso8601}.compact
|
|
214
|
+
file = folder.join("snapshot.json")
|
|
215
|
+
file.sub_ext(".part").write(JSON.pretty_generate(captured))
|
|
216
|
+
file.sub_ext(".part").rename(file)
|
|
217
|
+
return url
|
|
218
|
+
end
|
|
219
|
+
raise Failure, "Diffle did not start. See #{log}.\n#{log.file? ? log.read.last(2000) : "No log was written."}" if !process.alive? || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
220
|
+
sleep 0.1
|
|
221
|
+
end
|
|
222
|
+
rescue Errno::ENOENT
|
|
223
|
+
raise Failure, "Diffle is not on PATH. Install it or set PROCTOLOG_DIFFLE to its executable."
|
|
224
|
+
rescue Failure
|
|
225
|
+
begin
|
|
226
|
+
Process.kill("TERM", pid) if pid
|
|
227
|
+
rescue Errno::ESRCH
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
raise
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def get(url, path)
|
|
234
|
+
origin = URI.parse(url)
|
|
235
|
+
raise Failure, "The saved Diffle address is not a loopback address." unless origin.scheme == "http" && origin.host == "127.0.0.1" && origin.port.positive?
|
|
236
|
+
|
|
237
|
+
response = Net::HTTP.start(origin.host, origin.port, nil, open_timeout: 2, read_timeout: 15) { it.get(path) }
|
|
238
|
+
raise Failure, "Diffle did not answer (#{response.code})." unless response.is_a?(Net::HTTPSuccess)
|
|
239
|
+
|
|
240
|
+
JSON.parse(response.body)
|
|
241
|
+
rescue IOError, SystemCallError, Timeout::Error, JSON::ParserError, URI::InvalidURIError => e
|
|
242
|
+
raise Failure, "Diffle could not be reached: #{e.message}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def git!(*arguments)
|
|
246
|
+
output, status = Open3.capture2e("git", *arguments)
|
|
247
|
+
raise Failure, "The private code-review checkout could not be prepared: #{output.strip}" unless status.success?
|
|
248
|
+
output
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# A decision record of the repository, under docs/adr/: where a decision is
|
|
5
|
+
# recorded once it is more than a ticked box. Read from disk, never copied.
|
|
6
|
+
class DecisionRecord < Data.define(:file, :title)
|
|
7
|
+
def self.all(directory = nil) = Array(directory || Project.current.decision_records).flat_map { it.glob("*.md") }.sort.map { from(it) }
|
|
8
|
+
|
|
9
|
+
# Its first heading names it; a file with none is named by its file.
|
|
10
|
+
def self.from(path) = new(file: path.basename.to_s, title: path.each_line.find { it.start_with?("# ") }&.delete_prefix("# ")&.strip || path.basename(".md").to_s)
|
|
11
|
+
end
|
|
12
|
+
end
|