Skip to content

Commit 1fdaddc

Browse files
committed
Adjust jira skills for handling formatted descriptions
Assisted-by: Claude Opus 4.6 Signed-off-by: John Collier <jcollier@redhat.com>
1 parent 765026f commit 1fdaddc

5 files changed

Lines changed: 279 additions & 32 deletions

File tree

skills/rhdh-jira/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Before attempting any REST API or GraphQL call:
8888
| `scripts/parse_issues.py` | Flatten, enrich, and filter acli JSON output. Solves the core problem: `acli search --json` can't return custom fields (team, story points, sprint). Pipe search results in, get clean data out. Use `--enrich` to fetch full fields, `-f team="X"` to filter by team. |
8989
| `scripts/command-metadata.json` | Single source of truth for sub-command descriptions and argument hints. |
9090
| `scripts/validate_components.py` | Validate `references/fields.md` component catalog against live Jira projects (RHIDP + RHDHPLAN). Reports drift in both directions. Run with `--json` for structured output. |
91+
| `scripts/jira-wiki-to-adf.py` | Convert a filled Jira wiki markup template to Atlassian Document Format JSON for use with `acli --description-file`. Handles `hN.` headings, `* ` bullets, `# ` ordered lists, `(?)` / `(/)` task items, `*bold*`, `_italic_`, `{{monospace}}`, backtick code. Usage: `python scripts/jira-wiki-to-adf.py input.txt > output.adf.json` |
9192

9293
## Projects
9394

@@ -150,7 +151,7 @@ Load only what the current task requires.
150151
3. **`--yes` is mandatory for mutations.** All `edit`, `transition`, `assign`, and `link create` commands prompt interactively without it. Always pass `--yes`.
151152
4. **`--fields` is restrictive on search.** Only accepts `key`, `summary`, `status`, `assignee`, `issuetype`, `priority`, `description`, `labels`. For components, sprint, fixVersions, and all custom fields — use `--json` or `scripts/parse_issues.py --enrich`.
152153
5. **Team field has two JQL syntaxes.** `customfield_10001` cannot be used in JQL WHERE clauses. However, `"Team[Team]" = {teamId}` (using the team UUID, not display name) works. Use the UUID syntax for JQL filtering; use `customfield_10001.name` in post-processing only when you need the display name from JSON output.
153-
6. **ADF vs plain text.** Reading descriptions via `--json` returns Atlassian Document Format (nested JSON). Creating/editing with `--description` accepts plain text. Don't try to round-trip ADF through `--description`.
154+
6. **ADF is required for formatted descriptions.** Reading descriptions via `--json` returns Atlassian Document Format (nested JSON). Jira Cloud's editor is ADF-native — plain text and Jira wiki markup (`h1.`, `*bold*`) both render as literal characters in the UI. For formatted descriptions, build ADF JSON directly in Python and pass via `--description-file`. Both `acli create` and `acli edit` accept ADF JSON via `--description-file`. Do not use Jira wiki markup in description files expecting it to render.
154155
7. **Acceptance Criteria field is almost always null.** Scan the description for "Requirements", "Acceptance Criteria", or bullet-style criteria instead of checking `customfield_10718`.
155156
8. **`--enrich` is MANDATORY for custom fields AND labels.** Both `acli search --json` and `acli view KEY --json` (without `--fields "*all"`) return only basic fields (assignee, issuetype, priority, status, summary). Labels, story points, team, sprint, size, and components will all appear as empty/null — looking like the data isn't set when it actually is. Always use `scripts/parse_issues.py --enrich` to get custom field data. Skipping `--enrich` is the #1 cause of false "missing data" reports.
156157
9. **`acli` cannot set arbitrary custom fields.** `acli jira workitem edit` does not have a `--custom` flag. Fields like Team, Size, Story Points, and Release Note Type can only be updated via the Jira REST API. Use `PUT /rest/api/3/issue/{key}` with the field payload (see `references/rest-api-fallback.md` for curl examples and payload formats). Find the token file at `.jira-token` next to the `acli` executable (discover the path with `readlink -f "$(which acli)"` or `where acli`). Never read the token file into context.
@@ -162,6 +163,7 @@ Load only what the current task requires.
162163
15. **Don't remove `rhdh-X.Y-candidate` labels.** Candidate labels track release targeting. Removing them without PM approval can silently drop a feature from release tracking.
163164
16. **Feature→Epic child links use Parent Link, not issuelinks.** Cross-project parent-child relationships (RHDHPLAN Feature → RHIDP Epic) use the `Parent Link` field (`customfield_10018`), not `issuelinks`. To find child Epics of a Feature, use JQL: `project = RHIDP AND type = Epic AND "Parent Link" = RHDHPLAN-XXX`. Checking `issuelinks` will show zero results and produce false "no child Epics" reports.
164165
17. **REST `/rest/api/3/search` returns 410 Gone.** This endpoint has been removed. Use POST to `/rest/api/3/search/jql` with body `{"jql": "...", "fields": [...], "maxResults": N}` instead. This only affects direct REST calls — `acli search` still works.
166+
18. **`acli workitem create` does not support `--priority`, `--component`, or `--yes`.** These flags exist on `edit`, `transition`, and `assign` but not on `create` — passing them causes "unknown flag" errors. After creating an issue, set priority, components, size (`customfield_10795`), and parent link (`customfield_10018`) together in a single `PUT /rest/api/3/issue/{key}` call.
165167

166168
## Error Handling
167169

skills/rhdh-jira/references/to-epic.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,27 +86,37 @@ Run the pre-creation check from `references/duplicates.md`. Search RHIDP Epics (
8686

8787
### Step 8 — Create Epic
8888

89-
Fill the template. Create the issue:
89+
Fill the template. Then convert to ADF using the helper script (see Gotcha #6). `acli create` accepts ADF via `--description-file`:
90+
91+
```bash
92+
python scripts/jira-wiki-to-adf.py /tmp/epic-filled.txt > /tmp/epic-desc.adf.json
93+
```
94+
95+
Create the issue — note `--priority`, `--component`, and `--yes` do not exist on `create` (see Gotcha #18):
9096

9197
```bash
9298
acli jira workitem create --project RHIDP --type Epic \
9399
--summary "Epic summary" \
94-
--description-file /tmp/epic-desc.txt \
95-
--assignee "ACCOUNT_ID" \
96-
--priority "Major" \
97-
--component "Plugins" \
98-
--yes
100+
--description-file /tmp/epic-desc.adf.json \
101+
--assignee "ACCOUNT_ID"
99102
```
100103

101-
If a parent Feature exists, link via REST:
104+
Then set priority, components, size, and parent Feature link together in one REST call. Cross-project parent links use `customfield_10018`, not `parent` (see Gotcha #16):
102105

103106
```bash
104107
curl -s -X PUT -u "$AUTH" -H "Content-Type: application/json" \
105-
-d '{"fields": {"parent": {"key": "RHDHPLAN-XXX"}}}' \
108+
-d '{
109+
"fields": {
110+
"priority": {"name": "Major"},
111+
"components": [{"name": "Catalog"}],
112+
"customfield_10795": {"value": "M"},
113+
"customfield_10018": "RHDHPLAN-XXX"
114+
}
115+
}' \
106116
"https://redhat.atlassian.net/rest/api/3/issue/RHIDP-XXX"
107117
```
108118

109-
Set Team and Size via REST — follow API preference order in SKILL.md.
119+
Set Team via REST — follow API preference order in SKILL.md.
110120

111121
### Step 9 — Comments
112122

skills/rhdh-jira/references/to-feature.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,36 @@ If a likely duplicate Feature is found, present it and ask: "This may already ex
9696

9797
### Step 7 — Create Feature
9898

99-
Fill the template with grill results. Save to a temp file. Create the issue:
99+
Fill the template with grill results. Save to a temp file. Then convert to ADF using the helper script (see Gotcha #6). `acli create` accepts ADF via `--description-file`:
100+
101+
```bash
102+
python scripts/jira-wiki-to-adf.py /tmp/feature-filled.txt > /tmp/feature-desc.adf.json
103+
```
104+
105+
Create the issue — note `--priority` and `--yes` do not exist on `create` (see Gotcha #18):
100106

101107
```bash
102108
acli jira workitem create --project RHDHPLAN --type Feature \
103109
--summary "Feature summary" \
104-
--description-file /tmp/feature-desc.txt \
110+
--description-file /tmp/feature-desc.adf.json \
105111
--assignee "ACCOUNT_ID" \
106-
--priority "Major" \
107-
--label "rhdh-2.1-candidate" \
108-
--yes
112+
--label "rhdh-2.1-candidate"
113+
```
114+
115+
Then set priority, Team, and Size together in one REST call:
116+
117+
```bash
118+
curl -s -X PUT -u "$AUTH" -H "Content-Type: application/json" \
119+
-d '{
120+
"fields": {
121+
"priority": {"name": "Major"},
122+
"customfield_10795": {"value": "M"}
123+
}
124+
}' \
125+
"https://redhat.atlassian.net/rest/api/3/issue/RHDHPLAN-XXX"
109126
```
110127

111-
Set additional fields via REST if needed (Team, Size) — follow API preference order in SKILL.md.
128+
Set Team via REST — follow API preference order in SKILL.md.
112129

113130
### Step 8 — Comments
114131

@@ -132,7 +149,7 @@ If yes:
132149
2. For each team, invoke the `to-epic` workflow with context carried down from this Feature:
133150
- The Feature's scope, AC, and customer considerations are established — don't re-grill on these
134151
- The Epic grill narrows to: delivery scope for *this team*, dependencies, team-specific AC
135-
3. Each Epic is automatically linked to the parent Feature via `parent` field
152+
3. Each Epic is automatically linked to the parent Feature via `customfield_10018` (cross-project parent link — see Gotcha #16 and to-epic.md Step 8)
136153

137154
## Error Handling
138155

skills/rhdh-jira/references/to-issue.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -119,36 +119,48 @@ Run the pre-creation check from `references/duplicates.md`. Scope to the target
119119

120120
### Step 9 — Create Issue
121121

122-
Fill the template. Create the issue:
122+
Fill the appropriate template (`assets/templates/story.txt`, `task.txt`, or `bug.txt`) with grill results, then convert to ADF using the helper script (see Gotcha #6). `acli create` accepts ADF via `--description-file`:
123+
124+
```bash
125+
python scripts/jira-wiki-to-adf.py /tmp/story-filled.txt > /tmp/story-desc.adf.json
126+
```
127+
128+
Create the issue — note `--priority`, `--component`, and `--yes` do not exist on `create` (see Gotcha #18):
123129

124130
```bash
125131
# Story
126132
acli jira workitem create --project RHIDP --type Story \
127133
--summary "Story summary" \
128-
--description-file /tmp/story-desc.txt \
129-
--assignee "ACCOUNT_ID" \
130-
--priority "Major" \
131-
--component "Plugins" \
132-
--yes
134+
--description-file /tmp/story-desc.adf.json \
135+
--assignee "ACCOUNT_ID"
133136

134137
# Bug (different project)
135138
acli jira workitem create --project RHDHBUGS --type Bug \
136139
--summary "Bug summary" \
137-
--description-file /tmp/bug-desc.txt \
138-
--priority "Critical" \
139-
--yes
140+
--description-file /tmp/bug-desc.adf.json
140141

141142
# Spike (Task with prefix)
142143
acli jira workitem create --project RHIDP --type Task \
143144
--summary "SPIKE: Research multi-source catalog merging" \
144-
--description-file /tmp/spike-desc.txt \
145-
--assignee "ACCOUNT_ID" \
146-
--priority "Major" \
147-
--component "Plugins" \
148-
--yes
145+
--description-file /tmp/spike-desc.adf.json \
146+
--assignee "ACCOUNT_ID"
147+
```
148+
149+
Then set priority, component, and story points together in one REST call:
150+
151+
```bash
152+
curl -s -X PUT -u "$AUTH" -H "Content-Type: application/json" \
153+
-d '{
154+
"fields": {
155+
"priority": {"name": "Major"},
156+
"components": [{"name": "Plugins"}],
157+
"story_points": 5
158+
}
159+
}' \
160+
"https://redhat.atlassian.net/rest/api/3/issue/RHIDP-YYY"
149161
```
150162

151-
If a parent Epic exists, link via REST:
163+
If a parent Epic exists, link via REST (same-project RHIDP→RHIDP uses native `parent` field):
152164

153165
```bash
154166
curl -s -X PUT -u "$AUTH" -H "Content-Type: application/json" \
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
"""Convert Jira wiki markup (template subset) to Atlassian Document Format JSON.
3+
4+
Handles: headings (hN.), paragraphs, bullet lists (* item), ordered lists (# item),
5+
task items ((?) TODO, (/) DONE), bold (*text*), italic (_text_),
6+
monospace ({{text}} and `backtick`).
7+
8+
Usage:
9+
python scripts/jira-wiki-to-adf.py <input.txt> # stdout
10+
python scripts/jira-wiki-to-adf.py <input.txt> <output.json> # file
11+
"""
12+
13+
import json
14+
import re
15+
import sys
16+
17+
18+
def parse_inline(text):
19+
"""Parse Jira wiki inline marks into ADF text nodes.
20+
21+
Order matters: bold (*text*) is matched before bare asterisks,
22+
monospace before backticks.
23+
"""
24+
nodes = []
25+
pattern = re.compile(r'\*([^*\n]+)\*|_([^_\n]+)_|\{\{([^}]+)\}\}|`([^`\n]+)`')
26+
last = 0
27+
for m in pattern.finditer(text):
28+
if m.start() > last:
29+
nodes.append({"type": "text", "text": text[last:m.start()]})
30+
if m.group(1) is not None: # *bold*
31+
nodes.append({"type": "text", "text": m.group(1), "marks": [{"type": "strong"}]})
32+
elif m.group(2) is not None: # _italic_
33+
nodes.append({"type": "text", "text": m.group(2), "marks": [{"type": "em"}]})
34+
elif m.group(3) is not None: # {{monospace}}
35+
nodes.append({"type": "text", "text": m.group(3), "marks": [{"type": "code"}]})
36+
else: # `backtick`
37+
nodes.append({"type": "text", "text": m.group(4), "marks": [{"type": "code"}]})
38+
last = m.end()
39+
if last < len(text):
40+
nodes.append({"type": "text", "text": text[last:]})
41+
return nodes or [{"type": "text", "text": ""}]
42+
43+
44+
def _para(text):
45+
return {"type": "paragraph", "content": parse_inline(text)}
46+
47+
48+
def _heading(level, text):
49+
return {"type": "heading", "attrs": {"level": level}, "content": parse_inline(text)}
50+
51+
52+
def _task_list(items, idx):
53+
return {
54+
"type": "taskList",
55+
"attrs": {"localId": f"tl-{idx}"},
56+
"content": [
57+
{
58+
"type": "taskItem",
59+
"attrs": {"localId": f"ti-{idx}-{i}", "state": "DONE" if checked else "TODO"},
60+
"content": parse_inline(text),
61+
}
62+
for i, (checked, text) in enumerate(items)
63+
],
64+
}
65+
66+
67+
def _bullet_list(items):
68+
return {
69+
"type": "bulletList",
70+
"content": [
71+
{"type": "listItem", "content": [_para(item)]}
72+
for item in items
73+
],
74+
}
75+
76+
77+
def _ordered_list(items):
78+
return {
79+
"type": "orderedList",
80+
"content": [
81+
{"type": "listItem", "content": [_para(item)]}
82+
for item in items
83+
],
84+
}
85+
86+
87+
# hN. text (heading)
88+
HEADING_RE = re.compile(r'^h([1-6])\.\s+(.*)')
89+
# (?) text (task item, unchecked)
90+
TASK_TODO_RE = re.compile(r'^\(\?\)\s+(.*)')
91+
# (/) text (task item, checked)
92+
TASK_DONE_RE = re.compile(r'^\(/\)\s+(.*)')
93+
# * text (bullet — asterisk + whitespace; does NOT match *bold*)
94+
BULLET_RE = re.compile(r'^\*\s+(.*)')
95+
# # text or # text (ordered list — optional leading whitespace)
96+
ORDERED_RE = re.compile(r'^\s*#\s+(.*)')
97+
98+
99+
def convert(wiki):
100+
lines = wiki.splitlines()
101+
content = []
102+
tl_idx = 0
103+
i = 0
104+
105+
while i < len(lines):
106+
line = lines[i]
107+
s = line.strip()
108+
109+
if not s:
110+
i += 1
111+
continue
112+
113+
# Heading
114+
m = HEADING_RE.match(s)
115+
if m:
116+
content.append(_heading(int(m.group(1)), m.group(2).strip()))
117+
i += 1
118+
continue
119+
120+
# Task items — collect consecutive (?) and (/) lines into one taskList
121+
if TASK_TODO_RE.match(s) or TASK_DONE_RE.match(s):
122+
items = []
123+
while i < len(lines):
124+
s2 = lines[i].strip()
125+
mt = TASK_TODO_RE.match(s2)
126+
md = TASK_DONE_RE.match(s2)
127+
if mt:
128+
items.append((False, mt.group(1)))
129+
i += 1
130+
elif md:
131+
items.append((True, md.group(1)))
132+
i += 1
133+
else:
134+
break
135+
content.append(_task_list(items, tl_idx))
136+
tl_idx += 1
137+
continue
138+
139+
# Bullet list (* item — requires space after *, so *bold* is not matched)
140+
m = BULLET_RE.match(s)
141+
if m:
142+
items = []
143+
while i < len(lines):
144+
bm = BULLET_RE.match(lines[i].strip())
145+
if bm:
146+
items.append(bm.group(1))
147+
i += 1
148+
else:
149+
break
150+
if items:
151+
content.append(_bullet_list(items))
152+
continue
153+
154+
# Ordered list (# item or # item with leading whitespace)
155+
m = ORDERED_RE.match(line)
156+
if m:
157+
items = []
158+
while i < len(lines):
159+
om = ORDERED_RE.match(lines[i])
160+
if om:
161+
items.append(om.group(1))
162+
i += 1
163+
else:
164+
break
165+
if items:
166+
content.append(_ordered_list(items))
167+
continue
168+
169+
# Paragraph — collect consecutive non-special lines
170+
para_lines = []
171+
while i < len(lines):
172+
s2 = lines[i].strip()
173+
raw = lines[i]
174+
if not s2:
175+
break
176+
if HEADING_RE.match(s2):
177+
break
178+
if TASK_TODO_RE.match(s2) or TASK_DONE_RE.match(s2):
179+
break
180+
if BULLET_RE.match(s2):
181+
break
182+
if ORDERED_RE.match(raw):
183+
break
184+
para_lines.append(s2)
185+
i += 1
186+
if para_lines:
187+
content.append(_para(' '.join(para_lines)))
188+
189+
return {"version": 1, "type": "doc", "content": content}
190+
191+
192+
if __name__ == '__main__':
193+
if len(sys.argv) < 2:
194+
print("Usage: jira-wiki-to-adf.py <input.txt> [output.json]", file=sys.stderr)
195+
sys.exit(1)
196+
197+
with open(sys.argv[1]) as f:
198+
wiki = f.read()
199+
200+
output = json.dumps(convert(wiki), ensure_ascii=False)
201+
202+
if len(sys.argv) >= 3:
203+
with open(sys.argv[2], 'w') as f:
204+
f.write(output)
205+
else:
206+
print(output)

0 commit comments

Comments
 (0)