thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
background.js (9388B)
1 // Background script: patch detection on displayed messages, opening the
2 // review tab, and servicing requests from it (series data, compose replies,
3 // apply/edit through the patchHost experiment).
4 //
5 // MV2 background pages can't be ES modules, so shared code is pulled in
6 // with dynamic import().
7
8 "use strict";
9
10 const MBOX_FROM = "From nobody Mon Sep 17 00:00:00 2001\n";
11
12 let modulesPromise = null;
13 function mods() {
14 if (!modulesPromise) {
15 modulesPromise = Promise.all([
16 import(messenger.runtime.getURL("modules/patch-detect.js")),
17 import(messenger.runtime.getURL("modules/series.js")),
18 import(messenger.runtime.getURL("modules/sourcehut.js")),
19 ]).then((loaded) => Object.assign({}, ...loaded));
20 }
21 return modulesPromise;
22 }
23
24 async function getConfig() {
25 const found = await messenger.storage.local.get("config");
26 return Object.assign({ editor: "", strategy: "am3", mappings: [] }, found.config);
27 }
28
29 // Apply, the directory chooser, and the editor launch are backed by the
30 // patchHost experiment bundled under api/patchHost/. It always ships with
31 // the extension, but its privileged code can still fail to load on a
32 // Thunderbird whose internals moved; turn that into a normal error reply.
33 function host() {
34 if (!messenger.patchHost) {
35 throw new Error(
36 "the bundled patchHost helper did not load (incompatible Thunderbird version?)"
37 );
38 }
39 return messenger.patchHost;
40 }
41
42 // ---------------------------------------------------------------------------
43 // Detection: the single toolbar ("Review") button lights up when the current
44 // selection — or the displayed message, in standalone message tabs — looks
45 // like a patch. (The message-header button was dropped: the multi-message
46 // summary view has no header toolbar, so it could never cover threads.)
47
48 messenger.browserAction.disable();
49
50 async function updateAction(tabId, messages) {
51 try {
52 const { isPatchMessage, parseSubject, getPlainBody } = await mods();
53 let patch = messages.some((message) => Boolean(parseSubject(message.subject)));
54 if (!patch && messages.length === 1) {
55 // Subject gave no hint; look for a diff in the body (single message
56 // only — fetching every body of a large selection would be wasteful).
57 patch = isPatchMessage(messages[0].subject, await getPlainBody(messages[0].id));
58 }
59 await messenger.browserAction[patch ? "enable" : "disable"](tabId);
60 } catch (e) {
61 console.warn("patch-review: detection failed", e);
62 }
63 }
64
65 messenger.mailTabs.onSelectedMessagesChanged.addListener((tab, selected) =>
66 updateAction(tab.id, (selected && selected.messages) || [])
67 );
68
69 // Covers messages opened in their own tab or window, where no mail-tab
70 // selection exists.
71 messenger.messageDisplay.onMessageDisplayed.addListener((tab, message) =>
72 updateAction(tab.id, [message])
73 );
74
75 async function openReview(messageId) {
76 await messenger.tabs.create({
77 url: messenger.runtime.getURL(`review/review.html?mid=${messageId}`),
78 });
79 }
80
81 messenger.browserAction.onClicked.addListener(async (tab) => {
82 let list = [];
83 try {
84 const selected = await messenger.mailTabs.getSelectedMessages(tab.id);
85 list = (selected && selected.messages) || [];
86 } catch (e) {
87 // Not a mail tab; fall through to the displayed message.
88 }
89 if (!list.length) {
90 const displayed = await messenger.messageDisplay.getDisplayedMessage(tab.id);
91 if (displayed) {
92 list = [displayed];
93 }
94 }
95 const { parseSubject } = await mods();
96 const message = list.find((header) => parseSubject(header.subject)) || list[0];
97 if (message) {
98 await openReview(message.id);
99 }
100 });
101
102 // ---------------------------------------------------------------------------
103 // Repo resolution: match the message's List-Id (or author) against the
104 // configured mappings.
105
106 async function listKeysFor(messageId) {
107 const keys = [];
108 try {
109 const full = await messenger.messages.getFull(messageId);
110 const listId = full.headers && full.headers["list-id"];
111 if (listId && listId.length) {
112 keys.push(listId[0]);
113 }
114 } catch (e) {
115 // headers unavailable; fall through to author only
116 }
117 return keys;
118 }
119
120 function resolveRepo(config, keys) {
121 for (const mapping of config.mappings || []) {
122 if (!mapping.match || !mapping.repo) {
123 continue;
124 }
125 if (keys.some((k) => k && k.toLowerCase().includes(mapping.match.toLowerCase()))) {
126 return mapping.repo;
127 }
128 }
129 return "";
130 }
131
132 // ---------------------------------------------------------------------------
133 // Requests from the review tab.
134
135 messenger.runtime.onMessage.addListener((request, sender, sendResponse) => {
136 handle(request)
137 .then(sendResponse)
138 .catch((e) => sendResponse({ ok: false, error: e.message || String(e) }));
139 return true; // async response
140 });
141
142 async function handle(request) {
143 const m = await mods();
144
145 switch (request.type) {
146 case "get-series": {
147 const anchor = await messenger.messages.get(request.messageId);
148 const series = await m.collectSeries(anchor);
149 const keys = [...(await listKeysFor(request.messageId)), anchor.author];
150 const config = await getConfig();
151 const { lastRepo } = await messenger.storage.local.get("lastRepo");
152 return {
153 ok: true,
154 series,
155 anchorHeaderMessageId: anchor.headerMessageId,
156 // Explicit mapping first, otherwise the last path that was applied to.
157 repo: resolveRepo(config, keys) || lastRepo || "",
158 listKeys: keys,
159 isSourcehut: m.isSourcehutList([
160 ...keys,
161 ...(anchor.recipients || []),
162 ...(anchor.ccList || []),
163 ]),
164 config,
165 };
166 }
167
168 case "send-review": {
169 // mode "send": send each reply immediately (the compose window opens
170 // and closes; the WebExtension API has no headless send).
171 // mode "preview": leave the compose windows open for the user.
172 const send = request.mode === "send";
173 const status = m.SOURCEHUT_STATUSES.includes(request.status) ? request.status : null;
174 const errors = [];
175 let done = 0;
176 for (const [i, reply] of request.replies.entries()) {
177 const details = {
178 isPlainText: true,
179 plainTextBody: reply.body,
180 };
181 // One status update per patchset: only the first reply carries it.
182 if (status && i === 0) {
183 details.customHeaders = [{ name: m.STATUS_HEADER, value: status }];
184 }
185 const tab = await messenger.compose.beginReply(reply.messageId, "replyToAll", details);
186 if (!send) {
187 done++;
188 continue;
189 }
190 try {
191 await messenger.compose.sendMessage(tab.id, { mode: "sendNow" });
192 done++;
193 } catch (e) {
194 // Leave the failed compose window open so nothing is lost.
195 errors.push(`reply ${i + 1}: ${e.message}`);
196 }
197 }
198 return {
199 ok: errors.length === 0,
200 sent: send ? done : 0,
201 opened: send ? 0 : done,
202 error: errors.length ? `Some replies were not sent:\n${errors.join("\n")}` : undefined,
203 };
204 }
205
206 case "apply-series": {
207 const config = await getConfig();
208 // Remember the path for the next review, until it is changed again.
209 await messenger.storage.local.set({ lastRepo: request.repo });
210 const patches = [];
211 for (const messageId of request.messageIds) {
212 patches.push(await rawMessage(messageId));
213 }
214 return host().apply(request.repo, config.strategy || "am3", patches);
215 }
216
217 case "open-editor": {
218 const config = await getConfig();
219 return host().openEditor(request.repo, config.editor || "", request.files || []);
220 }
221
222 case "download-series": {
223 const parts = [];
224 for (const messageId of request.messageIds) {
225 const raw = await rawMessage(messageId);
226 parts.push(raw.endsWith("\n") ? raw : raw + "\n");
227 }
228 const url = URL.createObjectURL(new Blob(parts, { type: "application/mbox" }));
229 try {
230 await messenger.downloads.download({
231 url,
232 filename: `${request.filename || "patchset"}.mbox`,
233 saveAs: true,
234 });
235 } catch (e) {
236 if (/cancel/i.test(e.message || "")) {
237 return { ok: true, cancelled: true };
238 }
239 throw e;
240 } finally {
241 // Give the download time to read the blob before releasing it.
242 setTimeout(() => URL.revokeObjectURL(url), 120000);
243 }
244 return { ok: true };
245 }
246
247 case "pick-repo":
248 // Native directory chooser; the repo field doubles as its start dir.
249 return host().pickDir(request.start || "");
250
251 case "describe-repo":
252 // Resolve a path to its repository name + current branch (or an error
253 // for not-a-repo). Used by the toolbar to display the target repo.
254 return host().describe(request.repo || "");
255
256 case "ping-host":
257 return host().ping();
258
259 default:
260 throw new Error(`unknown request: ${request.type}`);
261 }
262 }
263
264 /** Raw RFC822 source with an mbox From_ separator, as git am expects. */
265 async function rawMessage(messageId) {
266 let raw = await messenger.messages.getRaw(messageId);
267 if (raw && typeof raw !== "string") {
268 raw = await raw.text(); // newer TB returns a File
269 }
270 if (!raw.startsWith("From ")) {
271 raw = MBOX_FROM + raw;
272 }
273 return raw;
274 }