second-read 0.0.0-stage → 0.1.1

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.
package/dist/gh.js ADDED
@@ -0,0 +1,350 @@
1
+ import { accessSync, closeSync, constants, openSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, delimiter, join, resolve } from "node:path";
3
+ import { KIND_NAMES } from "./checks.js";
4
+ import { shQuote } from "./git.js";
5
+ export const SHIM_MARKER = "# Added by second-read: the gh shim.";
6
+ const TEXT_FLAGS = {
7
+ "--title": "title",
8
+ "-t": "title",
9
+ "--body": "body",
10
+ "-b": "body",
11
+ "--body-file": "bodyFile",
12
+ "-F": "bodyFile",
13
+ };
14
+ // Closing or reopening with a comment posts the comment.
15
+ const COMMENT_FLAGS = { "--comment": "body", "-c": "body" };
16
+ // A merge commit's message: its subject and body.
17
+ const MERGE_FLAGS = {
18
+ "--subject": "title",
19
+ "-t": "title",
20
+ "--body": "body",
21
+ "-b": "body",
22
+ "--body-file": "bodyFile",
23
+ "-F": "bodyFile",
24
+ };
25
+ const RELEASE_FLAGS = {
26
+ "--title": "title",
27
+ "-t": "title",
28
+ "--notes": "body",
29
+ "-n": "body",
30
+ "--notes-file": "bodyFile",
31
+ "-F": "bodyFile",
32
+ };
33
+ const DESCRIPTION_FLAGS = { "--description": "body", "-d": "body" };
34
+ const text = (kind, valued = []) => ({ kind, flags: TEXT_FLAGS, valued });
35
+ const comment = (valued = []) => ({ kind: "comment", flags: COMMENT_FLAGS, valued });
36
+ const ISSUE_CREATE = ["--assignee", "-a", "--label", "-l", "--milestone", "-m", "--project", "-p", "--recover",
37
+ "--template", "-T"];
38
+ const ISSUE_EDIT = ["--add-assignee", "--add-label", "--add-project", "--milestone", "-m", "--remove-assignee",
39
+ "--remove-label", "--remove-project"];
40
+ const PR_CREATE = [...ISSUE_CREATE, "--base", "-B", "--head", "-H", "--reviewer", "-r"];
41
+ const PR_EDIT = [...ISSUE_EDIT, "--add-reviewer", "--base", "-B", "--remove-reviewer"];
42
+ // A post's kind only picks how it's checked, and these check like a comment; each has its own noun.
43
+ const release = (posting) => ({
44
+ kind: "comment",
45
+ flags: RELEASE_FLAGS,
46
+ valued: ["--discussion-category", "--target"],
47
+ noun: "this release",
48
+ bodyName: "notes",
49
+ nameName: "tag",
50
+ ...posting,
51
+ });
52
+ // gh release create [<tag>] [<asset>...]
53
+ const releaseCreate = release({
54
+ valued: ["--discussion-category", "--notes-start-tag", "--target"],
55
+ args: (post, [tag]) => {
56
+ if (tag !== undefined)
57
+ post.names.push(tag);
58
+ },
59
+ });
60
+ // A repository's name, from gh repo create [<name>] or gh repo rename [<new name>].
61
+ const repository = (posting) => ({
62
+ kind: "comment",
63
+ flags: DESCRIPTION_FLAGS,
64
+ valued: [],
65
+ noun: "this repository",
66
+ bodyName: "description",
67
+ ...posting,
68
+ });
69
+ const repoName = (post, [name]) => {
70
+ if (name !== undefined)
71
+ post.names.push(name);
72
+ };
73
+ const repoCreate = repository({
74
+ valued: ["--gitignore", "-g", "--homepage", "-h", "--license", "-l", "--remote", "-r", "--source", "-s", "--team",
75
+ "-t", "--template", "-p"],
76
+ args: repoName,
77
+ });
78
+ // A gist's files are a new post of their own, not lines added to the repository's files, so exempt_files never
79
+ // applies to them.
80
+ const gist = (posting) => ({
81
+ kind: "comment",
82
+ flags: { "--desc": "body", "-d": "body" },
83
+ valued: [],
84
+ noun: "this gist",
85
+ bodyName: "description",
86
+ fileName: "contents",
87
+ nameName: "file name",
88
+ ...posting,
89
+ });
90
+ // Each argument is a file to upload, "-" being stdin. With none, it uploads stdin when it's piped in. A file is
91
+ // uploaded under its own name, and stdin under the name --filename gives.
92
+ const gistCreate = gist({
93
+ flags: { "--desc": "body", "-d": "body", "--filename": "name", "-f": "name" },
94
+ args: (post, files) => {
95
+ const named = post.names.at(-1);
96
+ post.names = [];
97
+ if (files.length === 0) {
98
+ files = ["-"];
99
+ post.stdinIfPiped = true;
100
+ }
101
+ post.bodyFiles.push(...files);
102
+ for (const file of files) {
103
+ if (file !== "-")
104
+ post.names.push(basename(file));
105
+ else if (named !== undefined)
106
+ post.names.push(named);
107
+ }
108
+ },
109
+ });
110
+ const label = (posting) => ({
111
+ kind: "comment",
112
+ flags: DESCRIPTION_FLAGS,
113
+ valued: ["--color", "-c"],
114
+ noun: "this label",
115
+ titleName: "name",
116
+ bodyName: "description",
117
+ ...posting,
118
+ });
119
+ const POSTS = {
120
+ issue: {
121
+ create: text("issue", ISSUE_CREATE),
122
+ new: text("issue", ISSUE_CREATE),
123
+ edit: text("issue", ISSUE_EDIT),
124
+ comment: text("comment"),
125
+ close: comment(["--duplicate-of", "--reason", "-r"]),
126
+ reopen: comment(),
127
+ },
128
+ pr: {
129
+ create: text("pr", PR_CREATE),
130
+ new: text("pr", PR_CREATE),
131
+ edit: text("pr", PR_EDIT),
132
+ comment: text("comment"),
133
+ review: text("review"),
134
+ close: comment(),
135
+ reopen: comment(),
136
+ merge: {
137
+ kind: "commit",
138
+ flags: MERGE_FLAGS,
139
+ valued: ["--author-email", "-A", "--match-head-commit"],
140
+ noun: "this merge commit",
141
+ titleName: "subject",
142
+ },
143
+ },
144
+ release: {
145
+ create: releaseCreate,
146
+ new: releaseCreate,
147
+ // The tag it's given now is checked, not the one it had, so a release's tag can be renamed.
148
+ edit: release({ flags: { ...RELEASE_FLAGS, "--tag": "name" } }),
149
+ },
150
+ gist: {
151
+ create: gistCreate,
152
+ new: gistCreate,
153
+ // gh gist edit <gist> [<file>]: the file replaces the one --filename picks, or is uploaded under the name --add
154
+ // gives. Without it, --add uploads the file it names.
155
+ edit: gist({
156
+ flags: { "--desc": "body", "-d": "body", "--add": "bodyFile", "-a": "bodyFile" },
157
+ valued: ["--filename", "-f", "--remove", "-r"],
158
+ args: (post, [, file]) => {
159
+ const added = post.bodyFiles.at(-1);
160
+ if (added !== undefined)
161
+ post.names.push(basename(added));
162
+ if (file !== undefined)
163
+ post.bodyFiles = [file];
164
+ },
165
+ }),
166
+ },
167
+ label: {
168
+ create: label({
169
+ args: (post, [name]) => {
170
+ if (name !== undefined)
171
+ post.title.push(name);
172
+ },
173
+ }),
174
+ // The name it's given now is checked, not the one it had, so a label can be renamed.
175
+ edit: label({ flags: { ...DESCRIPTION_FLAGS, "--name": "title", "-n": "title" } }),
176
+ },
177
+ repo: {
178
+ create: repoCreate,
179
+ new: repoCreate,
180
+ edit: repository({
181
+ valued: ["--add-topic", "--default-branch", "--homepage", "-h", "--remove-topic",
182
+ "--squash-merge-commit-message", "--visibility"],
183
+ }),
184
+ rename: repository({ flags: {}, args: repoName }),
185
+ },
186
+ };
187
+ // The text a posting gh command sends, or undefined for any other command, which passes straight through.
188
+ export function parseGh(args) {
189
+ // The command words come first, with only -R and its value among them.
190
+ const words = [];
191
+ for (let i = 0; i < args.length && words.length < 2; i++) {
192
+ const arg = args[i];
193
+ if (arg === "--")
194
+ break;
195
+ if (arg === "-R" || arg === "--repo")
196
+ i++;
197
+ else if (!arg.startsWith("-"))
198
+ words.push(arg);
199
+ }
200
+ const posting = POSTS[words[0] ?? ""]?.[words[1] ?? ""];
201
+ if (posting === undefined)
202
+ return undefined;
203
+ const post = {
204
+ kind: posting.kind,
205
+ noun: posting.noun ?? KIND_NAMES[posting.kind],
206
+ titleName: posting.titleName ?? "title",
207
+ bodyName: posting.bodyName ?? "body",
208
+ fileName: posting.fileName ?? posting.bodyName ?? "body",
209
+ nameName: posting.nameName ?? "name",
210
+ title: [],
211
+ body: [],
212
+ bodyFiles: [],
213
+ names: [],
214
+ };
215
+ const takesValue = (flag) => flag in posting.flags || posting.valued.includes(flag) || flag === "-R" || flag === "--repo";
216
+ // The arguments, the command words first.
217
+ const positional = [];
218
+ for (let i = 0; i < args.length; i++) {
219
+ const arg = args[i];
220
+ if (arg === "--") {
221
+ positional.push(...args.slice(i + 1));
222
+ break;
223
+ }
224
+ let name;
225
+ let value;
226
+ if (arg.startsWith("--")) {
227
+ const eq = arg.indexOf("=");
228
+ name = eq < 0 ? arg : arg.slice(0, eq);
229
+ if (eq >= 0)
230
+ value = arg.slice(eq + 1);
231
+ }
232
+ else if (arg.startsWith("-") && arg.length > 1) {
233
+ const flag = shortFlag(arg, takesValue);
234
+ if (flag === undefined)
235
+ continue;
236
+ ({ name, value } = flag);
237
+ }
238
+ else {
239
+ positional.push(arg);
240
+ continue;
241
+ }
242
+ const field = posting.flags[name];
243
+ if (field === undefined) {
244
+ if (value === undefined && takesValue(name))
245
+ i++;
246
+ continue;
247
+ }
248
+ if (value === undefined)
249
+ value = args[++i];
250
+ if (value === undefined)
251
+ continue;
252
+ if (field === "bodyFile")
253
+ post.bodyFiles.push(value);
254
+ else if (field === "name")
255
+ post.names.push(value);
256
+ else
257
+ post[field].push(value);
258
+ }
259
+ posting.args?.(post, positional.slice(2));
260
+ return post;
261
+ }
262
+ // A group of short flags, read as gh reads them: the first that takes a value takes the rest of the argument, past
263
+ // an "=", or the next argument, which `value` is left undefined for. Those before it are switches, and a switch
264
+ // given "=" takes the rest as its own. Undefined when the group is all switches.
265
+ export function shortFlag(arg, takesValue) {
266
+ let j = 1;
267
+ for (; j < arg.length && !takesValue(`-${arg[j]}`); j++) {
268
+ if (arg[j + 1] === "=" && j + 2 < arg.length)
269
+ return undefined;
270
+ }
271
+ if (j === arg.length)
272
+ return undefined;
273
+ if (j + 1 === arg.length)
274
+ return { name: `-${arg[j]}`, value: undefined };
275
+ return { name: `-${arg[j]}`, value: arg.slice(arg[j + 1] === "=" && j + 2 < arg.length ? j + 2 : j + 1) };
276
+ }
277
+ // Each text a posting gh command sends, with where it goes. `read` gives a body file's text, "-" being stdin.
278
+ export function postTexts(post, read) {
279
+ const noun = post.noun;
280
+ return [
281
+ ...post.title.map((t) => [`the ${post.titleName} of ${noun}`, t]),
282
+ ...post.body.map((t) => [`the ${post.bodyName} of ${noun}`, t]),
283
+ ...post.bodyFiles.map((f) => [
284
+ f === "-" ? `the ${post.fileName} of ${noun}` : `the ${post.fileName} of ${noun}, from ${f}`,
285
+ read(f),
286
+ ]),
287
+ ];
288
+ }
289
+ // Each name a posting gh command gives, with where it goes. A name is checked as a branch name is.
290
+ export function postNames(post) {
291
+ return post.names.map((n) => [`the ${post.nameName} of ${post.noun}`, n]);
292
+ }
293
+ export function shimScript(command) {
294
+ return `#!/bin/sh
295
+ ${SHIM_MARKER} It checks what gh is about to post, then runs the real gh.
296
+ exec ${command.map(shQuote).join(" ")} gh-shim "$0" "$@"
297
+ `;
298
+ }
299
+ // The first gh on PATH that isn't this shim: its own folder is skipped, and so is any other copy of the shim,
300
+ // so it can never call itself.
301
+ export function findRealGh(pathEnv, cwd, platform, skipDirs) {
302
+ const skip = new Set(skipDirs.map(real));
303
+ const names = platform === "win32" ? ["gh.exe", "gh.cmd", "gh.bat", "gh"] : ["gh"];
304
+ for (const entry of pathEnv.split(delimiter)) {
305
+ const dir = resolve(cwd, entry);
306
+ if (skip.has(real(dir)))
307
+ continue;
308
+ for (const name of names) {
309
+ const path = join(dir, name);
310
+ if (isExecutableFile(path) && !isShim(path))
311
+ return path;
312
+ }
313
+ }
314
+ return undefined;
315
+ }
316
+ export function isShim(path) {
317
+ let fd;
318
+ try {
319
+ fd = openSync(path, "r");
320
+ const head = Buffer.alloc(512);
321
+ const read = readSync(fd, head, 0, head.length, 0);
322
+ return head.subarray(0, read).toString("utf8").includes(SHIM_MARKER);
323
+ }
324
+ catch {
325
+ return false;
326
+ }
327
+ finally {
328
+ if (fd !== undefined)
329
+ closeSync(fd);
330
+ }
331
+ }
332
+ function isExecutableFile(path) {
333
+ try {
334
+ if (!statSync(path).isFile())
335
+ return false;
336
+ accessSync(path, constants.X_OK);
337
+ return true;
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ }
343
+ function real(path) {
344
+ try {
345
+ return realpathSync(path);
346
+ }
347
+ catch {
348
+ return path;
349
+ }
350
+ }