@shipwrights/source-jira 0.1.0 → 0.2.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 CHANGED
@@ -81,7 +81,7 @@ config:
81
81
 
82
82
  ## Status
83
83
 
84
- v0.1.0 — Phase 1. Implements `healthcheck` + `listAvailable`. Materialise, transitions, and PR-link writes land in later phases.
84
+ v0.2.0 — Phase 2. Implements `healthcheck`, `listAvailable`, `pickNext`, and `materialize` (full ADF→markdown rendering of Jira issue descriptions). Status transitions (`markStatus`) and PR-link writes (`attachPR`) still throw `"lands in Phase N"` errors and arrive in v0.3 / v0.4.
85
85
 
86
86
  ## License
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipwrights/source-jira",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Jira backlog source adapter for @shipwrights/core. Pulls issues via JQL, materialises them as epic files, writes status transitions and PR links back to Jira.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -0,0 +1,192 @@
1
+ // ADF (Atlassian Document Format) → markdown converter.
2
+ //
3
+ // Handles the 80% of ADF nodes that show up in a normal Jira issue
4
+ // description: paragraphs, headings, bullet / ordered lists, code blocks,
5
+ // blockquotes, horizontal rules, links, inline cards, mentions, emoji.
6
+ //
7
+ // Unsupported nodes (tables, panels, status pills, expand/collapse, dates,
8
+ // embeds) emit an HTML comment so the consumer sees what was dropped without
9
+ // the document falling apart silently. Tables are common enough to flag, but
10
+ // implementing them well needs row/header logic that doesn't earn its keep
11
+ // for a v1.
12
+ //
13
+ // Marks (inline text formatting) handled: strong, em, code, strike, link.
14
+ // Underline and subsup render as plain text (no clean markdown equivalent).
15
+
16
+ const UNSUPPORTED_NODES = new Set([
17
+ "table",
18
+ "tableRow",
19
+ "tableHeader",
20
+ "tableCell",
21
+ "panel",
22
+ "expand",
23
+ "nestedExpand",
24
+ "status",
25
+ "date",
26
+ "mediaGroup",
27
+ "mediaSingle",
28
+ "media",
29
+ "embedCard",
30
+ "decisionList",
31
+ "decisionItem",
32
+ "taskList",
33
+ "taskItem",
34
+ ]);
35
+
36
+ /**
37
+ * Convert an ADF root document to markdown.
38
+ *
39
+ * @param {object | null | undefined} adf - the document; usually fields.description from a Jira issue
40
+ * @returns {string} markdown (empty string for null/undefined)
41
+ */
42
+ export function adfToMarkdown(adf) {
43
+ if (!adf) return "";
44
+ if (typeof adf === "string") return adf; // already plain text
45
+ if (adf.type !== "doc") {
46
+ // Some endpoints return a single node — handle gracefully.
47
+ return renderNode(adf, { listDepth: 0 }).trim();
48
+ }
49
+ const out = renderChildren(adf.content ?? [], { listDepth: 0 }, "\n\n").trim();
50
+ return out;
51
+ }
52
+
53
+ function renderChildren(nodes, ctx, separator = "") {
54
+ if (!Array.isArray(nodes)) return "";
55
+ return nodes
56
+ .map((n) => renderNode(n, ctx))
57
+ .filter((s) => s !== "")
58
+ .join(separator);
59
+ }
60
+
61
+ function renderNode(node, ctx) {
62
+ if (!node || typeof node !== "object") return "";
63
+ const type = node.type;
64
+ switch (type) {
65
+ case "text":
66
+ return renderTextWithMarks(node);
67
+ case "paragraph":
68
+ return renderChildren(node.content ?? [], ctx);
69
+ case "hardBreak":
70
+ return " \n"; // markdown line break: two spaces + newline
71
+ case "heading":
72
+ return renderHeading(node, ctx);
73
+ case "bulletList":
74
+ return renderList(node, ctx, "-");
75
+ case "orderedList":
76
+ return renderList(node, ctx, "1.");
77
+ case "listItem":
78
+ return renderChildren(node.content ?? [], ctx, "\n");
79
+ case "codeBlock":
80
+ return renderCodeBlock(node);
81
+ case "blockquote":
82
+ return renderBlockquote(node, ctx);
83
+ case "rule":
84
+ return "---";
85
+ case "inlineCard":
86
+ return renderInlineCard(node);
87
+ case "mention":
88
+ return renderMention(node);
89
+ case "emoji":
90
+ return renderEmoji(node);
91
+ default:
92
+ if (UNSUPPORTED_NODES.has(type)) {
93
+ return `<!-- unsupported ADF node: ${type} -->`;
94
+ }
95
+ // Unknown node — try to render children, fall back to a comment
96
+ if (Array.isArray(node.content) && node.content.length > 0) {
97
+ return renderChildren(node.content, ctx);
98
+ }
99
+ return `<!-- unknown ADF node: ${type} -->`;
100
+ }
101
+ }
102
+
103
+ function renderTextWithMarks(node) {
104
+ let text = node.text ?? "";
105
+ if (!text) return "";
106
+ const marks = node.marks ?? [];
107
+ for (const mark of marks) {
108
+ text = applyMark(text, mark);
109
+ }
110
+ return text;
111
+ }
112
+
113
+ function applyMark(text, mark) {
114
+ switch (mark.type) {
115
+ case "strong":
116
+ return `**${text}**`;
117
+ case "em":
118
+ return `*${text}*`;
119
+ case "code":
120
+ return `\`${text}\``;
121
+ case "strike":
122
+ return `~~${text}~~`;
123
+ case "link": {
124
+ const href = mark.attrs?.href ?? "";
125
+ return `[${text}](${href})`;
126
+ }
127
+ case "underline":
128
+ // No native markdown for underline. Render as text + comment so consumers
129
+ // can spot if they want to add it back via HTML.
130
+ return text;
131
+ case "subsup":
132
+ return text;
133
+ default:
134
+ return text;
135
+ }
136
+ }
137
+
138
+ function renderHeading(node, ctx) {
139
+ const level = Math.min(Math.max(node.attrs?.level ?? 1, 1), 6);
140
+ const inner = renderChildren(node.content ?? [], ctx);
141
+ return `${"#".repeat(level)} ${inner}`;
142
+ }
143
+
144
+ function renderList(node, ctx, marker) {
145
+ const childCtx = { ...ctx, listDepth: ctx.listDepth + 1 };
146
+ const indent = " ".repeat(ctx.listDepth);
147
+ return (node.content ?? [])
148
+ .map((item) => {
149
+ const inner = renderChildren(item.content ?? [], childCtx, "\n");
150
+ const lines = inner.split("\n");
151
+ const first = lines.shift() ?? "";
152
+ // Continuation lines: only add the +2 indent for plain text. Lines
153
+ // that already start with whitespace are nested-list output that
154
+ // self-indents at the correct depth.
155
+ const rest = lines.map((l) => {
156
+ if (l === "" || /^\s/.test(l)) return l;
157
+ return `${indent} ${l}`;
158
+ });
159
+ return `${indent}${marker} ${first}${rest.length > 0 ? `\n${rest.join("\n")}` : ""}`;
160
+ })
161
+ .join("\n");
162
+ }
163
+
164
+ function renderCodeBlock(node) {
165
+ const lang = node.attrs?.language ?? "";
166
+ const content = (node.content ?? [])
167
+ .map((c) => c.text ?? "")
168
+ .join("");
169
+ return `\`\`\`${lang}\n${content}\n\`\`\``;
170
+ }
171
+
172
+ function renderBlockquote(node, ctx) {
173
+ const inner = renderChildren(node.content ?? [], ctx, "\n\n");
174
+ return inner
175
+ .split("\n")
176
+ .map((line) => `> ${line}`)
177
+ .join("\n");
178
+ }
179
+
180
+ function renderInlineCard(node) {
181
+ const url = node.attrs?.url ?? "";
182
+ return url ? `[${url}](${url})` : "";
183
+ }
184
+
185
+ function renderMention(node) {
186
+ const text = node.attrs?.text ?? node.attrs?.id ?? "user";
187
+ return text.startsWith("@") ? text : `@${text}`;
188
+ }
189
+
190
+ function renderEmoji(node) {
191
+ return node.attrs?.text ?? node.attrs?.shortName ?? "";
192
+ }
package/src/client.mjs CHANGED
@@ -107,6 +107,20 @@ export function createClient({ host, email, token, fetch: fetchImpl = fetch } =
107
107
  return out;
108
108
  },
109
109
 
110
+ /**
111
+ * Get a single issue by key or id, including its full description (which
112
+ * search responses don't return). Used by materialize().
113
+ *
114
+ * @param {string} issueKeyOrId
115
+ * @param {{ fields?: string[], expand?: string }} opts
116
+ */
117
+ getIssue(issueKeyOrId, { fields, expand } = {}) {
118
+ const query = {};
119
+ if (Array.isArray(fields) && fields.length > 0) query.fields = fields.join(",");
120
+ if (expand) query.expand = expand;
121
+ return request("GET", `/issue/${encodeURIComponent(issueKeyOrId)}`, { query });
122
+ },
123
+
110
124
  /**
111
125
  * Low-level escape hatch. Useful for tests and for endpoints not yet
112
126
  * added to this client.
package/src/index.mjs CHANGED
@@ -4,11 +4,17 @@
4
4
  //
5
5
  // { healthcheck, listAvailable, pickNext, materialize, markStatus, attachPR }
6
6
  //
7
- // Phase 1 ships healthcheck + listAvailable + pickNext. The remaining three
8
- // land in Phases 2–4.
7
+ // Phase 1 shipped healthcheck + listAvailable + pickNext.
8
+ // Phase 2 (this file) adds materialize: fetch the full issue, render its ADF
9
+ // description to markdown, write an epic file with frontmatter.
10
+ //
11
+ // markStatus and attachPR still throw "Phase N" errors — they land in 3 and 4.
9
12
 
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
10
15
  import { createClient } from "./client.mjs";
11
16
  import { validateJql, fieldsForSearch } from "./jql.mjs";
17
+ import { adfToMarkdown } from "./adf-to-markdown.mjs";
12
18
 
13
19
  const PRIORITY_ORDER = { Highest: 0, High: 1, Medium: 2, Low: 3, Lowest: 4 };
14
20
 
@@ -84,6 +90,102 @@ function comparePriority(a, b) {
84
90
  return (a.id ?? "").localeCompare(b.id ?? "");
85
91
  }
86
92
 
93
+ /**
94
+ * Lowercase-hyphen-only slug, truncated to a sensible length.
95
+ */
96
+ function slugify(title) {
97
+ return String(title ?? "")
98
+ .toLowerCase()
99
+ .replace(/[^a-z0-9]+/g, "-")
100
+ .replace(/^-+|-+$/g, "")
101
+ .slice(0, 60) || "untitled";
102
+ }
103
+
104
+ /**
105
+ * Map a Jira priority name to Shipwright's P0..P3 if it matches a known label;
106
+ * otherwise pass through. Jira's default priorities are Highest, High, Medium,
107
+ * Low, Lowest — we collapse to the closest P.
108
+ */
109
+ function priorityCodeFromName(name) {
110
+ switch (name) {
111
+ case "Highest":
112
+ return "P0";
113
+ case "High":
114
+ return "P1";
115
+ case "Medium":
116
+ return "P2";
117
+ case "Low":
118
+ case "Lowest":
119
+ return "P3";
120
+ default:
121
+ return name ?? "P2";
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Pull a list of acceptance criteria from a description's markdown body.
127
+ * Recognises two common patterns:
128
+ * - "## Acceptance" or "## Acceptance Criteria" heading followed by a list
129
+ * - a top-level checkbox list (`- [ ] criterion`)
130
+ * Returns [] when no recognizable section is found.
131
+ */
132
+ function extractAcceptance(markdown) {
133
+ if (!markdown) return [];
134
+ const headingRe = /^##\s+Acceptance(?:\s+Criteria)?\s*$([\s\S]*?)(?=^##\s|\Z)/im;
135
+ const match = markdown.match(headingRe);
136
+ const block = match ? match[1] : markdown;
137
+ const bullets = [];
138
+ for (const line of block.split(/\r?\n/)) {
139
+ const m = line.match(/^\s*[-*]\s+(?:\[[\sxX]\]\s+)?(.+?)\s*$/);
140
+ if (m) bullets.push(m[1]);
141
+ }
142
+ return bullets;
143
+ }
144
+
145
+ /**
146
+ * Build the epic markdown document from an enriched BacklogItem + rendered
147
+ * description. Output structure mirrors @shipwrights/core's epic schema.
148
+ */
149
+ function buildEpicMarkdown(item, descriptionMd) {
150
+ const acceptance = extractAcceptance(descriptionMd);
151
+ const priorityCode = priorityCodeFromName(item.priority);
152
+ const sourceBlock = item.metadata
153
+ ? `source:\n kind: jira\n issue_key: ${item.metadata.issueKey}\n jira_url: ${item.metadata.jiraUrl ?? ""}`
154
+ : "";
155
+ const acceptanceBlock = acceptance.length > 0
156
+ ? `acceptance:\n${acceptance.map((a) => ` - ${escapeYamlInline(a)}`).join("\n")}`
157
+ : "acceptance: []";
158
+
159
+ return `---
160
+ id: ${item.id}
161
+ title: ${escapeYamlInline(item.title)}
162
+ status: refined
163
+ priority: ${priorityCode}
164
+ domain: ${item.domain ?? "full-stack"}
165
+ owner: claude
166
+ parents: ${formatParents(item.parents)}
167
+ ${acceptanceBlock}
168
+ size: ${item.size ?? "medium"}
169
+ ${sourceBlock}
170
+ ---
171
+
172
+ ## Why
173
+
174
+ ${descriptionMd || "_(no description in Jira)_"}
175
+ `;
176
+ }
177
+
178
+ function formatParents(parents) {
179
+ if (!Array.isArray(parents) || parents.length === 0) return "[]";
180
+ return `[${parents.join(", ")}]`;
181
+ }
182
+
183
+ function escapeYamlInline(value) {
184
+ const s = String(value ?? "");
185
+ if (/^[A-Za-z0-9 _\-.,!?()]+$/.test(s)) return s;
186
+ return JSON.stringify(s);
187
+ }
188
+
87
189
  /**
88
190
  * Factory called by @shipwrights/core's source-loader.
89
191
  */
@@ -158,14 +260,51 @@ export function createSource(rawConfig = {}) {
158
260
  },
159
261
 
160
262
  /**
161
- * Phases 2–4: not yet implemented. Each throws a clear error rather than
162
- * silently no-oping, so consumers know they're on a Phase 1 release.
263
+ * Phase 2: fetch the Jira issue (full description), render ADF
264
+ * markdown, write a refined epic file with frontmatter. Returns
265
+ * { epicFilePath, created } per the BacklogSource contract.
266
+ *
267
+ * The epic file is written with `status: refined` so the orchestrator
268
+ * skips re-running the PO refinement step. Acceptance criteria are
269
+ * parsed from the description if a recognizable `## Acceptance` or
270
+ * checkbox section is found; otherwise left empty for the user/PO to
271
+ * fill in.
163
272
  */
164
- async materialize(_item, _targetDir) {
165
- throw new Error(
166
- "@shipwrights/source-jira: materialize() lands in Phase 2 (next release). Use listAvailable() / pickNext() for now and materialise epic files by hand.",
167
- );
273
+ async materialize(item, targetDir) {
274
+ if (!item?.id) {
275
+ throw new Error("@shipwrights/source-jira: materialize() needs a BacklogItem with an id");
276
+ }
277
+ if (!targetDir || typeof targetDir !== "string") {
278
+ throw new Error("@shipwrights/source-jira: materialize() needs a targetDir path");
279
+ }
280
+
281
+ const fullFields = fieldsForSearch({ fieldMapping: field_mapping }).concat(["description"]);
282
+ const issue = await client.getIssue(item.id, { fields: fullFields });
283
+ const enriched = toBacklogItem(issue, { idPrefix: id_prefix, fieldMapping: field_mapping });
284
+
285
+ const description = adfToMarkdown(issue.fields?.description);
286
+ const slug = slugify(enriched.title);
287
+ const filename = `${enriched.id}-${slug}.md`;
288
+ const path = join(targetDir, filename);
289
+ const created = !existsSync(path);
290
+
291
+ mkdirSync(targetDir, { recursive: true });
292
+ const body = buildEpicMarkdown(enriched, description);
293
+ // Be polite: if the file already exists with status > refined, don't
294
+ // overwrite its body — only refresh the frontmatter title in case it
295
+ // changed in Jira.
296
+ if (!created) {
297
+ const existing = readFileSync(path, "utf8");
298
+ const status = (existing.match(/^status:\s*(\S+)/m) ?? [])[1];
299
+ if (status && status !== "idea" && status !== "refined") {
300
+ // Don't clobber in-flight epics; leave them alone.
301
+ return { epicFilePath: path, created: false };
302
+ }
303
+ }
304
+ writeFileSync(path, body, "utf8");
305
+ return { epicFilePath: path, created };
168
306
  },
307
+
169
308
  async markStatus(_itemId, _status) {
170
309
  throw new Error(
171
310
  "@shipwrights/source-jira: markStatus() lands in Phase 3 (next release).",