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 |
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 };