ticketlens 0.9.9 → 0.9.11
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/package.json +1 -1
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +13 -2
- package/skills/jtb/scripts/lib/brief-assembler.mjs +1 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +20 -1
- package/skills/jtb/scripts/lib/requirement-extractor.mjs +7 -8
- package/skills/jtb/scripts/lib/styled-assembler.mjs +1 -0
package/package.json
CHANGED
|
@@ -83,8 +83,19 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
83
83
|
onProgress?.(` download ${a.filename}`);
|
|
84
84
|
try {
|
|
85
85
|
const headers = buildAuthHeader(env);
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
// Prefer no-redirect so auth headers stay on the Jira origin.
|
|
89
|
+
response = await fetcher(a.content, { headers, redirect: 'error' });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Jira Cloud attachment URLs sometimes redirect to CDN/S3 presigned URLs.
|
|
92
|
+
// On redirect, retry without auth — the presigned URL provides its own authentication.
|
|
93
|
+
if (err?.cause?.message?.includes('redirect mode is set to error')) {
|
|
94
|
+
response = await fetcher(a.content, { redirect: 'follow' });
|
|
95
|
+
} else {
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
88
99
|
if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
|
|
89
100
|
const buffer = await response.arrayBuffer();
|
|
90
101
|
fs.writeFileSync(localPath, Buffer.from(buffer));
|
|
@@ -12,6 +12,7 @@ export function assembleBrief(ticket, codeRefs = null, templateSections = null)
|
|
|
12
12
|
sections.push(`# ${ticket.key}: ${ticket.summary}`);
|
|
13
13
|
|
|
14
14
|
const meta = [`**Type:** ${ticket.type}`, `**Status:** ${ticket.status}`, `**Priority:** ${ticket.priority}`, `**Assignee:** ${ticket.assignee ?? 'Unassigned'}`, `**Reporter:** ${ticket.reporter ?? 'Unknown'}`];
|
|
15
|
+
if (ticket.sprint) meta.push(`**Sprint:** ${ticket.sprint}`);
|
|
15
16
|
if (ticket.created) meta.push(`**Created:** ${ticket.created.split('T')[0]}`);
|
|
16
17
|
if (ticket.updated) meta.push(`**Updated:** ${ticket.updated.split('T')[0]}`);
|
|
17
18
|
sections.push(meta.join(' | '));
|
|
@@ -12,6 +12,24 @@ function toText(value) {
|
|
|
12
12
|
return adfToText(value);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Extracts the active sprint name from customfield_10020.
|
|
17
|
+
* Cloud v3: array of sprint objects — prefers active, falls back to last.
|
|
18
|
+
* Server v2: serialized Java string — extracts via name= regex.
|
|
19
|
+
*/
|
|
20
|
+
function parseSprint(value) {
|
|
21
|
+
if (!value) return null;
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
const sprint = value.find(s => s.state === 'active') ?? value[value.length - 1];
|
|
24
|
+
return sprint?.name ?? null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'string') {
|
|
27
|
+
const m = value.match(/name=([^,\]]+)/);
|
|
28
|
+
return m ? m[1].trim() : null;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
/**
|
|
16
34
|
* Returns the ISO date string when the ticket entered its current status,
|
|
17
35
|
* derived from Jira's changelog history. Walks backwards (most recent first)
|
|
@@ -50,6 +68,7 @@ export function normalizeTicket(raw) {
|
|
|
50
68
|
created: f.created ?? null,
|
|
51
69
|
updated: f.updated ?? null,
|
|
52
70
|
statusChangedAt,
|
|
71
|
+
sprint: parseSprint(f.customfield_10020),
|
|
53
72
|
labels: f.labels ?? [],
|
|
54
73
|
components: (f.components ?? []).map(c => c.name),
|
|
55
74
|
comments: (f.comment?.comments ?? []).map(c => ({
|
|
@@ -171,7 +190,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
171
190
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
172
191
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
173
192
|
|
|
174
|
-
const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate,created';
|
|
193
|
+
const fields = 'summary,status,assignee,priority,issuetype,comment,updated,statuscategorychangedate,created,customfield_10020';
|
|
175
194
|
const params = new URLSearchParams({ jql, fields, maxResults: String(maxResults) });
|
|
176
195
|
if (expandChangelog) params.set('expand', 'changelog');
|
|
177
196
|
const endpoint = apiVersion >= 3 ? `/rest/api/3/search/jql` : `/rest/api/2/search`;
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
const RE_GWT = /^\s*(given|when|then)\s+(.+)/i;
|
|
8
8
|
const RE_MUST_ITEM = /^\s*[-*•]\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
|
|
9
9
|
const RE_NUM_MUST = /^\s*\d+\.\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
|
|
10
|
-
const RE_AC_HEADER = /^\s
|
|
10
|
+
const RE_AC_HEADER = /^\s*(?:#+\s*|h[1-6]\.\s*)?acceptance criteria\s*:?\s*$/i;
|
|
11
|
+
const RE_HEADING = /^\s*(?:#+|h[1-6]\.)\s+/i;
|
|
11
12
|
const RE_BULLET = /^\s*[-*•]\s+(.+)/;
|
|
12
13
|
const RE_NUM_ITEM = /^\s*\d+\.\s+(.+)/;
|
|
13
14
|
|
|
@@ -19,32 +20,30 @@ export function extractRequirements(text) {
|
|
|
19
20
|
let inAcSection = false;
|
|
20
21
|
|
|
21
22
|
for (const line of lines) {
|
|
22
|
-
// Given/When/Then
|
|
23
23
|
const gwt = RE_GWT.exec(line);
|
|
24
24
|
if (gwt) { results.push(line.trim()); continue; }
|
|
25
25
|
|
|
26
|
-
// Acceptance Criteria section header
|
|
27
26
|
if (RE_AC_HEADER.test(line)) { inAcSection = true; continue; }
|
|
28
27
|
|
|
29
|
-
// Exit
|
|
30
|
-
if (inAcSection &&
|
|
28
|
+
// Exit on any explicit heading (markdown ## or wiki h2.) that is not the AC header
|
|
29
|
+
if (inAcSection && RE_HEADING.test(line) && !RE_AC_HEADER.test(line)) {
|
|
31
30
|
inAcSection = false;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
// must/should/shall in bullet
|
|
35
33
|
const mustItem = RE_MUST_ITEM.exec(line);
|
|
36
34
|
if (mustItem) { results.push(mustItem[1].trim()); continue; }
|
|
37
35
|
|
|
38
|
-
// must/should/shall in numbered item
|
|
39
36
|
const numMust = RE_NUM_MUST.exec(line);
|
|
40
37
|
if (numMust) { results.push(numMust[1].trim()); continue; }
|
|
41
38
|
|
|
42
|
-
// Inside AC section: capture all bullet and numbered items
|
|
43
39
|
if (inAcSection) {
|
|
44
40
|
const bullet = RE_BULLET.exec(line);
|
|
45
41
|
if (bullet) { results.push(bullet[1].trim()); continue; }
|
|
46
42
|
const numItem = RE_NUM_ITEM.exec(line);
|
|
47
43
|
if (numItem) { results.push(numItem[1].trim()); continue; }
|
|
44
|
+
// Capture plain sentences — ADF paragraph nodes output as bare text (no list marker)
|
|
45
|
+
const plain = line.trim();
|
|
46
|
+
if (plain) { results.push(plain); continue; }
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
49
|
|
|
@@ -114,6 +114,7 @@ export function styleBrief(ticket, codeRefs = null, opts = {}) {
|
|
|
114
114
|
`${s.dim('Priority:')} ${ticket.priority}`,
|
|
115
115
|
`${s.dim('Assignee:')} ${ticket.assignee ?? 'Unassigned'}`,
|
|
116
116
|
];
|
|
117
|
+
if (ticket.sprint) meta.push(`${s.dim('Sprint:')} ${ticket.sprint}`);
|
|
117
118
|
if (ticket.created) meta.push(`${s.dim('Created:')} ${ticket.created.split('T')[0]}`);
|
|
118
119
|
if (ticket.updated) meta.push(`${s.dim('Updated:')} ${ticket.updated.split('T')[0]}`);
|
|
119
120
|
sections.push(meta.join(s.dim(' · ')));
|