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

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 }