@baiyibai-antora/jira-retriever 1.0.0-beta.5

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.
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // jira-retrieve: pull Jira issues selected by JQL down as AsciiDoc files.
5
+ // Configuration via jira.yml and/or flags; credentials from the environment.
6
+
7
+ const fsp = require('node:fs/promises')
8
+ const ospath = require('node:path')
9
+ const yaml = require('js-yaml')
10
+ const { AtlassianClient, credentialsFromEnv } = require('@baiyibai-antora/atlassian-client')
11
+ const { retrieveIssues, DEFAULT_FIELDS } = require('../lib')
12
+
13
+ const USAGE = `Usage: jira-retrieve [options] [jira.yml]
14
+
15
+ Writes one .adoc file per issue (KEY.adoc): scalar fields become header
16
+ attributes, the description becomes the body, comments and other rich-text
17
+ fields become sections.
18
+
19
+ jira.yml:
20
+ jql: project = DEMO ORDER BY key
21
+ fields: [summary, description, labels, status, comment] # ids or names
22
+ out: issues
23
+
24
+ Options:
25
+ --jql <query> JQL (overrides the config file)
26
+ --fields <a,b,c> fields to retrieve (default: ${DEFAULT_FIELDS.join(',')})
27
+ --out <dir> output directory (default: ./issues)
28
+ --keep-colors keep text/background colors as role spans
29
+ -h, --help show this help
30
+
31
+ Environment: ATLASSIAN_BASE_URL (or JIRA_BASE_URL), ATLASSIAN_EMAIL,
32
+ ATLASSIAN_API_TOKEN`
33
+
34
+ function parseArgs (argv) {
35
+ const opts = { jql: null, fields: null, out: null, styles: 'drop', config: null }
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i]
38
+ if (arg === '-h' || arg === '--help') return { help: true }
39
+ else if (arg === '--jql') opts.jql = argv[++i]
40
+ else if (arg === '--fields') opts.fields = argv[++i].split(',').map((f) => f.trim()).filter(Boolean)
41
+ else if (arg === '--out') opts.out = argv[++i]
42
+ else if (arg === '--keep-colors') opts.styles = 'roles'
43
+ else if (arg.startsWith('-')) throw new Error(`unknown option ${arg}`)
44
+ else if (opts.config) throw new Error('only one config file may be given')
45
+ else opts.config = arg
46
+ }
47
+ return opts
48
+ }
49
+
50
+ async function main () {
51
+ let opts
52
+ try {
53
+ opts = parseArgs(process.argv.slice(2))
54
+ } catch (err) {
55
+ process.stderr.write(`jira-retrieve: ${err.message}\n${USAGE}\n`)
56
+ return 1
57
+ }
58
+ if (opts.help) {
59
+ process.stdout.write(USAGE + '\n')
60
+ return 0
61
+ }
62
+
63
+ let config = {}
64
+ if (opts.config) config = yaml.load(await fsp.readFile(opts.config, 'utf8')) || {}
65
+ const jql = opts.jql || config.jql
66
+ if (!jql) {
67
+ process.stderr.write(`jira-retrieve: no JQL given (pass --jql or a config file)\n${USAGE}\n`)
68
+ return 1
69
+ }
70
+
71
+ let client
72
+ try {
73
+ client = new AtlassianClient({ ...credentialsFromEnv(), serviceName: 'Jira' })
74
+ } catch (err) {
75
+ process.stderr.write(`jira-retrieve: ${err.message}\n`)
76
+ return 1
77
+ }
78
+
79
+ const logger = { warn: (msg) => process.stderr.write(`jira-retrieve: ${msg}\n`) }
80
+ const { files, issues } = await retrieveIssues({
81
+ client,
82
+ jql,
83
+ fields: opts.fields || config.fields || undefined,
84
+ styles: opts.styles,
85
+ logger,
86
+ })
87
+
88
+ const outDir = opts.out || config.out || 'issues'
89
+ for (const file of files) {
90
+ const dest = ospath.join(outDir, ...file.path.split('/'))
91
+ await fsp.mkdir(ospath.dirname(dest), { recursive: true })
92
+ await fsp.writeFile(dest, file.contents)
93
+ }
94
+ process.stdout.write(`retrieved ${issues.length} issue(s) to ${outDir}\n`)
95
+ return 0
96
+ }
97
+
98
+ main().then(
99
+ (code) => {
100
+ process.exitCode = code
101
+ },
102
+ (err) => {
103
+ process.stderr.write(`jira-retrieve: ${err.message}\n`)
104
+ process.exitCode = 1
105
+ }
106
+ )
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ const { retrieveIssues, DEFAULT_FIELDS } = require('./retrieve')
4
+
5
+ module.exports = { retrieveIssues, DEFAULT_FIELDS }
@@ -0,0 +1,277 @@
1
+ 'use strict'
2
+
3
+ const { posix: path } = require('node:path')
4
+ const { toAdoc } = require('@baiyibai-antora/asciidoc-adf-converter')
5
+
6
+ // Retrieve Jira issues selected by JQL as an in-memory AsciiDoc file tree,
7
+ // one file per issue, in the tagged issue format (see the README): the
8
+ // issue is a level-1 section carrying its scalar fields as named block
9
+ // attributes, the description is the section body, and comments, custom
10
+ // rich-text fields, and attachments are tagged subsections. Everything
11
+ // semantic is a walkable node attribute — no document attributes, so files
12
+ // can be concatenated or included without collisions, and jira-publish can
13
+ // consume them.
14
+ //
15
+ // The v3 REST API returns rich-text fields as ADF. Attachments download
16
+ // into _attachments/KEY/. Inline media in ADF reference Media Services
17
+ // UUIDs that no v3 payload maps to attachments; the bridge is the rendered
18
+ // HTML (expand=renderedFields), where the same images appear in document
19
+ // order as /attachment/content/<id> URLs. When the counts line up the
20
+ // media resolve to the downloaded files; otherwise they stay adf-media:
21
+ // placeholders with a warning.
22
+
23
+ const RICH_DEFAULT = [
24
+ 'summary', 'description', 'issuetype', 'status', 'priority', 'assignee',
25
+ 'reporter', 'labels', 'components', 'fixVersions', 'parent', 'created',
26
+ 'updated', 'comment', 'attachment', 'issuelinks', 'subtasks',
27
+ ]
28
+
29
+ // Noise dropped when `fields: all` expands to *all.
30
+ const ALL_SKIP = new Set([
31
+ 'avatarUrls', 'votes', 'watches', 'worklog', 'progress', 'aggregateprogress',
32
+ 'aggregatetimeestimate', 'aggregatetimeoriginalestimate', 'aggregatetimespent',
33
+ 'timeestimate', 'timeoriginalestimate', 'timespent', 'timetracking',
34
+ 'workratio', 'lastViewed', 'statuscategorychangedate', 'thumbnail',
35
+ 'security', 'environment',
36
+ ])
37
+
38
+ const isAdfDoc = (value) => Boolean(value) && typeof value === 'object' && value.type === 'doc'
39
+
40
+ function attrValue (value) {
41
+ if (value == null) return null
42
+ if (Array.isArray(value)) return value.map(attrValue).filter((v) => v != null).join(', ')
43
+ if (typeof value === 'object') return value.name || value.displayName || value.key || value.value || null
44
+ return String(value)
45
+ }
46
+
47
+ function attrName (field) {
48
+ return String(field)
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/^-+|-+$/g, '')
52
+ }
53
+
54
+ // One `name=value` pair for an AsciiDoc attribute list: quoted unless the
55
+ // value is plainly safe, newlines flattened.
56
+ function attrPair (name, value) {
57
+ let v = String(value).replace(/\s*\n\s*/g, ' ').trim()
58
+ if (/[^A-Za-z0-9_.:@/-]/.test(v)) v = `"${v.replace(/"/g, '\\"')}"`
59
+ return `${name}=${v}`
60
+ }
61
+
62
+ // System fields keep predictable attribute names regardless of how the
63
+ // site's field catalog displays them; custom fields derive from their name.
64
+ const SYSTEM_ATTR = {
65
+ issuetype: 'jira-issuetype',
66
+ status: 'jira-status',
67
+ priority: 'jira-priority',
68
+ labels: 'jira-labels',
69
+ components: 'jira-components',
70
+ fixVersions: 'jira-fix-versions',
71
+ created: 'jira-created',
72
+ updated: 'jira-updated',
73
+ resolution: 'jira-resolution',
74
+ duedate: 'jira-duedate',
75
+ project: 'jira-project',
76
+ }
77
+
78
+ async function resolveFields (client, wanted) {
79
+ const catalog = await client.request('GET', '/rest/api/3/field')
80
+ const byName = new Map((catalog || []).map((f) => [f.name.toLowerCase(), f]))
81
+ const byId = new Map((catalog || []).map((f) => [f.id, f]))
82
+ return wanted.map((entry) => {
83
+ const field = byId.get(entry) || byName.get(String(entry).toLowerCase())
84
+ if (!field) throw new Error(`unknown Jira field "${entry}" (use an id or the exact display name)`)
85
+ return { id: field.id, name: field.name }
86
+ })
87
+ }
88
+
89
+ // Media Services UUIDs (ADF, document order) zipped against the attachment
90
+ // content ids in the rendered HTML of the same body.
91
+ function walkMediaIds (node, out = []) {
92
+ if (!node || typeof node !== 'object') return out
93
+ if (node.type === 'media' || node.type === 'mediaInline') {
94
+ if ((node.attrs || {}).type === 'file' && node.attrs.id) out.push(node.attrs.id)
95
+ }
96
+ for (const child of node.content || []) walkMediaIds(child, out)
97
+ return out
98
+ }
99
+
100
+ function mediaMapFor (adf, renderedHtml, attachmentsById, warn, label) {
101
+ const uuids = walkMediaIds(adf)
102
+ if (!uuids.length) return new Map()
103
+ const numeric = [...String(renderedHtml || '').matchAll(/\/attachment\/(?:content|thumbnail)\/(\d+)/g)].map((m) => m[1])
104
+ if (numeric.length !== uuids.length) {
105
+ warn(`${label}: ${uuids.length} media node(s) but ${numeric.length} attachment reference(s) in the rendered body; leaving adf-media placeholders`)
106
+ return new Map()
107
+ }
108
+ const map = new Map()
109
+ uuids.forEach((uuid, i) => {
110
+ const att = attachmentsById.get(numeric[i])
111
+ if (att) map.set(uuid, att)
112
+ })
113
+ return map
114
+ }
115
+
116
+ function issueToAdoc (issue, resolved, opts, warn) {
117
+ const { styles, attachmentDir, attachmentFiles } = opts
118
+ const values = issue.fields || {}
119
+ const rendered = issue.renderedFields || {}
120
+ const key = issue.key
121
+ const attachments = Array.isArray(values.attachment) ? values.attachment : []
122
+ const attachmentsById = new Map(attachments.map((att) => [String(att.id), att]))
123
+
124
+ const convert = (adf, label, renderedHtml, levelOffset) => {
125
+ const mediaMap = mediaMapFor(adf, renderedHtml, attachmentsById, warn, `${key} ${label}`)
126
+ const resolveMedia = ({ id }) => {
127
+ const att = mediaMap.get(String(id))
128
+ if (!att) return null
129
+ attachmentFiles.add(String(att.id))
130
+ return path.join(attachmentDir, att.filename)
131
+ }
132
+ const result = toAdoc(adf, { styles, levelOffset, resolveMedia })
133
+ for (const warning of result.warnings) warn(`${key} ${label}: ${warning}`)
134
+ return result.adoc.trimEnd()
135
+ }
136
+
137
+ const attrs = [attrPair('jira', 'issue'), attrPair('jira-key', key)]
138
+ if (issue.id) attrs.push(attrPair('jira-id', issue.id))
139
+ attrs.push(attrPair('jira-project', key.replace(/-\d+$/, '')))
140
+ const sections = []
141
+ let description = ''
142
+
143
+ const push = (name, value) => {
144
+ const v = attrValue(value)
145
+ if (v != null && v !== '') attrs.push(attrPair(name, v))
146
+ }
147
+
148
+ for (const field of resolved) {
149
+ const value = values[field.id]
150
+ if (field.id === 'summary' || value == null) continue
151
+ switch (field.id) {
152
+ case 'description':
153
+ if (isAdfDoc(value)) description = convert(value, 'description', rendered.description, 1)
154
+ break
155
+ case 'comment': {
156
+ for (const comment of (value.comments || []).filter((c) => isAdfDoc(c.body))) {
157
+ const author = (comment.author && comment.author.displayName) || 'unknown'
158
+ const date = comment.created ? String(comment.created).slice(0, 10) : ''
159
+ const cAttrs = [attrPair('jira', 'comment'), attrPair('jira-id', comment.id)]
160
+ cAttrs.push(attrPair('jira-author', author))
161
+ if (comment.author && comment.author.accountId) cAttrs.push(attrPair('jira-author-id', comment.author.accountId))
162
+ if (comment.created) cAttrs.push(attrPair('jira-created', comment.created))
163
+ const renderedBody = ((rendered.comment || {}).comments || []).find((c) => String(c.id) === String(comment.id))
164
+ sections.push(
165
+ `[${cAttrs.join(',')}]\n=== ${author}${date ? ` (${date})` : ''}\n\n` +
166
+ convert(comment.body, `comment ${comment.id}`, renderedBody && renderedBody.body, 2)
167
+ )
168
+ }
169
+ break
170
+ }
171
+ case 'attachment': {
172
+ if (!attachments.length) break
173
+ const items = attachments.map((att) => {
174
+ attachmentFiles.add(String(att.id))
175
+ const target = path.join(attachmentDir, att.filename)
176
+ const size = att.size >= 1024 ? `${Math.round(att.size / 1024)} KB` : `${att.size} B`
177
+ const author = (att.author && att.author.displayName) || 'unknown'
178
+ const date = att.created ? String(att.created).slice(0, 10) : ''
179
+ return `* link:${target}[${att.filename}] (${att.mimeType || 'unknown'}, ${size}, ${author}${date ? `, ${date}` : ''})`
180
+ })
181
+ sections.push(`[jira=attachments]\n=== Attachments\n\n${items.join('\n')}`)
182
+ break
183
+ }
184
+ case 'assignee':
185
+ case 'reporter':
186
+ push(`jira-${field.id}`, value.displayName)
187
+ if (value.accountId) push(`jira-${field.id}-id`, value.accountId)
188
+ break
189
+ case 'parent':
190
+ push('jira-parent', value.key)
191
+ break
192
+ case 'issuelinks': {
193
+ const links = value
194
+ .map((link) => {
195
+ if (link.outwardIssue) return `${link.type.outward} ${link.outwardIssue.key}`
196
+ if (link.inwardIssue) return `${link.type.inward} ${link.inwardIssue.key}`
197
+ return null
198
+ })
199
+ .filter(Boolean)
200
+ if (links.length) attrs.push(attrPair('jira-links', links.join('; ')))
201
+ break
202
+ }
203
+ case 'subtasks':
204
+ if (value.length) push('jira-subtasks', value.map((s) => s.key).join(', '))
205
+ break
206
+ default:
207
+ if (isAdfDoc(value)) {
208
+ sections.push(
209
+ `[${attrPair('jira', 'field')},${attrPair('jira-field-name', field.name)}]\n=== ${field.name}\n\n` +
210
+ convert(value, field.name, rendered[field.id], 2)
211
+ )
212
+ } else {
213
+ push(SYSTEM_ATTR[field.id] || `jira-${attrName(field.name)}`, value)
214
+ }
215
+ }
216
+ }
217
+
218
+ const summary = values.summary || key
219
+ const parts = [`[${attrs.join(',')}]\n== ${key}: ${summary}`, description, ...sections].filter(Boolean)
220
+ return parts.join('\n\n') + '\n'
221
+ }
222
+
223
+ async function retrieveIssues ({ client, jql, fields, styles, logger }) {
224
+ const warnings = []
225
+ const warn = (msg) => {
226
+ warnings.push(msg)
227
+ if (logger) logger.warn(msg)
228
+ }
229
+ if (!jql) throw new Error('a jql query is required')
230
+
231
+ let resolved
232
+ let fieldParam
233
+ if (fields === 'all' || (Array.isArray(fields) && fields.length === 1 && fields[0] === 'all')) {
234
+ const catalog = await client.request('GET', '/rest/api/3/field')
235
+ resolved = (catalog || [])
236
+ .filter((f) => !ALL_SKIP.has(f.id))
237
+ .map((f) => ({ id: f.id, name: f.name }))
238
+ fieldParam = '*all'
239
+ } else {
240
+ resolved = await resolveFields(client, fields || RICH_DEFAULT)
241
+ if (!resolved.some((f) => f.id === 'summary')) resolved.unshift({ id: 'summary', name: 'Summary' })
242
+ fieldParam = resolved.map((f) => f.id).join(',')
243
+ }
244
+
245
+ const files = []
246
+ const issues = []
247
+ let pageToken
248
+ do {
249
+ const params = new URLSearchParams({ jql, fields: fieldParam, maxResults: '100', expand: 'renderedFields' })
250
+ if (pageToken) params.set('nextPageToken', pageToken)
251
+ const page = await client.request('GET', `/rest/api/3/search/jql?${params}`)
252
+ for (const issue of (page && page.issues) || []) {
253
+ const attachmentDir = path.join('_attachments', issue.key)
254
+ const attachmentFiles = new Set()
255
+ const contents = issueToAdoc(issue, resolved, { styles, attachmentDir, attachmentFiles }, warn)
256
+
257
+ for (const att of (issue.fields || {}).attachment || []) {
258
+ if (!attachmentFiles.has(String(att.id)) || !att.content) continue
259
+ const bytes = await client.download(String(att.content).replace(client.baseUrl, ''))
260
+ if (bytes) {
261
+ files.push({ path: path.join(attachmentDir, att.filename), contents: bytes })
262
+ } else {
263
+ warn(`${issue.key}: attachment ${att.filename} could not be downloaded`)
264
+ }
265
+ }
266
+
267
+ const filePath = `${issue.key}.adoc`
268
+ files.push({ path: filePath, contents })
269
+ issues.push({ key: issue.key, path: filePath, summary: (issue.fields || {}).summary || issue.key })
270
+ }
271
+ pageToken = page && page.nextPageToken
272
+ } while (pageToken)
273
+
274
+ return { files, issues, warnings }
275
+ }
276
+
277
+ module.exports = { retrieveIssues, DEFAULT_FIELDS: RICH_DEFAULT }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@baiyibai-antora/jira-retriever",
3
+ "version": "1.0.0-beta.5",
4
+ "description": "Retrieve Jira Cloud issues as AsciiDoc: JQL selection over the v3 REST API, rich-text fields arrive as ADF and convert with @baiyibai-antora/asciidoc-adf-converter",
5
+ "license": "MIT",
6
+ "author": "baiyibai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://gitlab.com/baiyibai-antora/confluora.git",
10
+ "directory": "packages/jira-retriever"
11
+ },
12
+ "type": "commonjs",
13
+ "main": "lib/index.js",
14
+ "bin": {
15
+ "jira-retrieve": "bin/jira-retrieve.js"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "bin"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*-test.js"
23
+ },
24
+ "dependencies": {
25
+ "@baiyibai-antora/asciidoc-adf-converter": "^1.0.0-beta.5",
26
+ "@baiyibai-antora/atlassian-client": "^1.0.0-beta.5",
27
+ "js-yaml": "^4.1.0"
28
+ },
29
+ "keywords": [
30
+ "confluora",
31
+ "jira",
32
+ "asciidoc",
33
+ "adf",
34
+ "issues",
35
+ "retriever"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }