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,949 @@
|
|
|
1
|
+
// The owner's choices on the decisions of a burst. The desk keeps them, in
|
|
2
|
+
// choices.json beside the marks, and every page starts from what it kept. This
|
|
3
|
+
// browser holds a copy as well: it carries a choice across tabs at once, and
|
|
4
|
+
// through a moment when the desk does not answer.
|
|
5
|
+
|
|
6
|
+
import { isPrNumber, prNumber } from "./pr-number.js";
|
|
7
|
+
import { toast } from "./toast.js";
|
|
8
|
+
|
|
9
|
+
const tools = document.querySelector("[data-review-draft]");
|
|
10
|
+
const { repository, burst } = tools.dataset;
|
|
11
|
+
const address = `/bursts/${encodeURIComponent(burst)}/choices`;
|
|
12
|
+
export const inventory = JSON.parse(
|
|
13
|
+
document.querySelector("#decision-inventory").textContent,
|
|
14
|
+
);
|
|
15
|
+
const current = new Map(inventory.map((decision) => [decision.id, decision]));
|
|
16
|
+
const storageKey = `proctolog:burst:v1:${repository}:${burst}`;
|
|
17
|
+
// Where this browser kept its copy before bursts had their name: read when
|
|
18
|
+
// the new key holds nothing yet, never written.
|
|
19
|
+
const earlierKey = `proctolog:review:v1:${repository}:${burst}`;
|
|
20
|
+
// The pull requests of the part a page is focused on, which is all a
|
|
21
|
+
// handover from it carries; none when the page shows the whole burst.
|
|
22
|
+
export const part = tools.dataset.partPrs?.split(" ").map(prNumber);
|
|
23
|
+
export const inPart = (pr) => !part || part.includes(pr);
|
|
24
|
+
export const labels = {
|
|
25
|
+
leave: "Leave as is",
|
|
26
|
+
question: "Ask a question",
|
|
27
|
+
change: "Suggest a change",
|
|
28
|
+
later: "Leave for later",
|
|
29
|
+
};
|
|
30
|
+
// What a saved choice is called once it is made.
|
|
31
|
+
const saidOf = {
|
|
32
|
+
leave: "Left as is",
|
|
33
|
+
question: "Question saved",
|
|
34
|
+
change: "Change saved",
|
|
35
|
+
later: "Left for later",
|
|
36
|
+
};
|
|
37
|
+
// A desk from before bursts had their name reads `review`: both are written.
|
|
38
|
+
const owned = (data) => Object.assign(data, { burst, review: burst });
|
|
39
|
+
const blank = () =>
|
|
40
|
+
owned({
|
|
41
|
+
version: 1,
|
|
42
|
+
repository,
|
|
43
|
+
choices: [],
|
|
44
|
+
pending: [],
|
|
45
|
+
});
|
|
46
|
+
let state = blank();
|
|
47
|
+
// Whether the desk took the last writing, and whether this browser keeps its copy.
|
|
48
|
+
let kept = true;
|
|
49
|
+
let mirrored = true;
|
|
50
|
+
let rounds;
|
|
51
|
+
export const draftsState = () => state;
|
|
52
|
+
export const registerRounds = (adapter) => {
|
|
53
|
+
rounds = adapter;
|
|
54
|
+
changed();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const announce = (message) => {
|
|
58
|
+
const text =
|
|
59
|
+
kept || mirrored
|
|
60
|
+
? message
|
|
61
|
+
: `${message} The desk did not answer and this browser keeps nothing: these choices are only in this tab. Back them up before leaving.`;
|
|
62
|
+
tools.querySelector("[data-draft-message]").textContent = text;
|
|
63
|
+
toast(text);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const validEntry = (entry) =>
|
|
67
|
+
entry &&
|
|
68
|
+
Object.hasOwn(labels, entry.kind) &&
|
|
69
|
+
typeof entry.text === "string" &&
|
|
70
|
+
entry.decision &&
|
|
71
|
+
typeof entry.decision.id === "string" &&
|
|
72
|
+
isPrNumber(entry.decision.pr) &&
|
|
73
|
+
["text", "lead", "head", "description", "branch"].every(
|
|
74
|
+
(key) => typeof entry.decision[key] === "string",
|
|
75
|
+
) &&
|
|
76
|
+
(entry.savedAt === undefined || typeof entry.savedAt === "string") &&
|
|
77
|
+
(entry.decision.key === undefined ||
|
|
78
|
+
(typeof entry.decision.key === "string" &&
|
|
79
|
+
/^[\w.-]+$/.test(entry.decision.key))) &&
|
|
80
|
+
(entry.requestId === undefined || typeof entry.requestId === "string") &&
|
|
81
|
+
(entry.batch === undefined || typeof entry.batch === "string") &&
|
|
82
|
+
(entry.grade === undefined ||
|
|
83
|
+
["blocking", "non_blocking"].includes(entry.grade));
|
|
84
|
+
|
|
85
|
+
const validRequest = (item) =>
|
|
86
|
+
item &&
|
|
87
|
+
typeof item.id === "string" &&
|
|
88
|
+
typeof item.text === "string" &&
|
|
89
|
+
["leave", "question", "change", "later", "remark", "rule"].includes(
|
|
90
|
+
item.kind,
|
|
91
|
+
) &&
|
|
92
|
+
item.source &&
|
|
93
|
+
typeof item.source.kind === "string" &&
|
|
94
|
+
(item.source.kind !== "code" ||
|
|
95
|
+
(item.source.anchor && typeof item.source.anchor.path === "string"));
|
|
96
|
+
|
|
97
|
+
const validate = (data) => {
|
|
98
|
+
if (
|
|
99
|
+
data?.version !== 1 ||
|
|
100
|
+
data.repository !== repository ||
|
|
101
|
+
(data.burst ?? data.review) !== burst ||
|
|
102
|
+
!Array.isArray(data.choices) ||
|
|
103
|
+
!Array.isArray(data.pending) ||
|
|
104
|
+
![...data.choices, ...data.pending].every(validEntry) ||
|
|
105
|
+
data.choices.some(
|
|
106
|
+
(entry) => entry.kind !== "leave" && !entry.text.trim(),
|
|
107
|
+
) ||
|
|
108
|
+
new Set(data.choices.map((entry) => entry.decision.id)).size !==
|
|
109
|
+
data.choices.length ||
|
|
110
|
+
new Set(data.pending.map((entry) => entry.decision.id)).size !==
|
|
111
|
+
data.pending.length ||
|
|
112
|
+
(data.requests !== undefined &&
|
|
113
|
+
(!Array.isArray(data.requests) || !data.requests.every(validRequest))) ||
|
|
114
|
+
(data.batches !== undefined &&
|
|
115
|
+
(!Array.isArray(data.batches) ||
|
|
116
|
+
!data.batches.every(
|
|
117
|
+
(batch) =>
|
|
118
|
+
typeof batch.id === "string" &&
|
|
119
|
+
Array.isArray(batch.items) &&
|
|
120
|
+
batch.items.every(validRequest) &&
|
|
121
|
+
(batch.heads === undefined ||
|
|
122
|
+
(batch.heads &&
|
|
123
|
+
typeof batch.heads === "object" &&
|
|
124
|
+
!Array.isArray(batch.heads) &&
|
|
125
|
+
Object.values(batch.heads).every(
|
|
126
|
+
(head) =>
|
|
127
|
+
head &&
|
|
128
|
+
typeof head.head === "string" &&
|
|
129
|
+
(head.descriptionText === undefined ||
|
|
130
|
+
typeof head.descriptionText === "string"),
|
|
131
|
+
))),
|
|
132
|
+
))) ||
|
|
133
|
+
(data.responses !== undefined &&
|
|
134
|
+
(!Array.isArray(data.responses) ||
|
|
135
|
+
!data.responses.every(
|
|
136
|
+
(report) =>
|
|
137
|
+
report &&
|
|
138
|
+
typeof report.id === "string" &&
|
|
139
|
+
typeof report.batchId === "string" &&
|
|
140
|
+
typeof report.createdAt === "string" &&
|
|
141
|
+
Array.isArray(report.results),
|
|
142
|
+
))) ||
|
|
143
|
+
(data.feedback !== undefined &&
|
|
144
|
+
(!data.feedback ||
|
|
145
|
+
typeof data.feedback !== "object" ||
|
|
146
|
+
Array.isArray(data.feedback)))
|
|
147
|
+
)
|
|
148
|
+
throw new Error("This is not a choices backup for this burst.");
|
|
149
|
+
return data;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const revisionOf = (data) => data?.revision || 0;
|
|
153
|
+
|
|
154
|
+
// This browser's copy, or nothing when it has none it can read. One that is
|
|
155
|
+
// there and cannot be read is never written over: it is left for a recovery
|
|
156
|
+
// by hand.
|
|
157
|
+
let unreadable = false;
|
|
158
|
+
const mirror = () => {
|
|
159
|
+
try {
|
|
160
|
+
const saved =
|
|
161
|
+
localStorage.getItem(storageKey) ?? localStorage.getItem(earlierKey);
|
|
162
|
+
return saved ? validate(JSON.parse(saved)) : undefined;
|
|
163
|
+
} catch {
|
|
164
|
+
mirrored = false;
|
|
165
|
+
unreadable = true;
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const writeMirror = () => {
|
|
171
|
+
if (unreadable) return;
|
|
172
|
+
try {
|
|
173
|
+
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
174
|
+
mirrored = true;
|
|
175
|
+
} catch {
|
|
176
|
+
mirrored = false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// What the desk kept when it rendered this page.
|
|
181
|
+
const fromDesk = () => {
|
|
182
|
+
try {
|
|
183
|
+
const document_ = JSON.parse(
|
|
184
|
+
document.querySelector("#burst-choices")?.textContent || "null",
|
|
185
|
+
);
|
|
186
|
+
return document_ ? validate(document_) : undefined;
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Sends the whole of it to the desk, one writing at a time: a change made
|
|
193
|
+
// while one is on its way goes with the next. Each change waits in `unsent`
|
|
194
|
+
// until the desk took it, so that when another page wrote first, it can be
|
|
195
|
+
// made again on top of theirs rather than lost.
|
|
196
|
+
let sending;
|
|
197
|
+
let queued = false;
|
|
198
|
+
let later;
|
|
199
|
+
let unsent = [];
|
|
200
|
+
const send = () => {
|
|
201
|
+
clearTimeout(later);
|
|
202
|
+
later = undefined;
|
|
203
|
+
if (sending) {
|
|
204
|
+
queued = true;
|
|
205
|
+
return sending;
|
|
206
|
+
}
|
|
207
|
+
const body = JSON.stringify(state);
|
|
208
|
+
const carried = unsent;
|
|
209
|
+
unsent = [];
|
|
210
|
+
sending = fetch(address, {
|
|
211
|
+
method: "PUT",
|
|
212
|
+
headers: {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
Accept: "application/json",
|
|
215
|
+
"X-CSRF-Token":
|
|
216
|
+
document.querySelector('meta[name="csrf-token"]')?.content ?? "",
|
|
217
|
+
},
|
|
218
|
+
body,
|
|
219
|
+
// A small writing outlives the page that sent it; a large one cannot.
|
|
220
|
+
keepalive: body.length < 60000,
|
|
221
|
+
})
|
|
222
|
+
.then(async (response) => {
|
|
223
|
+
if (response.status === 409) {
|
|
224
|
+
const theirs = (await response.json()).document;
|
|
225
|
+
unsent = [...carried, ...unsent];
|
|
226
|
+
if (revisionOf(theirs) < revisionOf(state)) return;
|
|
227
|
+
state = reconcile(validate(theirs));
|
|
228
|
+
for (const mutate of unsent) mutate(state);
|
|
229
|
+
owned(state);
|
|
230
|
+
state.revision = revisionOf(theirs) + 1;
|
|
231
|
+
writeMirror();
|
|
232
|
+
changed();
|
|
233
|
+
queued = true;
|
|
234
|
+
announce(
|
|
235
|
+
"Another page of this burst saved choices while this one was open, so this page now shows them too. What you just did here is kept on top of them.",
|
|
236
|
+
);
|
|
237
|
+
} else if (!response.ok) {
|
|
238
|
+
throw new Error(`the desk answered ${response.status}`);
|
|
239
|
+
}
|
|
240
|
+
kept = true;
|
|
241
|
+
})
|
|
242
|
+
.catch((error) => {
|
|
243
|
+
unsent = [...carried, ...unsent];
|
|
244
|
+
const first = kept;
|
|
245
|
+
kept = false;
|
|
246
|
+
if (first || !mirrored)
|
|
247
|
+
announce(
|
|
248
|
+
`Not saved to the desk (${error.message}).${mirrored ? " This browser keeps your choices and sends them when the desk answers." : ""}`,
|
|
249
|
+
);
|
|
250
|
+
})
|
|
251
|
+
.finally(() => {
|
|
252
|
+
sending = undefined;
|
|
253
|
+
if (queued) {
|
|
254
|
+
queued = false;
|
|
255
|
+
send();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
return sending;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Resolves once the desk has what this page has: a page waits for it before
|
|
262
|
+
// it goes to another that counts the choices.
|
|
263
|
+
export const settled = async () => {
|
|
264
|
+
if (later) send();
|
|
265
|
+
while (sending) await sending;
|
|
266
|
+
};
|
|
267
|
+
globalThis.proctologSettled = settled;
|
|
268
|
+
|
|
269
|
+
const decisionIdentity = (entry) => {
|
|
270
|
+
const matches = inventory.filter(
|
|
271
|
+
(item) =>
|
|
272
|
+
item.pr === entry.decision.pr &&
|
|
273
|
+
item.lead === entry.decision.lead &&
|
|
274
|
+
item.text === entry.decision.text,
|
|
275
|
+
);
|
|
276
|
+
const key =
|
|
277
|
+
entry.decision.key || (matches.length === 1 ? matches[0].key : undefined);
|
|
278
|
+
return JSON.stringify(
|
|
279
|
+
key
|
|
280
|
+
? [entry.decision.pr, key]
|
|
281
|
+
: [entry.decision.pr, entry.decision.lead, entry.decision.text],
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
const reconcile = (data) => {
|
|
285
|
+
for (const field of ["choices", "pending"]) {
|
|
286
|
+
const claimed = new Set();
|
|
287
|
+
for (const entry of data[field]) {
|
|
288
|
+
entry.requestId ||= `${field === "choices" ? "choice" : "draft"}:${entry.decision.id}:${entry.savedAt || "legacy"}`;
|
|
289
|
+
const matches = inventory.filter(
|
|
290
|
+
(item) =>
|
|
291
|
+
item.pr === entry.decision.pr &&
|
|
292
|
+
(entry.decision.key
|
|
293
|
+
? item.key === entry.decision.key
|
|
294
|
+
: item.lead === entry.decision.lead &&
|
|
295
|
+
item.text === entry.decision.text),
|
|
296
|
+
);
|
|
297
|
+
const match =
|
|
298
|
+
matches.length === 1 && !claimed.has(matches[0].id)
|
|
299
|
+
? matches[0]
|
|
300
|
+
: undefined;
|
|
301
|
+
if (match) claimed.add(match.id);
|
|
302
|
+
entry.decision = {
|
|
303
|
+
...entry.decision,
|
|
304
|
+
...(match
|
|
305
|
+
? { id: match.id, position: match.position, key: match.key }
|
|
306
|
+
: { id: `missing:${entry.requestId}` }),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return data;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// One set of choices with another laid over it, as a backup is restored: a
|
|
314
|
+
// choice or a draft of `over` takes the place of the one on the same decision,
|
|
315
|
+
// and the records of what was handed over and what came back are joined, never
|
|
316
|
+
// rewritten.
|
|
317
|
+
const merged = (base, over) => {
|
|
318
|
+
const result = structuredClone(base);
|
|
319
|
+
for (const field of ["choices", "pending"]) {
|
|
320
|
+
const entries = new Map(
|
|
321
|
+
result[field].map((entry) => [decisionIdentity(entry), entry]),
|
|
322
|
+
);
|
|
323
|
+
for (const entry of over[field])
|
|
324
|
+
entries.set(decisionIdentity(entry), entry);
|
|
325
|
+
result[field] = [...entries.values()];
|
|
326
|
+
}
|
|
327
|
+
for (const field of ["batches", "responses", "requests"]) {
|
|
328
|
+
const entries = new Map(
|
|
329
|
+
(result[field] || []).map((entry) => [entry.id, entry]),
|
|
330
|
+
);
|
|
331
|
+
for (const entry of over[field] || []) {
|
|
332
|
+
if (
|
|
333
|
+
entries.has(entry.id) &&
|
|
334
|
+
JSON.stringify(entries.get(entry.id)) !== JSON.stringify(entry)
|
|
335
|
+
)
|
|
336
|
+
throw new Error(
|
|
337
|
+
`The backup changes an existing ${field} record: ${entry.id}`,
|
|
338
|
+
);
|
|
339
|
+
entries.set(entry.id, entry);
|
|
340
|
+
}
|
|
341
|
+
result[field] = [...entries.values()];
|
|
342
|
+
}
|
|
343
|
+
result.feedback = { ...(result.feedback || {}) };
|
|
344
|
+
for (const [id, entry] of Object.entries(over.feedback || {})) {
|
|
345
|
+
const existing = result.feedback[id];
|
|
346
|
+
if (!existing || Date.parse(entry.at) > Date.parse(existing.at))
|
|
347
|
+
result.feedback[id] = entry;
|
|
348
|
+
}
|
|
349
|
+
return reconcile(result);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const choiceFor = (id) =>
|
|
353
|
+
state.choices.find((entry) => entry.decision.id === id);
|
|
354
|
+
export const pendingFor = (id) =>
|
|
355
|
+
state.pending.find((entry) => entry.decision.id === id);
|
|
356
|
+
// A decision's words without its theme marker, which is no part of them:
|
|
357
|
+
// naming or mending a theme never reopens a choice.
|
|
358
|
+
const bare = (text) => text.replace(/\s*<!-- proctolog:theme [\w-]+ -->/g, "");
|
|
359
|
+
|
|
360
|
+
// A choice left with every other non-blocking decision at once. It was made
|
|
361
|
+
// on less attention than one made by itself, so it stands only while its
|
|
362
|
+
// decision reads as it did and is still non-blocking.
|
|
363
|
+
export const leftInBulk = (entry) => !!entry.batch && !!entry.grade;
|
|
364
|
+
|
|
365
|
+
// Why a choice no longer stands, or nothing while it does: "Words changed",
|
|
366
|
+
// "Promoted to blocking", "Pictures changed". The desk counts by the same
|
|
367
|
+
// rules (Choices#current? in choices.rb), so the page and the pane agree.
|
|
368
|
+
export const reopened = (entry) => {
|
|
369
|
+
if (
|
|
370
|
+
rounds?.isChoiceClosed?.(entry) ||
|
|
371
|
+
rounds?.isActivePR?.(entry.decision.pr) === false
|
|
372
|
+
)
|
|
373
|
+
return "";
|
|
374
|
+
return wordsMoved(entry) || (picturesMoved(entry) ? "Pictures changed" : "");
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Each picture a choice was made on, matched by name with those its
|
|
378
|
+
// decision rests on now: changed, added, removed, the same, or unverified
|
|
379
|
+
// while its pixels are not known, then or now. A choice made before
|
|
380
|
+
// pictures were recorded has none to hold it to.
|
|
381
|
+
export const picturesSince = (entry) => {
|
|
382
|
+
const was = entry.decision.pictures;
|
|
383
|
+
const now = current.get(entry.decision.id)?.pictures;
|
|
384
|
+
if (!was || !now) return [];
|
|
385
|
+
const names = [...new Set([...was, ...now].map(({ name }) => name))];
|
|
386
|
+
return names.map((name) => {
|
|
387
|
+
const before = was.find((picture) => picture.name === name);
|
|
388
|
+
const after = now.find((picture) => picture.name === name);
|
|
389
|
+
return { name, before, after, said: compared(before, after) };
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
const compared = (before, after) => {
|
|
393
|
+
if (!before) return "added";
|
|
394
|
+
if (!after) return "removed";
|
|
395
|
+
if (!before.signature || !after.signature) return "unverified";
|
|
396
|
+
return before.signature === after.signature ? "same" : "changed";
|
|
397
|
+
};
|
|
398
|
+
const picturesMoved = (entry) =>
|
|
399
|
+
picturesSince(entry).some(({ said }) =>
|
|
400
|
+
["changed", "added", "removed"].includes(said),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
// Its words, theme markers aside. One the owner made by itself that kept
|
|
404
|
+
// its reference through a rewrite stands; one left with every non-blocking
|
|
405
|
+
// decision at once stands only while its words are the same and its
|
|
406
|
+
// decision is still non-blocking.
|
|
407
|
+
const wordsMoved = (entry) => {
|
|
408
|
+
const at = current.get(entry.decision.id);
|
|
409
|
+
const reads = (item) =>
|
|
410
|
+
item?.pr === entry.decision.pr &&
|
|
411
|
+
item.lead === entry.decision.lead &&
|
|
412
|
+
bare(item.text) === bare(entry.decision.text);
|
|
413
|
+
const matching = inventory.filter(reads);
|
|
414
|
+
const latest = reads(at)
|
|
415
|
+
? at
|
|
416
|
+
: matching.length === 1
|
|
417
|
+
? matching[0]
|
|
418
|
+
: undefined;
|
|
419
|
+
if (leftInBulk(entry)) {
|
|
420
|
+
if (!latest) return "Words changed";
|
|
421
|
+
return entry.grade === "non_blocking" && latest.grade === "blocking"
|
|
422
|
+
? "Promoted to blocking"
|
|
423
|
+
: "";
|
|
424
|
+
}
|
|
425
|
+
if (entry.kind === "leave" && at?.key && at.key === entry.decision.key)
|
|
426
|
+
return "";
|
|
427
|
+
return latest ? "" : "Words changed";
|
|
428
|
+
};
|
|
429
|
+
export const stale = (entry) => !!reopened(entry);
|
|
430
|
+
|
|
431
|
+
// A decision is reviewed once the owner chose on its present words. One that
|
|
432
|
+
// is ticked on GitHub is not asked again either, and is counted apart.
|
|
433
|
+
export const chosen = (item) => {
|
|
434
|
+
const saved = choiceFor(item.id);
|
|
435
|
+
return !!saved && !stale(saved);
|
|
436
|
+
};
|
|
437
|
+
export const reviewed = (item) => chosen(item) || !!item.ticked;
|
|
438
|
+
|
|
439
|
+
const plural = (count, one, many = `${one}s`) =>
|
|
440
|
+
`${count} ${count === 1 ? one : many}`;
|
|
441
|
+
|
|
442
|
+
// How far the decisions are, over the burst, over one pull request, or over
|
|
443
|
+
// several: those of a part.
|
|
444
|
+
export const progress = (pr) => {
|
|
445
|
+
const of = (number) => pr === undefined || [pr].flat().includes(number);
|
|
446
|
+
const items = inventory.filter((item) => of(item.pr));
|
|
447
|
+
const mine = (entry) => of(entry.decision.pr);
|
|
448
|
+
const picked = items.filter(chosen).length;
|
|
449
|
+
const ticked = items.filter((item) => !chosen(item) && item.ticked).length;
|
|
450
|
+
return {
|
|
451
|
+
total: items.length,
|
|
452
|
+
chosen: picked,
|
|
453
|
+
ticked,
|
|
454
|
+
left: items.length - picked - ticked,
|
|
455
|
+
drafts: state.pending.filter(mine).length,
|
|
456
|
+
again: state.choices.filter(
|
|
457
|
+
(entry) =>
|
|
458
|
+
mine(entry) &&
|
|
459
|
+
!entry.decision.id.startsWith("missing:") &&
|
|
460
|
+
stale(entry),
|
|
461
|
+
).length,
|
|
462
|
+
retained: state.choices.filter(
|
|
463
|
+
(entry) => mine(entry) && entry.decision.id.startsWith("missing:"),
|
|
464
|
+
).length,
|
|
465
|
+
saved: state.choices.filter(mine).length,
|
|
466
|
+
};
|
|
467
|
+
};
|
|
468
|
+
export const scope = tools.dataset.pr ? prNumber(tools.dataset.pr) : undefined;
|
|
469
|
+
|
|
470
|
+
const changed = () => {
|
|
471
|
+
const now = progress(scope);
|
|
472
|
+
tools.querySelector("[data-draft-summary]").textContent = [
|
|
473
|
+
`${plural(now.saved, "choice")} saved`,
|
|
474
|
+
`${now.left} to review`,
|
|
475
|
+
`${now.ticked} ticked on GitHub`,
|
|
476
|
+
`${now.again} need review again`,
|
|
477
|
+
plural(now.drafts, "unfinished draft"),
|
|
478
|
+
...(now.retained
|
|
479
|
+
? [`${plural(now.retained, "earlier choice")} retained`]
|
|
480
|
+
: []),
|
|
481
|
+
].join(" · ");
|
|
482
|
+
tools.querySelector("[data-progress]").replaceChildren(...progressLine(now));
|
|
483
|
+
side();
|
|
484
|
+
document.dispatchEvent(new CustomEvent("drafts-changed"));
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// The pane at the left counts the same choices: its rows follow them as they
|
|
488
|
+
// are made, in the marks the server wrote them with.
|
|
489
|
+
const side = () => {
|
|
490
|
+
const count = (number) => number.toLocaleString("en-US");
|
|
491
|
+
for (const row of document.querySelectorAll("[data-side-pr]")) {
|
|
492
|
+
const now = progress(prNumber(row.dataset.sidePr));
|
|
493
|
+
if (!now.total) continue;
|
|
494
|
+
const done = now.chosen + now.ticked;
|
|
495
|
+
row.querySelector("[data-side-mark]").textContent = !now.left
|
|
496
|
+
? "x"
|
|
497
|
+
: done
|
|
498
|
+
? "~"
|
|
499
|
+
: "";
|
|
500
|
+
row.querySelector("[data-side-fraction]").textContent =
|
|
501
|
+
`${done}/${now.total}`;
|
|
502
|
+
}
|
|
503
|
+
const overall = document.querySelector("[data-side-overall]");
|
|
504
|
+
if (!overall || !inventory.length) return;
|
|
505
|
+
const all = progress();
|
|
506
|
+
// Focused on a part, what is left in it against the burst, over its own bar.
|
|
507
|
+
const part = overall.dataset.partPrs?.split(" ").map(Number);
|
|
508
|
+
const shown = part ? progress(part) : all;
|
|
509
|
+
overall.querySelector("span").textContent = part
|
|
510
|
+
? `${count(shown.left)} / ${count(all.left)} decisions left, part / burst`
|
|
511
|
+
: `${count(all.chosen + all.ticked)} of ${count(all.total)} decisions reviewed`;
|
|
512
|
+
overall.querySelector("meter").value = shown.chosen + shown.ticked;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// The one line a page leads with: how many are reviewed of how many, a bar,
|
|
516
|
+
// and only what else is not nought.
|
|
517
|
+
const progressLine = (now) => {
|
|
518
|
+
const said = (tag, text, className) =>
|
|
519
|
+
Object.assign(document.createElement(tag), {
|
|
520
|
+
textContent: text,
|
|
521
|
+
className: className ?? "",
|
|
522
|
+
});
|
|
523
|
+
if (!now.total)
|
|
524
|
+
return [
|
|
525
|
+
said(
|
|
526
|
+
"span",
|
|
527
|
+
scope ? "No decisions on this pull request" : "No decisions",
|
|
528
|
+
),
|
|
529
|
+
];
|
|
530
|
+
const done = now.chosen + now.ticked;
|
|
531
|
+
const bar = Object.assign(document.createElement("meter"), {
|
|
532
|
+
max: now.total,
|
|
533
|
+
value: done,
|
|
534
|
+
});
|
|
535
|
+
return [
|
|
536
|
+
said("strong", `${done} of ${now.total} reviewed`),
|
|
537
|
+
bar,
|
|
538
|
+
...[
|
|
539
|
+
[now.left, `${now.left} to review`, "left"],
|
|
540
|
+
[now.ticked, `${now.ticked} ticked on GitHub`],
|
|
541
|
+
[now.again, `${now.again} to review again`, "again"],
|
|
542
|
+
[now.drafts, plural(now.drafts, "unfinished draft"), "again"],
|
|
543
|
+
]
|
|
544
|
+
.filter(([count]) => count)
|
|
545
|
+
.map(([, text, className]) => said("span", text, className)),
|
|
546
|
+
];
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Every change goes to this browser's copy at once and to the desk right
|
|
550
|
+
// after; `soon` lets the letters of a draft go together.
|
|
551
|
+
const persist = (mutate, { soon = false } = {}) => {
|
|
552
|
+
const theirs = mirror();
|
|
553
|
+
if (revisionOf(theirs) > revisionOf(state)) state = reconcile(theirs);
|
|
554
|
+
mutate(state);
|
|
555
|
+
unsent.push(mutate);
|
|
556
|
+
owned(state);
|
|
557
|
+
state.revision = revisionOf(state) + 1;
|
|
558
|
+
writeMirror();
|
|
559
|
+
if (soon) later ??= setTimeout(send, 500);
|
|
560
|
+
else send();
|
|
561
|
+
changed();
|
|
562
|
+
};
|
|
563
|
+
export const updateDrafts = persist;
|
|
564
|
+
|
|
565
|
+
// Where the cursor rests is this browser's own business: it is not worth a
|
|
566
|
+
// writing to the desk each time it moves.
|
|
567
|
+
const cursorKey = `${storageKey}:cursor`;
|
|
568
|
+
export const rememberCursor = (id) => {
|
|
569
|
+
try {
|
|
570
|
+
localStorage.setItem(cursorKey, id);
|
|
571
|
+
} catch {
|
|
572
|
+
// A browser that keeps nothing starts each page at its first decision.
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
export const cursor = () => {
|
|
576
|
+
try {
|
|
577
|
+
return (
|
|
578
|
+
localStorage.getItem(cursorKey) ??
|
|
579
|
+
localStorage.getItem(`${earlierKey}:cursor`) ??
|
|
580
|
+
state.cursor
|
|
581
|
+
);
|
|
582
|
+
} catch {
|
|
583
|
+
return state.cursor;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// An unfinished answer, kept as it is typed. One with nothing in it is not a
|
|
588
|
+
// draft: opening an editor and closing it again leaves no trace.
|
|
589
|
+
export const draft = (id, kind, text) => {
|
|
590
|
+
if (!text.trim() && !pendingFor(id)) return;
|
|
591
|
+
persist(
|
|
592
|
+
(data) => {
|
|
593
|
+
const previous = data.pending.find((entry) => entry.decision.id === id);
|
|
594
|
+
data.pending = data.pending.filter((entry) => entry.decision.id !== id);
|
|
595
|
+
if (!text.trim()) return;
|
|
596
|
+
data.pending.push({
|
|
597
|
+
decision: current.get(id),
|
|
598
|
+
kind,
|
|
599
|
+
text,
|
|
600
|
+
requestId: previous?.requestId || `draft:${id}:${crypto.randomUUID()}`,
|
|
601
|
+
});
|
|
602
|
+
},
|
|
603
|
+
{ soon: true },
|
|
604
|
+
);
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// Each step u can take back: how to put back what it changed, what to say
|
|
608
|
+
// once it is, and the decision to return to. A choice touches one decision,
|
|
609
|
+
// a gesture several; a rule touches the rule alone.
|
|
610
|
+
const undo = [];
|
|
611
|
+
const remember = (ids) => {
|
|
612
|
+
const was = ids.map((id) => ({
|
|
613
|
+
choice: choiceFor(id),
|
|
614
|
+
pending: pendingFor(id),
|
|
615
|
+
}));
|
|
616
|
+
undo.push({
|
|
617
|
+
at: ids[0],
|
|
618
|
+
said:
|
|
619
|
+
ids.length > 1
|
|
620
|
+
? `${plural(ids.length, "choice")} taken back.`
|
|
621
|
+
: "Last choice undone.",
|
|
622
|
+
restore: (data) => {
|
|
623
|
+
withoutThese(data, ids);
|
|
624
|
+
for (const { choice, pending } of was) {
|
|
625
|
+
if (choice) data.choices.push(choice);
|
|
626
|
+
if (pending) data.pending.push(pending);
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
};
|
|
631
|
+
const withoutThese = (data, ids) => {
|
|
632
|
+
data.choices = data.choices.filter(
|
|
633
|
+
(entry) => !ids.includes(entry.decision.id),
|
|
634
|
+
);
|
|
635
|
+
data.pending = data.pending.filter(
|
|
636
|
+
(entry) => !ids.includes(entry.decision.id),
|
|
637
|
+
);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
export const saveChoice = (id, kind, text = "") => {
|
|
641
|
+
if (!Object.hasOwn(labels, kind) || (kind !== "leave" && !text.trim()))
|
|
642
|
+
return false;
|
|
643
|
+
remember([id]);
|
|
644
|
+
persist((data) => {
|
|
645
|
+
withoutThese(data, [id]);
|
|
646
|
+
data.choices.push({
|
|
647
|
+
requestId: `choice:${id}:${crypto.randomUUID()}`,
|
|
648
|
+
decision: current.get(id),
|
|
649
|
+
kind,
|
|
650
|
+
text: text.trim(),
|
|
651
|
+
savedAt: new Date().toISOString(),
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
announce(`${saidOf[kind]} · u undoes it`);
|
|
655
|
+
return true;
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// Leaves these decisions as they are in one gesture: each gets a choice of
|
|
659
|
+
// its own, all under one batch, which records the grade each had then, and
|
|
660
|
+
// u takes them back together.
|
|
661
|
+
export const leaveInBulk = (ids) => {
|
|
662
|
+
if (!ids.length) return false;
|
|
663
|
+
const batch = `bulk:${crypto.randomUUID()}`;
|
|
664
|
+
const savedAt = new Date().toISOString();
|
|
665
|
+
remember(ids);
|
|
666
|
+
persist((data) => {
|
|
667
|
+
withoutThese(data, ids);
|
|
668
|
+
for (const id of ids)
|
|
669
|
+
data.choices.push({
|
|
670
|
+
requestId: `choice:${id}:${crypto.randomUUID()}`,
|
|
671
|
+
decision: current.get(id),
|
|
672
|
+
kind: "leave",
|
|
673
|
+
text: "",
|
|
674
|
+
savedAt,
|
|
675
|
+
batch,
|
|
676
|
+
grade: current.get(id).grade,
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
return true;
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// One answer given to several decisions: each gets a choice of its own, all
|
|
683
|
+
// under one batch, so u takes them back together and a handover carries the
|
|
684
|
+
// answer out once in each part it touches. Each stands or reopens alone.
|
|
685
|
+
export const saveTogether = (ids, kind, text = "") => {
|
|
686
|
+
if (!Object.hasOwn(labels, kind) || (kind !== "leave" && !text.trim()))
|
|
687
|
+
return false;
|
|
688
|
+
const batch = `together:${crypto.randomUUID()}`;
|
|
689
|
+
const savedAt = new Date().toISOString();
|
|
690
|
+
remember(ids);
|
|
691
|
+
persist((data) => {
|
|
692
|
+
withoutThese(data, ids);
|
|
693
|
+
for (const id of ids)
|
|
694
|
+
data.choices.push({
|
|
695
|
+
requestId: `choice:${id}:${crypto.randomUUID()}`,
|
|
696
|
+
decision: current.get(id),
|
|
697
|
+
kind,
|
|
698
|
+
text: text.trim(),
|
|
699
|
+
savedAt,
|
|
700
|
+
batch,
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
announce(
|
|
704
|
+
`${saidOf[kind]}, one answer for ${plural(ids.length, "decision")} · u undoes it`,
|
|
705
|
+
);
|
|
706
|
+
return true;
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// How many other decisions a choice was answered with, when it was one of
|
|
710
|
+
// several given one answer.
|
|
711
|
+
export const answeredWith = (entry) =>
|
|
712
|
+
entry.batch?.startsWith("together:")
|
|
713
|
+
? state.choices.filter(
|
|
714
|
+
(other) => other !== entry && other.batch === entry.batch,
|
|
715
|
+
).length
|
|
716
|
+
: 0;
|
|
717
|
+
|
|
718
|
+
export const clearChoice = (id) => {
|
|
719
|
+
remember([id]);
|
|
720
|
+
persist((data) => withoutThese(data, [id]));
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
// Takes back the last step; answers the first decision it touched.
|
|
724
|
+
export const undoChoice = () => {
|
|
725
|
+
const previous = undo.pop();
|
|
726
|
+
if (!previous) {
|
|
727
|
+
toast("Nothing to undo on this page.");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
persist(previous.restore);
|
|
731
|
+
announce(previous.said);
|
|
732
|
+
return previous.at;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
// The rule made of a choice, while it stands: one for a choice made by
|
|
736
|
+
// itself, one for all those given in one gesture.
|
|
737
|
+
export const ruleFor = (entry) =>
|
|
738
|
+
(state.requests || []).find(
|
|
739
|
+
(item) =>
|
|
740
|
+
item.kind === "rule" &&
|
|
741
|
+
!item.withdrawn &&
|
|
742
|
+
(entry.batch
|
|
743
|
+
? item.source.batch === entry.batch
|
|
744
|
+
: item.source.choice === entry.requestId),
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// The rule as the agent last had it, once a handover carried it.
|
|
748
|
+
const lastSent = (id) =>
|
|
749
|
+
(state.batches || [])
|
|
750
|
+
.flatMap((batch) => batch.items)
|
|
751
|
+
.findLast((item) => item.id === id);
|
|
752
|
+
|
|
753
|
+
// Makes an answer a rule the agent records, or rewrites the one made of it:
|
|
754
|
+
// a request of its own, so that the agent's answer to the rule never
|
|
755
|
+
// answers the choice. One the agent has already says which line it replaces.
|
|
756
|
+
export const saveRule = (entry, text) => {
|
|
757
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
758
|
+
if (!line) return false;
|
|
759
|
+
const rule = ruleFor(entry);
|
|
760
|
+
const id = rule?.id ?? `rule:${crypto.randomUUID()}`;
|
|
761
|
+
const sent = lastSent(id)?.text;
|
|
762
|
+
undo.push({
|
|
763
|
+
at: entry.decision.id,
|
|
764
|
+
said: rule ? "Rule put back as it was." : "Rule taken back.",
|
|
765
|
+
restore: (data) => {
|
|
766
|
+
data.requests = (data.requests || []).filter((item) => item.id !== id);
|
|
767
|
+
if (rule) data.requests.push(rule);
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
persist((data) => {
|
|
771
|
+
const together = entry.batch
|
|
772
|
+
? data.choices.filter((other) => other.batch === entry.batch)
|
|
773
|
+
: [entry];
|
|
774
|
+
data.requests = [
|
|
775
|
+
...(data.requests || []).filter((item) => item.id !== id),
|
|
776
|
+
{
|
|
777
|
+
id,
|
|
778
|
+
pr: entry.decision.pr,
|
|
779
|
+
kind: "rule",
|
|
780
|
+
text: line,
|
|
781
|
+
source: {
|
|
782
|
+
kind: "rule",
|
|
783
|
+
choice: entry.requestId,
|
|
784
|
+
...(entry.batch ? { batch: entry.batch } : {}),
|
|
785
|
+
decisions: together.map(({ decision }) => ({
|
|
786
|
+
id: decision.id,
|
|
787
|
+
pr: decision.pr,
|
|
788
|
+
lead: decision.lead,
|
|
789
|
+
})),
|
|
790
|
+
},
|
|
791
|
+
...(sent && sent !== line ? { replaces: sent } : {}),
|
|
792
|
+
},
|
|
793
|
+
];
|
|
794
|
+
});
|
|
795
|
+
announce(`${rule ? "Rule rewritten" : "Rule made"} · u undoes it`);
|
|
796
|
+
return true;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const download = (text, name, type) => {
|
|
800
|
+
const url = URL.createObjectURL(new Blob([text], { type }));
|
|
801
|
+
const link = document.createElement("a");
|
|
802
|
+
link.href = url;
|
|
803
|
+
link.download = name;
|
|
804
|
+
link.click();
|
|
805
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// The desk writes the handover as a folder, out of the choices it keeps,
|
|
809
|
+
// where a new session in the Claude app can read it; `preview` answers
|
|
810
|
+
// what it would write, file by file, and writes nothing.
|
|
811
|
+
const post = async (path, body = {}) => {
|
|
812
|
+
const focus = tools.dataset.part
|
|
813
|
+
? `?part=${encodeURIComponent(tools.dataset.part)}`
|
|
814
|
+
: "";
|
|
815
|
+
const response = await fetch(
|
|
816
|
+
`/bursts/${encodeURIComponent(burst)}/${path}${focus}`,
|
|
817
|
+
{
|
|
818
|
+
method: "POST",
|
|
819
|
+
headers: {
|
|
820
|
+
"Content-Type": "application/json",
|
|
821
|
+
"X-CSRF-Token":
|
|
822
|
+
document.querySelector('meta[name="csrf-token"]')?.content ?? "",
|
|
823
|
+
},
|
|
824
|
+
body: JSON.stringify(body),
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
if (!response.ok) throw new Error(`the desk answered ${response.status}`);
|
|
828
|
+
return response;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
export const previewHandover = async () =>
|
|
832
|
+
(await post("prompt/preview")).text();
|
|
833
|
+
|
|
834
|
+
// `again` takes back what an agent took and never answered: the desk holds
|
|
835
|
+
// it back otherwise, so that two agents never do the same work.
|
|
836
|
+
let handingOver = false;
|
|
837
|
+
export const handOver = async ({ again = false } = {}) => {
|
|
838
|
+
if (handingOver) return;
|
|
839
|
+
handingOver = true;
|
|
840
|
+
const button = tools.querySelector("[data-hand-over]");
|
|
841
|
+
button.disabled = true;
|
|
842
|
+
try {
|
|
843
|
+
announce("Collecting code comments and responses…");
|
|
844
|
+
await rounds.refresh();
|
|
845
|
+
await settled();
|
|
846
|
+
const { file, session, prompt, requests, withAgent } = await (
|
|
847
|
+
await post("prompt", { again: again === true })
|
|
848
|
+
).json();
|
|
849
|
+
// The batch the desk wrote becomes this page's, so its answer is known.
|
|
850
|
+
await rounds.refresh();
|
|
851
|
+
Object.assign(tools.querySelector("[data-new-session]"), {
|
|
852
|
+
href: session,
|
|
853
|
+
title: file,
|
|
854
|
+
hidden: false,
|
|
855
|
+
});
|
|
856
|
+
// A handover of the whole burst takes in what it holds now: what joined
|
|
857
|
+
// or left it is no longer news, and what left with choices has been
|
|
858
|
+
// handed over. A handover of one part leaves the rest as it was.
|
|
859
|
+
if (!part)
|
|
860
|
+
fetch(`/bursts/${encodeURIComponent(burst)}/membership/handed_over`, {
|
|
861
|
+
method: "POST",
|
|
862
|
+
headers: {
|
|
863
|
+
"X-CSRF-Token":
|
|
864
|
+
document.querySelector('meta[name="csrf-token"]')?.content ?? "",
|
|
865
|
+
},
|
|
866
|
+
}).catch(() => {});
|
|
867
|
+
const copied = await navigator.clipboard
|
|
868
|
+
?.writeText(prompt)
|
|
869
|
+
.then(() => true)
|
|
870
|
+
.catch(() => false);
|
|
871
|
+
announce(
|
|
872
|
+
`Handover written, ${plural(requests, "request")}: ${file}. “Open a new session with it” starts one in the Claude app that reads it${copied ? ", and its prompt is on the clipboard" : ""}.${withAgent ? ` ${plural(withAgent, "request")} still with an agent that has not answered ${withAgent === 1 ? "was" : "were"} left out: Returned work can hand them over again.` : ""}`,
|
|
873
|
+
);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
announce(
|
|
876
|
+
`The handover could not be written: ${error.message}. Your browser choices are still here.`,
|
|
877
|
+
);
|
|
878
|
+
} finally {
|
|
879
|
+
handingOver = false;
|
|
880
|
+
button.disabled = false;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
tools.querySelector("[data-hand-over]").addEventListener("click", handOver);
|
|
885
|
+
tools.querySelector("[data-download-choices]").addEventListener("click", () => {
|
|
886
|
+
download(
|
|
887
|
+
JSON.stringify(state, null, 2),
|
|
888
|
+
`proctolog-${burst}-choices.json`,
|
|
889
|
+
"application/json",
|
|
890
|
+
);
|
|
891
|
+
});
|
|
892
|
+
tools
|
|
893
|
+
.querySelector("[data-import-choices]")
|
|
894
|
+
.addEventListener("change", async (event) => {
|
|
895
|
+
const file = event.target.files[0];
|
|
896
|
+
if (!file) return;
|
|
897
|
+
try {
|
|
898
|
+
const restored = merged(state, validate(JSON.parse(await file.text())));
|
|
899
|
+
persist((data) => Object.assign(data, restored));
|
|
900
|
+
undo.length = 0;
|
|
901
|
+
announce("Choices restored. Changed PRs still need review again.");
|
|
902
|
+
} catch (error) {
|
|
903
|
+
announce(`Choices could not be restored: ${error.message}`);
|
|
904
|
+
} finally {
|
|
905
|
+
event.target.value = "";
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// Another tab wrote: this one follows it.
|
|
910
|
+
window.addEventListener("storage", (event) => {
|
|
911
|
+
if (event.key !== storageKey) return;
|
|
912
|
+
const theirs = mirror();
|
|
913
|
+
if (revisionOf(theirs) <= revisionOf(state)) return;
|
|
914
|
+
state = reconcile(theirs);
|
|
915
|
+
changed();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// A page starts from what the desk kept, unless this browser is ahead of it:
|
|
919
|
+
// a choice made while the desk was away, or made before the desk kept choices
|
|
920
|
+
// at all. Those from before are merged into the desk's once, like a backup.
|
|
921
|
+
const begin = () => {
|
|
922
|
+
const desk = fromDesk();
|
|
923
|
+
const here = mirror();
|
|
924
|
+
const ahead = here && (!desk || revisionOf(here) > revisionOf(desk));
|
|
925
|
+
const before = here && desk && !here.revision;
|
|
926
|
+
try {
|
|
927
|
+
state = reconcile(
|
|
928
|
+
before ? merged(desk, here) : ahead ? here : desk || blank(),
|
|
929
|
+
);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
// What this browser held stays where it is, for a backup to be made of it by hand.
|
|
932
|
+
state = reconcile(desk);
|
|
933
|
+
changed();
|
|
934
|
+
announce(
|
|
935
|
+
`This browser's earlier choices could not be joined with the desk's (${error.message}). They are still in this browser's storage; the desk's are shown.`,
|
|
936
|
+
);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (before) state.revision = revisionOf(desk) + 1;
|
|
940
|
+
else if (ahead) state.revision = Math.max(revisionOf(here), 1);
|
|
941
|
+
if (here || desk) writeMirror();
|
|
942
|
+
if (before || ahead) send();
|
|
943
|
+
changed();
|
|
944
|
+
if (unreadable)
|
|
945
|
+
announce(
|
|
946
|
+
"What this browser had saved for this burst could not be read, and was left as it is. The desk's choices are shown, and new ones are saved to the desk.",
|
|
947
|
+
);
|
|
948
|
+
};
|
|
949
|
+
begin();
|