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