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,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The rounds of a burst's review: every request the owner made, where it
|
|
5
|
+
# stands between them and the agent, and what the next pass hands over.
|
|
6
|
+
# All of it is read from what the desk keeps: the open notes and the code
|
|
7
|
+
# comments nobody resolved, the choices the pages write, the batch each
|
|
8
|
+
# handover carried and the answers that came back. So a pass can be taken
|
|
9
|
+
# with no page open, by the agent itself, halfway through a review. The
|
|
10
|
+
# pages' script shows the same rounds by the same rules, in
|
|
11
|
+
# review-rounds.js.
|
|
12
|
+
class Rounds
|
|
13
|
+
# A decision in its place in the burst, under the id the pages give it.
|
|
14
|
+
Placed = Data.define(:stop, :decision) do
|
|
15
|
+
def id = "#{pr}-#{decision.position}"
|
|
16
|
+
|
|
17
|
+
def pr = stop.number
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A choice or a draft a page kept, with the id its request goes by and
|
|
21
|
+
# the decision it was made on, while the burst still has it.
|
|
22
|
+
Choice = Data.define(:entry, :request_id, :at) do
|
|
23
|
+
def pr = entry.dig("decision", "pr")
|
|
24
|
+
|
|
25
|
+
def kind = entry["kind"]
|
|
26
|
+
|
|
27
|
+
def text = entry["text"].presence || entry.dig("decision", "lead")
|
|
28
|
+
|
|
29
|
+
# The decision as the choice was made on it, under the id it goes by
|
|
30
|
+
# now: a "missing:" one once the burst no longer has it.
|
|
31
|
+
def decision = entry["decision"].deep_symbolize_keys.merge(at ? {id: at.id, position: at.decision.position, key: at.decision.key} : {id: "missing:#{request_id}"})
|
|
32
|
+
|
|
33
|
+
# Left with every other non-blocking decision at once.
|
|
34
|
+
def in_bulk? = entry["batch"].present? && entry["grade"].present?
|
|
35
|
+
|
|
36
|
+
# The one answer given to several decisions it shares.
|
|
37
|
+
def together = entry["batch"].to_s.start_with?("together:") ? entry["batch"] : nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# An answer to a request: the result, the response it came in, and the
|
|
41
|
+
# batch and request it answers, in the order the batches were written.
|
|
42
|
+
Answer = Data.define(:response, :result, :batch, :request, :order) do
|
|
43
|
+
# How the owner's reply names the answer it replies to.
|
|
44
|
+
def key = "#{response[:id]}:#{response[:createdAt]}"
|
|
45
|
+
|
|
46
|
+
def newness = [order, Rounds.moment(response[:createdAt])]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A time as a page or an agent wrote it; one that cannot be read is
|
|
50
|
+
# taken for the oldest.
|
|
51
|
+
def self.moment(text)
|
|
52
|
+
Time.iso8601(text.to_s)
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
Time.at(0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(burst, route:, focus: Focus.new(route, nil))
|
|
58
|
+
@burst, @route, @focus = burst, route, focus
|
|
59
|
+
@kept = burst.choices
|
|
60
|
+
@document = @kept.document || {}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The owner's open notes and the code comments nobody resolved, across
|
|
64
|
+
# the whole burst, each as a request.
|
|
65
|
+
def requests
|
|
66
|
+
@requests ||= [*@route.flat_map { notes_of(it.number, pr: it.number) }, *notes_of("sitting", pr: nil),
|
|
67
|
+
*@route.flat_map { CodeReview.new(@burst, it.number).requests }]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What the next pass asks of the agent, as a batch of its own, with what
|
|
71
|
+
# it should know beside. What an agent took and has not answered yet is
|
|
72
|
+
# held back, so that two never do the same work, unless taken `again`.
|
|
73
|
+
def worklist(batch_id: "preview", again: false, at: Time.current)
|
|
74
|
+
Handover::Worklist.new(batch_id:, created_at: at.utc.iso8601(3), heads:, items: work(again:),
|
|
75
|
+
with_agent: again ? [] : with_agent, awaiting_owner: ids_in(:owner, :question), closed: ids_in(:closed),
|
|
76
|
+
left_at_once:, answered_together:, unfinished_drafts:, unreviewed:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Each pull request of the burst at the head the desk read.
|
|
80
|
+
def heads = @heads ||= @route.to_h { [it.number, {head: it.head_oid, title: it.title, branch: it.pull_request.head, description: it.description.digest}] }
|
|
81
|
+
|
|
82
|
+
# For the next pass; out with an agent that has not answered; answered
|
|
83
|
+
# and the owner's to confirm, or to answer; closed; or kept from an
|
|
84
|
+
# earlier review for its context.
|
|
85
|
+
def state_of(item)
|
|
86
|
+
latest = latest(item)
|
|
87
|
+
reply = reply_to(item) if latest
|
|
88
|
+
if archived?(item) then :archived
|
|
89
|
+
elsif item[:kind] == "leave" then :closed
|
|
90
|
+
elsif reply&.dig(:replyTo) == latest&.key && reply&.dig(:resolution) == "confirmed" then :closed
|
|
91
|
+
elsif latest.nil? || reply&.dig(:replyTo) == latest.key then out?(item) ? :out : :agent
|
|
92
|
+
else (latest.result[:status] == "needs_owner") ? :question : :owner
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# What an agent took and has not answered, with the handover it went in.
|
|
97
|
+
def with_agent
|
|
98
|
+
current.select { in_focus?(it[:pr]) && state_of(it) == :out }.map do |item|
|
|
99
|
+
batch = carrier(item)
|
|
100
|
+
{**item.slice(:id, :pr, :kind, :text), batchId: batch[:id], handedOverAt: batch[:createdAt]}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def work(again:)
|
|
107
|
+
current.select { in_focus?(it[:pr]) && [:agent, *(:out if again)].include?(state_of(it)) }.map do |item|
|
|
108
|
+
latest = latest(item)
|
|
109
|
+
sent_back = {ownerReply: reopened(item)&.slice(:kind, :text), previousResponse: (latest&.result if reopened(item))}.compact
|
|
110
|
+
{**item, source: {**item[:source], reviewedHead: reviewed_head(item, latest)}, **sent_back}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The head the request was last looked at on: the one its last answer
|
|
115
|
+
# names, or the one the owner reviewed.
|
|
116
|
+
def reviewed_head(item, latest)
|
|
117
|
+
source = item[:source]
|
|
118
|
+
latest&.result&.dig(:head).presence || source[:reviewedHead].presence || source.dig(:decision, :head).presence || source[:head]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The owner's reply that sent an answered request back to the agent.
|
|
122
|
+
def reopened(item) = reply_to(item)&.then { it if it[:resolution] == "reopened" }
|
|
123
|
+
|
|
124
|
+
def ids_in(*states) = everything.select { in_focus?(it[:pr]) && states.include?(state_of(it)) }.pluck(:id)
|
|
125
|
+
|
|
126
|
+
# The requests that stand now: notes and code comments, the owner's
|
|
127
|
+
# choices, and what they asked beside the decisions.
|
|
128
|
+
def current = @current ||= [*requests, *choices.map { choice_item(it) }, *asked_beside.reject { it[:withdrawn] }]
|
|
129
|
+
|
|
130
|
+
# Those, and what an earlier batch carried that came back answered.
|
|
131
|
+
def everything
|
|
132
|
+
@everything ||= current + batches.flat_map { it[:items] }.select { latest(it) }.uniq { it[:id] }.reject { |item| current.any? { it[:id] == item[:id] } }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def notes_of(marked, pr:)
|
|
136
|
+
@burst.marks(marked).notes.select(&:open?).map do |note|
|
|
137
|
+
{id: "note:#{note.id}", pr:, kind: note.kind, text: note.text,
|
|
138
|
+
source: {kind: "note", noteId: note.id, anchor: note.place&.to_h, head: @route.at(pr)&.head_oid}}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def choice_item(choice)
|
|
143
|
+
{id: choice.request_id, pr: choice.pr, kind: choice.kind, text: choice.text,
|
|
144
|
+
source: {kind: "decision", decision: choice.decision, needsReview: stale?(choice)}}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def asked_beside = @asked_beside ||= Array(@document["requests"]).map(&:deep_symbolize_keys)
|
|
148
|
+
|
|
149
|
+
def archived?(item)
|
|
150
|
+
(item[:pr] && !heads.key?(item[:pr])) || asked_beside.any? { it[:id] == item[:id] && it[:withdrawn] } ||
|
|
151
|
+
(item.dig(:source, :kind) == "decision" && choices.none? { it.request_id == item[:id] }) ||
|
|
152
|
+
(item[:kind] == "leave" && item.dig(:source, :decision, :id).to_s.start_with?("missing:"))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The batch that last carried a request as it stands, with the owner's
|
|
156
|
+
# last reply to it when they reopened it.
|
|
157
|
+
def carrier(item)
|
|
158
|
+
reply = reopened(item)
|
|
159
|
+
batches.rfind do |batch|
|
|
160
|
+
batch[:items].any? { it[:id] == item[:id] && signature(it) == signature(item) && (!reply || it[:ownerReply]&.values_at(:kind, :text) == reply.values_at(:kind, :text)) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Taken by a pass that no answer came back to yet.
|
|
165
|
+
def out?(item)
|
|
166
|
+
batch = carrier(item)
|
|
167
|
+
batch && responses.none? { it[:batchId] == batch[:id] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The answer that stands for a request: the newest, or the one the
|
|
171
|
+
# owner confirmed, or, once they replied, the one that came after.
|
|
172
|
+
def latest(item)
|
|
173
|
+
(@latest ||= {}).fetch([item[:id], signature(item)]) do |key|
|
|
174
|
+
answers = answers_to(item)
|
|
175
|
+
reply = reply_to(item)
|
|
176
|
+
replied_to = answers.find { it.key == reply[:replyTo] } if reply
|
|
177
|
+
@latest[key] = case reply&.dig(:resolution)
|
|
178
|
+
when "confirmed" then replied_to || answers.max_by(&:newness)
|
|
179
|
+
when "reopened" then answers.select { after?(it, reply) }.max_by(&:newness) || replied_to || answers.max_by(&:newness)
|
|
180
|
+
else answers.max_by(&:newness)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def answers_to(item)
|
|
186
|
+
responses.filter_map do |response|
|
|
187
|
+
batch = batches.find { it[:id] == response[:batchId] }
|
|
188
|
+
request = batch[:items].find { it[:id] == item[:id] }
|
|
189
|
+
result = response[:results].find { it[:id] == item[:id] }
|
|
190
|
+
Answer.new(response:, result:, batch:, request:, order: batches.index(batch)) if request && result && signature(request) == signature(item)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def after?(answer, reply)
|
|
195
|
+
Rounds.moment(answer.batch[:createdAt]) >= Rounds.moment(reply[:at]) && answer.request[:ownerReply]&.values_at(:kind, :text) == reply.values_at(:kind, :text)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The owner's last word on a request, while it is about the request as
|
|
199
|
+
# it stands: one about earlier words is no longer theirs to it.
|
|
200
|
+
def reply_to(item)
|
|
201
|
+
reply = @document.dig("feedback", item[:id])&.deep_symbolize_keys
|
|
202
|
+
reply if reply && intent_of(reply) == signature(item)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def intent_of(reply)
|
|
206
|
+
JSON.parse(reply[:intent].to_s)
|
|
207
|
+
rescue JSON::ParserError
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def signature(item) = [item[:kind], item[:text]]
|
|
211
|
+
|
|
212
|
+
# Every batch a handover carried, the oldest first: those the pages keep,
|
|
213
|
+
# and those the desk wrote that no page has taken in yet.
|
|
214
|
+
def batches
|
|
215
|
+
@batches ||= [*Array(@document["batches"]), *Handover.kept(@burst).map(&:batch)].map(&:deep_symbolize_keys)
|
|
216
|
+
.uniq { it[:id] }.sort_by { Rounds.moment(it[:createdAt]) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Every answer that came back to a batch the desk knows, as the page
|
|
220
|
+
# takes them in: a response naming a request its batch did not carry is
|
|
221
|
+
# refused whole. One written again under a new time is a newer version
|
|
222
|
+
# of it, and both are kept.
|
|
223
|
+
def responses
|
|
224
|
+
@responses ||= [*Array(@document["responses"]), *ResponseFiles.new(@burst).all].map(&:deep_symbolize_keys).uniq { it.values_at(:id, :createdAt) }.select do |response|
|
|
225
|
+
asked = batches.find { it[:id] == response[:batchId] }&.dig(:items)&.pluck(:id)
|
|
226
|
+
ids = response[:results].pluck(:id)
|
|
227
|
+
asked && ids.uniq.size == ids.size && (ids - asked).empty?
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Every decision of the burst as it reads now.
|
|
232
|
+
def placed = @placed ||= @route.flat_map { |stop| stop.description.decisions.map { Placed.new(stop:, decision: it) } }
|
|
233
|
+
|
|
234
|
+
def choices = @choices ||= kept("choices", "choice")
|
|
235
|
+
|
|
236
|
+
def drafts = @drafts ||= kept("pending", "draft")
|
|
237
|
+
|
|
238
|
+
# Each entry with the decision it was made on, as the page's reconcile()
|
|
239
|
+
# finds it: by its key, or by its words where it has none, and never one
|
|
240
|
+
# decision for two entries.
|
|
241
|
+
def kept(field, kind)
|
|
242
|
+
claimed = Set.new
|
|
243
|
+
Array(@document[field]).map do |entry|
|
|
244
|
+
was = entry["decision"]
|
|
245
|
+
matches = placed.select { it.pr == was["pr"] && (was["key"] ? it.decision.key == was["key"] : [it.decision.lead, it.decision.text] == was.values_at("lead", "text")) }
|
|
246
|
+
at = matches.first if matches.one? && claimed.add?(matches.first.id)
|
|
247
|
+
Choice.new(entry:, request_id: entry["requestId"] || "#{kind}:#{was["id"]}:#{entry["savedAt"] || "legacy"}", at:)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# A choice no longer stands once its decision changed under it, unless
|
|
252
|
+
# the owner confirmed what came back of it, or its pull request left.
|
|
253
|
+
def stale?(choice)
|
|
254
|
+
return false if confirmed?(choice) || !heads.key?(choice.pr)
|
|
255
|
+
|
|
256
|
+
decision = choice.at&.decision || reading_as(choice)
|
|
257
|
+
!(decision && @kept.stands?(choice.entry, decision))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The one decision of its pull request that reads as the choice's did.
|
|
261
|
+
def reading_as(choice)
|
|
262
|
+
was = choice.entry["decision"]
|
|
263
|
+
placed.select { it.pr == choice.pr && [it.decision.lead, it.decision.bare_text] == [was["lead"], Theme.unmarked(was["text"])] }.then { it.first.decision if it.one? }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def confirmed?(choice)
|
|
267
|
+
item = {id: choice.request_id, kind: choice.kind, text: choice.text}
|
|
268
|
+
reply_to(item)&.then { it[:resolution] == "confirmed" && latest(item)&.key == it[:replyTo] }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def in_focus?(pr) = @focus.whole? || (!pr.nil? && @focus.at(pr).present?)
|
|
272
|
+
|
|
273
|
+
# The choices a handover carries, in the burst's order.
|
|
274
|
+
def in_order(kept) = kept.select { in_focus?(it.pr) }.sort_by { |choice| placed.index { it.id == choice.decision[:id] } || placed.size }
|
|
275
|
+
|
|
276
|
+
def left_at_once
|
|
277
|
+
in_order(choices).select { it.in_bulk? && !stale?(it) }.group_by(&:pr)
|
|
278
|
+
.map { |pr, left| {pr:, decisions: left.map { {id: it.decision[:id], lead: it.decision[:lead]} }} }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# One answer given to several decisions, as a copy for each part it
|
|
282
|
+
# touches, carried out once at its earliest pull request there.
|
|
283
|
+
def answered_together
|
|
284
|
+
choices.select { it.together && !stale?(it) }.group_by(&:together).flat_map do |batch, answered|
|
|
285
|
+
copies = answered.group_by { @route.at(it.pr)&.part&.to_param || it.pr }.values.map do |here|
|
|
286
|
+
sorted = here.sort_by { |choice| @route.find_index { it.number == choice.pr } || 0 }
|
|
287
|
+
{words: @route.at(sorted.first.pr)&.part&.in_words || "##{sorted.first.pr}", at: sorted.first.pr,
|
|
288
|
+
decisions: sorted.map { {id: it.decision[:id], pr: it.pr, lead: it.decision[:lead]} }}
|
|
289
|
+
end
|
|
290
|
+
copies.select { in_focus?(it[:at]) }.map { |copy| {batch:, kind: answered.first.kind, text: answered.first.entry["text"], **copy, elsewhere: copies - [copy]} }
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def unfinished_drafts = drafts.select { in_focus?(it.pr) }.map { {**it.entry.deep_symbolize_keys, requestId: it.request_id, decision: it.decision} }
|
|
295
|
+
|
|
296
|
+
# What the owner has not reviewed yet, or not since it changed under
|
|
297
|
+
# their choice: no instruction, only what they have not reached.
|
|
298
|
+
def unreviewed
|
|
299
|
+
placed.select { in_focus?(it.pr) }.filter_map do |decision|
|
|
300
|
+
choice = choices.find { it.decision[:id] == decision.id }
|
|
301
|
+
{id: decision.id, pr: decision.pr, lead: decision.decision.lead, **(choice ? {changed: true} : {})} if choice.nil? || stale?(choice)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The order to take a burst in, part by part: each of GitHub's native stacks
|
|
5
|
+
# the burst names, from the bottom up by GitHub's own positions; then each
|
|
6
|
+
# chain found from a top by following base branches down; then the pull
|
|
7
|
+
# requests that stand alone.
|
|
8
|
+
#
|
|
9
|
+
# A pull request shows once: a stack holds it before a chain does, and a
|
|
10
|
+
# chain before it stands alone. A chain that meets a pull request placed
|
|
11
|
+
# before it stops there and sits on it, so a change below is still carried
|
|
12
|
+
# up through it: #1085 sits on #1062, the top of stack #1055.
|
|
13
|
+
class Route
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
# A piece of a burst the owner can take on its own. Its id is its stack's
|
|
17
|
+
# number, its top's number, or "alone": GitHub never gives a stack and a
|
|
18
|
+
# pull request the same number. A chain that sits on another part names
|
|
19
|
+
# that part's id as `above`.
|
|
20
|
+
Part = Data.define(:id, :kind, :above) do
|
|
21
|
+
def self.stack(number) = new(id: number, kind: :stack, above: nil)
|
|
22
|
+
|
|
23
|
+
def self.chain(top, above: nil) = new(id: top, kind: :chain, above:)
|
|
24
|
+
|
|
25
|
+
def self.alone = new(id: "alone", kind: :alone, above: nil)
|
|
26
|
+
|
|
27
|
+
def stack? = kind == :stack
|
|
28
|
+
|
|
29
|
+
def alone? = kind == :alone
|
|
30
|
+
|
|
31
|
+
# As a sentence names it: "stack #1055", "the chain up to #1086".
|
|
32
|
+
def in_words
|
|
33
|
+
if alone? then "the pull requests that stand alone"
|
|
34
|
+
elsif stack? then "stack ##{id}"
|
|
35
|
+
else "the chain up to ##{id}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_param = id.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A pull request in its place: its layer of `of` within its part, and the
|
|
43
|
+
# number of the pull request it sits on, nil at the bottom of a stack, of
|
|
44
|
+
# a chain on nothing, and for one that stands alone.
|
|
45
|
+
Stop = Data.define(:pull_request, :layer, :of, :part, :on) do
|
|
46
|
+
delegate :number, :title, :url, :base, :description, :files, :head_oid, :described_at, :committed_at, :open_threads, to: :pull_request
|
|
47
|
+
|
|
48
|
+
def initialize(part: Part.alone, on: nil, **) = super
|
|
49
|
+
|
|
50
|
+
def stacked? = of > 1 || !on.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `stacks` are the native stacks the burst names, `tops` the tops of the
|
|
54
|
+
# chains it follows, `loose` the pull requests it names to stand alone.
|
|
55
|
+
# `kept` holds each stack's members, bottom first, as they were last
|
|
56
|
+
# read: a stack is taken from it when GitHub could not say where its pull
|
|
57
|
+
# requests stand.
|
|
58
|
+
def initialize(among:, tops: [], stacks: [], loose: [], kept: {})
|
|
59
|
+
@stacks, @tops, @loose, @kept = stacks, tops, loose, kept
|
|
60
|
+
@pull_requests = among.index_by(&:number)
|
|
61
|
+
@by_head = among.index_by(&:head)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def each(&) = stops.each(&)
|
|
65
|
+
|
|
66
|
+
def parts = stops.map(&:part).uniq
|
|
67
|
+
|
|
68
|
+
def stops_of(part) = select { it.part == part }
|
|
69
|
+
|
|
70
|
+
def part(id) = parts.find { it.to_param == id.to_s }
|
|
71
|
+
|
|
72
|
+
def at(number) = stops_by_number[number]
|
|
73
|
+
|
|
74
|
+
# Whether GitHub said where its pull requests stand in its stacks: when it
|
|
75
|
+
# did not, a stack's members are those kept from its last reading.
|
|
76
|
+
def stacks_known? = @pull_requests.each_value.all?(&:stack_known?)
|
|
77
|
+
|
|
78
|
+
# The stops on either side of one, in the order of the route.
|
|
79
|
+
def before(stop) = each_cons(2).find { |_, later| later == stop }&.first
|
|
80
|
+
|
|
81
|
+
def after(stop) = each_cons(2).find { |earlier, _| earlier == stop }&.last
|
|
82
|
+
|
|
83
|
+
# What a layer sits on, in its own part or one before it, and the first
|
|
84
|
+
# that sits on it.
|
|
85
|
+
def under(stop) = stop.on&.then { at(it) }
|
|
86
|
+
|
|
87
|
+
def over(stop) = find { it.on == stop.number }
|
|
88
|
+
|
|
89
|
+
# Every layer that sits on it, however far up and in whichever part,
|
|
90
|
+
# nearest first.
|
|
91
|
+
def above(stop) = select { |other| below(other).include?(stop) }
|
|
92
|
+
|
|
93
|
+
# The other stops that change files this one changes, each with those files.
|
|
94
|
+
def sharing_files_with(stop) = others_than(stop).index_with { it.files & stop.files }.compact_blank
|
|
95
|
+
|
|
96
|
+
# The other stops its description names.
|
|
97
|
+
def named_by(stop) = others_than(stop).select { stop.description.mentions.include?(it.number) }
|
|
98
|
+
|
|
99
|
+
# Pull requests the burst names that are no longer open: merged, or closed.
|
|
100
|
+
def gone = (@tops + @loose) - @pull_requests.keys
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def others_than(stop) = reject { it == stop }
|
|
105
|
+
|
|
106
|
+
def stops_by_number = @stops_by_number ||= index_by(&:number)
|
|
107
|
+
|
|
108
|
+
# What a stop stands on, down to the bottom it rests on.
|
|
109
|
+
def below(stop) = Enumerator.produce(stop) { under(it) || raise(StopIteration) }.drop(1)
|
|
110
|
+
|
|
111
|
+
def stops
|
|
112
|
+
@stops ||= {}.then do |placed|
|
|
113
|
+
[*@stacks.map { native(it, placed) }, *@tops.map { chain(it, placed) }, alone(placed)].flatten.compact
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A native stack's members by their positions, or as they were last read.
|
|
118
|
+
def native(number, placed)
|
|
119
|
+
members = stacks_known? ? @pull_requests.each_value.select { it.stack == number }.sort_by(&:position) : @pull_requests.values_at(*@kept.fetch(number, [])).compact
|
|
120
|
+
layers(members.reject { placed.key?(it.number) }, Part.stack(number), placed)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Followed down from its top until a base is not the branch of an open
|
|
124
|
+
# pull request, or is that of one placed already, which it then sits on.
|
|
125
|
+
# A top with nothing under it and nothing to sit on stands alone.
|
|
126
|
+
def chain(top, placed)
|
|
127
|
+
pull_request = @pull_requests[top]
|
|
128
|
+
return if pull_request.nil? || placed.key?(top)
|
|
129
|
+
|
|
130
|
+
followed = Enumerator.produce(pull_request) { @by_head.fetch(it.base) { raise StopIteration } }.take_while { !placed.key?(it.number) }
|
|
131
|
+
on = @by_head[followed.last.base]&.then { placed[it.number] }
|
|
132
|
+
return if followed.one? && on.nil?
|
|
133
|
+
|
|
134
|
+
layers(followed.reverse, Part.chain(top, above: on&.part&.id), placed, on: on&.number)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def alone(placed)
|
|
138
|
+
singles = (@tops + @loose).filter_map { @pull_requests[it] }.uniq.reject { placed.key?(it.number) }
|
|
139
|
+
singles.map { place(Stop.new(pull_request: it, layer: 1, of: 1, part: Part.alone, on: nil), placed) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Each layer sits on the one under it, and the first on `on`.
|
|
143
|
+
def layers(pull_requests, part, placed, on: nil)
|
|
144
|
+
pull_requests.each.with_index(1).map do |pull_request, layer|
|
|
145
|
+
place(Stop.new(pull_request:, layer:, of: pull_requests.size, part:, on: (layer == 1) ? on : pull_requests[layer - 2].number), placed)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def place(stop, placed) = (placed[stop.number] = stop)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proctolog
|
|
4
|
+
# The rules the owner made of answers in a burst. Each is a request of its
|
|
5
|
+
# own in the choices document, made from a choice and attached to it, so
|
|
6
|
+
# the agent's answer to a rule never answers the choice. The pages' script
|
|
7
|
+
# makes a rule and hands it over; the burst page lists them, and edits or
|
|
8
|
+
# withdraws one here. A rule is as far as its request: written, sent,
|
|
9
|
+
# answered, confirmed, followed as review-rounds.js follows every request.
|
|
10
|
+
class Rules
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
Missing = Class.new(Failure)
|
|
14
|
+
Blank = Class.new(Failure)
|
|
15
|
+
|
|
16
|
+
# A decision a rule was made from, as the choice named it.
|
|
17
|
+
From = Data.define(:pr, :lead)
|
|
18
|
+
|
|
19
|
+
# A rule as the burst page shows it. `replaces` is the line the agent
|
|
20
|
+
# recorded before an edit, until the replacement is handed over.
|
|
21
|
+
Rule = Data.define(:id, :pr, :text, :from, :state, :replaces) do
|
|
22
|
+
def to_param = id
|
|
23
|
+
|
|
24
|
+
def withdrawn? = state == :withdrawn
|
|
25
|
+
|
|
26
|
+
def within?(focus) = focus.whole? || from.any? { focus.at(it.pr) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `handovers` are those the desk kept: a pass an agent took itself is
|
|
30
|
+
# in no page's choices until a page takes its batch in.
|
|
31
|
+
def initialize(choices, handovers: [])
|
|
32
|
+
@choices, @handovers = choices, handovers
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def each(&) = requests.map { rule(it) }.each(&)
|
|
36
|
+
|
|
37
|
+
def find(id) = detect { it.id == id } || raise(Missing, "This burst has no rule #{id}.")
|
|
38
|
+
|
|
39
|
+
# A rule the agent does not have yet changes where it stands. One it has
|
|
40
|
+
# changes too, and says which line it replaces until that is handed over.
|
|
41
|
+
def edit(id, text)
|
|
42
|
+
text = text.to_s.squish.presence or raise Blank, "A rule is one line: write it, or withdraw the rule."
|
|
43
|
+
|
|
44
|
+
write(id) do |rule, _|
|
|
45
|
+
rule["text"] = text
|
|
46
|
+
sent = last_sent(id)&.fetch("text")
|
|
47
|
+
(sent && sent != text) ? rule["replaces"] = sent : rule.delete("replaces")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A rule the agent does not have yet goes. One it has stays, withdrawn,
|
|
52
|
+
# and an ordinary request asks the agent to take it out again.
|
|
53
|
+
def withdraw(id)
|
|
54
|
+
write(id) do |rule, requests|
|
|
55
|
+
next requests.delete(rule) unless (sent = last_sent(id))
|
|
56
|
+
|
|
57
|
+
rule["withdrawn"] = true
|
|
58
|
+
requests << {"id" => "request:#{SecureRandom.uuid}", "pr" => rule["pr"], "kind" => "change",
|
|
59
|
+
"text" => "Withdraw the rule \"#{sent["text"]}\" (#{id}): take it out of where you recorded it, and name that file as evidence.",
|
|
60
|
+
"source" => {"kind" => "rule_withdrawn", "rule" => id}}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def document = @choices.document || {}
|
|
67
|
+
|
|
68
|
+
def requests = Array(document["requests"]).select { it["kind"] == "rule" }
|
|
69
|
+
|
|
70
|
+
def batches = [*Array(document["batches"]), *@handovers.map(&:batch)].uniq { it["id"] }.sort_by { Rounds.moment(it["createdAt"]) }
|
|
71
|
+
|
|
72
|
+
def rule(request)
|
|
73
|
+
Rule.new(id: request["id"], pr: request["pr"], text: request["text"], replaces: request["replaces"], state: state_of(request),
|
|
74
|
+
from: Array(request.dig("source", "decisions")).map { From.new(pr: it["pr"], lead: it["lead"]) })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Written until a handover holds it in these words; answered once the
|
|
78
|
+
# agent's response to that handover does; confirmed once the owner said
|
|
79
|
+
# so of these words. The owner sending it back makes it written again.
|
|
80
|
+
def state_of(request)
|
|
81
|
+
return :withdrawn if request["withdrawn"]
|
|
82
|
+
|
|
83
|
+
said = said_of(request)
|
|
84
|
+
return :confirmed if said&.dig("resolution") == "confirmed"
|
|
85
|
+
|
|
86
|
+
since = (Time.iso8601(said["at"]) if said&.dig("resolution") == "reopened")
|
|
87
|
+
held = handed_over(request).select { since.nil? || Time.iso8601(it["createdAt"]) >= since }
|
|
88
|
+
return :answered if held.any? { answered_in?(it, request) }
|
|
89
|
+
|
|
90
|
+
held.any? ? :sent : :written
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The owner's last word on what came back, while it is on these words.
|
|
94
|
+
def said_of(request)
|
|
95
|
+
said = document.dig("feedback", request["id"]) or return
|
|
96
|
+
said if intent_of(said) == [request["kind"], request["text"]]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def intent_of(said)
|
|
100
|
+
JSON.parse(said["intent"].to_s)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def handed_over(request) = batches.select { |batch| batch["items"].any? { it["id"] == request["id"] && it["text"] == request["text"] } }
|
|
105
|
+
|
|
106
|
+
def answered_in?(batch, request) = Array(document["responses"]).any? { |report| report["batchId"] == batch["id"] && report["results"].any? { it["id"] == request["id"] } }
|
|
107
|
+
|
|
108
|
+
# The rule as the agent last had it.
|
|
109
|
+
def last_sent(id) = batches.flat_map { it["items"] }.rfind { it["id"] == id }
|
|
110
|
+
|
|
111
|
+
# Changes the kept document, numbered past the writing it changes so a
|
|
112
|
+
# page left open catches up with it rather than writing over it.
|
|
113
|
+
def write(id)
|
|
114
|
+
changed = document.deep_dup
|
|
115
|
+
requests = changed["requests"] ||= []
|
|
116
|
+
rule = requests.find { it["id"] == id && it["kind"] == "rule" } or raise Missing, "This burst has no rule #{id}."
|
|
117
|
+
yield rule, requests
|
|
118
|
+
@choices.keep(changed.merge("revision" => @choices.revision + 1))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|