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,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# A description as GitHub rendered it, made ready for the desk's page: cut
|
|
5
|
+
# into sections at each <h2>, its pictures served from this Mac, its links to
|
|
6
|
+
# pull requests of the burst pointing at the desk, and the owner's notes
|
|
7
|
+
# drawn where they belong.
|
|
8
|
+
#
|
|
9
|
+
# The words of a section are those of GitHub's nodes alone: a note quotes
|
|
10
|
+
# `section.text`, and the page's script measures the same text in the browser.
|
|
11
|
+
# What the desk adds holds no words of a section: marks and boxes on pictures
|
|
12
|
+
# have no text of their own, and an element it puts in (a "+", a form, the
|
|
13
|
+
# notes under a box) carries `data-desk`, which both sides skip.
|
|
14
|
+
class Rendering
|
|
15
|
+
# A picture's lasting identity is the UUID inside its signed address.
|
|
16
|
+
UPLOAD = %r{/\d+-(?<asset>\h{8}(?:-\h{4}){3}-\h{12})\.(?<extension>\w+)\?}
|
|
17
|
+
|
|
18
|
+
# A picture GitHub never held, an unopened pull request's, at the address
|
|
19
|
+
# the desk already serves it from.
|
|
20
|
+
SERVED = %r{\A/pictures/(?<asset>\h{8}(?:-\h{4}){3}-\h{12})\.(?<extension>\w+)\z}
|
|
21
|
+
|
|
22
|
+
# Each picture an HTML shows, by its id and its kind.
|
|
23
|
+
def self.pictures(html) = html.to_s.scan(/src="([^"]*)"/).flatten.filter_map { (it.match(UPLOAD) || it.match(SERVED))&.captures }.uniq
|
|
24
|
+
|
|
25
|
+
# A half of a before/after pair, by its picture's name.
|
|
26
|
+
PAIR = /\A(?<name>.+)-(?<half>before|after)\z/
|
|
27
|
+
|
|
28
|
+
# One part of a description: what stands under an <h2>, or before the first.
|
|
29
|
+
class Section
|
|
30
|
+
attr_reader :heading, :body, :notes
|
|
31
|
+
|
|
32
|
+
def initialize(heading, body)
|
|
33
|
+
@heading, @body, @notes = heading, body, []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def text = nodes.map(&:content).join
|
|
37
|
+
|
|
38
|
+
def words = text.split.size
|
|
39
|
+
|
|
40
|
+
# Each picture by itself: one alone, or each half of a pair.
|
|
41
|
+
def figures = body.css("figure.picture:not(.pair), figure.pair > .half")
|
|
42
|
+
|
|
43
|
+
def open_decisions = body.css("li.task-list-item").count { !it.at_css("input[checked]") }
|
|
44
|
+
|
|
45
|
+
# The boxes of a task list, not the lists inside them.
|
|
46
|
+
def boxes = body.css("li.task-list-item").reject { it.ancestors("li").any? }
|
|
47
|
+
|
|
48
|
+
# The decisions as the desk shows them: each box numbered, and the
|
|
49
|
+
# non-blocking ones folded under their heading.
|
|
50
|
+
def lay_out_decisions
|
|
51
|
+
number_boxes
|
|
52
|
+
fold_non_blocking
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Named as Description#decisions numbers them, so that a link can point at one.
|
|
56
|
+
def number_boxes = boxes.each_with_index { |box, position| box["id"] = "decision-#{position}" }
|
|
57
|
+
|
|
58
|
+
# Adds at the end of each box what the block answers for its place, as
|
|
59
|
+
# the desk's own: the words of the section stay as they were.
|
|
60
|
+
def furnish_boxes = boxes.each_with_index { |box, position| box.add_child(yield(position).to_s.strip) }
|
|
61
|
+
|
|
62
|
+
# The non-blocking decisions folded away under their own heading:
|
|
63
|
+
# GitHub's "Non-blocking" heading becomes the fold's summary, and what
|
|
64
|
+
# follows it in the section goes inside. The words of the section, and
|
|
65
|
+
# their order, stay as they were; the count beside the heading is the
|
|
66
|
+
# desk's.
|
|
67
|
+
def fold_non_blocking
|
|
68
|
+
heading = body.element_children.find { it.name == "h3" && it.text.strip.casecmp?("Non-blocking") } or return
|
|
69
|
+
fold = element("details", "class" => "non-blocking", "data-non-blocking" => "")
|
|
70
|
+
summary = element("summary", {})
|
|
71
|
+
heading.children.each { summary << it }
|
|
72
|
+
summary << element("span", "class" => "count", "data-desk" => "", "data-non-blocking-count" => "")
|
|
73
|
+
heading.xpath("following-sibling::node()").each { fold << it }
|
|
74
|
+
fold.prepend_child(summary)
|
|
75
|
+
heading.replace(fold)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def noted? = notes.any?(&:live?)
|
|
79
|
+
|
|
80
|
+
def html = body.to_html
|
|
81
|
+
|
|
82
|
+
# Wraps the slice of each text node that a range of the text covers, so
|
|
83
|
+
# that words crossing <strong> or <code> are marked in several pieces.
|
|
84
|
+
def mark(range, note)
|
|
85
|
+
pieces = slices(range).map { |node, slice| wrap(node, slice, note) }
|
|
86
|
+
return false if pieces.empty?
|
|
87
|
+
|
|
88
|
+
pieces.first["id"] = "note-#{note.id}"
|
|
89
|
+
notes << note
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A box on a picture, in percent of its size; a box with no width and no
|
|
94
|
+
# height is a pin.
|
|
95
|
+
def draw(figure, note, box)
|
|
96
|
+
x, y, width, height = box.map { (it * 100).round(2) }
|
|
97
|
+
pin = width.zero? && height.zero?
|
|
98
|
+
style = {left: x, top: y, width:, height:}.then { pin ? it.slice(:left, :top) : it }.map { |property, percent| "#{property}: #{percent}%" }.join("; ")
|
|
99
|
+
figure.add_child(element("span", tags(note).merge("class" => pin ? "region pin" : "region", "id" => "note-#{note.id}", "style" => style)))
|
|
100
|
+
notes << note
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def document = body.document
|
|
106
|
+
|
|
107
|
+
def element(name, attributes) = document.create_element(name, attributes)
|
|
108
|
+
|
|
109
|
+
def tags(note) = {"data-note" => note.id, "data-kind" => note.kind, "data-state" => note.state}
|
|
110
|
+
|
|
111
|
+
# The text nodes that are GitHub's, in order.
|
|
112
|
+
def nodes = body.xpath(".//text()[not(ancestor::*[@data-desk])]")
|
|
113
|
+
|
|
114
|
+
def slices(range)
|
|
115
|
+
at = 0
|
|
116
|
+
nodes.filter_map do |node|
|
|
117
|
+
from, to = at, (at += node.content.length)
|
|
118
|
+
inside = ([range.begin, from].max - from)...([range.end, to].min - from)
|
|
119
|
+
[node, inside] if inside.size.positive? && node.content[inside].present?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wrap(node, slice, note)
|
|
124
|
+
text = node.content
|
|
125
|
+
element("mark", tags(note).merge("class" => "note")).tap do |mark|
|
|
126
|
+
mark.add_child(document.create_text_node(text[slice]))
|
|
127
|
+
node.replace(mark)
|
|
128
|
+
mark.add_previous_sibling(document.create_text_node(text[...slice.begin])) unless slice.begin.zero?
|
|
129
|
+
mark.add_next_sibling(document.create_text_node(text[slice.end..])) unless slice.end == text.length
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# What was drawn and what was not: `margin` holds the notes found a place,
|
|
135
|
+
# in the order of the page; `about` those that are about the whole pull
|
|
136
|
+
# request; `lost` those still waiting on someone whose words or picture are
|
|
137
|
+
# gone; `set_aside` the rest, withdrawn or confirmed and out of place.
|
|
138
|
+
attr_reader :sections, :margin, :about, :lost, :set_aside
|
|
139
|
+
|
|
140
|
+
# `pages` maps the address of a pull request on GitHub to the desk's page
|
|
141
|
+
# for it. `linked`, the names of the pictures some line links, folds away
|
|
142
|
+
# those none does; without it every picture stands.
|
|
143
|
+
def initialize(html, notes: [], pages: {}, linked: nil)
|
|
144
|
+
fragment = Nokogiri::HTML5.fragment(html.to_s)
|
|
145
|
+
@retaken = Set.new
|
|
146
|
+
serve_pictures_from_here(fragment)
|
|
147
|
+
pair_pictures(fragment)
|
|
148
|
+
fold_unlinked(fragment, linked) if linked
|
|
149
|
+
point_links_at_the_desk(fragment, pages)
|
|
150
|
+
@sections = sections_of(fragment)
|
|
151
|
+
lay(notes)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A picture was retaken since the note was drawn on it: only its name is the same.
|
|
155
|
+
def retaken?(note) = @retaken.include?(note.id)
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def serve_pictures_from_here(fragment)
|
|
160
|
+
fragment.css("img").each do |img|
|
|
161
|
+
upload = img["src"].to_s.then { it.match(UPLOAD) || it.match(SERVED) } or next
|
|
162
|
+
link = img.parent if img.parent.name == "a" && img.parent.element_children.one? && img.parent.text.blank?
|
|
163
|
+
(link || img).replace(figure_of(fragment.document, img["alt"], **upload.named_captures.symbolize_keys))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def figure_of(document, name, asset:, extension:)
|
|
168
|
+
document.create_element("figure", "id" => "screenshot-#{name}", "class" => "picture", "data-picture" => name, "data-asset" => asset).tap do |figure|
|
|
169
|
+
figure.add_child(document.create_element("img", "src" => "/pictures/#{asset}.#{extension}", "alt" => name, "loading" => "lazy", "draggable" => "false"))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A picture named NAME-before and one named NAME-after are one picture of a
|
|
174
|
+
# change: a figure where the first stood, both halves side by side, which
|
|
175
|
+
# a decision links as #screenshot-NAME. Each half keeps its name and its
|
|
176
|
+
# asset, so a note is drawn on the half it was made on. A half without its
|
|
177
|
+
# other half, or a name another picture has too, stays as it was.
|
|
178
|
+
def pair_pictures(fragment)
|
|
179
|
+
figures = fragment.css("figure.picture")
|
|
180
|
+
named = figures.group_by { it["data-picture"] }
|
|
181
|
+
figures.filter_map { it["data-picture"].to_s[PAIR, :name] }.uniq.each do |name|
|
|
182
|
+
before, after = named["#{name}-before"], named["#{name}-after"]
|
|
183
|
+
next unless before&.one? && after&.one? && !named.key?(name)
|
|
184
|
+
|
|
185
|
+
before.first.replace(pair_of(fragment.document, name, before.first, after.first))
|
|
186
|
+
after.first.remove
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def pair_of(document, name, *halves)
|
|
191
|
+
document.create_element("figure", "id" => "screenshot-#{name}", "class" => "picture pair", "data-picture" => name).tap do |pair|
|
|
192
|
+
halves.each do |figure|
|
|
193
|
+
said = figure["data-picture"][PAIR, :half]
|
|
194
|
+
half = document.create_element("span", "class" => "half", "id" => figure["id"], "data-half" => said, "data-picture" => figure["data-picture"], "data-asset" => figure["data-asset"])
|
|
195
|
+
half << figure.at_css("img") << document.create_element("span", said, "class" => "said", "data-desk" => "")
|
|
196
|
+
pair << half
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Each run of pictures that nothing links, each with the caption under
|
|
202
|
+
# it, folds where it stood into one "More pictures (N)", which a reader
|
|
203
|
+
# opens like a section. A picture a line links stands, and ends a run.
|
|
204
|
+
def fold_unlinked(fragment, linked)
|
|
205
|
+
runs = [[]]
|
|
206
|
+
# A paragraph a picture was parsed out of stays empty, and is no block.
|
|
207
|
+
fragment.element_children.reject { it.name == "p" && it.children.all?(&:blank?) }.each do |node|
|
|
208
|
+
if pictures?(node) && !linked?(node, linked) then runs.last << node
|
|
209
|
+
elsif caption?(node) && pictures?(runs.last.last) then runs.last << node
|
|
210
|
+
elsif runs.last.any? then runs << []
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
runs.reject(&:empty?).each { fold(fragment.document, it) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def fold(document, run)
|
|
217
|
+
count = run.sum { figures_of(it).size }
|
|
218
|
+
folded = document.create_element("details", "class" => "more-pictures", "data-more-pictures" => "")
|
|
219
|
+
folded << (document.create_element("summary") << document.create_element("span", "More pictures (#{count})", "data-desk" => ""))
|
|
220
|
+
# Everything from its first block to its last, the space between them
|
|
221
|
+
# too, so the words of the section read as they did.
|
|
222
|
+
spanned = [run.first].tap { |nodes| nodes << nodes.last.next_sibling until nodes.last == run.last }
|
|
223
|
+
run.first.add_previous_sibling(folded)
|
|
224
|
+
spanned.each { folded << it }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A block that is pictures and nothing more: a figure, or a paragraph of them.
|
|
228
|
+
def pictures?(node) = !node.nil? && (picture?(node) || (node.at_css("figure.picture") && node.xpath(".//text()[not(ancestor::figure)]").all?(&:blank?)))
|
|
229
|
+
|
|
230
|
+
# Told by its own name and class: Nokogiri's matches? searches the whole
|
|
231
|
+
# document for every node it is asked about, and a description has hundreds.
|
|
232
|
+
def picture?(node) = node.name == "figure" && node.classes.include?("picture")
|
|
233
|
+
|
|
234
|
+
# A block's pictures: the block itself when it is one, else those in it.
|
|
235
|
+
def figures_of(node) = picture?(node) ? [node] : node.css("figure.picture")
|
|
236
|
+
|
|
237
|
+
# The words under a picture: a paragraph with no picture in it.
|
|
238
|
+
def caption?(node) = node.name == "p" && node.at_css("figure.picture").nil?
|
|
239
|
+
|
|
240
|
+
# Whether a line links one of its pictures, or a half of one of its pairs.
|
|
241
|
+
def linked?(node, linked)
|
|
242
|
+
figures_of(node).flat_map { [it["data-picture"], *it.css(".half").map { |half| half["data-picture"] }] }.any? { linked.include?(it) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def point_links_at_the_desk(fragment, pages)
|
|
246
|
+
fragment.css("a[href]").each { |link| link["href"] = pages[link["href"]] if pages.key?(link["href"]) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def sections_of(fragment)
|
|
250
|
+
fragment.children.slice_before { it.name == "h2" }.filter_map do |nodes|
|
|
251
|
+
heading = nodes.shift.text.strip if nodes.first.name == "h2"
|
|
252
|
+
next if heading.nil? && nodes.all?(&:blank?)
|
|
253
|
+
|
|
254
|
+
body = fragment.document.create_element("div", "data-quotable" => "", "data-section" => heading.to_s)
|
|
255
|
+
nodes.each { body << it }
|
|
256
|
+
Section.new(heading, body).tap { it.lay_out_decisions if heading == Description::DECISIONS }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def lay(notes)
|
|
261
|
+
active = notes.reject(&:withdrawn?)
|
|
262
|
+
@about = active.reject { anchored?(it) }
|
|
263
|
+
drawn, unplaced = active.select { anchored?(it) }.partition { draw(it) }
|
|
264
|
+
@lost = unplaced.select(&:live?)
|
|
265
|
+
@set_aside = notes - @about - drawn - @lost
|
|
266
|
+
@margin = sections.flat_map { it.body.css("[data-note]") }.pluck("data-note").uniq.filter_map { |id| drawn.find { it.id == id } }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def anchored?(note) = (note.place in Note::Quote | Note::Region)
|
|
270
|
+
|
|
271
|
+
def draw(note)
|
|
272
|
+
case note.place
|
|
273
|
+
in Note::Quote => quote then mark_quote(note, quote)
|
|
274
|
+
in Note::Region => region then draw_region(note, region)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# In the section it was written under; in another only when the words are
|
|
279
|
+
# distinctive enough to be the evidence themselves, as a short phrase
|
|
280
|
+
# standing elsewhere is likelier another sentence than the one moved.
|
|
281
|
+
def mark_quote(note, quote)
|
|
282
|
+
own, others = sections.partition { it.heading == quote.section }
|
|
283
|
+
(quote.distinctive? ? own + others : own).any? do |section|
|
|
284
|
+
range = quote.locate(section.text)
|
|
285
|
+
range && section.mark(range, note)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# On the picture it was drawn on, or on the one of the same name when
|
|
290
|
+
# that was retaken, if only one is.
|
|
291
|
+
def draw_region(note, region)
|
|
292
|
+
pictures = sections.flat_map { |section| section.figures.map { |figure| [section, figure] } }
|
|
293
|
+
holder = pictures.find { |_, figure| figure["data-asset"] == region.asset } || retaken(note, pictures.select { |_, figure| figure["data-picture"] == region.picture })
|
|
294
|
+
holder&.then { |section, figure| section.draw(figure, note, region.box) }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The picture a note was drawn on is gone: its name says which one is
|
|
298
|
+
# meant only when no other picture has it.
|
|
299
|
+
def retaken(note, named)
|
|
300
|
+
named.first.tap { @retaken << note.id } if named.one?
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# What agents wrote back about a hand-over: YAML files under reports/, each
|
|
5
|
+
# a list of entries naming a note, the commit that holds the change and a
|
|
6
|
+
# reply. Taking them in moves an open note to "addressed" with the newest
|
|
7
|
+
# entry that names it, and the owner then confirms it.
|
|
8
|
+
#
|
|
9
|
+
# A note the owner reopened carries the answer it was reopened against. The
|
|
10
|
+
# same answer does not address it a second time: only a different commit or
|
|
11
|
+
# a different reply does.
|
|
12
|
+
class Reports
|
|
13
|
+
NOTE = /\A(?<of>sitting|\d+)-\d+\z/
|
|
14
|
+
|
|
15
|
+
Entry = Data.define(:note, :commit, :reply, :report) do
|
|
16
|
+
def answer = {"commit" => commit, "text" => reply}.compact
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(burst)
|
|
20
|
+
@burst = burst
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Answers the notes it moved.
|
|
24
|
+
def take_in = newest_for_each_note.filter_map { address(it) }
|
|
25
|
+
|
|
26
|
+
# Entries that name no note of this burst: a mistyped id, said on a page
|
|
27
|
+
# rather than dropped.
|
|
28
|
+
def unmatched = entries.reject { note_of(it) }
|
|
29
|
+
|
|
30
|
+
# Reports that could not be read as a list of entries.
|
|
31
|
+
def unreadable = files.reject { list_in(it) }
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def address(entry)
|
|
36
|
+
note = note_of(entry)
|
|
37
|
+
return unless note&.open? && note.reply&.slice("commit", "text") != entry.answer
|
|
38
|
+
|
|
39
|
+
marks_of(entry).move(note.id, to: "addressed", reply: entry.answer.merge("report" => entry.report))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def newest_for_each_note = entries.group_by(&:note).values.map(&:last)
|
|
43
|
+
|
|
44
|
+
# The marks a note's id points into: a pull request's, or the sitting's.
|
|
45
|
+
def marks_of(entry)
|
|
46
|
+
NOTE.match(entry.note)&.[](:of)&.then { @burst.marks((it == "sitting") ? it : it.to_i) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def note_of(entry) = marks_of(entry)&.note(entry.note)
|
|
50
|
+
|
|
51
|
+
# Oldest report first, and each report's entries in its own order.
|
|
52
|
+
def entries
|
|
53
|
+
files.flat_map do |file|
|
|
54
|
+
(list_in(file) || []).grep(Hash).map { Entry.new(note: it["note"].to_s, commit: it["commit"], reply: it["reply"], report: file.basename.to_s) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def files = Marks.directory_of(@burst).join("reports").glob("*.yml").sort
|
|
59
|
+
|
|
60
|
+
def list_in(file)
|
|
61
|
+
YAML.safe_load_file(file).then { it if it.is_a?(Array) }
|
|
62
|
+
rescue Psych::Exception
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
class ResponseFiles
|
|
5
|
+
FORMAT = "proctolog-response/v1"
|
|
6
|
+
STATUSES = %w[done answered deferred needs_owner blocked].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(burst) = @burst = burst
|
|
9
|
+
|
|
10
|
+
def folder = Marks.directory_of(@burst).join("responses")
|
|
11
|
+
|
|
12
|
+
def all
|
|
13
|
+
folder.glob("*.json").sort.filter_map do |file|
|
|
14
|
+
data = JSON.parse(file.read)
|
|
15
|
+
validate!(data)
|
|
16
|
+
data
|
|
17
|
+
rescue JSON::ParserError, Failure => e
|
|
18
|
+
errors << "#{file.basename}: #{e.message}"
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def errors = @errors ||= []
|
|
24
|
+
|
|
25
|
+
def validate!(data)
|
|
26
|
+
valid = data.is_a?(Hash) && data["format"] == FORMAT && data["repository"] == @burst.repository && data["review"] == @burst.slug &&
|
|
27
|
+
%w[id batchId summary createdAt].all? { data[it].is_a?(String) && data[it].present? } && data["results"].is_a?(Array)
|
|
28
|
+
raise Failure, "Not a #{FORMAT} response for this burst." unless valid
|
|
29
|
+
Time.iso8601(data.fetch("createdAt"))
|
|
30
|
+
|
|
31
|
+
ids = data["results"].map do |result|
|
|
32
|
+
raise Failure, "A result needs id, status, summary, details, evidence and verification." unless result.is_a?(Hash) &&
|
|
33
|
+
%w[id summary details].all? { result[it].is_a?(String) } && result["id"].present? && result["summary"].present? &&
|
|
34
|
+
STATUSES.include?(result["status"]) && result["evidence"].is_a?(Array) && result["verification"].is_a?(Array)
|
|
35
|
+
|
|
36
|
+
raise Failure, "Invalid evidence or verification for #{result["id"]}." unless result["evidence"].all? { it.is_a?(Hash) && it["label"].is_a?(String) } &&
|
|
37
|
+
result["verification"].all? { it.is_a?(Hash) && it["command"].is_a?(String) && %w[passed failed not_run].include?(it["result"]) }
|
|
38
|
+
raise Failure, "A deferred result needs a backlog reference or proposed task." if result["status"] == "deferred" && (!result["backlog"].is_a?(String) || result["backlog"].blank?)
|
|
39
|
+
raise Failure, "A result head must be a full commit hash." if result.key?("head") && !result["head"].to_s.match?(/\A\h{40}\z/)
|
|
40
|
+
|
|
41
|
+
if result["status"] == "needs_owner"
|
|
42
|
+
question = result["question"]
|
|
43
|
+
raise Failure, "A question needs prompt, recommendation and a blocking boolean." unless question.is_a?(Hash) &&
|
|
44
|
+
%w[prompt recommendation].all? { question[it].is_a?(String) && question[it].present? } && [true, false].include?(question["blocking"])
|
|
45
|
+
end
|
|
46
|
+
result["id"]
|
|
47
|
+
end
|
|
48
|
+
raise Failure, "The response repeats a result id." unless ids.uniq.size == ids.size
|
|
49
|
+
raise Failure, "Follow-ups must be a list of suggestions." if data.key?("followups") && (!data["followups"].is_a?(Array) || data["followups"].any? { !it.is_a?(String) })
|
|
50
|
+
data
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
raise Failure, "The response has an invalid createdAt time."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|