@blogic-cz/agent-tools 1.12.0 → 1.13.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/README.md +1 -0
- package/dist/gh-tool/pr/checkbox-markdown.d.ts +22 -0
- package/dist/gh-tool/pr/checkbox-markdown.d.ts.map +1 -0
- package/dist/gh-tool/pr/checkbox.d.ts +21 -0
- package/dist/gh-tool/pr/checkbox.d.ts.map +1 -0
- package/dist/gh-tool/pr/commands.d.ts +13 -4
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/index.d.ts +1 -1
- package/dist/gh-tool/pr/index.d.ts.map +1 -1
- package/dist/gh-tool/read/porcelain.d.ts +4 -2
- package/dist/gh-tool/read/porcelain.d.ts.map +1 -1
- package/dist/gh-tool/read/pulls.d.ts +1 -0
- package/dist/gh-tool/read/pulls.d.ts.map +1 -1
- package/dist/gh-tool/read/reads.d.ts +3 -0
- package/dist/gh-tool/read/reads.d.ts.map +1 -1
- package/dist/gh-tool/read/transport.d.ts +9 -0
- package/dist/gh-tool/read/transport.d.ts.map +1 -1
- package/dist/gh-tool/read/writes.d.ts +2 -1
- package/dist/gh-tool/read/writes.d.ts.map +1 -1
- package/dist/gh-tool/types.d.ts +2 -1
- package/dist/gh-tool/types.d.ts.map +1 -1
- package/dist/gh-tool/workflow.d.ts +2 -1
- package/dist/gh-tool/workflow.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/gh-tool/main.ts +2 -0
- package/src/gh-tool/pr/checkbox-markdown.ts +317 -0
- package/src/gh-tool/pr/checkbox.ts +113 -0
- package/src/gh-tool/pr/commands.ts +66 -1
- package/src/gh-tool/pr/core.ts +61 -28
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/read/porcelain.ts +2 -1
- package/src/gh-tool/read/pulls.ts +19 -0
- package/src/gh-tool/read/reads.ts +2 -1
- package/src/gh-tool/read/transport.ts +85 -1
- package/src/gh-tool/read/writes.ts +19 -2
- package/src/gh-tool/types.ts +9 -1
- package/src/gh-tool/workflow.ts +63 -11
- package/src/shared/content-security.ts +1 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
export type CheckboxOutlineEntry =
|
|
2
|
+
| { readonly type: "task"; readonly text: string; readonly checked: boolean }
|
|
3
|
+
| { readonly type: "heading"; readonly text: string; readonly level: number };
|
|
4
|
+
|
|
5
|
+
export type CheckboxOutlineParser = (body: string) => readonly CheckboxOutlineEntry[] | null;
|
|
6
|
+
|
|
7
|
+
type CheckboxLine = {
|
|
8
|
+
readonly before: string;
|
|
9
|
+
readonly offset: number;
|
|
10
|
+
readonly boxIndex: number;
|
|
11
|
+
readonly checked: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ScannedEntry = CheckboxOutlineEntry & {
|
|
15
|
+
readonly offset?: number;
|
|
16
|
+
readonly boxIndex?: number;
|
|
17
|
+
readonly before?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Fence = {
|
|
21
|
+
readonly marker: "`" | "~";
|
|
22
|
+
readonly length: number;
|
|
23
|
+
readonly listIndent: number | null;
|
|
24
|
+
};
|
|
25
|
+
type ListItemIndent = { readonly indent: number; readonly contentIndent: number };
|
|
26
|
+
|
|
27
|
+
const taskSuffix = /^[ \t]*\[([ xX])\][ \t]*(.*)$/;
|
|
28
|
+
const listMarker = /^( {0,3})(?:[-*+]|[0-9]+[.)])([ \t]+)/;
|
|
29
|
+
const quoteMarker = /^ {0,3}>[ \t]?/;
|
|
30
|
+
const plainSourceText = (text: string) =>
|
|
31
|
+
text
|
|
32
|
+
.replace(/(\*\*|__|~~|\*|_)(\S(?:.*?\S)?)\1/g, "$2")
|
|
33
|
+
.replace(/&/g, "&")
|
|
34
|
+
.replace(/</g, "<")
|
|
35
|
+
.replace(/>/g, ">")
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'|'/g, "'")
|
|
38
|
+
.trim();
|
|
39
|
+
|
|
40
|
+
const removeContainers = (line: string) => {
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const rest = line.slice(cursor);
|
|
44
|
+
const quote = quoteMarker.exec(rest);
|
|
45
|
+
if (quote !== null) {
|
|
46
|
+
cursor += quote[0].length;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const list = listMarker.exec(rest);
|
|
50
|
+
if (list !== null) {
|
|
51
|
+
cursor += list[0].length;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
return { text: line.slice(cursor), cursor };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const openingFence = (line: string, listIndent: number | null): Fence | null => {
|
|
59
|
+
const content = removeContainers(line).text;
|
|
60
|
+
const match = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)\r?$/.exec(content);
|
|
61
|
+
if (match === null) return null;
|
|
62
|
+
const marker = match[1]?.[0];
|
|
63
|
+
const info = match[2] ?? "";
|
|
64
|
+
if ((marker !== "`" && marker !== "~") || (marker === "`" && info.includes("`"))) return null;
|
|
65
|
+
return { marker, length: match[1]?.length ?? 0, listIndent };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const closesFence = (line: string, fence: Fence) => {
|
|
69
|
+
const content = removeContainers(line).text;
|
|
70
|
+
const match = /^ {0,3}(`+|~+)[ \t]*\r?$/.exec(content);
|
|
71
|
+
const markers = match?.[1] ?? "";
|
|
72
|
+
return markers.length >= fence.length && markers[0] === fence.marker;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const headingFromLine = (line: string) => {
|
|
76
|
+
const { text } = removeContainers(line);
|
|
77
|
+
const match = /^ {0,3}(#{1,6})[ \t]+(.*?)\r?$/.exec(text);
|
|
78
|
+
if (match === null) return null;
|
|
79
|
+
return {
|
|
80
|
+
level: match[1]?.length ?? 0,
|
|
81
|
+
text: plainSourceText((match[2] ?? "").replace(/[ \t]+#+[ \t]*$/, "")),
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const scanOutline = (body: string): ScannedEntry[] => {
|
|
86
|
+
const entries: ScannedEntry[] = [];
|
|
87
|
+
const lines = body.match(/[^\n]*(?:\n|$)/g) ?? [];
|
|
88
|
+
let offset = 0;
|
|
89
|
+
let fence: Fence | null = null;
|
|
90
|
+
const listItems: ListItemIndent[] = [];
|
|
91
|
+
|
|
92
|
+
for (const raw of lines) {
|
|
93
|
+
if (raw.length === 0) continue;
|
|
94
|
+
const line = (raw.endsWith("\n") ? raw.slice(0, -1) : raw).replace(/\r$/, "");
|
|
95
|
+
const lineStart = offset;
|
|
96
|
+
offset += raw.length;
|
|
97
|
+
|
|
98
|
+
const marker = listMarker.exec(line);
|
|
99
|
+
if (marker !== null) {
|
|
100
|
+
const indent = marker[1]?.length ?? 0;
|
|
101
|
+
while ((listItems.at(-1)?.indent ?? -1) >= indent) {
|
|
102
|
+
listItems.pop();
|
|
103
|
+
}
|
|
104
|
+
listItems.push({ indent, contentIndent: indent + marker[0].length });
|
|
105
|
+
} else if (line.trim() !== "") {
|
|
106
|
+
const leading = /^ */.exec(line)?.[0].length ?? 0;
|
|
107
|
+
while (listItems.length > 0 && leading < (listItems.at(-1)?.contentIndent ?? 0)) {
|
|
108
|
+
listItems.pop();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (fence !== null) {
|
|
113
|
+
if (fence.listIndent !== null && line.trim() !== "") {
|
|
114
|
+
const leading = /^ */.exec(line)?.[0].length ?? 0;
|
|
115
|
+
if (leading < fence.listIndent) fence = null;
|
|
116
|
+
}
|
|
117
|
+
if (fence !== null) {
|
|
118
|
+
if (closesFence(line, fence)) fence = null;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const context = listItems.at(-1);
|
|
124
|
+
const opening = openingFence(line, context?.contentIndent ?? null);
|
|
125
|
+
if (opening !== null) {
|
|
126
|
+
fence = opening;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const heading = headingFromLine(line);
|
|
131
|
+
if (heading !== null) {
|
|
132
|
+
entries.push({ type: "heading", ...heading });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { text, cursor } = removeContainers(line);
|
|
137
|
+
const task = taskSuffix.exec(text);
|
|
138
|
+
if (task === null) continue;
|
|
139
|
+
const checked = task[1] !== " ";
|
|
140
|
+
const boxIndex = cursor + (text.indexOf("[") + 1);
|
|
141
|
+
const content = plainSourceText(task[2] ?? "");
|
|
142
|
+
entries.push({
|
|
143
|
+
type: "task",
|
|
144
|
+
text: content,
|
|
145
|
+
checked,
|
|
146
|
+
offset: lineStart + boxIndex,
|
|
147
|
+
boxIndex,
|
|
148
|
+
before: line.replace(/\r$/, ""),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return entries;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const tokenPrefix = "\uE000checkbox:";
|
|
155
|
+
const tokenSuffix = ":\uE001";
|
|
156
|
+
const makeToken = (id: number) => `${tokenPrefix}${id}${tokenSuffix}`;
|
|
157
|
+
const tokenPattern = /\uE000checkbox:(\d+):\uE001/g;
|
|
158
|
+
|
|
159
|
+
export const bunMarkdownOutline: CheckboxOutlineParser = (body) => {
|
|
160
|
+
const markdown = (globalThis as typeof globalThis & { Bun?: typeof Bun }).Bun?.markdown;
|
|
161
|
+
if (markdown?.render === undefined) return null;
|
|
162
|
+
|
|
163
|
+
const nodes: Array<{ readonly entry: CheckboxOutlineEntry | null; readonly children: string }> =
|
|
164
|
+
[];
|
|
165
|
+
const token = (entry: CheckboxOutlineEntry | null, children: string) => {
|
|
166
|
+
const id = nodes.push({ entry, children }) - 1;
|
|
167
|
+
return makeToken(id);
|
|
168
|
+
};
|
|
169
|
+
const plain = (value: string) => value.replace(tokenPattern, "").trim();
|
|
170
|
+
|
|
171
|
+
const rendered = markdown.render(body, {
|
|
172
|
+
text: (children) => children,
|
|
173
|
+
strong: (children) => children,
|
|
174
|
+
emphasis: (children) => children,
|
|
175
|
+
strikethrough: (children) => children,
|
|
176
|
+
link: (children) => children,
|
|
177
|
+
image: (children) => children,
|
|
178
|
+
codespan: (children) => children,
|
|
179
|
+
heading: (children, meta) =>
|
|
180
|
+
token({ type: "heading", level: meta.level, text: plain(children) }, children),
|
|
181
|
+
listItem: (children, meta) =>
|
|
182
|
+
token(
|
|
183
|
+
typeof meta.checked === "boolean"
|
|
184
|
+
? { type: "task", checked: meta.checked, text: plain(children) }
|
|
185
|
+
: null,
|
|
186
|
+
children,
|
|
187
|
+
),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const outline: CheckboxOutlineEntry[] = [];
|
|
191
|
+
const expand = (value: string) => {
|
|
192
|
+
for (const match of value.matchAll(tokenPattern)) {
|
|
193
|
+
const idText = match[1];
|
|
194
|
+
const id = idText === undefined ? -1 : Number(idText);
|
|
195
|
+
const node = nodes[id];
|
|
196
|
+
if (node === undefined || match.index === undefined)
|
|
197
|
+
throw new Error("Invalid Bun Markdown callback outline");
|
|
198
|
+
if (node.entry !== null) outline.push(node.entry);
|
|
199
|
+
expand(node.children);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
expand(rendered);
|
|
203
|
+
return outline;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const sameOutline = (scanned: readonly ScannedEntry[], parsed: readonly CheckboxOutlineEntry[]) => {
|
|
207
|
+
return (
|
|
208
|
+
scanned.length === parsed.length &&
|
|
209
|
+
scanned.every((entry, index) => {
|
|
210
|
+
const actual = parsed[index];
|
|
211
|
+
return (
|
|
212
|
+
actual !== undefined &&
|
|
213
|
+
entry.type === actual.type &&
|
|
214
|
+
entry.text === actual.text &&
|
|
215
|
+
(entry.type !== "heading" || (actual.type === "heading" && entry.level === actual.level)) &&
|
|
216
|
+
(entry.type !== "task" || (actual.type === "task" && entry.checked === actual.checked))
|
|
217
|
+
);
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const sameEntry = (left: CheckboxOutlineEntry, right: CheckboxOutlineEntry) =>
|
|
223
|
+
left.type === right.type &&
|
|
224
|
+
left.text === right.text &&
|
|
225
|
+
(left.type !== "heading" || (right.type === "heading" && left.level === right.level)) &&
|
|
226
|
+
(left.type !== "task" || (right.type === "task" && left.checked === right.checked));
|
|
227
|
+
|
|
228
|
+
export const validateCheckboxEdit = (
|
|
229
|
+
body: string,
|
|
230
|
+
offset: number,
|
|
231
|
+
replacement: " " | "x",
|
|
232
|
+
parseOutline: CheckboxOutlineParser = bunMarkdownOutline,
|
|
233
|
+
) => {
|
|
234
|
+
const scanned = scanOutline(body);
|
|
235
|
+
const original = parseOutline(body);
|
|
236
|
+
const selectedIndex = scanned.findIndex(
|
|
237
|
+
(entry) => entry.type === "task" && entry.offset === offset,
|
|
238
|
+
);
|
|
239
|
+
if (
|
|
240
|
+
original === null ||
|
|
241
|
+
!sameOutline(scanned, original) ||
|
|
242
|
+
selectedIndex < 0 ||
|
|
243
|
+
original[selectedIndex]?.type !== "task" ||
|
|
244
|
+
body[offset] === undefined ||
|
|
245
|
+
(replacement !== " " && replacement !== "x")
|
|
246
|
+
) {
|
|
247
|
+
throw new Error("PR body structure is ambiguous; tick this item in the GitHub UI");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const editedBody = `${body.slice(0, offset)}${replacement}${body.slice(offset + 1)}`;
|
|
251
|
+
const edited = parseOutline(editedBody);
|
|
252
|
+
if (edited === null || edited.length !== original.length) {
|
|
253
|
+
throw new Error("PR body structure is ambiguous; tick this item in the GitHub UI");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const expected = [...original];
|
|
257
|
+
const selected = expected[selectedIndex];
|
|
258
|
+
if (selected?.type === "task") {
|
|
259
|
+
expected[selectedIndex] = { ...selected, checked: replacement !== " " };
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
!edited.every((entry, index) => {
|
|
263
|
+
const expectedEntry = expected[index];
|
|
264
|
+
return expectedEntry !== undefined && sameEntry(expectedEntry, entry);
|
|
265
|
+
})
|
|
266
|
+
) {
|
|
267
|
+
throw new Error("PR body structure is ambiguous; tick this item in the GitHub UI");
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const findCheckboxLines = (
|
|
272
|
+
body: string,
|
|
273
|
+
match: string,
|
|
274
|
+
section: string | null,
|
|
275
|
+
parseOutline: CheckboxOutlineParser = bunMarkdownOutline,
|
|
276
|
+
) => {
|
|
277
|
+
const scanned = scanOutline(body);
|
|
278
|
+
const parsed = parseOutline(body);
|
|
279
|
+
if (parsed === null || !sameOutline(scanned, parsed)) {
|
|
280
|
+
throw new Error("PR body structure is ambiguous; tick this item in the GitHub UI");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const matches: CheckboxLine[] = [];
|
|
284
|
+
let sectionLevel: number | null = null;
|
|
285
|
+
let inSection = section === null;
|
|
286
|
+
|
|
287
|
+
for (const entry of scanned) {
|
|
288
|
+
if (entry.type === "heading") {
|
|
289
|
+
if (sectionLevel !== null && entry.level <= sectionLevel) {
|
|
290
|
+
inSection = false;
|
|
291
|
+
sectionLevel = null;
|
|
292
|
+
}
|
|
293
|
+
if (section !== null && entry.text === section) {
|
|
294
|
+
inSection = true;
|
|
295
|
+
sectionLevel = entry.level;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!inSection || !entry.text.includes(match)) continue;
|
|
300
|
+
if (entry.offset === undefined || entry.boxIndex === undefined || entry.before === undefined)
|
|
301
|
+
continue;
|
|
302
|
+
matches.push({
|
|
303
|
+
before: entry.before,
|
|
304
|
+
offset: entry.offset,
|
|
305
|
+
boxIndex: entry.boxIndex,
|
|
306
|
+
checked: entry.checked,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return matches;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export const scanCheckboxOutline = (body: string): readonly CheckboxOutlineEntry[] =>
|
|
313
|
+
scanOutline(body).map((entry) =>
|
|
314
|
+
entry.type === "task"
|
|
315
|
+
? { type: entry.type, text: entry.text, checked: entry.checked }
|
|
316
|
+
: { type: entry.type, text: entry.text, level: entry.level },
|
|
317
|
+
);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { GitHubCommandError } from "#gh/errors";
|
|
4
|
+
import {
|
|
5
|
+
bunMarkdownOutline,
|
|
6
|
+
findCheckboxLines,
|
|
7
|
+
validateCheckboxEdit,
|
|
8
|
+
type CheckboxOutlineParser,
|
|
9
|
+
} from "#gh/pr/checkbox-markdown";
|
|
10
|
+
import { repoSlug } from "#gh/read/keys";
|
|
11
|
+
import { reads, resolveRepo } from "#gh/read/reads";
|
|
12
|
+
import { writes } from "#gh/read/writes";
|
|
13
|
+
import { validateOutboundText } from "#gh/text-input";
|
|
14
|
+
|
|
15
|
+
type CheckboxState = "checked" | "unchecked";
|
|
16
|
+
|
|
17
|
+
const readPRBody = Effect.fn("pr.checkbox.readPRBody")(function* (pr: number) {
|
|
18
|
+
const repo = yield* resolveRepo(null);
|
|
19
|
+
const body = yield* reads.prBody(repo, pr);
|
|
20
|
+
return { repo, body };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const checkboxMatchError = (pr: number, count: number) =>
|
|
24
|
+
new GitHubCommandError({
|
|
25
|
+
command: "gh-tool pr checkbox",
|
|
26
|
+
exitCode: 1,
|
|
27
|
+
stderr: `Found ${count} matching task items in PR #${pr}`,
|
|
28
|
+
message: `Expected one matching task item in PR #${pr}, found ${count}`,
|
|
29
|
+
hint: "Use --section to narrow the search or pass a longer --match.",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const togglePRCheckbox = Effect.fn("pr.togglePRCheckbox")(function* (
|
|
33
|
+
opts: {
|
|
34
|
+
pr: number;
|
|
35
|
+
match: string;
|
|
36
|
+
section: string | null;
|
|
37
|
+
state: CheckboxState;
|
|
38
|
+
},
|
|
39
|
+
parseOutline: CheckboxOutlineParser = bunMarkdownOutline,
|
|
40
|
+
) {
|
|
41
|
+
yield* validateOutboundText(opts.match, "gh-tool pr checkbox");
|
|
42
|
+
if (opts.section !== null) {
|
|
43
|
+
yield* validateOutboundText(opts.section, "gh-tool pr checkbox");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (opts.pr <= 0 || opts.match.length === 0 || opts.section === "") {
|
|
47
|
+
return yield* new GitHubCommandError({
|
|
48
|
+
command: "gh-tool pr checkbox",
|
|
49
|
+
exitCode: 1,
|
|
50
|
+
stderr: "--pr must be positive, --match must be non-empty, and --section must be non-empty",
|
|
51
|
+
message: "Invalid PR checkbox selection",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const first = yield* readPRBody(opts.pr);
|
|
56
|
+
const matches = yield* Effect.try({
|
|
57
|
+
try: () => findCheckboxLines(first.body, opts.match, opts.section, parseOutline),
|
|
58
|
+
catch: (cause) =>
|
|
59
|
+
new GitHubCommandError({
|
|
60
|
+
command: "gh-tool pr checkbox",
|
|
61
|
+
exitCode: 1,
|
|
62
|
+
stderr: String(cause),
|
|
63
|
+
message: "Cannot locate PR checkbox safely",
|
|
64
|
+
hint: "PR body structure is ambiguous; tick this item in the GitHub UI.",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (matches.length !== 1) return yield* checkboxMatchError(opts.pr, matches.length);
|
|
68
|
+
|
|
69
|
+
const selected = matches[0];
|
|
70
|
+
if (selected === undefined) return yield* checkboxMatchError(opts.pr, 0);
|
|
71
|
+
|
|
72
|
+
const alreadyRequested = opts.state === "checked" ? selected.checked : !selected.checked;
|
|
73
|
+
if (alreadyRequested) {
|
|
74
|
+
return {
|
|
75
|
+
pr: opts.pr,
|
|
76
|
+
state: "unchanged" as const,
|
|
77
|
+
before: selected.before,
|
|
78
|
+
after: selected.before,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const box = opts.state === "checked" ? "x" : " ";
|
|
83
|
+
yield* Effect.try({
|
|
84
|
+
try: () => validateCheckboxEdit(first.body, selected.offset, box, parseOutline),
|
|
85
|
+
catch: (cause) =>
|
|
86
|
+
new GitHubCommandError({
|
|
87
|
+
command: "gh-tool pr checkbox",
|
|
88
|
+
exitCode: 1,
|
|
89
|
+
stderr: String(cause),
|
|
90
|
+
message: "Cannot locate PR checkbox safely",
|
|
91
|
+
hint: "PR body structure is ambiguous; tick this item in the GitHub UI.",
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
const after = `${selected.before.slice(0, selected.boxIndex)}${box}${selected.before.slice(selected.boxIndex + 1)}`;
|
|
95
|
+
yield* writes.prCheckbox(
|
|
96
|
+
{
|
|
97
|
+
repo: first.repo,
|
|
98
|
+
pr: opts.pr,
|
|
99
|
+
expectedBody: first.body,
|
|
100
|
+
boxOffset: selected.offset,
|
|
101
|
+
replacement: box,
|
|
102
|
+
selectionText: [opts.match, ...(opts.section === null ? [] : [opts.section])],
|
|
103
|
+
},
|
|
104
|
+
[`pr:${repoSlug(first.repo)}#${opts.pr}`, `pulls:${repoSlug(first.repo)}`],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
pr: opts.pr,
|
|
109
|
+
state: "updated" as const,
|
|
110
|
+
before: selected.before,
|
|
111
|
+
after,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
@@ -7,7 +7,7 @@ import type { CheckResult, PRStatusResult } from "#gh/types";
|
|
|
7
7
|
|
|
8
8
|
import { formatOption, logFormatted, logText } from "#shared";
|
|
9
9
|
import { GitHubService } from "#gh/read/transport";
|
|
10
|
-
import { GitHubCommandError } from "#gh/errors";
|
|
10
|
+
import { GitHubCommandError, GitHubTimeoutError } from "#gh/errors";
|
|
11
11
|
|
|
12
12
|
const emptyBatchError = (batch: string) =>
|
|
13
13
|
new GitHubCommandError({
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "#gh/config";
|
|
34
34
|
|
|
35
35
|
import { mergeStack, readStack, unstackStack } from "./stack";
|
|
36
|
+
import { togglePRCheckbox } from "./checkbox";
|
|
36
37
|
import {
|
|
37
38
|
closePR,
|
|
38
39
|
collectWithStableState,
|
|
@@ -721,6 +722,51 @@ export const prEditCommand = Command.make(
|
|
|
721
722
|
),
|
|
722
723
|
).pipe(Command.withDescription("Edit an existing PR's title, body, or other metadata"));
|
|
723
724
|
|
|
725
|
+
export const prCheckboxCommand = Command.make(
|
|
726
|
+
"checkbox",
|
|
727
|
+
{
|
|
728
|
+
check: Flag.Boolean("check").pipe(
|
|
729
|
+
Flag.withDescription("Mark the matching task item as checked"),
|
|
730
|
+
Flag.withDefault(false),
|
|
731
|
+
),
|
|
732
|
+
format: formatOption,
|
|
733
|
+
match: Flag.String("match").pipe(
|
|
734
|
+
Flag.withDescription("Plain text that identifies one task item"),
|
|
735
|
+
),
|
|
736
|
+
pr: Flag.Int("pr").pipe(Flag.withDescription("PR number to update")),
|
|
737
|
+
repo: repoOption,
|
|
738
|
+
section: Flag.String("section").pipe(
|
|
739
|
+
Flag.withDescription("Limit the search to a Markdown heading"),
|
|
740
|
+
Flag.optional,
|
|
741
|
+
),
|
|
742
|
+
uncheck: Flag.Boolean("uncheck").pipe(
|
|
743
|
+
Flag.withDescription("Mark the matching task item as unchecked"),
|
|
744
|
+
Flag.withDefault(false),
|
|
745
|
+
),
|
|
746
|
+
},
|
|
747
|
+
({ check, format, match, pr, repo, section, uncheck }) =>
|
|
748
|
+
withRepo(
|
|
749
|
+
repo,
|
|
750
|
+
Effect.gen(function* () {
|
|
751
|
+
if (check === uncheck) {
|
|
752
|
+
return yield* new GitHubCommandError({
|
|
753
|
+
command: "gh-tool pr checkbox",
|
|
754
|
+
exitCode: 1,
|
|
755
|
+
stderr: "Provide exactly one of --check or --uncheck",
|
|
756
|
+
message: "Provide exactly one of --check or --uncheck",
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
const result = yield* togglePRCheckbox({
|
|
760
|
+
pr,
|
|
761
|
+
match,
|
|
762
|
+
section: Option.getOrNull(section),
|
|
763
|
+
state: check ? "checked" : "unchecked",
|
|
764
|
+
});
|
|
765
|
+
yield* logFormatted(result, format);
|
|
766
|
+
}),
|
|
767
|
+
),
|
|
768
|
+
).pipe(Command.withDescription("Check or uncheck one task item in a PR body"));
|
|
769
|
+
|
|
724
770
|
export const prCloseCommand = Command.make(
|
|
725
771
|
"close",
|
|
726
772
|
{
|
|
@@ -1013,6 +1059,25 @@ export const prRerunChecksCommand = Command.make(
|
|
|
1013
1059
|
timeoutSeconds: timeout,
|
|
1014
1060
|
});
|
|
1015
1061
|
yield* logFormatted(result, format);
|
|
1062
|
+
if (watch && (result.status === "watch_timeout" || result.status === "discovery_timeout")) {
|
|
1063
|
+
return yield* new GitHubTimeoutError({
|
|
1064
|
+
message: result.message,
|
|
1065
|
+
timeoutMs: timeout * 1000,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
if (
|
|
1069
|
+
watch &&
|
|
1070
|
+
result.runs !== undefined &&
|
|
1071
|
+
result.runs.length > 0 &&
|
|
1072
|
+
result.status !== "completed"
|
|
1073
|
+
) {
|
|
1074
|
+
return yield* new GitHubCommandError({
|
|
1075
|
+
command: "pr rerun-checks --watch",
|
|
1076
|
+
exitCode: 1,
|
|
1077
|
+
stderr: "",
|
|
1078
|
+
message: result.message,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1016
1081
|
}),
|
|
1017
1082
|
),
|
|
1018
1083
|
).pipe(
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Clock, Console, Effect, Option, Result } from "effect";
|
|
1
|
+
import { Clock, Console, Effect, Option, Result, Semaphore } from "effect";
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
BranchPRDetail,
|
|
@@ -2027,7 +2027,7 @@ const readRerunState = (runId: string, withStatus: boolean) =>
|
|
|
2027
2027
|
Effect.gen(function* () {
|
|
2028
2028
|
const run = yield* reads.runView(yield* resolveRepo(null), Number(runId), "origin");
|
|
2029
2029
|
const fields = withStatus
|
|
2030
|
-
? (["databaseId", "attempt", "status", "jobs"] as const)
|
|
2030
|
+
? (["databaseId", "attempt", "status", "conclusion", "jobs"] as const)
|
|
2031
2031
|
: (["databaseId", "attempt", "jobs"] as const);
|
|
2032
2032
|
return project(run, fields);
|
|
2033
2033
|
});
|
|
@@ -2051,18 +2051,21 @@ const readJobDiagnosis = Effect.fn("pr.readJobDiagnosis")(function* (
|
|
|
2051
2051
|
const discoverRerun = Effect.fn("pr.discoverRerun")(function* (
|
|
2052
2052
|
runId: string,
|
|
2053
2053
|
currentAttempt: number | null,
|
|
2054
|
-
currentJobIds: readonly number[],
|
|
2055
2054
|
deadline: number,
|
|
2055
|
+
runViewReads: Semaphore.Semaphore,
|
|
2056
2056
|
) {
|
|
2057
|
-
const read =
|
|
2058
|
-
|
|
2059
|
-
|
|
2057
|
+
const read = runViewReads
|
|
2058
|
+
.withPermit(readRerunState(runId, true))
|
|
2059
|
+
.pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
|
|
2060
2060
|
const outcome = yield* waits.until(
|
|
2061
2061
|
read,
|
|
2062
2062
|
(latest) =>
|
|
2063
2063
|
latest !== null &&
|
|
2064
|
-
|
|
2065
|
-
|
|
2064
|
+
currentAttempt !== null &&
|
|
2065
|
+
currentAttempt > 0 &&
|
|
2066
|
+
latest.attempt !== undefined &&
|
|
2067
|
+
latest.attempt !== null &&
|
|
2068
|
+
latest.attempt === currentAttempt + 1,
|
|
2066
2069
|
{
|
|
2067
2070
|
intervalMs: 5000,
|
|
2068
2071
|
timeoutMs: Math.max(0, deadline - Number(yield* Clock.currentTimeMillis)),
|
|
@@ -2107,12 +2110,19 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
|
|
|
2107
2110
|
Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
|
|
2108
2111
|
);
|
|
2109
2112
|
let evidence: RerunRetryEvidence = { state: "eligible", diagnosis: diagnoseLogEntries([]) };
|
|
2113
|
+
if (
|
|
2114
|
+
options.watch === true &&
|
|
2115
|
+
(run?.attempt === undefined || run.attempt === null || run.attempt <= 0)
|
|
2116
|
+
) {
|
|
2117
|
+
evidence = { state: "unavailable", reason: "current attempt unavailable for watched rerun" };
|
|
2118
|
+
}
|
|
2110
2119
|
if (failedOnly) {
|
|
2111
2120
|
const jobIds = run === null ? null : resolveJobIdsForFailedChecks(runChecks, run.jobs);
|
|
2112
2121
|
if (
|
|
2113
2122
|
run === null ||
|
|
2114
2123
|
run.attempt === null ||
|
|
2115
2124
|
run.attempt === undefined ||
|
|
2125
|
+
run.attempt <= 0 ||
|
|
2116
2126
|
jobIds === null ||
|
|
2117
2127
|
jobIds.length === 0
|
|
2118
2128
|
) {
|
|
@@ -2202,26 +2212,19 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
|
|
|
2202
2212
|
}
|
|
2203
2213
|
|
|
2204
2214
|
if (watch) {
|
|
2205
|
-
const
|
|
2206
|
-
candidates,
|
|
2207
|
-
(candidate, index) =>
|
|
2208
|
-
results[index]?.success
|
|
2209
|
-
? discoverRerun(
|
|
2210
|
-
candidate.runId,
|
|
2211
|
-
candidate.run?.attempt ?? null,
|
|
2212
|
-
candidate.run?.jobs?.map((job) => job.databaseId) ?? [],
|
|
2213
|
-
deadline,
|
|
2214
|
-
)
|
|
2215
|
-
: Effect.succeed(null),
|
|
2216
|
-
{ concurrency: 2 },
|
|
2217
|
-
);
|
|
2215
|
+
const runViewReads = yield* Semaphore.make(2);
|
|
2218
2216
|
yield* Effect.forEach(
|
|
2219
2217
|
candidates,
|
|
2220
2218
|
(candidate, index) =>
|
|
2221
2219
|
Effect.gen(function* () {
|
|
2222
2220
|
const result = results[index];
|
|
2223
2221
|
if (result === undefined || !result.success) return;
|
|
2224
|
-
const next =
|
|
2222
|
+
const next = yield* discoverRerun(
|
|
2223
|
+
candidate.runId,
|
|
2224
|
+
candidate.run?.attempt ?? null,
|
|
2225
|
+
deadline,
|
|
2226
|
+
runViewReads,
|
|
2227
|
+
);
|
|
2225
2228
|
result.newAttempt = next?.attempt ?? null;
|
|
2226
2229
|
result.newJobIds = next?.jobs.map((job) => job.databaseId) ?? null;
|
|
2227
2230
|
if (next === null) {
|
|
@@ -2232,8 +2235,17 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
|
|
|
2232
2235
|
let latest = next;
|
|
2233
2236
|
const outcome = yield* waits.until(
|
|
2234
2237
|
Effect.suspend(() =>
|
|
2235
|
-
readRerunState(candidate.runId, true).pipe(
|
|
2238
|
+
runViewReads.withPermit(readRerunState(candidate.runId, true)).pipe(
|
|
2236
2239
|
Effect.catchTag("GitHubCommandError", () => Effect.succeed(latest)),
|
|
2240
|
+
Effect.map((refreshed) =>
|
|
2241
|
+
refreshed.attempt !== undefined &&
|
|
2242
|
+
refreshed.attempt !== null &&
|
|
2243
|
+
next.attempt !== undefined &&
|
|
2244
|
+
next.attempt !== null &&
|
|
2245
|
+
refreshed.attempt < next.attempt
|
|
2246
|
+
? latest
|
|
2247
|
+
: refreshed,
|
|
2248
|
+
),
|
|
2237
2249
|
Effect.tap((refreshed) =>
|
|
2238
2250
|
Effect.sync(() => {
|
|
2239
2251
|
latest = refreshed;
|
|
@@ -2241,7 +2253,11 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
|
|
|
2241
2253
|
),
|
|
2242
2254
|
),
|
|
2243
2255
|
),
|
|
2244
|
-
(value) =>
|
|
2256
|
+
(value) =>
|
|
2257
|
+
next.attempt !== undefined &&
|
|
2258
|
+
next.attempt !== null &&
|
|
2259
|
+
value.attempt === next.attempt &&
|
|
2260
|
+
value.status === "completed",
|
|
2245
2261
|
{
|
|
2246
2262
|
initial: { value: latest },
|
|
2247
2263
|
intervalMs: 5000,
|
|
@@ -2250,17 +2266,34 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
|
|
|
2250
2266
|
);
|
|
2251
2267
|
latest = outcome.kind === "done" ? outcome.value : outcome.last;
|
|
2252
2268
|
result.latestAttempt = latest;
|
|
2253
|
-
result.status =
|
|
2269
|
+
result.status = outcome.kind === "done" ? "completed" : "watch_timeout";
|
|
2254
2270
|
}),
|
|
2255
|
-
{ concurrency:
|
|
2271
|
+
{ concurrency: "unbounded" },
|
|
2256
2272
|
);
|
|
2257
2273
|
}
|
|
2274
|
+
const status = results.some((result) => !result.success)
|
|
2275
|
+
? "failed"
|
|
2276
|
+
: !watch
|
|
2277
|
+
? "rerun_started"
|
|
2278
|
+
: results.some((result) => result.status === "discovery_timeout")
|
|
2279
|
+
? "discovery_timeout"
|
|
2280
|
+
: results.some((result) => result.status === "watch_timeout")
|
|
2281
|
+
? "watch_timeout"
|
|
2282
|
+
: results.every((result) => result.latestAttempt?.conclusion === "success")
|
|
2283
|
+
? "completed"
|
|
2284
|
+
: "failed";
|
|
2258
2285
|
const report: RerunChecksReport = {
|
|
2259
|
-
status
|
|
2286
|
+
status,
|
|
2260
2287
|
rerun: results.filter((result) => result.success).length,
|
|
2261
2288
|
failed: results.filter((result) => !result.success).length,
|
|
2262
2289
|
runs: results,
|
|
2263
|
-
message:
|
|
2290
|
+
message: watch
|
|
2291
|
+
? status === "watch_timeout" || status === "discovery_timeout"
|
|
2292
|
+
? "Rerun watch timed out; this is not a CI verdict"
|
|
2293
|
+
: status === "completed"
|
|
2294
|
+
? "Watched reruns completed successfully"
|
|
2295
|
+
: "Watched reruns did not complete successfully"
|
|
2296
|
+
: `Rerun ${results.filter((result) => result.success).length}/${results.length} GitHub Actions runs`,
|
|
2264
2297
|
};
|
|
2265
2298
|
return report;
|
|
2266
2299
|
});
|