second-read 0.0.0-stage → 0.1.0

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,179 @@
1
+ import { accessSync, closeSync, constants, openSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { 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 text = (kind) => ({ kind, flags: TEXT_FLAGS });
26
+ const comment = { kind: "comment", flags: COMMENT_FLAGS };
27
+ const POSTS = {
28
+ issue: {
29
+ create: text("issue"),
30
+ new: text("issue"),
31
+ edit: text("issue"),
32
+ comment: text("comment"),
33
+ close: comment,
34
+ reopen: comment,
35
+ },
36
+ pr: {
37
+ create: text("pr"),
38
+ new: text("pr"),
39
+ edit: text("pr"),
40
+ comment: text("comment"),
41
+ review: text("review"),
42
+ close: comment,
43
+ reopen: comment,
44
+ merge: { kind: "commit", flags: MERGE_FLAGS, noun: "this merge commit", titleName: "subject" },
45
+ },
46
+ };
47
+ // The text a posting gh command sends, or undefined for any other command, which passes straight through.
48
+ export function parseGh(args) {
49
+ // The command words come first, with only -R and its value among them.
50
+ const words = [];
51
+ for (let i = 0; i < args.length && words.length < 2; i++) {
52
+ const arg = args[i];
53
+ if (arg === "--")
54
+ break;
55
+ if (arg === "-R" || arg === "--repo")
56
+ i++;
57
+ else if (!arg.startsWith("-"))
58
+ words.push(arg);
59
+ }
60
+ const posting = POSTS[words[0] ?? ""]?.[words[1] ?? ""];
61
+ if (posting === undefined)
62
+ return undefined;
63
+ const post = {
64
+ kind: posting.kind,
65
+ noun: posting.noun ?? KIND_NAMES[posting.kind],
66
+ titleName: posting.titleName ?? "title",
67
+ title: [],
68
+ body: [],
69
+ bodyFiles: [],
70
+ };
71
+ for (let i = 0; i < args.length; i++) {
72
+ const arg = args[i];
73
+ if (arg === "--")
74
+ break;
75
+ if (arg === "-R" || arg === "--repo") {
76
+ i++;
77
+ continue;
78
+ }
79
+ let name;
80
+ let value;
81
+ if (arg.startsWith("--")) {
82
+ const eq = arg.indexOf("=");
83
+ name = eq < 0 ? arg : arg.slice(0, eq);
84
+ if (eq >= 0)
85
+ value = arg.slice(eq + 1);
86
+ }
87
+ else if (arg.startsWith("-") && arg.length > 1) {
88
+ // A short flag takes its value from the next argument, or joined: "-tTitle" or "-t=Title".
89
+ name = arg.slice(0, 2);
90
+ if (arg.length > 2)
91
+ value = arg.slice(arg[2] === "=" ? 3 : 2);
92
+ }
93
+ else {
94
+ continue;
95
+ }
96
+ const field = posting.flags[name];
97
+ if (field === undefined)
98
+ continue;
99
+ if (value === undefined)
100
+ value = args[++i];
101
+ if (value === undefined)
102
+ continue;
103
+ if (field === "bodyFile")
104
+ post.bodyFiles.push(value);
105
+ else
106
+ post[field].push(value);
107
+ }
108
+ return post;
109
+ }
110
+ // Each text a posting gh command sends, with where it goes. `read` gives a body file's text, "-" being stdin.
111
+ export function postTexts(post, read) {
112
+ const noun = post.noun;
113
+ return [
114
+ ...post.title.map((t) => [`the ${post.titleName} of ${noun}`, t]),
115
+ ...post.body.map((t) => [`the body of ${noun}`, t]),
116
+ ...post.bodyFiles.map((f) => [
117
+ f === "-" ? `the body of ${noun}` : `the body of ${noun}, from ${f}`,
118
+ read(f),
119
+ ]),
120
+ ];
121
+ }
122
+ export function shimScript(command) {
123
+ return `#!/bin/sh
124
+ ${SHIM_MARKER} It checks what gh is about to post, then runs the real gh.
125
+ exec ${command.map(shQuote).join(" ")} gh-shim "$0" "$@"
126
+ `;
127
+ }
128
+ // The first gh on PATH that isn't this shim: its own folder is skipped, and so is any other copy of the shim,
129
+ // so it can never call itself.
130
+ export function findRealGh(pathEnv, cwd, platform, skipDirs) {
131
+ const skip = new Set(skipDirs.map(real));
132
+ const names = platform === "win32" ? ["gh.exe", "gh.cmd", "gh.bat", "gh"] : ["gh"];
133
+ for (const entry of pathEnv.split(delimiter)) {
134
+ const dir = resolve(cwd, entry);
135
+ if (skip.has(real(dir)))
136
+ continue;
137
+ for (const name of names) {
138
+ const path = join(dir, name);
139
+ if (isExecutableFile(path) && !isShim(path))
140
+ return path;
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+ export function isShim(path) {
146
+ let fd;
147
+ try {
148
+ fd = openSync(path, "r");
149
+ const head = Buffer.alloc(512);
150
+ const read = readSync(fd, head, 0, head.length, 0);
151
+ return head.subarray(0, read).toString("utf8").includes(SHIM_MARKER);
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ finally {
157
+ if (fd !== undefined)
158
+ closeSync(fd);
159
+ }
160
+ }
161
+ function isExecutableFile(path) {
162
+ try {
163
+ if (!statSync(path).isFile())
164
+ return false;
165
+ accessSync(path, constants.X_OK);
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ function real(path) {
173
+ try {
174
+ return realpathSync(path);
175
+ }
176
+ catch {
177
+ return path;
178
+ }
179
+ }