@jwilger/pi-development-system 0.59.0 → 0.61.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/package.json +1 -1
- package/src/planning/task-record.ts +64 -13
- package/src/tracker/github.ts +22 -8
- package/src/tracker/item-format.ts +13 -14
- package/src/tracker/work-item-tool.ts +2 -1
package/package.json
CHANGED
|
@@ -38,6 +38,39 @@ const PLACEHOLDER =
|
|
|
38
38
|
|
|
39
39
|
const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
|
|
40
40
|
|
|
41
|
+
const FENCE_LINE = /^\s*(`{3,}|~{3,})(.*)$/;
|
|
42
|
+
const HEADING = /^#{1,6}\s/;
|
|
43
|
+
|
|
44
|
+
/** Opening fence: its character and length. Backtick fences cannot carry backticks in the info string. */
|
|
45
|
+
const opening = (line: string): { char: string; length: number } | undefined => {
|
|
46
|
+
const m = FENCE_LINE.exec(line);
|
|
47
|
+
const run = m?.[1];
|
|
48
|
+
if (run === undefined || (run.startsWith("`") && (m?.[2] ?? "").includes("`"))) return undefined;
|
|
49
|
+
return { char: run.slice(0, 1), length: run.length };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A closing fence repeats the opening character at least as many times and carries no text. */
|
|
53
|
+
const closes = (line: string, open: { char: string; length: number }): boolean => {
|
|
54
|
+
const m = FENCE_LINE.exec(line);
|
|
55
|
+
const run = m?.[1];
|
|
56
|
+
return (
|
|
57
|
+
run?.startsWith(open.char) === true && run.length >= open.length && (m?.[2] ?? "").trim() === ""
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** For each line, whether it sits inside (or on the edge of) a fenced code block, as CommonMark reads fences. */
|
|
62
|
+
const fencedFlags = (lines: readonly string[]): boolean[] => {
|
|
63
|
+
let open: { char: string; length: number } | undefined;
|
|
64
|
+
return lines.map((line) => {
|
|
65
|
+
if (open === undefined) {
|
|
66
|
+
open = opening(line);
|
|
67
|
+
return open !== undefined;
|
|
68
|
+
}
|
|
69
|
+
if (closes(line, open)) open = undefined;
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
41
74
|
type Sections = { readonly text: Map<Section, string>; readonly duplicates: Section[] };
|
|
42
75
|
|
|
43
76
|
/** Splits the body into section texts keyed by name; text may continue over several lines. */
|
|
@@ -45,8 +78,9 @@ function splitSections(lines: readonly string[]): Sections {
|
|
|
45
78
|
const found = new Map<Section, string[]>();
|
|
46
79
|
const duplicates: Section[] = [];
|
|
47
80
|
let current: string[] | undefined;
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
const fenced = fencedFlags(lines);
|
|
82
|
+
for (const [at, line] of lines.entries()) {
|
|
83
|
+
const match = fenced[at] === true ? null : SECTION_LINE.exec(line);
|
|
50
84
|
const name = match?.[1]?.trim();
|
|
51
85
|
if (match !== null && name !== undefined && isSection(name)) {
|
|
52
86
|
if (found.has(name)) duplicates.push(name);
|
|
@@ -60,12 +94,13 @@ function splitSections(lines: readonly string[]): Sections {
|
|
|
60
94
|
|
|
61
95
|
type Located = { readonly id: string; readonly title: string; readonly lines: readonly string[] };
|
|
62
96
|
|
|
63
|
-
/** Every `## <id> — <title>` block; any other
|
|
97
|
+
/** Every `## <id> — <title>` block; any other heading (outside code fences) ends the block before it. */
|
|
64
98
|
function recordsIn(lines: readonly string[]): Located[] {
|
|
65
99
|
const records: { id: string; title: string; lines: string[] }[] = [];
|
|
66
100
|
let open: { id: string; title: string; lines: string[] } | undefined;
|
|
67
|
-
|
|
68
|
-
|
|
101
|
+
const fenced = fencedFlags(lines);
|
|
102
|
+
for (const [at, line] of lines.entries()) {
|
|
103
|
+
if (fenced[at] !== true && HEADING.test(line)) {
|
|
69
104
|
const header = HEADER.exec(line);
|
|
70
105
|
open =
|
|
71
106
|
header === null
|
|
@@ -77,13 +112,26 @@ function recordsIn(lines: readonly string[]): Located[] {
|
|
|
77
112
|
return records;
|
|
78
113
|
}
|
|
79
114
|
|
|
80
|
-
|
|
115
|
+
/** A command or result without its backticks, or the body of a fenced block (its info string dropped). */
|
|
116
|
+
const stripTicks = (text: string): string => {
|
|
117
|
+
const lines = text.split("\n");
|
|
118
|
+
const fence = opening(lines[0] ?? "");
|
|
119
|
+
if (fence !== undefined) {
|
|
120
|
+
const body = lines.slice(1);
|
|
121
|
+
if (closes(body.at(-1) ?? "", fence)) body.pop();
|
|
122
|
+
return body.join("\n").trim();
|
|
123
|
+
}
|
|
124
|
+
return text.replace(/^`+|`+$/g, "").trim();
|
|
125
|
+
};
|
|
81
126
|
|
|
82
|
-
const stepsOf = (text: string): string[] =>
|
|
83
|
-
text.split("\n")
|
|
84
|
-
|
|
127
|
+
const stepsOf = (text: string): string[] => {
|
|
128
|
+
const lines = text.split("\n");
|
|
129
|
+
const fenced = fencedFlags(lines);
|
|
130
|
+
return lines.flatMap((line, at) => {
|
|
131
|
+
const item = fenced[at] === true ? undefined : STEP.exec(line)?.[1]?.trim();
|
|
85
132
|
return item === undefined || item === "" ? [] : [item];
|
|
86
133
|
});
|
|
134
|
+
};
|
|
87
135
|
|
|
88
136
|
function concrete(name: "Run" | "Expected", text: string): string | undefined {
|
|
89
137
|
const bare = stripTicks(text);
|
|
@@ -130,10 +178,13 @@ function locate(markdown: string, id: string | undefined): Located | ParseError
|
|
|
130
178
|
const records = recordsIn(markdown.split("\n"));
|
|
131
179
|
const names = records.map((r) => r.id).join(", ");
|
|
132
180
|
if (id !== undefined) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
181
|
+
const named = records.filter((r) => r.id === id);
|
|
182
|
+
const [first] = named;
|
|
183
|
+
if (first === undefined)
|
|
184
|
+
return parseError(`no task record "${id}" (found: ${names || "none"})`);
|
|
185
|
+
return named.length === 1
|
|
186
|
+
? first
|
|
187
|
+
: parseError(`task record "${id}" appears ${named.length} times; ids must be unique`);
|
|
137
188
|
}
|
|
138
189
|
const [only, ...rest] = records;
|
|
139
190
|
if (only === undefined) return parseError(NO_HEADER);
|
package/src/tracker/github.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { Exec } from "../core/exec.ts";
|
|
2
2
|
import { ok } from "../core/result.ts";
|
|
3
3
|
import { unwritable } from "./item-format.ts";
|
|
4
|
+
|
|
5
|
+
/** Our status marker is a label; a user label with that name would change the status as a side effect. */
|
|
6
|
+
const unusable = (item: { title: string; labels: readonly string[] }): string | undefined =>
|
|
7
|
+
item.labels.some(isMarker)
|
|
8
|
+
? '"in-progress" is reserved for the in-progress status; set status instead'
|
|
9
|
+
: unwritable(item);
|
|
10
|
+
|
|
4
11
|
import {
|
|
5
12
|
type ItemFilter,
|
|
6
13
|
type NewWorkItem,
|
|
@@ -14,6 +21,8 @@ import {
|
|
|
14
21
|
|
|
15
22
|
const FIELDS = "number,title,body,state,labels,comments";
|
|
16
23
|
const IN_PROGRESS = "in-progress";
|
|
24
|
+
/** GitHub label names are case-insensitive, so `In-Progress` is our marker too. */
|
|
25
|
+
const isMarker = (name: string): boolean => name.toLowerCase() === IN_PROGRESS;
|
|
17
26
|
const ISSUE_NUMBER = /^[1-9][0-9]*$/;
|
|
18
27
|
const STATE_ARG = { open: "open", "in-progress": "open", done: "closed" } as const;
|
|
19
28
|
|
|
@@ -38,7 +47,7 @@ const isIssue = (value: unknown): value is GhIssue =>
|
|
|
38
47
|
|
|
39
48
|
const statusOf = (state: string, labels: readonly string[]): WorkStatus => {
|
|
40
49
|
if (state === "CLOSED") return "done";
|
|
41
|
-
return labels.
|
|
50
|
+
return labels.some(isMarker) ? "in-progress" : "open";
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
const toItem = (issue: GhIssue): WorkItem => {
|
|
@@ -48,7 +57,7 @@ const toItem = (issue: GhIssue): WorkItem => {
|
|
|
48
57
|
title: issue.title,
|
|
49
58
|
body: issue.body ?? "",
|
|
50
59
|
status: statusOf(issue.state, names),
|
|
51
|
-
labels: names.filter((n) => n
|
|
60
|
+
labels: names.filter((n) => !isMarker(n)),
|
|
52
61
|
comments: (issue.comments ?? []).map((c) => c.body),
|
|
53
62
|
};
|
|
54
63
|
};
|
|
@@ -108,7 +117,7 @@ const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
|
|
|
108
117
|
return raw.ok ? ok(toItem(raw.value)) : raw;
|
|
109
118
|
};
|
|
110
119
|
|
|
111
|
-
const LIST_LIMIT =
|
|
120
|
+
const LIST_LIMIT = 1000;
|
|
112
121
|
|
|
113
122
|
/** The `gh issue list` arguments that select exactly the wanted status where gh can, so the limit bites last. */
|
|
114
123
|
const listArgs = (status: WorkStatus | undefined): string[] => {
|
|
@@ -132,9 +141,14 @@ const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<Wor
|
|
|
132
141
|
if (!out.ok) return out;
|
|
133
142
|
const parsed = parseIssues(out.value);
|
|
134
143
|
if (parsed === undefined) return trackerError("gh issue list: unexpected output");
|
|
135
|
-
|
|
144
|
+
// gh lists newest first. Cutting off old closed issues is harmless; cutting off open work is not.
|
|
145
|
+
if (parsed.length >= LIST_LIMIT && filter.status !== "done") {
|
|
146
|
+
const advice =
|
|
147
|
+
filter.status === undefined
|
|
148
|
+
? "; ask for one status to narrow it"
|
|
149
|
+
: "; this adapter lists at most that many";
|
|
136
150
|
return trackerError(
|
|
137
|
-
`gh issue list returned ${LIST_LIMIT} issues, so the list may be cut short
|
|
151
|
+
`gh issue list returned ${LIST_LIMIT} issues, so the list may be cut short${advice}`,
|
|
138
152
|
);
|
|
139
153
|
}
|
|
140
154
|
const items = parsed.map(toItem);
|
|
@@ -142,7 +156,7 @@ const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<Wor
|
|
|
142
156
|
};
|
|
143
157
|
|
|
144
158
|
const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
145
|
-
const problem =
|
|
159
|
+
const problem = unusable({ title: input.title, labels: input.labels ?? [] });
|
|
146
160
|
if (problem !== undefined) return trackerError(problem);
|
|
147
161
|
const labels = (input.labels ?? []).flatMap((l) => ["--label", l]);
|
|
148
162
|
const out = await gh([
|
|
@@ -203,12 +217,12 @@ const updateIssue = async (
|
|
|
203
217
|
id: string,
|
|
204
218
|
patch: WorkItemPatch,
|
|
205
219
|
): Promise<TrackerResult<WorkItem>> => {
|
|
206
|
-
const problem =
|
|
220
|
+
const problem = unusable({ title: patch.title ?? "x", labels: patch.labels ?? [] });
|
|
207
221
|
if (problem !== undefined) return trackerError(problem);
|
|
208
222
|
const raw = await viewRaw(gh, id);
|
|
209
223
|
if (!raw.ok) return raw;
|
|
210
224
|
const current = toItem(raw.value);
|
|
211
|
-
const hadMarker = (raw.value.labels ?? []).some((l) => l.name
|
|
225
|
+
const hadMarker = (raw.value.labels ?? []).some((l) => isMarker(l.name));
|
|
212
226
|
if (patch.status === "in-progress") {
|
|
213
227
|
// No --force: that would recolour the team's existing label. "Already exists" is success.
|
|
214
228
|
const made = await gh(["label", "create", IN_PROGRESS]);
|
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
type WorkStatus,
|
|
8
8
|
} from "./types.ts";
|
|
9
9
|
|
|
10
|
+
/** Every line terminator `.` in the item header pattern cannot cross, plus NEL for safety. */
|
|
11
|
+
const LINE_BREAK = /[\r\n\u2028\u2029\u0085]/;
|
|
12
|
+
|
|
10
13
|
const COMMENTS = "\n## Comments\n";
|
|
11
14
|
|
|
12
15
|
/** Comment lines are quoted so nothing a comment says can look like one of our headings. */
|
|
@@ -24,25 +27,21 @@ const unquote = (text: string): string =>
|
|
|
24
27
|
|
|
25
28
|
/** What the file format cannot hold, or undefined when the item can be written and read back. */
|
|
26
29
|
export const unwritable = (item: Pick<WorkItem, "title" | "labels">): string | undefined => {
|
|
27
|
-
if (
|
|
30
|
+
if (LINE_BREAK.test(item.title)) return "a work item title must be a single line";
|
|
28
31
|
if (item.title.trim() === "") return "a work item title cannot be blank";
|
|
29
|
-
const bad = item.labels.find((l) =>
|
|
32
|
+
const bad = item.labels.find((l) => LINE_BREAK.test(l) || l.includes(",") || l.trim() === "");
|
|
30
33
|
return bad === undefined
|
|
31
34
|
? undefined
|
|
32
35
|
: `label ${JSON.stringify(bad)} is blank or holds a comma or line break`;
|
|
33
36
|
};
|
|
34
37
|
|
|
38
|
+
/** Written to pass our own markdownlint: no trailing space, no repeated heading text, no stacked blank lines. */
|
|
35
39
|
export const renderItem = (item: WorkItem): string => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
`Labels: ${item.labels.join(", ")}`,
|
|
42
|
-
"",
|
|
43
|
-
item.body,
|
|
44
|
-
COMMENTS + comments,
|
|
45
|
-
].join("\n");
|
|
40
|
+
const labels = item.labels.length === 0 ? "" : ` ${item.labels.join(", ")}`;
|
|
41
|
+
const top = `# ${item.title}\n\nStatus: ${item.status}\nLabels:${labels}`;
|
|
42
|
+
const body = item.body === "" ? [] : [item.body];
|
|
43
|
+
const comments = item.comments.map((c, n) => `\n### Comment ${n + 1}\n\n${quote(c)}\n`);
|
|
44
|
+
return `${[top, ...body, "## Comments"].join("\n\n")}\n${comments.join("")}`;
|
|
46
45
|
};
|
|
47
46
|
|
|
48
47
|
const isStatus = (value: string): value is WorkStatus =>
|
|
@@ -50,7 +49,7 @@ const isStatus = (value: string): value is WorkStatus =>
|
|
|
50
49
|
|
|
51
50
|
const commentsOf = (section: string): string[] =>
|
|
52
51
|
section
|
|
53
|
-
.split(/^### Comment\n\n/m)
|
|
52
|
+
.split(/^### Comment(?: \d+)?\n\n/m)
|
|
54
53
|
.slice(1)
|
|
55
54
|
.map((c) => unquote(c.replace(/\n+$/, "")));
|
|
56
55
|
|
|
@@ -59,7 +58,7 @@ export const parseItem = (id: string, raw: string): TrackerResult<WorkItem> => {
|
|
|
59
58
|
const marker = text.lastIndexOf(COMMENTS);
|
|
60
59
|
const main = marker < 0 ? text : text.slice(0, marker);
|
|
61
60
|
const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));
|
|
62
|
-
const head = /^# (.+)\n\nStatus: (.*)\nLabels: (.*)
|
|
61
|
+
const head = /^# (.+)\n\nStatus: (.*)\nLabels:[ ]?(.*)(?:\n\n|\n$|$)/.exec(main);
|
|
63
62
|
if (head === null)
|
|
64
63
|
return trackerError(`work item ${id}: expected "# title", Status and Labels lines`);
|
|
65
64
|
const [whole, title = "", status = "", labels = ""] = head;
|
|
@@ -24,7 +24,8 @@ const Parameters = Type.Object({
|
|
|
24
24
|
),
|
|
25
25
|
status: Type.Optional(
|
|
26
26
|
Type.Union([Type.Literal("open"), Type.Literal("in-progress"), Type.Literal("done")], {
|
|
27
|
-
description:
|
|
27
|
+
description:
|
|
28
|
+
"Status (update), or a filter (list). On GitHub, listing done shows the newest 1000 closed issues.",
|
|
28
29
|
}),
|
|
29
30
|
),
|
|
30
31
|
labels: Type.Optional(Type.Array(Type.String(), { description: "Labels (create, update)." })),
|