@jwilger/pi-development-system 0.58.0 → 0.60.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/jev/questions/readiness.ts +5 -0
- package/src/planning/task-record.ts +39 -11
- package/src/tracker/github.ts +55 -14
- package/src/tracker/item-format.ts +10 -13
package/package.json
CHANGED
|
@@ -27,6 +27,9 @@ export const READINESS_QUESTIONS = {
|
|
|
27
27
|
steps: vague(
|
|
28
28
|
"`task.steps` should be small steps a reviewer could reject independently. Are any steps large, combined ('implement and wire everything'), or out of order with the test-first step?",
|
|
29
29
|
),
|
|
30
|
+
check: vague(
|
|
31
|
+
"`task.run` should be an actual command a person can type, and `task.expected` an observable result such as a count, output or exit status. Is `run` prose or a placeholder rather than a command (for example 'run the tests'), or is `expected` a restatement of success (for example 'tests pass', 'should work') rather than something observed?",
|
|
32
|
+
),
|
|
30
33
|
tooBig: {
|
|
31
34
|
type: "bool",
|
|
32
35
|
instructions:
|
|
@@ -60,6 +63,8 @@ export async function judgeTaskReadiness(
|
|
|
60
63
|
interfaces: clip(record.interfaces, 1500),
|
|
61
64
|
firstFailingTest: clip(record.firstFailingTest, 600),
|
|
62
65
|
steps: record.steps.slice(0, 10).map((s) => clip(s, 300)),
|
|
66
|
+
run: clip(record.run, 300),
|
|
67
|
+
expected: clip(record.expected, 300),
|
|
63
68
|
},
|
|
64
69
|
},
|
|
65
70
|
READINESS_QUESTIONS,
|
|
@@ -28,15 +28,29 @@ type Section = (typeof SECTIONS)[number];
|
|
|
28
28
|
|
|
29
29
|
const HEADER = /^## (\S+) [—-] (.+)$/;
|
|
30
30
|
const SECTION_LINE = /^\*\*([^:*]+)(?::\*\*|\*\*:)\s*(.*)$/;
|
|
31
|
-
|
|
31
|
+
/** Top-level list items only; indented sub-items belong to the step above them. */
|
|
32
|
+
const STEP = /^(?:\d+[.)]|[-*])\s+(.*)$/;
|
|
32
33
|
const STEPS_MIN = 3;
|
|
33
34
|
const STEPS_MAX = 7;
|
|
34
35
|
/** Words that stand in for a command or an observable result. */
|
|
35
36
|
const PLACEHOLDER =
|
|
36
|
-
/^(?:(?:
|
|
37
|
+
/^(?:(?:tbd|tbc)\b.*|todo\s*(?::.*)?|n\/?a|none|-|…|\.\.\.|works?|it works|passes|ok)\.?$/i;
|
|
37
38
|
|
|
38
39
|
const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
|
|
39
40
|
|
|
41
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
42
|
+
const HEADING = /^#{1,6}\s/;
|
|
43
|
+
|
|
44
|
+
/** For each line, whether it sits inside (or on the edge of) a fenced code block. */
|
|
45
|
+
const fencedFlags = (lines: readonly string[]): boolean[] => {
|
|
46
|
+
let open = false;
|
|
47
|
+
return lines.map((line) => {
|
|
48
|
+
if (!FENCE.test(line)) return open;
|
|
49
|
+
open = !open;
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
40
54
|
type Sections = { readonly text: Map<Section, string>; readonly duplicates: Section[] };
|
|
41
55
|
|
|
42
56
|
/** Splits the body into section texts keyed by name; text may continue over several lines. */
|
|
@@ -44,8 +58,9 @@ function splitSections(lines: readonly string[]): Sections {
|
|
|
44
58
|
const found = new Map<Section, string[]>();
|
|
45
59
|
const duplicates: Section[] = [];
|
|
46
60
|
let current: string[] | undefined;
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
const fenced = fencedFlags(lines);
|
|
62
|
+
for (const [at, line] of lines.entries()) {
|
|
63
|
+
const match = fenced[at] === true ? null : SECTION_LINE.exec(line);
|
|
49
64
|
const name = match?.[1]?.trim();
|
|
50
65
|
if (match !== null && name !== undefined && isSection(name)) {
|
|
51
66
|
if (found.has(name)) duplicates.push(name);
|
|
@@ -59,12 +74,13 @@ function splitSections(lines: readonly string[]): Sections {
|
|
|
59
74
|
|
|
60
75
|
type Located = { readonly id: string; readonly title: string; readonly lines: readonly string[] };
|
|
61
76
|
|
|
62
|
-
/** Every `## <id> — <title>` block; any other
|
|
77
|
+
/** Every `## <id> — <title>` block; any other heading (outside code fences) ends the block before it. */
|
|
63
78
|
function recordsIn(lines: readonly string[]): Located[] {
|
|
64
79
|
const records: { id: string; title: string; lines: string[] }[] = [];
|
|
65
80
|
let open: { id: string; title: string; lines: string[] } | undefined;
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
const fenced = fencedFlags(lines);
|
|
82
|
+
for (const [at, line] of lines.entries()) {
|
|
83
|
+
if (fenced[at] !== true && HEADING.test(line)) {
|
|
68
84
|
const header = HEADER.exec(line);
|
|
69
85
|
open =
|
|
70
86
|
header === null
|
|
@@ -76,13 +92,25 @@ function recordsIn(lines: readonly string[]): Located[] {
|
|
|
76
92
|
return records;
|
|
77
93
|
}
|
|
78
94
|
|
|
79
|
-
|
|
95
|
+
/** A command or result without its backticks, or the body of a fenced block (its info string dropped). */
|
|
96
|
+
const stripTicks = (text: string): string => {
|
|
97
|
+
const lines = text.split("\n");
|
|
98
|
+
if (FENCE.test(lines[0] ?? "")) {
|
|
99
|
+
const body = lines.slice(1);
|
|
100
|
+
if (FENCE.test(body.at(-1) ?? "")) body.pop();
|
|
101
|
+
return body.join("\n").trim();
|
|
102
|
+
}
|
|
103
|
+
return text.replace(/^`+|`+$/g, "").trim();
|
|
104
|
+
};
|
|
80
105
|
|
|
81
|
-
const stepsOf = (text: string): string[] =>
|
|
82
|
-
text.split("\n")
|
|
83
|
-
|
|
106
|
+
const stepsOf = (text: string): string[] => {
|
|
107
|
+
const lines = text.split("\n");
|
|
108
|
+
const fenced = fencedFlags(lines);
|
|
109
|
+
return lines.flatMap((line, at) => {
|
|
110
|
+
const item = fenced[at] === true ? undefined : STEP.exec(line)?.[1]?.trim();
|
|
84
111
|
return item === undefined || item === "" ? [] : [item];
|
|
85
112
|
});
|
|
113
|
+
};
|
|
86
114
|
|
|
87
115
|
function concrete(name: "Run" | "Expected", text: string): string | undefined {
|
|
88
116
|
const bare = stripTicks(text);
|
package/src/tracker/github.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { Exec } from "../core/exec.ts";
|
|
2
2
|
import { ok } from "../core/result.ts";
|
|
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.includes("in-progress")
|
|
8
|
+
? '"in-progress" is reserved for the in-progress status; set status instead'
|
|
9
|
+
: unwritable(item);
|
|
10
|
+
|
|
3
11
|
import {
|
|
4
12
|
type ItemFilter,
|
|
5
13
|
type NewWorkItem,
|
|
@@ -94,27 +102,55 @@ const bindGh =
|
|
|
94
102
|
return trackerError(`gh ${args.slice(0, 2).join(" ")} failed: ${why}`);
|
|
95
103
|
};
|
|
96
104
|
|
|
97
|
-
const
|
|
105
|
+
const viewRaw = async (gh: Gh, id: string): Promise<TrackerResult<GhIssue>> => {
|
|
98
106
|
if (!ISSUE_NUMBER.test(id)) return notNumber(id);
|
|
99
107
|
const out = await gh(["issue", "view", id, "--json", FIELDS]);
|
|
100
108
|
if (!out.ok) return out;
|
|
101
109
|
const parsed = parseIssue(out.value);
|
|
102
|
-
return parsed === undefined
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
return parsed === undefined ? trackerError(`gh issue view ${id}: unexpected output`) : ok(parsed);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
|
|
114
|
+
const raw = await viewRaw(gh, id);
|
|
115
|
+
return raw.ok ? ok(toItem(raw.value)) : raw;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const LIST_LIMIT = 500;
|
|
119
|
+
|
|
120
|
+
/** The `gh issue list` arguments that select exactly the wanted status where gh can, so the limit bites last. */
|
|
121
|
+
const listArgs = (status: WorkStatus | undefined): string[] => {
|
|
122
|
+
const state = status === undefined ? "all" : STATE_ARG[status];
|
|
123
|
+
const marker = status === "in-progress" ? ["--label", IN_PROGRESS] : [];
|
|
124
|
+
return [
|
|
125
|
+
"issue",
|
|
126
|
+
"list",
|
|
127
|
+
"--state",
|
|
128
|
+
state,
|
|
129
|
+
...marker,
|
|
130
|
+
"--limit",
|
|
131
|
+
String(LIST_LIMIT),
|
|
132
|
+
"--json",
|
|
133
|
+
FIELDS,
|
|
134
|
+
];
|
|
105
135
|
};
|
|
106
136
|
|
|
107
137
|
const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<WorkItem[]>> => {
|
|
108
|
-
const
|
|
109
|
-
const out = await gh(["issue", "list", "--state", state, "--limit", "200", "--json", FIELDS]);
|
|
138
|
+
const out = await gh(listArgs(filter.status));
|
|
110
139
|
if (!out.ok) return out;
|
|
111
140
|
const parsed = parseIssues(out.value);
|
|
112
141
|
if (parsed === undefined) return trackerError("gh issue list: unexpected output");
|
|
142
|
+
if (parsed.length >= LIST_LIMIT) {
|
|
143
|
+
return trackerError(
|
|
144
|
+
`gh issue list returned ${LIST_LIMIT} issues, so the list may be cut short; ask for one status (open, in-progress or done) to narrow it`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
113
147
|
const items = parsed.map(toItem);
|
|
114
148
|
return ok(filter.status === undefined ? items : items.filter((i) => i.status === filter.status));
|
|
115
149
|
};
|
|
116
150
|
|
|
117
151
|
const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
|
|
152
|
+
const problem = unusable({ title: input.title, labels: input.labels ?? [] });
|
|
153
|
+
if (problem !== undefined) return trackerError(problem);
|
|
118
154
|
const labels = (input.labels ?? []).flatMap((l) => ["--label", l]);
|
|
119
155
|
const out = await gh([
|
|
120
156
|
"issue",
|
|
@@ -132,10 +168,9 @@ const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<Wo
|
|
|
132
168
|
};
|
|
133
169
|
|
|
134
170
|
/** Label changes that make the issue's labels exactly `wanted` (plus the in-progress marker when asked for). */
|
|
135
|
-
const labelFlags = (current: WorkItem, patch: WorkItemPatch): string[] => {
|
|
171
|
+
const labelFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => {
|
|
136
172
|
const wanted = patch.labels ?? current.labels;
|
|
137
173
|
const marked = (patch.status ?? current.status) === "in-progress";
|
|
138
|
-
const hadMarker = current.status === "in-progress";
|
|
139
174
|
const add = [
|
|
140
175
|
...wanted.filter((l) => !current.labels.includes(l)),
|
|
141
176
|
...(marked && !hadMarker ? [IN_PROGRESS] : []),
|
|
@@ -150,10 +185,12 @@ const labelFlags = (current: WorkItem, patch: WorkItemPatch): string[] => {
|
|
|
150
185
|
];
|
|
151
186
|
};
|
|
152
187
|
|
|
153
|
-
const editFlags = (current: WorkItem, patch: WorkItemPatch): string[] => [
|
|
188
|
+
const editFlags = (current: WorkItem, hadMarker: boolean, patch: WorkItemPatch): string[] => [
|
|
154
189
|
...(patch.title === undefined ? [] : ["--title", patch.title]),
|
|
155
190
|
...(patch.body === undefined ? [] : ["--body", patch.body]),
|
|
156
|
-
...(patch.labels === undefined && patch.status === undefined
|
|
191
|
+
...(patch.labels === undefined && patch.status === undefined
|
|
192
|
+
? []
|
|
193
|
+
: labelFlags(current, hadMarker, patch)),
|
|
157
194
|
];
|
|
158
195
|
|
|
159
196
|
/** Closing or reopening only when the issue is not already in the wanted state. */
|
|
@@ -173,19 +210,23 @@ const updateIssue = async (
|
|
|
173
210
|
id: string,
|
|
174
211
|
patch: WorkItemPatch,
|
|
175
212
|
): Promise<TrackerResult<WorkItem>> => {
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
213
|
+
const problem = unusable({ title: patch.title ?? "x", labels: patch.labels ?? [] });
|
|
214
|
+
if (problem !== undefined) return trackerError(problem);
|
|
215
|
+
const raw = await viewRaw(gh, id);
|
|
216
|
+
if (!raw.ok) return raw;
|
|
217
|
+
const current = toItem(raw.value);
|
|
218
|
+
const hadMarker = (raw.value.labels ?? []).some((l) => l.name === IN_PROGRESS);
|
|
178
219
|
if (patch.status === "in-progress") {
|
|
179
220
|
// No --force: that would recolour the team's existing label. "Already exists" is success.
|
|
180
221
|
const made = await gh(["label", "create", IN_PROGRESS]);
|
|
181
222
|
if (!(made.ok || /already exists/i.test(made.error.message))) return made;
|
|
182
223
|
}
|
|
183
|
-
const flags = editFlags(current
|
|
224
|
+
const flags = editFlags(current, hadMarker, patch);
|
|
184
225
|
if (flags.length > 0) {
|
|
185
226
|
const edited = await gh(["issue", "edit", id, ...flags]);
|
|
186
227
|
if (!edited.ok) return edited;
|
|
187
228
|
}
|
|
188
|
-
const command = stateCommand(current
|
|
229
|
+
const command = stateCommand(current, patch.status);
|
|
189
230
|
if (command !== undefined) {
|
|
190
231
|
const changed = await gh(["issue", command, id]);
|
|
191
232
|
if (!changed.ok) return changed;
|
|
@@ -32,17 +32,13 @@ export const unwritable = (item: Pick<WorkItem, "title" | "labels">): string | u
|
|
|
32
32
|
: `label ${JSON.stringify(bad)} is blank or holds a comma or line break`;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/** Written to pass our own markdownlint: no trailing space, no repeated heading text, no stacked blank lines. */
|
|
35
36
|
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");
|
|
37
|
+
const labels = item.labels.length === 0 ? "" : ` ${item.labels.join(", ")}`;
|
|
38
|
+
const top = `# ${item.title}\n\nStatus: ${item.status}\nLabels:${labels}`;
|
|
39
|
+
const body = item.body === "" ? [] : [item.body];
|
|
40
|
+
const comments = item.comments.map((c, n) => `\n### Comment ${n + 1}\n\n${quote(c)}\n`);
|
|
41
|
+
return `${[top, ...body, "## Comments"].join("\n\n")}\n${comments.join("")}`;
|
|
46
42
|
};
|
|
47
43
|
|
|
48
44
|
const isStatus = (value: string): value is WorkStatus =>
|
|
@@ -50,15 +46,16 @@ const isStatus = (value: string): value is WorkStatus =>
|
|
|
50
46
|
|
|
51
47
|
const commentsOf = (section: string): string[] =>
|
|
52
48
|
section
|
|
53
|
-
.split(/^### Comment\n\n/m)
|
|
49
|
+
.split(/^### Comment(?: \d+)?\n\n/m)
|
|
54
50
|
.slice(1)
|
|
55
51
|
.map((c) => unquote(c.replace(/\n+$/, "")));
|
|
56
52
|
|
|
57
|
-
export const parseItem = (id: string,
|
|
53
|
+
export const parseItem = (id: string, raw: string): TrackerResult<WorkItem> => {
|
|
54
|
+
const text = raw.replace(/\r\n?/g, "\n");
|
|
58
55
|
const marker = text.lastIndexOf(COMMENTS);
|
|
59
56
|
const main = marker < 0 ? text : text.slice(0, marker);
|
|
60
57
|
const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));
|
|
61
|
-
const head = /^# (.+)\n\nStatus: (.*)\nLabels: (.*)
|
|
58
|
+
const head = /^# (.+)\n\nStatus: (.*)\nLabels:[ ]?(.*)(?:\n\n|\n$|$)/.exec(main);
|
|
62
59
|
if (head === null)
|
|
63
60
|
return trackerError(`work item ${id}: expected "# title", Status and Labels lines`);
|
|
64
61
|
const [whole, title = "", status = "", labels = ""] = head;
|