passalong 0.1.0 → 0.3.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/README.md +54 -7
- package/bin/passalong +302 -22
- package/package.json +2 -2
- package/skill/SKILL.md +72 -7
- package/src/api.js +70 -3
- package/src/guide.js +264 -5
- package/src/mcp.js +444 -28
- package/src/passalong.js +290 -21
package/src/api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Client for the hosted sync API (apps/api). Everything here is optional: with no token the
|
|
2
|
-
// CLI is a purely local tool, and every function throws
|
|
2
|
+
// CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
|
|
3
3
|
import { readConfig } from "./store.js";
|
|
4
4
|
|
|
5
|
-
export const DEFAULT_API = "https://passalong.
|
|
5
|
+
export const DEFAULT_API = "https://passalong.dev";
|
|
6
6
|
|
|
7
7
|
export class ApiError extends Error {
|
|
8
8
|
constructor(status, message) {
|
|
@@ -54,16 +54,83 @@ async function call(path, { method = "GET", body, auth = true, raw = false } = {
|
|
|
54
54
|
return raw ? res.text() : res.json();
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const q = (params) => {
|
|
58
|
+
const s = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
|
|
59
|
+
return s ? `?${s}` : "";
|
|
60
|
+
};
|
|
61
|
+
|
|
57
62
|
/** Mint a fresh account and return its token. No email, no password: the token is the account. */
|
|
58
63
|
export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
|
|
59
64
|
export const me = () => call("/v1/me");
|
|
65
|
+
export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
|
|
66
|
+
|
|
67
|
+
export const teams = () => call("/v1/teams");
|
|
68
|
+
export const team = (slug) => call(`/v1/teams/${encodeURIComponent(slug)}`);
|
|
69
|
+
export const createTeam = (name) => call("/v1/teams", { method: "POST", body: { name } });
|
|
70
|
+
export const invite = (slug, email = "") =>
|
|
71
|
+
call(`/v1/teams/${encodeURIComponent(slug)}/invites`, { method: "POST", body: { email } });
|
|
72
|
+
export const join = (code) =>
|
|
73
|
+
call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
|
|
74
|
+
|
|
75
|
+
export const board = () => call("/v1/board");
|
|
76
|
+
/** What you did, newest first. `since` is a date prefix: 2026, 2026-09, 2026-09-11. */
|
|
77
|
+
export const log = ({ repo = "", since = "", limit = 0 } = {}) =>
|
|
78
|
+
call(`/v1/log${q({ repo, since, limit: limit || "" })}`);
|
|
79
|
+
export const notifications = ({ unread = true, limit = 0 } = {}) =>
|
|
80
|
+
call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
|
|
81
|
+
/** No ids means "everything unread". */
|
|
82
|
+
export const markRead = (ids = []) =>
|
|
83
|
+
call("/v1/notifications/read", { method: "POST", body: { ids } });
|
|
84
|
+
|
|
60
85
|
export const publish = (id, markdown) =>
|
|
61
86
|
call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
|
|
62
|
-
export const list = (
|
|
87
|
+
export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
|
|
88
|
+
export const inbox = () => call("/v1/inbox");
|
|
89
|
+
/** Open a report for a set of issues to be filed under. */
|
|
90
|
+
export const createReport = (body) => call("/v1/reports", { method: "POST", body });
|
|
91
|
+
/** One report and the issues filed under it, grouped by product area. */
|
|
92
|
+
export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
|
|
63
93
|
export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
|
|
64
94
|
export const setStatus = (id, status) =>
|
|
65
95
|
call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
|
|
66
96
|
export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
|
|
97
|
+
/** Does it actually work? `note` is required when it does not. */
|
|
98
|
+
export const verdict = (id, ok, note = "") =>
|
|
99
|
+
call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
|
|
100
|
+
|
|
101
|
+
export const ack = (id, taken, note = "") =>
|
|
102
|
+
call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Upload an image as evidence, and get back the URL a guide points at.
|
|
106
|
+
*
|
|
107
|
+
* Not `call()`: that serialises a body as JSON or markdown, and this route reads bytes and takes
|
|
108
|
+
* the content type as the declaration of what they are. `name` is the label, and it travels in a
|
|
109
|
+
* header rather than the body for the same reason.
|
|
110
|
+
*/
|
|
111
|
+
export async function uploadShot(bytes, type, name = "") {
|
|
112
|
+
const t = token();
|
|
113
|
+
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
114
|
+
const headers = { authorization: `Bearer ${t}`, "content-type": type };
|
|
115
|
+
// A header is latin-1: a filename with an accent in it throws on the way out rather than at the
|
|
116
|
+
// server, and the label is not worth failing an upload over.
|
|
117
|
+
if (name) headers["x-shot-name"] = String(name).replace(/[^\x20-\x7e]/g, "");
|
|
118
|
+
let res;
|
|
119
|
+
try {
|
|
120
|
+
res = await fetch(`${baseUrl()}/v1/shots`, { method: "POST", headers, body: bytes });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
123
|
+
}
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const body = await res.text();
|
|
126
|
+
let message = body;
|
|
127
|
+
try {
|
|
128
|
+
message = JSON.parse(body).message || body;
|
|
129
|
+
} catch {}
|
|
130
|
+
throw new ApiError(res.status, message || res.statusText);
|
|
131
|
+
}
|
|
132
|
+
return (await res.json()).shot;
|
|
133
|
+
}
|
|
67
134
|
|
|
68
135
|
/** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
|
|
69
136
|
export async function fetchShared(url) {
|
package/src/guide.js
CHANGED
|
@@ -4,8 +4,29 @@
|
|
|
4
4
|
// lists of strings, so a deliberately tiny parser covers it and stays readable when hand-edited.
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A guide is a draft or it is published. `consumed` and `promoted` are legacy: they were an author
|
|
9
|
+
* lifecycle laid over a transfer that already reports itself — `consumed` duplicated the verdict,
|
|
10
|
+
* and `promoted` was a pull count with a name. Nothing sets them any more.
|
|
11
|
+
*
|
|
12
|
+
* They stay in this list because they are *accepted*, not produced. The value lives in frontmatter
|
|
13
|
+
* inside markdown files in other people's repositories, and `validate()` rejects a status it does
|
|
14
|
+
* not know — so removing them here would make a guide shared a month ago fail to re-share today.
|
|
15
|
+
*/
|
|
7
16
|
export const STATUSES = ["draft", "published", "consumed", "promoted"];
|
|
8
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What a guide is for. The receiving agent behaves completely differently depending on the
|
|
20
|
+
* answer, so it is a field rather than something inferred from the body.
|
|
21
|
+
*
|
|
22
|
+
* transfer work that is finished here and should be repeated there — follow the Steps
|
|
23
|
+
* bug something broken there — do NOT follow anything; fix it
|
|
24
|
+
*
|
|
25
|
+
* A guide with no `kind` is a transfer guide: every guide written before this existed is one,
|
|
26
|
+
* and defaulting the other way would turn them all into bug reports.
|
|
27
|
+
*/
|
|
28
|
+
export const KINDS = ["transfer", "bug"];
|
|
29
|
+
|
|
9
30
|
// Body sections in the order a guide should present them. The heading text is what the
|
|
10
31
|
// receiving agent keys on, so keep these stable.
|
|
11
32
|
export const SECTIONS = [
|
|
@@ -17,7 +38,86 @@ export const SECTIONS = [
|
|
|
17
38
|
"Gotchas",
|
|
18
39
|
];
|
|
19
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The product areas a bug report offers, mirrored in apps/api/src/guide.ts the same way this
|
|
43
|
+
* file's parsing rules are.
|
|
44
|
+
*
|
|
45
|
+
* Offered, not enforced: nothing rejects an area that is not on this list. A team that ships a
|
|
46
|
+
* sixth surface should be able to file against it that afternoon rather than after a release, so
|
|
47
|
+
* this is what a menu shows and what turns a slug back into words — not a gate.
|
|
48
|
+
*/
|
|
49
|
+
export const AREAS = [
|
|
50
|
+
{ slug: "web", label: "Web App" },
|
|
51
|
+
{ slug: "mobile", label: "Mobile App" },
|
|
52
|
+
{ slug: "storefront", label: "Storefront Editor" },
|
|
53
|
+
{ slug: "landing", label: "Landing Page" },
|
|
54
|
+
{ slug: "auth", label: "Auth Page" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** How badly it is broken. Same accept-anything rule as AREAS. */
|
|
58
|
+
export const SEVERITIES = [
|
|
59
|
+
{ slug: "s1", label: "Blocker" },
|
|
60
|
+
{ slug: "s2", label: "Major" },
|
|
61
|
+
{ slug: "s3", label: "Minor" },
|
|
62
|
+
{ slug: "s4", label: "Cosmetic" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A bug report's sections. The repro is under `Reproduce`, never `Steps`.
|
|
67
|
+
*
|
|
68
|
+
* That distinction is the whole reason `kind` exists. `Steps` is an instruction to execute —
|
|
69
|
+
* "follow its Steps" is what the MCP server tells every agent that pulls a guide — and steps that
|
|
70
|
+
* reproduce a defect are the one list that must never be run as a remedy. An agent handed a bug
|
|
71
|
+
* with its repro under `Steps` will faithfully reproduce the bug, check the Verification, find it
|
|
72
|
+
* false because the bug is real, and report that the guide does not work.
|
|
73
|
+
*/
|
|
74
|
+
export const BUG_SECTIONS = ["Problem", "Reproduce", "Verification", "Gotchas"];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One line at the top of every bug, saying what the document is.
|
|
78
|
+
*
|
|
79
|
+
* `kind: bug` in the frontmatter is the machine-readable answer and `Reproduce` is a heading no
|
|
80
|
+
* one executes by mistake, but neither is a sentence. This is, and it is inside the document, so
|
|
81
|
+
* it reaches a reader that fetched a share link over plain HTTP and has never heard of Passalong.
|
|
82
|
+
*/
|
|
83
|
+
export const BUG_LEAD =
|
|
84
|
+
"> **Bug report.** The steps under Reproduce show the problem \u2014 they are not a fix to " +
|
|
85
|
+
"apply. Fix what Problem describes, then check Verification.";
|
|
86
|
+
|
|
87
|
+
/** The sections a guide of this kind presents, in order. */
|
|
88
|
+
export function sectionsFor(kind) {
|
|
89
|
+
return kind === "bug" ? BUG_SECTIONS : SECTIONS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The sections it cannot be published without. */
|
|
93
|
+
const REQUIRED = { transfer: ["Problem", "Steps"], bug: ["Problem", "Reproduce"] };
|
|
94
|
+
|
|
20
95
|
// Fields that hold a list of strings. Everything else is a plain string.
|
|
96
|
+
/**
|
|
97
|
+
* One tag, in the one style tags are written in: lowercase, words joined by a hyphen.
|
|
98
|
+
*
|
|
99
|
+
* Nothing used to normalise them, so `custom-fields` and `additional_information` could sit on the
|
|
100
|
+
* same guide and the same idea exist twice under two spellings. A tag is a controlled vocabulary,
|
|
101
|
+
* not prose — its whole value is that two people who mean the same thing write the same string.
|
|
102
|
+
* Mirrors `tag()` in apps/api/src/guide.ts.
|
|
103
|
+
*/
|
|
104
|
+
export function tag(raw) {
|
|
105
|
+
return String(raw ?? "")
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/[\s_]+/g, "-")
|
|
109
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
110
|
+
.replace(/-{2,}/g, "-")
|
|
111
|
+
.slice(0, 32)
|
|
112
|
+
.replace(/^-+|-+$/g, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A guide's tags: normalised, emptied of blanks, deduped once two spellings become one. */
|
|
116
|
+
export function tagList(raw) {
|
|
117
|
+
const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
|
118
|
+
return [...new Set(list.map(tag).filter(Boolean))];
|
|
119
|
+
}
|
|
120
|
+
|
|
21
121
|
const LIST_FIELDS = new Set(["stack_assumptions", "tags"]);
|
|
22
122
|
|
|
23
123
|
// IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
|
|
@@ -40,11 +140,45 @@ function scalar(raw) {
|
|
|
40
140
|
return v;
|
|
41
141
|
}
|
|
42
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Split a flow sequence's inner text on the commas *between* items, not the ones inside them.
|
|
145
|
+
* `quote()` wraps any value containing a comma, so a plain `split(",")` tears those items apart —
|
|
146
|
+
* and the pieces keep their stray quotes, because `scalar()` only unwraps a value quoted at both
|
|
147
|
+
* ends. Serialising a list and parsing it back has to return the same list.
|
|
148
|
+
*/
|
|
149
|
+
function items(inner) {
|
|
150
|
+
const out = [];
|
|
151
|
+
let buf = "";
|
|
152
|
+
let quoted = null;
|
|
153
|
+
for (let i = 0; i < inner.length; i++) {
|
|
154
|
+
const c = inner[i];
|
|
155
|
+
if (quoted) {
|
|
156
|
+
// `quote()` escapes an inner double quote as \", which does not close the value.
|
|
157
|
+
if (c === "\\" && quoted === '"' && inner[i + 1] === '"') {
|
|
158
|
+
buf += c + inner[i + 1];
|
|
159
|
+
i++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
buf += c;
|
|
163
|
+
if (c === quoted) quoted = null;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (c === '"' || c === "'") quoted = c;
|
|
167
|
+
if (c === ",") {
|
|
168
|
+
out.push(buf);
|
|
169
|
+
buf = "";
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
buf += c;
|
|
173
|
+
}
|
|
174
|
+
out.push(buf);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
43
178
|
function inlineList(raw) {
|
|
44
179
|
const inner = raw.trim().slice(1, -1).trim();
|
|
45
180
|
if (inner === "") return [];
|
|
46
|
-
return inner
|
|
47
|
-
.split(",")
|
|
181
|
+
return items(inner)
|
|
48
182
|
.map(scalar)
|
|
49
183
|
.filter((s) => s !== "");
|
|
50
184
|
}
|
|
@@ -56,7 +190,9 @@ export function parseFrontmatter(text) {
|
|
|
56
190
|
let listKey = null;
|
|
57
191
|
for (const line of lines) {
|
|
58
192
|
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
59
|
-
|
|
193
|
+
// YAML lets a block sequence sit flush with its key, so the indent is optional. Requiring it
|
|
194
|
+
// dropped `- item` lines silently: they match no key either, and the field stayed empty.
|
|
195
|
+
const item = /^\s*-\s*(.*)$/.exec(line);
|
|
60
196
|
if (item && listKey) {
|
|
61
197
|
meta[listKey].push(scalar(item[1]));
|
|
62
198
|
continue;
|
|
@@ -76,6 +212,9 @@ export function parseFrontmatter(text) {
|
|
|
76
212
|
}
|
|
77
213
|
}
|
|
78
214
|
for (const k of LIST_FIELDS) if (meta[k] === undefined) meta[k] = [];
|
|
215
|
+
// Both ends of this module: what it reads and what it writes are in one style, so a guide
|
|
216
|
+
// written before there was a rule comes back normalised and goes out normalised.
|
|
217
|
+
meta.tags = tagList(meta.tags);
|
|
79
218
|
return meta;
|
|
80
219
|
}
|
|
81
220
|
|
|
@@ -88,12 +227,23 @@ function quote(v) {
|
|
|
88
227
|
const META_ORDER = [
|
|
89
228
|
"id",
|
|
90
229
|
"title",
|
|
230
|
+
"kind",
|
|
91
231
|
"created",
|
|
92
232
|
"author",
|
|
93
233
|
"source_context",
|
|
234
|
+
// Where it came from, in the two senses a guide has one: `source_context` is the repo and branch
|
|
235
|
+
// it was written in, `parent` is the guide it was written *out of*.
|
|
236
|
+
"parent",
|
|
94
237
|
"status",
|
|
238
|
+
"team",
|
|
239
|
+
"to",
|
|
95
240
|
"stack_assumptions",
|
|
96
241
|
"tags",
|
|
242
|
+
// Bug reports only. `report` is the parent a set of issues was filed under; the other two are
|
|
243
|
+
// where it is and how badly it is broken.
|
|
244
|
+
"report",
|
|
245
|
+
"area",
|
|
246
|
+
"severity",
|
|
97
247
|
];
|
|
98
248
|
|
|
99
249
|
export function serializeFrontmatter(meta) {
|
|
@@ -103,7 +253,7 @@ export function serializeFrontmatter(meta) {
|
|
|
103
253
|
];
|
|
104
254
|
const out = [];
|
|
105
255
|
for (const key of keys) {
|
|
106
|
-
const v = meta[key];
|
|
256
|
+
const v = key === "tags" ? tagList(meta[key]) : meta[key];
|
|
107
257
|
if (Array.isArray(v)) {
|
|
108
258
|
out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
|
|
109
259
|
} else {
|
|
@@ -146,13 +296,30 @@ export function validate({ meta, body }) {
|
|
|
146
296
|
const errors = [];
|
|
147
297
|
if (!meta.title) errors.push("frontmatter needs a title");
|
|
148
298
|
if (meta.id && !ID_RE.test(meta.id)) errors.push(`id "${meta.id}" is not a valid passalong id`);
|
|
299
|
+
// A parent is optional, and a self-parent is always a mistake worth refusing.
|
|
300
|
+
//
|
|
301
|
+
// A malformed one is not refused, though, and that is deliberate. `parent:` is a field name this
|
|
302
|
+
// format did not reserve until now, so a guide written months ago may already carry one meaning
|
|
303
|
+
// something else entirely — and a guide shared then has to still re-share today. Rejecting the
|
|
304
|
+
// value would make that document unpublishable by its own author, which is the failure the
|
|
305
|
+
// STATUSES comment above describes. The server drops a parent it cannot resolve, so the worst
|
|
306
|
+
// case is lineage that silently does not apply rather than work nobody can hand on.
|
|
307
|
+
if (meta.parent && meta.id && meta.parent === meta.id)
|
|
308
|
+
errors.push("a guide cannot follow itself");
|
|
149
309
|
if (meta.status && !STATUSES.includes(meta.status)) {
|
|
150
310
|
errors.push(`status must be one of ${STATUSES.join(", ")}`);
|
|
151
311
|
}
|
|
312
|
+
const kind = meta.kind || "transfer";
|
|
313
|
+
if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
|
|
152
314
|
const have = sections(body);
|
|
153
|
-
for (const s of [
|
|
315
|
+
for (const s of REQUIRED[kind] || REQUIRED.transfer) {
|
|
154
316
|
if (!have[s]) errors.push(`missing "## ${s}" section`);
|
|
155
317
|
}
|
|
318
|
+
// A bug whose repro sits under `Steps` is the failure mode `kind` exists to prevent, and it is
|
|
319
|
+
// worth catching at publish rather than letting an agent discover it by reproducing the bug.
|
|
320
|
+
if (kind === "bug" && have.Steps) {
|
|
321
|
+
errors.push('a bug uses "## Reproduce", not "## Steps" — an agent executes Steps');
|
|
322
|
+
}
|
|
156
323
|
if (body.includes("<!-- passalong:")) errors.push("template placeholders are still in the body");
|
|
157
324
|
return errors;
|
|
158
325
|
}
|
|
@@ -168,8 +335,63 @@ export function stamp(guide, defaults = {}) {
|
|
|
168
335
|
return { meta, body: guide.body };
|
|
169
336
|
}
|
|
170
337
|
|
|
338
|
+
/**
|
|
339
|
+
* One bug, as the guide it becomes.
|
|
340
|
+
*
|
|
341
|
+
* This is the filing shape: fields in, a valid document out, so nothing that files a bug has to
|
|
342
|
+
* remember which heading the repro goes under. The hub's form assembles its own because it has
|
|
343
|
+
* more to say — a device string, screenshots, and a rich text field it converts — but the section
|
|
344
|
+
* names and the order are these, and `validate()` is the thing both answer to.
|
|
345
|
+
*/
|
|
346
|
+
export function bugGuide({
|
|
347
|
+
title = "",
|
|
348
|
+
problem = "",
|
|
349
|
+
reproduce = "",
|
|
350
|
+
verification = "",
|
|
351
|
+
gotchas = "",
|
|
352
|
+
severity = "s3",
|
|
353
|
+
area = "",
|
|
354
|
+
report = "",
|
|
355
|
+
environment = "",
|
|
356
|
+
status = "published",
|
|
357
|
+
} = {}) {
|
|
358
|
+
const body = [
|
|
359
|
+
// Written into the document, not added by whatever served it.
|
|
360
|
+
//
|
|
361
|
+
// get_guide says this in front of the markdown, but that only reaches an MCP client. A share
|
|
362
|
+
// link is plain markdown any agent can fetch over HTTP, and the route that serves it cannot
|
|
363
|
+
// decorate the response: `api.byLink` pulls guides *through* that route and re-serialises what
|
|
364
|
+
// it gets, so anything added there would be written to disk and published back on the next
|
|
365
|
+
// share. A line in the body survives that round trip unchanged and reaches every reader.
|
|
366
|
+
BUG_LEAD,
|
|
367
|
+
"",
|
|
368
|
+
"## Problem",
|
|
369
|
+
problem.trim() || "_No description given._",
|
|
370
|
+
"",
|
|
371
|
+
"## Reproduce",
|
|
372
|
+
reproduce.trim() || "_Not recorded — the description above is what there is._",
|
|
373
|
+
];
|
|
374
|
+
// Both are optional and both are worse than absent when empty: a Verification heading with
|
|
375
|
+
// nothing under it says nobody knows what fixed looks like, on the section a fixer reads first.
|
|
376
|
+
if (verification.trim()) body.push("", "## Verification", verification.trim());
|
|
377
|
+
if (gotchas.trim()) body.push("", "## Gotchas", gotchas.trim());
|
|
378
|
+
|
|
379
|
+
const meta = {
|
|
380
|
+
title,
|
|
381
|
+
kind: "bug",
|
|
382
|
+
status,
|
|
383
|
+
tags: ["bug", environment, area].filter(Boolean),
|
|
384
|
+
};
|
|
385
|
+
if (report) meta.report = report;
|
|
386
|
+
if (area) meta.area = area;
|
|
387
|
+
if (severity) meta.severity = severity;
|
|
388
|
+
if (environment) meta.source_context = environment;
|
|
389
|
+
return serialize({ meta, body: body.join("\n") });
|
|
390
|
+
}
|
|
391
|
+
|
|
171
392
|
/** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
|
|
172
393
|
export function template(meta = {}) {
|
|
394
|
+
if (meta.kind === "bug") return bugTemplate(meta);
|
|
173
395
|
const body = [
|
|
174
396
|
"## Problem",
|
|
175
397
|
"<!-- passalong: What was broken or needed, in two or three sentences. -->",
|
|
@@ -203,6 +425,43 @@ export function template(meta = {}) {
|
|
|
203
425
|
});
|
|
204
426
|
}
|
|
205
427
|
|
|
428
|
+
/**
|
|
429
|
+
* One bug, as a guide. Four sections and no `Steps` anywhere in it.
|
|
430
|
+
*
|
|
431
|
+
* `Verification` carries its usual meaning and does its usual job: it is what the person who
|
|
432
|
+
* fixes this runs to show they did, and it is what they are answering when they give a verdict.
|
|
433
|
+
* For a bug that is simply the behaviour that should have happened.
|
|
434
|
+
*/
|
|
435
|
+
function bugTemplate(meta = {}) {
|
|
436
|
+
const body = [
|
|
437
|
+
"## Problem",
|
|
438
|
+
"<!-- passalong: What is broken, and what it stops someone doing. -->",
|
|
439
|
+
"",
|
|
440
|
+
"## Reproduce",
|
|
441
|
+
"<!-- passalong: How to see it. These steps produce the bug — they are not a fix to apply. -->",
|
|
442
|
+
"",
|
|
443
|
+
"## Verification",
|
|
444
|
+
"<!-- passalong: What should happen instead, as something the fixer can check. -->",
|
|
445
|
+
"",
|
|
446
|
+
"## Gotchas",
|
|
447
|
+
"<!-- passalong: Anything already ruled out, or that made it hard to pin down. -->",
|
|
448
|
+
].join("\n");
|
|
449
|
+
return serialize({
|
|
450
|
+
meta: {
|
|
451
|
+
title: "",
|
|
452
|
+
kind: "bug",
|
|
453
|
+
author: "",
|
|
454
|
+
source_context: "",
|
|
455
|
+
status: "draft",
|
|
456
|
+
severity: "s3",
|
|
457
|
+
stack_assumptions: [],
|
|
458
|
+
tags: ["bug"],
|
|
459
|
+
...meta,
|
|
460
|
+
},
|
|
461
|
+
body,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
206
465
|
/** Strip the template comments so an untouched section reads as empty rather than as a prompt. */
|
|
207
466
|
export function stripPlaceholders(body) {
|
|
208
467
|
return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();
|