From 66f70e4db1f36a36eab764d067c96bf78ccec134 Mon Sep 17 00:00:00 2001 From: jhonDoe15 Date: Tue, 22 Sep 2026 22:18:26 +0300 Subject: [PATCH 1/3] fix(skills): compact Jira implementation reads --- .gitignore | 2 + skills/twg-jira/SKILL.md | 14 + .../scripts/jira_workitem_implementation.py | 242 ++++++++++++++++++ .../test/test_jira_workitem_implementation.py | 202 +++++++++++++++ skills/twg/SKILL.md | 9 +- 5 files changed, 466 insertions(+), 3 deletions(-) create mode 100755 skills/twg-jira/scripts/jira_workitem_implementation.py create mode 100644 skills/twg-jira/test/test_jira_workitem_implementation.py diff --git a/.gitignore b/.gitignore index 2dd31050..dae2784e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .env .env.* *.log +__pycache__/ +*.py[cod] # Generated public export staging release/ diff --git a/skills/twg-jira/SKILL.md b/skills/twg-jira/SKILL.md index 1f022be7..35d155c8 100644 --- a/skills/twg-jira/SKILL.md +++ b/skills/twg-jira/SKILL.md @@ -54,6 +54,20 @@ inside a cross-product answer. Run one focused `twg help describe ""` before an unfamiliar or consequential mutation. +## Implementation-bearing workitem reads + +When implementation depends on a known workitem's description, acceptance +criteria, dependencies, or constraints, execute +[`scripts/jira_workitem_implementation.py`](scripts/jira_workitem_implementation.py) +with all known keys in one call. Resolve the script path relative to this +`SKILL.md` and run `python3 `. The script fetches bounded +native fields, converts Jira ADF to Markdown, and emits compact JSON. + +Use `@compact` only for identity, status, owner, and URL. Do not open raw TWG +stdout to recover implementation requirements. If the script fails, report its +diagnostic and stop that evidence branch instead of falling back to the raw +payload. + ## Jira Semantics - "Issue" and "workitem" refer to the same Jira object in user language. diff --git a/skills/twg-jira/scripts/jira_workitem_implementation.py b/skills/twg-jira/scripts/jira_workitem_implementation.py new file mode 100755 index 00000000..f30e8333 --- /dev/null +++ b/skills/twg-jira/scripts/jira_workitem_implementation.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path + + +JIRA_FIELDS = "summary,description,status,parent,issuetype" + + +def render_inlines(nodes): + return "".join(render_inline(node) for node in nodes or []) + + +def render_inline(node): + node_type = node.get("type") + if node_type == "text": + text = node.get("text", "") + for mark in node.get("marks", []): + mark_type = mark.get("type") + if mark_type == "code": + text = f"`{text}`" + elif mark_type == "strong": + text = f"**{text}**" + elif mark_type == "em": + text = f"*{text}*" + elif mark_type == "strike": + text = f"~~{text}~~" + elif mark_type == "link": + href = mark.get("attrs", {}).get("href") + if href: + text = f"[{text}]({href})" + return text + if node_type == "hardBreak": + return "\n" + if node_type in {"mention", "emoji", "status"}: + attributes = node.get("attrs", {}) + return ( + attributes.get("text") + or attributes.get("displayName") + or attributes.get("shortName") + or "" + ) + if node_type in {"inlineCard", "blockCard", "embedCard"}: + return node.get("attrs", {}).get("url", "") + return render_inlines(node.get("content")) + + +def render_block(node): + node_type = node.get("type") + content = node.get("content", []) + if node_type == "doc": + return "\n\n".join(filter(None, (render_block(child).strip() for child in content))) + if node_type == "heading": + level = min(max(int(node.get("attrs", {}).get("level", 1)), 1), 6) + return f"{'#' * level} {render_inlines(content)}" + if node_type == "paragraph": + return render_inlines(content) + if node_type == "codeBlock": + language = node.get("attrs", {}).get("language", "") + return f"```{language}\n{render_inlines(content)}\n```" + if node_type == "blockquote": + rendered = "\n\n".join(render_block(child) for child in content) + return "\n".join(f"> {line}" if line else ">" for line in rendered.splitlines()) + if node_type == "rule": + return "---" + if node_type in {"bulletList", "orderedList"}: + start = int(node.get("attrs", {}).get("order", 1)) + rendered_items = [] + for index, item in enumerate(content): + marker = "-" if node_type == "bulletList" else f"{start + index}." + body = render_block(item).strip() + lines = body.splitlines() or [""] + rendered_items.append( + "\n".join([f"{marker} {lines[0]}", *(f" {line}" for line in lines[1:])]) + ) + return "\n".join(rendered_items) + if node_type == "listItem": + return "\n".join(filter(None, (render_block(child).strip() for child in content))) + if node_type == "table": + rows = [render_table_row(row) for row in content] + if not rows: + return "" + separator = "| " + " | ".join("---" for _ in rows[0]) + " |" + rendered = [render_table_cells(rows[0]), separator] + rendered.extend(render_table_cells(row) for row in rows[1:]) + return "\n".join(rendered) + if node_type in {"tableCell", "tableHeader"}: + return " ".join(filter(None, (render_block(child).strip() for child in content))) + if node_type in {"panel", "expand", "nestedExpand"}: + title = node.get("attrs", {}).get("title") + body = "\n\n".join(filter(None, (render_block(child).strip() for child in content))) + return "\n\n".join(filter(None, (title, body))) + if content: + return "\n\n".join(filter(None, (render_block(child).strip() for child in content))) + return render_inline(node) + + +def render_table_row(row): + return [render_block(cell).replace("|", "\\|").replace("\n", "
") for cell in row.get("content", [])] + + +def render_table_cells(cells): + return "| " + " | ".join(cells) + " |" + + +def compact_text(value): + if isinstance(value, dict) and value.get("type") == "doc": + return render_block(value).strip() + if isinstance(value, str): + return value.strip() + return None + + +def named_value(value): + if isinstance(value, dict): + return value.get("name") + return value if isinstance(value, str) else None + + +def project_workitem(workitem): + parent = workitem.get("parent") + projected_parent = None + if isinstance(parent, dict): + parent_fields = parent.get("fields") if isinstance(parent.get("fields"), dict) else {} + projected_parent = compact_object( + { + "key": parent.get("key"), + "summary": parent.get("summary") or parent_fields.get("summary"), + } + ) + return compact_object( + { + "key": workitem.get("key"), + "summary": workitem.get("summary"), + "status": named_value(workitem.get("status")), + "issueType": named_value(workitem.get("issuetype")), + "parent": projected_parent, + "description": compact_text(workitem.get("description")), + "url": workitem.get("url"), + } + ) + + +def compact_object(value): + return {key: item for key, item in value.items() if item not in (None, "", {}, [])} + + +def normalize_payload(payload): + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + data = payload.get("data", payload) + if isinstance(data, list): + return data + if isinstance(data, dict) and isinstance(data.get("items"), list): + return [ + item.get("data", item) if isinstance(item, dict) else item + for item in data["items"] + ] + if isinstance(data, dict): + return [data] + raise ValueError("Unexpected TWG Jira payload shape") + + +def build_command(binary, keys): + return [ + binary, + "jira", + "workitem", + "get", + *keys, + "--fields", + JIRA_FIELDS, + "-o", + "json", + "--output-summary", + "none", + ] + + +def fetch_workitems(keys): + binary = shutil.which("twg") + if binary is None: + fallback = Path.home() / ".local/bin/twg" + if not fallback.is_file(): + raise RuntimeError("Required command not found: twg") + binary = str(fallback) + result = subprocess.run( + build_command(binary, keys), + capture_output=True, + check=False, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "TWG command failed without diagnostic output" + raise RuntimeError(detail) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError("TWG did not return JSON") from error + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="Fetch Jira workitems and emit implementation-bearing fields with ADF as Markdown." + ) + parser.add_argument("keys", nargs="*", help="Jira workitem keys") + parser.add_argument( + "--input", + type=Path, + help="Read an existing TWG JSON payload instead of calling TWG", + ) + arguments = parser.parse_args() + if arguments.input is None and not arguments.keys: + parser.error("provide at least one Jira key or --input") + if arguments.input is not None and arguments.keys: + parser.error("Jira keys cannot be combined with --input") + return arguments + + +def main(): + arguments = parse_arguments() + try: + payload = ( + json.loads(arguments.input.read_text(encoding="utf-8")) + if arguments.input is not None + else fetch_workitems(arguments.keys) + ) + projected = [project_workitem(item) for item in normalize_payload(payload)] + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: + print(f"jira-workitem-implementation: {error}", file=sys.stderr) + return 1 + print(json.dumps({"items": projected}, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/twg-jira/test/test_jira_workitem_implementation.py b/skills/twg-jira/test/test_jira_workitem_implementation.py new file mode 100644 index 00000000..4e060eb6 --- /dev/null +++ b/skills/twg-jira/test/test_jira_workitem_implementation.py @@ -0,0 +1,202 @@ +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT_PATH = ( + Path(__file__).parents[1] / "scripts/jira_workitem_implementation.py" +) +SPEC = importlib.util.spec_from_file_location( + "jira_workitem_implementation", SCRIPT_PATH +) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class JiraWorkitemImplementationTest(unittest.TestCase): + def test_projects_implementation_fields_and_renders_adf(self): + workitem = { + "expand": "renderedFields,names,schema", + "self": "https://api.example.invalid/issue/123", + "key": "PROJ-123", + "summary": "Trace rate limits", + "status": {"name": "Open", "self": "https://api.example.invalid/status/1"}, + "issuetype": {"name": "Story", "iconUrl": "https://example.invalid/icon"}, + "parent": { + "key": "PROJ-100", + "fields": {"summary": "Rate limiting", "priority": {"name": "Medium"}}, + }, + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Outcome"}], + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Trace "}, + { + "type": "text", + "text": "Redis", + "marks": [{"type": "code"}], + }, + {"type": "text", "text": " commands."}, + ], + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Acceptance criteria"}], + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Keep keys private", + "marks": [{"type": "strong"}], + } + ], + } + ], + } + ], + }, + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Tag"}], + } + ], + }, + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Value"}], + } + ], + }, + ], + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "decision"} + ], + } + ], + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "allowed", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://example.invalid/docs" + }, + } + ], + } + ], + } + ], + }, + ], + }, + ], + }, + ], + }, + } + + projected = MODULE.project_workitem(workitem) + + self.assertEqual( + projected, + { + "key": "PROJ-123", + "summary": "Trace rate limits", + "status": "Open", + "issueType": "Story", + "parent": {"key": "PROJ-100", "summary": "Rate limiting"}, + "description": ( + "## Outcome\n\n" + "Trace `Redis` commands.\n\n" + "## Acceptance criteria\n\n" + "- **Keep keys private**\n\n" + "| Tag | Value |\n" + "| --- | --- |\n" + "| decision | [allowed](https://example.invalid/docs) |" + ), + }, + ) + self.assertNotIn("self", projected) + self.assertNotIn("expand", projected) + + def test_normalizes_single_and_batch_payloads(self): + issue = {"key": "PROJ-123"} + + self.assertEqual(MODULE.normalize_payload([issue]), [issue]) + self.assertEqual(MODULE.normalize_payload({"data": issue}), [issue]) + self.assertEqual( + MODULE.normalize_payload({"data": {"items": [{"data": issue}]}}), + [issue], + ) + + def test_builds_a_bounded_json_command(self): + command = MODULE.build_command("/usr/local/bin/twg", ["PROJ-123", "PROJ-124"]) + + self.assertEqual( + command, + [ + "/usr/local/bin/twg", + "jira", + "workitem", + "get", + "PROJ-123", + "PROJ-124", + "--fields", + "summary,description,status,parent,issuetype", + "-o", + "json", + "--output-summary", + "none", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/twg/SKILL.md b/skills/twg/SKILL.md index 9f1bab2f..30acfb5c 100644 --- a/skills/twg/SKILL.md +++ b/skills/twg/SKILL.md @@ -44,7 +44,9 @@ Run `twg `. On shell `command not found`, use `$HOME/.local/bin/twg` tell user to add that directory to PATH. Do not treat auth or command errors as PATH failures. -Do not add per-command env prefixes unless requested; hosts may set `TWG_AGENT_DEFAULTS=1`. +Do not add per-command env prefixes. Pair `--agent-fields` with +`-o json --output-summary auto`; do not rely on the host setting +`TWG_AGENT_DEFAULTS=1`. Use `stdout_inline` first when present. Outside benchmark lanes, inspect `output_files.compact` only when inline evidence is incomplete; full stdout is the last resort. @@ -77,8 +79,9 @@ request credentials. One call per entity is the costliest mistake: every call re-submits the whole conversation. When a `get` accepts a repeated identifier - live help marks these -"one or more" - pass the whole set in one call with `--agent-fields @compact`, -about twenty IDs at a time. Otherwise answer from a query or tree route +"one or more" - pass the whole set in one call with +`-o json --output-summary auto --agent-fields @compact`, about twenty IDs at a +time. Otherwise answer from a query or tree route (`... query`, `pr-tree`, `work-tree`, `workitem-tree`, `org-tree`, `context`) rather than hydrating each entity, or hydrate a ranked sample and say what was omitted. Pick the projection before the batch call; re-running one batch to From b59d2003a4b0a00fa6c89083d81621a43bee9437 Mon Sep 17 00:00:00 2001 From: jhonDoe15 Date: Tue, 22 Sep 2026 22:27:25 +0300 Subject: [PATCH 2/3] refactor(skills): use Node for Jira helper --- .gitignore | 2 - skills/twg-jira/SKILL.md | 4 +- .../scripts/jira-workitem-implementation.mjs | 298 ++++++++++++++++++ .../scripts/jira_workitem_implementation.py | 242 -------------- .../jira-workitem-implementation.test.mjs | 194 ++++++++++++ .../test/test_jira_workitem_implementation.py | 202 ------------ 6 files changed, 494 insertions(+), 448 deletions(-) create mode 100755 skills/twg-jira/scripts/jira-workitem-implementation.mjs delete mode 100755 skills/twg-jira/scripts/jira_workitem_implementation.py create mode 100644 skills/twg-jira/test/jira-workitem-implementation.test.mjs delete mode 100644 skills/twg-jira/test/test_jira_workitem_implementation.py diff --git a/.gitignore b/.gitignore index dae2784e..2dd31050 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,6 @@ .env .env.* *.log -__pycache__/ -*.py[cod] # Generated public export staging release/ diff --git a/skills/twg-jira/SKILL.md b/skills/twg-jira/SKILL.md index 35d155c8..97295e71 100644 --- a/skills/twg-jira/SKILL.md +++ b/skills/twg-jira/SKILL.md @@ -58,9 +58,9 @@ consequential mutation. When implementation depends on a known workitem's description, acceptance criteria, dependencies, or constraints, execute -[`scripts/jira_workitem_implementation.py`](scripts/jira_workitem_implementation.py) +[`scripts/jira-workitem-implementation.mjs`](scripts/jira-workitem-implementation.mjs) with all known keys in one call. Resolve the script path relative to this -`SKILL.md` and run `python3 `. The script fetches bounded +`SKILL.md` and run `node `. The script fetches bounded native fields, converts Jira ADF to Markdown, and emits compact JSON. Use `@compact` only for identity, status, owner, and URL. Do not open raw TWG diff --git a/skills/twg-jira/scripts/jira-workitem-implementation.mjs b/skills/twg-jira/scripts/jira-workitem-implementation.mjs new file mode 100755 index 00000000..7d615f34 --- /dev/null +++ b/skills/twg-jira/scripts/jira-workitem-implementation.mjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const JIRA_FIELDS = "summary,description,status,parent,issuetype"; + +function contentOf(node) { + return Array.isArray(node?.content) ? node.content : []; +} + +export function renderInlines(nodes = []) { + return nodes.map((node) => renderInline(node)).join(""); +} + +export function renderInline(node) { + if (node?.type === "text") { + let text = typeof node.text === "string" ? node.text : ""; + for (const mark of Array.isArray(node.marks) ? node.marks : []) { + if (mark.type === "code") text = `\`${text}\``; + else if (mark.type === "strong") text = `**${text}**`; + else if (mark.type === "em") text = `*${text}*`; + else if (mark.type === "strike") text = `~~${text}~~`; + else if (mark.type === "link" && mark.attrs?.href) + text = `[${text}](${String(mark.attrs.href)})`; + } + return text; + } + if (node?.type === "hardBreak") return "\n"; + if (["mention", "emoji", "status"].includes(node?.type)) { + return String( + node.attrs?.text ?? + node.attrs?.displayName ?? + node.attrs?.shortName ?? + "", + ); + } + if (["inlineCard", "blockCard", "embedCard"].includes(node?.type)) + return String(node.attrs?.url ?? ""); + return renderInlines(contentOf(node)); +} + +export function renderBlock(node) { + const content = contentOf(node); + if (node?.type === "doc") + return content + .map((child) => renderBlock(child).trim()) + .filter(Boolean) + .join("\n\n"); + if (node?.type === "heading") { + const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6); + return `${"#".repeat(level)} ${renderInlines(content)}`; + } + if (node?.type === "paragraph") return renderInlines(content); + if (node?.type === "codeBlock") { + const language = String(node.attrs?.language ?? ""); + return `\`\`\`${language}\n${renderInlines(content)}\n\`\`\``; + } + if (node?.type === "blockquote") { + const rendered = content.map((child) => renderBlock(child)).join("\n\n"); + return rendered + .split(/\r\n|\r|\n/u) + .map((line) => (line ? `> ${line}` : ">")) + .join("\n"); + } + if (node?.type === "rule") return "---"; + if (["bulletList", "orderedList"].includes(node?.type)) { + const start = Number(node.attrs?.order) || 1; + return content + .map((item, index) => { + const marker = node.type === "bulletList" ? "-" : `${start + index}.`; + const lines = renderBlock(item).trim().split(/\r\n|\r|\n/u); + return [ + `${marker} ${lines[0] ?? ""}`, + ...lines.slice(1).map((line) => ` ${line}`), + ].join("\n"); + }) + .join("\n"); + } + if (node?.type === "listItem") + return content + .map((child) => renderBlock(child).trim()) + .filter(Boolean) + .join("\n"); + if (node?.type === "table") { + const rows = content.map((row) => renderTableRow(row)); + if (rows.length === 0) return ""; + const separator = rows[0].map(() => "---"); + return [rows[0], separator, ...rows.slice(1)] + .map((row) => renderTableCells(row)) + .join("\n"); + } + if (["tableCell", "tableHeader"].includes(node?.type)) + return content + .map((child) => renderBlock(child).trim()) + .filter(Boolean) + .join(" "); + if (["panel", "expand", "nestedExpand"].includes(node?.type)) { + const title = typeof node.attrs?.title === "string" ? node.attrs.title : ""; + const body = content + .map((child) => renderBlock(child).trim()) + .filter(Boolean) + .join("\n\n"); + return [title, body].filter(Boolean).join("\n\n"); + } + if (content.length > 0) + return content + .map((child) => renderBlock(child).trim()) + .filter(Boolean) + .join("\n\n"); + return renderInline(node); +} + +function renderTableRow(row) { + return contentOf(row).map((cell) => + renderBlock(cell).replaceAll("|", "\\|").replaceAll(/\r\n|\r|\n/gu, "
"), + ); +} + +function renderTableCells(cells) { + return `| ${cells.join(" | ")} |`; +} + +function compactText(value) { + if (value?.type === "doc") return renderBlock(value).trim(); + return typeof value === "string" ? value.trim() : undefined; +} + +function namedValue(value) { + if (value && typeof value === "object") return value.name; + return typeof value === "string" ? value : undefined; +} + +function compactObject(value) { + return Object.fromEntries( + Object.entries(value).filter( + ([, item]) => + item !== undefined && + item !== null && + item !== "" && + !(Array.isArray(item) && item.length === 0) && + !( + typeof item === "object" && + !Array.isArray(item) && + Object.keys(item).length === 0 + ), + ), + ); +} + +export function projectWorkitem(workitem) { + const parent = workitem?.parent; + const parentFields = + parent?.fields && typeof parent.fields === "object" ? parent.fields : {}; + const projectedParent = + parent && typeof parent === "object" + ? compactObject({ + key: parent.key, + summary: parent.summary ?? parentFields.summary, + }) + : undefined; + return compactObject({ + key: workitem?.key, + summary: workitem?.summary, + status: namedValue(workitem?.status), + issueType: namedValue(workitem?.issuetype), + parent: projectedParent, + description: compactText(workitem?.description), + url: workitem?.url, + }); +} + +export function normalizePayload(payload) { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== "object") + throw new TypeError("Unexpected TWG Jira payload shape"); + const data = payload.data ?? payload; + if (Array.isArray(data)) return data; + if (Array.isArray(data?.items)) + return data.items.map((item) => item?.data ?? item); + if (data && typeof data === "object") return [data]; + throw new TypeError("Unexpected TWG Jira payload shape"); +} + +export function buildArguments(keys) { + return [ + "jira", + "workitem", + "get", + ...keys, + "--fields", + JIRA_FIELDS, + "-o", + "json", + "--output-summary", + "none", + ]; +} + +function twgCandidates() { + const fallback = + process.platform === "win32" && process.env.LOCALAPPDATA + ? path.join( + process.env.LOCALAPPDATA, + "Programs", + "twg", + "bin", + "twg.exe", + ) + : path.join(homedir(), ".local", "bin", "twg"); + return ["twg", fallback]; +} + +function fetchWorkitems(keys) { + for (const binary of twgCandidates()) { + if (binary !== "twg" && !existsSync(binary)) continue; + const result = spawnSync(binary, buildArguments(keys), { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error?.code === "ENOENT") continue; + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error( + result.stderr.trim() || "TWG command failed without diagnostic output", + ); + try { + return JSON.parse(result.stdout); + } catch (error) { + throw new Error("TWG did not return JSON", { cause: error }); + } + } + throw new Error("Required command not found: twg"); +} + +function parseArguments(arguments_) { + const keys = []; + let inputPath; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--input") { + inputPath = arguments_[index + 1]; + if (!inputPath || inputPath.startsWith("-")) + throw new Error("--input requires a path"); + index += 1; + } else if (argument === "-h" || argument === "--help") { + return { help: true, keys: [] }; + } else if (argument.startsWith("-")) { + throw new Error(`Unknown option: ${argument}`); + } else { + keys.push(argument); + } + } + if (inputPath && keys.length > 0) + throw new Error("Jira keys cannot be combined with --input"); + if (!inputPath && keys.length === 0) + throw new Error("Provide at least one Jira key or --input"); + return { help: false, inputPath, keys }; +} + +export async function run(arguments_ = process.argv.slice(2)) { + try { + const options = parseArguments(arguments_); + if (options.help) { + process.stdout.write( + "Usage: jira-workitem-implementation.mjs | --input \n", + ); + return 0; + } + const payload = options.inputPath + ? JSON.parse(await readFile(options.inputPath, "utf8")) + : fetchWorkitems(options.keys); + const items = normalizePayload(payload).map((item) => + projectWorkitem(item), + ); + process.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`); + return 0; + } catch (error) { + process.stderr.write( + `jira-workitem-implementation: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + return 1; + } +} + +const invokedPath = process.argv[1]; +if ( + invokedPath && + import.meta.url === pathToFileURL(path.resolve(invokedPath)).href +) { + process.exitCode = await run(); +} diff --git a/skills/twg-jira/scripts/jira_workitem_implementation.py b/skills/twg-jira/scripts/jira_workitem_implementation.py deleted file mode 100755 index f30e8333..00000000 --- a/skills/twg-jira/scripts/jira_workitem_implementation.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import shutil -import subprocess -import sys -from pathlib import Path - - -JIRA_FIELDS = "summary,description,status,parent,issuetype" - - -def render_inlines(nodes): - return "".join(render_inline(node) for node in nodes or []) - - -def render_inline(node): - node_type = node.get("type") - if node_type == "text": - text = node.get("text", "") - for mark in node.get("marks", []): - mark_type = mark.get("type") - if mark_type == "code": - text = f"`{text}`" - elif mark_type == "strong": - text = f"**{text}**" - elif mark_type == "em": - text = f"*{text}*" - elif mark_type == "strike": - text = f"~~{text}~~" - elif mark_type == "link": - href = mark.get("attrs", {}).get("href") - if href: - text = f"[{text}]({href})" - return text - if node_type == "hardBreak": - return "\n" - if node_type in {"mention", "emoji", "status"}: - attributes = node.get("attrs", {}) - return ( - attributes.get("text") - or attributes.get("displayName") - or attributes.get("shortName") - or "" - ) - if node_type in {"inlineCard", "blockCard", "embedCard"}: - return node.get("attrs", {}).get("url", "") - return render_inlines(node.get("content")) - - -def render_block(node): - node_type = node.get("type") - content = node.get("content", []) - if node_type == "doc": - return "\n\n".join(filter(None, (render_block(child).strip() for child in content))) - if node_type == "heading": - level = min(max(int(node.get("attrs", {}).get("level", 1)), 1), 6) - return f"{'#' * level} {render_inlines(content)}" - if node_type == "paragraph": - return render_inlines(content) - if node_type == "codeBlock": - language = node.get("attrs", {}).get("language", "") - return f"```{language}\n{render_inlines(content)}\n```" - if node_type == "blockquote": - rendered = "\n\n".join(render_block(child) for child in content) - return "\n".join(f"> {line}" if line else ">" for line in rendered.splitlines()) - if node_type == "rule": - return "---" - if node_type in {"bulletList", "orderedList"}: - start = int(node.get("attrs", {}).get("order", 1)) - rendered_items = [] - for index, item in enumerate(content): - marker = "-" if node_type == "bulletList" else f"{start + index}." - body = render_block(item).strip() - lines = body.splitlines() or [""] - rendered_items.append( - "\n".join([f"{marker} {lines[0]}", *(f" {line}" for line in lines[1:])]) - ) - return "\n".join(rendered_items) - if node_type == "listItem": - return "\n".join(filter(None, (render_block(child).strip() for child in content))) - if node_type == "table": - rows = [render_table_row(row) for row in content] - if not rows: - return "" - separator = "| " + " | ".join("---" for _ in rows[0]) + " |" - rendered = [render_table_cells(rows[0]), separator] - rendered.extend(render_table_cells(row) for row in rows[1:]) - return "\n".join(rendered) - if node_type in {"tableCell", "tableHeader"}: - return " ".join(filter(None, (render_block(child).strip() for child in content))) - if node_type in {"panel", "expand", "nestedExpand"}: - title = node.get("attrs", {}).get("title") - body = "\n\n".join(filter(None, (render_block(child).strip() for child in content))) - return "\n\n".join(filter(None, (title, body))) - if content: - return "\n\n".join(filter(None, (render_block(child).strip() for child in content))) - return render_inline(node) - - -def render_table_row(row): - return [render_block(cell).replace("|", "\\|").replace("\n", "
") for cell in row.get("content", [])] - - -def render_table_cells(cells): - return "| " + " | ".join(cells) + " |" - - -def compact_text(value): - if isinstance(value, dict) and value.get("type") == "doc": - return render_block(value).strip() - if isinstance(value, str): - return value.strip() - return None - - -def named_value(value): - if isinstance(value, dict): - return value.get("name") - return value if isinstance(value, str) else None - - -def project_workitem(workitem): - parent = workitem.get("parent") - projected_parent = None - if isinstance(parent, dict): - parent_fields = parent.get("fields") if isinstance(parent.get("fields"), dict) else {} - projected_parent = compact_object( - { - "key": parent.get("key"), - "summary": parent.get("summary") or parent_fields.get("summary"), - } - ) - return compact_object( - { - "key": workitem.get("key"), - "summary": workitem.get("summary"), - "status": named_value(workitem.get("status")), - "issueType": named_value(workitem.get("issuetype")), - "parent": projected_parent, - "description": compact_text(workitem.get("description")), - "url": workitem.get("url"), - } - ) - - -def compact_object(value): - return {key: item for key, item in value.items() if item not in (None, "", {}, [])} - - -def normalize_payload(payload): - if isinstance(payload, list): - return payload - if isinstance(payload, dict): - data = payload.get("data", payload) - if isinstance(data, list): - return data - if isinstance(data, dict) and isinstance(data.get("items"), list): - return [ - item.get("data", item) if isinstance(item, dict) else item - for item in data["items"] - ] - if isinstance(data, dict): - return [data] - raise ValueError("Unexpected TWG Jira payload shape") - - -def build_command(binary, keys): - return [ - binary, - "jira", - "workitem", - "get", - *keys, - "--fields", - JIRA_FIELDS, - "-o", - "json", - "--output-summary", - "none", - ] - - -def fetch_workitems(keys): - binary = shutil.which("twg") - if binary is None: - fallback = Path.home() / ".local/bin/twg" - if not fallback.is_file(): - raise RuntimeError("Required command not found: twg") - binary = str(fallback) - result = subprocess.run( - build_command(binary, keys), - capture_output=True, - check=False, - text=True, - ) - if result.returncode != 0: - detail = result.stderr.strip() or "TWG command failed without diagnostic output" - raise RuntimeError(detail) - try: - return json.loads(result.stdout) - except json.JSONDecodeError as error: - raise RuntimeError("TWG did not return JSON") from error - - -def parse_arguments(): - parser = argparse.ArgumentParser( - description="Fetch Jira workitems and emit implementation-bearing fields with ADF as Markdown." - ) - parser.add_argument("keys", nargs="*", help="Jira workitem keys") - parser.add_argument( - "--input", - type=Path, - help="Read an existing TWG JSON payload instead of calling TWG", - ) - arguments = parser.parse_args() - if arguments.input is None and not arguments.keys: - parser.error("provide at least one Jira key or --input") - if arguments.input is not None and arguments.keys: - parser.error("Jira keys cannot be combined with --input") - return arguments - - -def main(): - arguments = parse_arguments() - try: - payload = ( - json.loads(arguments.input.read_text(encoding="utf-8")) - if arguments.input is not None - else fetch_workitems(arguments.keys) - ) - projected = [project_workitem(item) for item in normalize_payload(payload)] - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: - print(f"jira-workitem-implementation: {error}", file=sys.stderr) - return 1 - print(json.dumps({"items": projected}, indent=2, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/twg-jira/test/jira-workitem-implementation.test.mjs b/skills/twg-jira/test/jira-workitem-implementation.test.mjs new file mode 100644 index 00000000..ff1a50db --- /dev/null +++ b/skills/twg-jira/test/jira-workitem-implementation.test.mjs @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildArguments, + normalizePayload, + projectWorkitem, +} from "../scripts/jira-workitem-implementation.mjs"; + +test("projects implementation fields and renders ADF", () => { + const workitem = { + expand: "renderedFields,names,schema", + self: "https://api.example.invalid/issue/123", + key: "PROJ-123", + summary: "Trace rate limits", + status: { + name: "Open", + self: "https://api.example.invalid/status/1", + }, + issuetype: { + name: "Story", + iconUrl: "https://example.invalid/icon", + }, + parent: { + key: "PROJ-100", + fields: { + summary: "Rate limiting", + priority: { name: "Medium" }, + }, + }, + description: { + type: "doc", + version: 1, + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Outcome" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "Trace " }, + { + type: "text", + text: "Redis", + marks: [{ type: "code" }], + }, + { type: "text", text: " commands." }, + ], + }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Acceptance criteria" }], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Keep keys private", + marks: [{ type: "strong" }], + }, + ], + }, + ], + }, + ], + }, + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + { + type: "tableHeader", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Tag" }], + }, + ], + }, + { + type: "tableHeader", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Value" }], + }, + ], + }, + ], + }, + { + type: "tableRow", + content: [ + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "decision" }], + }, + ], + }, + { + type: "tableCell", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "allowed", + marks: [ + { + type: "link", + attrs: { + href: "https://example.invalid/docs", + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + assert.deepEqual(projectWorkitem(workitem), { + key: "PROJ-123", + summary: "Trace rate limits", + status: "Open", + issueType: "Story", + parent: { + key: "PROJ-100", + summary: "Rate limiting", + }, + description: [ + "## Outcome", + "", + "Trace `Redis` commands.", + "", + "## Acceptance criteria", + "", + "- **Keep keys private**", + "", + "| Tag | Value |", + "| --- | --- |", + "| decision | [allowed](https://example.invalid/docs) |", + ].join("\n"), + }); +}); + +test("normalizes single and batch payloads", () => { + const issue = { key: "PROJ-123" }; + + assert.deepEqual(normalizePayload([issue]), [issue]); + assert.deepEqual(normalizePayload({ data: issue }), [issue]); + assert.deepEqual( + normalizePayload({ data: { items: [{ data: issue }] } }), + [issue], + ); +}); + +test("builds a bounded JSON command", () => { + assert.deepEqual(buildArguments(["PROJ-123", "PROJ-124"]), [ + "jira", + "workitem", + "get", + "PROJ-123", + "PROJ-124", + "--fields", + "summary,description,status,parent,issuetype", + "-o", + "json", + "--output-summary", + "none", + ]); +}); diff --git a/skills/twg-jira/test/test_jira_workitem_implementation.py b/skills/twg-jira/test/test_jira_workitem_implementation.py deleted file mode 100644 index 4e060eb6..00000000 --- a/skills/twg-jira/test/test_jira_workitem_implementation.py +++ /dev/null @@ -1,202 +0,0 @@ -import importlib.util -import unittest -from pathlib import Path - - -SCRIPT_PATH = ( - Path(__file__).parents[1] / "scripts/jira_workitem_implementation.py" -) -SPEC = importlib.util.spec_from_file_location( - "jira_workitem_implementation", SCRIPT_PATH -) -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class JiraWorkitemImplementationTest(unittest.TestCase): - def test_projects_implementation_fields_and_renders_adf(self): - workitem = { - "expand": "renderedFields,names,schema", - "self": "https://api.example.invalid/issue/123", - "key": "PROJ-123", - "summary": "Trace rate limits", - "status": {"name": "Open", "self": "https://api.example.invalid/status/1"}, - "issuetype": {"name": "Story", "iconUrl": "https://example.invalid/icon"}, - "parent": { - "key": "PROJ-100", - "fields": {"summary": "Rate limiting", "priority": {"name": "Medium"}}, - }, - "description": { - "type": "doc", - "version": 1, - "content": [ - { - "type": "heading", - "attrs": {"level": 2}, - "content": [{"type": "text", "text": "Outcome"}], - }, - { - "type": "paragraph", - "content": [ - {"type": "text", "text": "Trace "}, - { - "type": "text", - "text": "Redis", - "marks": [{"type": "code"}], - }, - {"type": "text", "text": " commands."}, - ], - }, - { - "type": "heading", - "attrs": {"level": 2}, - "content": [{"type": "text", "text": "Acceptance criteria"}], - }, - { - "type": "bulletList", - "content": [ - { - "type": "listItem", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "Keep keys private", - "marks": [{"type": "strong"}], - } - ], - } - ], - } - ], - }, - { - "type": "table", - "content": [ - { - "type": "tableRow", - "content": [ - { - "type": "tableHeader", - "content": [ - { - "type": "paragraph", - "content": [{"type": "text", "text": "Tag"}], - } - ], - }, - { - "type": "tableHeader", - "content": [ - { - "type": "paragraph", - "content": [{"type": "text", "text": "Value"}], - } - ], - }, - ], - }, - { - "type": "tableRow", - "content": [ - { - "type": "tableCell", - "content": [ - { - "type": "paragraph", - "content": [ - {"type": "text", "text": "decision"} - ], - } - ], - }, - { - "type": "tableCell", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": "allowed", - "marks": [ - { - "type": "link", - "attrs": { - "href": "https://example.invalid/docs" - }, - } - ], - } - ], - } - ], - }, - ], - }, - ], - }, - ], - }, - } - - projected = MODULE.project_workitem(workitem) - - self.assertEqual( - projected, - { - "key": "PROJ-123", - "summary": "Trace rate limits", - "status": "Open", - "issueType": "Story", - "parent": {"key": "PROJ-100", "summary": "Rate limiting"}, - "description": ( - "## Outcome\n\n" - "Trace `Redis` commands.\n\n" - "## Acceptance criteria\n\n" - "- **Keep keys private**\n\n" - "| Tag | Value |\n" - "| --- | --- |\n" - "| decision | [allowed](https://example.invalid/docs) |" - ), - }, - ) - self.assertNotIn("self", projected) - self.assertNotIn("expand", projected) - - def test_normalizes_single_and_batch_payloads(self): - issue = {"key": "PROJ-123"} - - self.assertEqual(MODULE.normalize_payload([issue]), [issue]) - self.assertEqual(MODULE.normalize_payload({"data": issue}), [issue]) - self.assertEqual( - MODULE.normalize_payload({"data": {"items": [{"data": issue}]}}), - [issue], - ) - - def test_builds_a_bounded_json_command(self): - command = MODULE.build_command("/usr/local/bin/twg", ["PROJ-123", "PROJ-124"]) - - self.assertEqual( - command, - [ - "/usr/local/bin/twg", - "jira", - "workitem", - "get", - "PROJ-123", - "PROJ-124", - "--fields", - "summary,description,status,parent,issuetype", - "-o", - "json", - "--output-summary", - "none", - ], - ) - - -if __name__ == "__main__": - unittest.main() From 836911992544c408d3470705d4dc23df5f1fccdc Mon Sep 17 00:00:00 2001 From: jhonDoe15 Date: Tue, 22 Sep 2026 22:33:12 +0300 Subject: [PATCH 3/3] fix(skills): detect incomplete Jira evidence --- skills/twg-jira/SKILL.md | 8 +- .../scripts/jira-workitem-implementation.mjs | 107 +++++++++++++++++- .../jira-workitem-implementation.test.mjs | 54 ++++++++- 3 files changed, 161 insertions(+), 8 deletions(-) diff --git a/skills/twg-jira/SKILL.md b/skills/twg-jira/SKILL.md index 97295e71..b6de89e9 100644 --- a/skills/twg-jira/SKILL.md +++ b/skills/twg-jira/SKILL.md @@ -61,12 +61,16 @@ criteria, dependencies, or constraints, execute [`scripts/jira-workitem-implementation.mjs`](scripts/jira-workitem-implementation.mjs) with all known keys in one call. Resolve the script path relative to this `SKILL.md` and run `node `. The script fetches bounded -native fields, converts Jira ADF to Markdown, and emits compact JSON. +native fields, projects formal issue links, converts Jira ADF to Markdown, and +emits compact JSON. Use `@compact` only for identity, status, owner, and URL. Do not open raw TWG stdout to recover implementation requirements. If the script fails, report its diagnostic and stop that evidence branch instead of falling back to the raw -payload. +payload. Complete the read when the requested keys are present and neither +`unsupportedAdfNodes` nor `unsupportedAdfMarks` is reported. If either is +present, name the unsupported features and mark that workitem's rich content +incomplete. ## Jira Semantics diff --git a/skills/twg-jira/scripts/jira-workitem-implementation.mjs b/skills/twg-jira/scripts/jira-workitem-implementation.mjs index 7d615f34..0b7b5263 100755 --- a/skills/twg-jira/scripts/jira-workitem-implementation.mjs +++ b/skills/twg-jira/scripts/jira-workitem-implementation.mjs @@ -7,7 +7,49 @@ import { homedir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -export const JIRA_FIELDS = "summary,description,status,parent,issuetype"; +export const JIRA_FIELDS = + "summary,description,status,parent,issuetype,issuelinks"; + +const KNOWN_ADF_NODES = new Set([ + "blockquote", + "blockCard", + "bulletList", + "codeBlock", + "decisionItem", + "decisionList", + "doc", + "embedCard", + "emoji", + "expand", + "hardBreak", + "heading", + "inlineCard", + "listItem", + "mention", + "nestedExpand", + "orderedList", + "panel", + "paragraph", + "rule", + "status", + "table", + "tableCell", + "tableHeader", + "tableRow", + "taskItem", + "taskList", + "text", +]); +const KNOWN_ADF_MARKS = new Set([ + "code", + "em", + "link", + "strike", + "strong", + "subsup", + "textColor", + "underline", +]); function contentOf(node) { return Array.isArray(node?.content) ? node.content : []; @@ -68,11 +110,16 @@ export function renderBlock(node) { .join("\n"); } if (node?.type === "rule") return "---"; - if (["bulletList", "orderedList"].includes(node?.type)) { + if ( + ["bulletList", "decisionList", "orderedList", "taskList"].includes( + node?.type, + ) + ) { const start = Number(node.attrs?.order) || 1; return content .map((item, index) => { - const marker = node.type === "bulletList" ? "-" : `${start + index}.`; + const marker = + node.type === "orderedList" ? `${start + index}.` : "-"; const lines = renderBlock(item).trim().split(/\r\n|\r|\n/u); return [ `${marker} ${lines[0] ?? ""}`, @@ -81,11 +128,17 @@ export function renderBlock(node) { }) .join("\n"); } - if (node?.type === "listItem") - return content + if (["decisionItem", "listItem", "taskItem"].includes(node?.type)) { + const rendered = content .map((child) => renderBlock(child).trim()) .filter(Boolean) .join("\n"); + if (node.type !== "taskItem") return rendered; + const checked = ["DONE", "COMPLETE"].includes(node.attrs?.state) + ? "x" + : " "; + return `[${checked}] ${rendered}`; + } if (node?.type === "table") { const rows = content.map((row) => renderTableRow(row)); if (rows.length === 0) return ""; @@ -152,6 +205,47 @@ function compactObject(value) { ); } +function adfDiagnostics(value) { + const nodes = new Set(); + const marks = new Set(); + const visit = (node) => { + if (!node || typeof node !== "object") return; + if (typeof node.type === "string" && !KNOWN_ADF_NODES.has(node.type)) + nodes.add(node.type); + for (const mark of Array.isArray(node.marks) ? node.marks : []) + if (typeof mark?.type === "string" && !KNOWN_ADF_MARKS.has(mark.type)) + marks.add(mark.type); + for (const child of contentOf(node)) visit(child); + }; + visit(value); + return { + unsupportedAdfNodes: [...nodes].sort(), + unsupportedAdfMarks: [...marks].sort(), + }; +} + +function projectIssueLinks(links) { + if (!Array.isArray(links)) return undefined; + return links + .map((link) => { + const linkedIssue = link?.inwardIssue ?? link?.outwardIssue; + if (!linkedIssue) return undefined; + const fields = + linkedIssue.fields && typeof linkedIssue.fields === "object" + ? linkedIssue.fields + : {}; + return compactObject({ + relationship: link.inwardIssue + ? link.type?.inward + : link.type?.outward, + key: linkedIssue.key, + summary: fields.summary, + status: namedValue(fields.status), + }); + }) + .filter(Boolean); +} + export function projectWorkitem(workitem) { const parent = workitem?.parent; const parentFields = @@ -163,6 +257,7 @@ export function projectWorkitem(workitem) { summary: parent.summary ?? parentFields.summary, }) : undefined; + const diagnostics = adfDiagnostics(workitem?.description); return compactObject({ key: workitem?.key, summary: workitem?.summary, @@ -170,7 +265,9 @@ export function projectWorkitem(workitem) { issueType: namedValue(workitem?.issuetype), parent: projectedParent, description: compactText(workitem?.description), + issueLinks: projectIssueLinks(workitem?.issuelinks), url: workitem?.url, + ...diagnostics, }); } diff --git a/skills/twg-jira/test/jira-workitem-implementation.test.mjs b/skills/twg-jira/test/jira-workitem-implementation.test.mjs index ff1a50db..21aaa7e4 100644 --- a/skills/twg-jira/test/jira-workitem-implementation.test.mjs +++ b/skills/twg-jira/test/jira-workitem-implementation.test.mjs @@ -27,6 +27,21 @@ test("projects implementation fields and renders ADF", () => { priority: { name: "Medium" }, }, }, + issuelinks: [ + { + type: { + inward: "is blocked by", + outward: "blocks", + }, + outwardIssue: { + key: "PROJ-124", + fields: { + summary: "Publish trace schema", + status: { name: "In Progress" }, + }, + }, + }, + ], description: { type: "doc", version: 1, @@ -163,7 +178,44 @@ test("projects implementation fields and renders ADF", () => { "| --- | --- |", "| decision | [allowed](https://example.invalid/docs) |", ].join("\n"), + issueLinks: [ + { + relationship: "blocks", + key: "PROJ-124", + summary: "Publish trace schema", + status: "In Progress", + }, + ], + }); +}); + +test("reports unsupported ADF instead of silently claiming completeness", () => { + const projected = projectWorkitem({ + key: "PROJ-123", + description: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Visible text", + marks: [{ type: "customMark" }], + }, + ], + }, + { + type: "mediaSingle", + content: [{ type: "media", attrs: { id: "redacted" } }], + }, + ], + }, }); + + assert.equal(projected.description, "Visible text"); + assert.deepEqual(projected.unsupportedAdfNodes, ["media", "mediaSingle"]); + assert.deepEqual(projected.unsupportedAdfMarks, ["customMark"]); }); test("normalizes single and batch payloads", () => { @@ -185,7 +237,7 @@ test("builds a bounded JSON command", () => { "PROJ-123", "PROJ-124", "--fields", - "summary,description,status,parent,issuetype", + "summary,description,status,parent,issuetype,issuelinks", "-o", "json", "--output-summary",