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

implementation.js (7453B)

      1 // Privileged WebExtension Experiment behind Apply series, Browse... and
      2 // Open editor. It ships inside the .xpi, so installing the extension is the
      3 // whole install — there is no separate native messaging host.
      4 //
      5 // This file runs with full chrome privileges in Thunderbird's parent
      6 // process; everything privileged the add-on does lives here. It sticks to
      7 // long-stable platform APIs — Subprocess to run git and the editor,
      8 // nsIFilePicker for the directory chooser, IOUtils/PathUtils for the patch
      9 // workspace — and the fallbacks below cover the differences between
     10 // Thunderbird 115 and current.
     11 
     12 "use strict";
     13 
     14 /* globals ExtensionAPI, Services, IOUtils, PathUtils, AppConstants, ChromeUtils, Cc, Ci */
     15 
     16 // Platform modules moved from .jsm to .sys.mjs across the supported
     17 // Thunderbird range; try the ESM spelling first.
     18 function importModule(name) {
     19   try {
     20     return ChromeUtils.importESModule(`resource://gre/modules/${name}.sys.mjs`);
     21   } catch (e) {
     22     return ChromeUtils.import(`resource://gre/modules/${name}.jsm`);
     23   }
     24 }
     25 
     26 const { Subprocess } = importModule("Subprocess");
     27 
     28 /** Run a command to completion, stderr folded into stdout. */
     29 async function run(command, args) {
     30   const proc = await Subprocess.call({
     31     command,
     32     arguments: args,
     33     stderr: "stdout",
     34   });
     35   let output = "";
     36   let chunk;
     37   while ((chunk = await proc.stdout.readString())) {
     38     output += chunk;
     39   }
     40   const { exitCode } = await proc.wait();
     41   return { exitCode, output };
     42 }
     43 
     44 async function findGit() {
     45   try {
     46     return await Subprocess.pathSearch("git", Subprocess.getEnvironment());
     47   } catch (e) {
     48     // Thunderbird launched from a desktop entry or .app bundle may see a
     49     // minimal PATH; try the usual install locations before giving up.
     50     for (const candidate of ["/usr/local/bin/git", "/opt/homebrew/bin/git", "/usr/bin/git"]) {
     51       if (await IOUtils.exists(candidate)) {
     52         return candidate;
     53       }
     54     }
     55     throw new Error("git not found in PATH");
     56   }
     57 }
     58 
     59 // Verify repo is a git worktree and return a short description for the
     60 // toolbar. The name is the top-level dir basename (what the user picked);
     61 // the branch is the checked-out branch or detached short SHA.
     62 async function describeRepo(git, repo) {
     63   if (!repo) {
     64     throw new Error("no repository path given");
     65   }
     66   const check = await run(git, ["-C", repo, "rev-parse", "--is-inside-work-tree"]);
     67   if (check.exitCode !== 0) {
     68     throw new Error(`not a git repository: ${repo}`);
     69   }
     70   const showToplevel = await run(git, ["-C", repo, "rev-parse", "--show-toplevel"]);
     71   const toplevel = showToplevel.output.trim();
     72   const name = toplevel ? toplevel.replace(/[\\/]+$/, "").split(/[\\/]/).pop() : repo;
     73   let branch = await run(git, ["-C", repo, "symbolic-ref", "--short", "HEAD"]);
     74   if (branch.exitCode !== 0) {
     75     // Detached HEAD: fall back to the short commit hash.
     76     branch = await run(git, ["-C", repo, "rev-parse", "--short", "HEAD"]);
     77   }
     78   return { ok: true, repo: toplevel || repo, name, branch: branch.output.trim() };
     79 }
     80 
     81 async function ping() {
     82   const git = await findGit();
     83   const version = await run(git, ["--version"]);
     84   return { ok: true, git, version: version.output.trim() };
     85 }
     86 
     87 async function describe(repo) {
     88   const git = await findGit();
     89   return describeRepo(git, repo);
     90 }
     91 
     92 async function apply(repo, strategy, patches) {
     93   const git = await findGit();
     94   await describeRepo(git, repo);
     95   if (!patches.length) {
     96     throw new Error("no patches in request");
     97   }
     98 
     99   const work = PathUtils.join(
    100     PathUtils.tempDir,
    101     `patch-review.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
    102   );
    103   await IOUtils.makeDirectory(work, { permissions: 0o700 });
    104   try {
    105     const args = ["-C", repo, "am"];
    106     if (strategy !== "am") {
    107       args.push("--3way");
    108     }
    109     for (const [i, patch] of patches.entries()) {
    110       const file = PathUtils.join(work, `${String(i + 1).padStart(4, "0")}.patch`);
    111       await IOUtils.writeUTF8(file, patch);
    112       args.push(file);
    113     }
    114     const am = await run(git, args);
    115     if (am.exitCode === 0) {
    116       return { ok: true, output: am.output };
    117     }
    118     const abort = await run(git, ["-C", repo, "am", "--abort"]);
    119     return {
    120       ok: false,
    121       output:
    122         am.output +
    123         abort.output +
    124         "(git am --abort ran; the repository is back to its previous state)\n",
    125     };
    126   } finally {
    127     IOUtils.remove(work, { recursive: true }).catch(() => {});
    128   }
    129 }
    130 
    131 async function pickDir(start) {
    132   const win = Services.wm.getMostRecentWindow(null);
    133   if (!win) {
    134     throw new Error("no window to attach the directory chooser to");
    135   }
    136   const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    137   // nsIFilePicker.init took a window before Thunderbird ~125 and takes a
    138   // BrowsingContext after; whichever the running version rejects, throws.
    139   try {
    140     picker.init(win.browsingContext, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
    141   } catch (e) {
    142     picker.init(win, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
    143   }
    144   if (start) {
    145     try {
    146       const dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
    147       dir.initWithPath(start);
    148       if (dir.exists() && dir.isDirectory()) {
    149         picker.displayDirectory = dir;
    150       }
    151     } catch (e) {
    152       // Not a usable start path; the picker opens at its default.
    153     }
    154   }
    155   const rv = await new Promise((resolve) => picker.open(resolve));
    156   if (rv !== Ci.nsIFilePicker.returnOK || !picker.file) {
    157     return { ok: true, output: "", cancelled: true };
    158   }
    159   return { ok: true, output: picker.file.path };
    160 }
    161 
    162 async function openEditor(repo, editor, files) {
    163   const git = await findGit();
    164   await describeRepo(git, repo);
    165   const env = Subprocess.getEnvironment();
    166   let command = (editor || "").trim() || (env.EDITOR || "").trim();
    167   if (!command) {
    168     // Nothing configured anywhere: open the repository directory instead.
    169     command = { macosx: "open", win: "start" }[AppConstants.platform] || "xdg-open";
    170     files = ["."];
    171   }
    172 
    173   if (AppConstants.platform === "win") {
    174     const line = [command, ...files.map((f) => `"${f}"`)].join(" ");
    175     await Subprocess.call({
    176       command: env.ComSpec || "C:\\Windows\\System32\\cmd.exe",
    177       arguments: ["/s", "/c", line],
    178       workdir: repo,
    179       stderr: "ignore",
    180     });
    181   } else {
    182     // The command may carry flags ("code -n"); let sh split it and append
    183     // the files. Output goes to /dev/null so a chatty editor can never
    184     // fill the pipe, and the editor is not waited on.
    185     await Subprocess.call({
    186       command: "/bin/sh",
    187       arguments: ["-c", `exec ${command} "$@" >/dev/null 2>&1`, "patch-review-edit", ...files],
    188       workdir: repo,
    189       stderr: "ignore",
    190     });
    191   }
    192   return { ok: true, output: `launched: ${command}` };
    193 }
    194 
    195 /** Errors become {ok: false, error} so callers get one uniform shape. */
    196 async function guarded(fn) {
    197   try {
    198     return await fn();
    199   } catch (e) {
    200     return { ok: false, error: e.message || String(e) };
    201   }
    202 }
    203 
    204 var patchHost = class extends ExtensionAPI {
    205   getAPI() {
    206     return {
    207       patchHost: {
    208         ping: () => guarded(ping),
    209         describe: (repo) => guarded(() => describe(repo)),
    210         apply: (repo, strategy, patches) => guarded(() => apply(repo, strategy, patches)),
    211         pickDir: (start) => guarded(() => pickDir(start)),
    212         openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
    213       },
    214     };
    215   }
    216 };