@knpkv/jira-cli 1.1.2 → 1.2.1
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/CHANGELOG.md +37 -0
- package/README.md +23 -7
- package/dist/AttachmentService.d.ts +22 -0
- package/dist/AttachmentService.d.ts.map +1 -0
- package/dist/AttachmentService.js +67 -0
- package/dist/AttachmentService.js.map +1 -0
- package/dist/IssueService.d.ts +2 -20
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +47 -36
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +1 -1
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js.map +1 -1
- package/dist/VersionService.d.ts.map +1 -1
- package/dist/VersionService.js +39 -45
- package/dist/VersionService.js.map +1 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +1 -1
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +1 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +2 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/issueAttachment.d.ts +6 -0
- package/dist/commands/issueAttachment.d.ts.map +1 -0
- package/dist/commands/issueAttachment.js +88 -0
- package/dist/commands/issueAttachment.js.map +1 -0
- package/dist/commands/layers.d.ts +4 -3
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +12 -4
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/attachmentInsertion.d.ts +10 -0
- package/dist/internal/attachmentInsertion.d.ts.map +1 -0
- package/dist/internal/attachmentInsertion.js +22 -0
- package/dist/internal/attachmentInsertion.js.map +1 -0
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +15 -3
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/sync/baseline.js +2 -1
- package/dist/internal/sync/baseline.js.map +1 -1
- package/dist/internal/sync/changes.js.map +1 -1
- package/dist/internal/sync/config.d.ts.map +1 -1
- package/dist/internal/sync/config.js +1 -1
- package/dist/internal/sync/config.js.map +1 -1
- package/dist/internal/sync/document.d.ts.map +1 -1
- package/dist/internal/sync/document.js +88 -29
- package/dist/internal/sync/document.js.map +1 -1
- package/dist/internal/sync/fieldValues.d.ts +0 -5
- package/dist/internal/sync/fieldValues.d.ts.map +1 -1
- package/dist/internal/sync/fieldValues.js +9 -4
- package/dist/internal/sync/fieldValues.js.map +1 -1
- package/dist/internal/sync/manifest.js.map +1 -1
- package/dist/internal/sync/schemas.d.ts +5 -5
- package/dist/internal/sync/schemas.d.ts.map +1 -1
- package/dist/internal/sync/schemas.js +2 -1
- package/dist/internal/sync/schemas.js.map +1 -1
- package/dist/internal/sync/types.d.ts +5 -2
- package/dist/internal/sync/types.d.ts.map +1 -1
- package/dist/internal/sync/types.js.map +1 -1
- package/package.json +13 -9
- package/src/AttachmentService.ts +106 -0
- package/src/IssueService.ts +81 -61
- package/src/JiraAuth.ts +7 -11
- package/src/VersionService.ts +142 -159
- package/src/bin.ts +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/issue.ts +3 -2
- package/src/commands/issueAttachment.ts +146 -0
- package/src/commands/layers.ts +20 -3
- package/src/commands/search.ts +1 -1
- package/src/index.ts +6 -0
- package/src/internal/attachmentInsertion.ts +44 -0
- package/src/internal/frontmatter.ts +17 -3
- package/src/internal/sync/baseline.ts +3 -3
- package/src/internal/sync/changes.ts +1 -1
- package/src/internal/sync/config.ts +1 -2
- package/src/internal/sync/document.ts +98 -29
- package/src/internal/sync/fieldValues.ts +4 -6
- package/src/internal/sync/manifest.ts +1 -1
- package/src/internal/sync/schemas.ts +3 -2
- package/src/internal/sync/types.ts +16 -4
- package/test/IssueService.test.ts +94 -0
- package/test/VersionService.test.ts +41 -20
- package/test/attachmentInsertion.test.ts +25 -0
- package/test/commandTree.test.ts +38 -0
- package/test/frontmatter.test.ts +20 -1
- package/test/integration.test.ts +94 -3
- package/test/layers.test.ts +12 -0
- package/test/syncDocument.test.ts +30 -1
- package/vitest.config.integration.ts +4 -0
- package/vitest.config.ts +4 -0
package/src/VersionService.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*
|
|
27
27
|
* @module
|
|
28
28
|
*/
|
|
29
|
-
import { JiraApiClient
|
|
29
|
+
import { JiraApiClient } from "@knpkv/jira-api-client"
|
|
30
30
|
import * as Context from "effect/Context"
|
|
31
31
|
import * as Effect from "effect/Effect"
|
|
32
32
|
import * as Layer from "effect/Layer"
|
|
@@ -198,7 +198,9 @@ const EXPAND = "approvers,driver,operations,issuesstatus,contributors"
|
|
|
198
198
|
|
|
199
199
|
/** Loosely-typed record helper for navigating untyped API JSON. */
|
|
200
200
|
type Raw = Record<string, unknown>
|
|
201
|
-
const
|
|
201
|
+
const isRaw = (value: unknown): value is Raw => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
202
|
+
const asRaw = (value: unknown): Raw => isRaw(value) ? value : {}
|
|
203
|
+
const rawArray = (value: unknown): ReadonlyArray<Raw> => Array.isArray(value) ? value.filter(isRaw) : []
|
|
202
204
|
|
|
203
205
|
/**
|
|
204
206
|
* Render a Jira custom-field value as a flat string.
|
|
@@ -219,13 +221,13 @@ export const renderCustomFieldValue = (raw: unknown): string | null => {
|
|
|
219
221
|
const parts = raw.map(renderCustomFieldValue).filter((v): v is string => !!v)
|
|
220
222
|
return parts.length > 0 ? parts.join(", ") : null
|
|
221
223
|
}
|
|
222
|
-
if (
|
|
223
|
-
const obj = raw
|
|
224
|
+
if (isRaw(raw)) {
|
|
225
|
+
const obj = raw
|
|
224
226
|
const parent = stringOrNull(obj["value"])
|
|
225
227
|
if (parent) {
|
|
226
228
|
const child = obj["child"]
|
|
227
|
-
if (child
|
|
228
|
-
const childValue = stringOrNull(
|
|
229
|
+
if (isRaw(child)) {
|
|
230
|
+
const childValue = stringOrNull(child["value"])
|
|
229
231
|
if (childValue) return `${parent} > ${childValue}`
|
|
230
232
|
}
|
|
231
233
|
return parent
|
|
@@ -241,8 +243,8 @@ export const renderCustomFieldValue = (raw: unknown): string | null => {
|
|
|
241
243
|
const stringOrNull = (v: unknown): string | null => typeof v === "string" && v.length > 0 ? v : null
|
|
242
244
|
|
|
243
245
|
export const personFromObject = (raw: unknown, fallbackId?: string): Person | null => {
|
|
244
|
-
if (raw
|
|
245
|
-
const obj = raw
|
|
246
|
+
if (isRaw(raw)) {
|
|
247
|
+
const obj = raw
|
|
246
248
|
const accountId = stringOrNull(obj["accountId"]) ?? fallbackId ?? null
|
|
247
249
|
if (!accountId) return null
|
|
248
250
|
return {
|
|
@@ -266,8 +268,8 @@ export const extractContributorIds = (raw: Raw): ReadonlyArray<string> => {
|
|
|
266
268
|
const ids: Array<string> = []
|
|
267
269
|
for (const c of field) {
|
|
268
270
|
if (typeof c === "string" && c.length > 0) ids.push(c)
|
|
269
|
-
else if (c
|
|
270
|
-
const id =
|
|
271
|
+
else if (isRaw(c)) {
|
|
272
|
+
const id = c["accountId"]
|
|
271
273
|
if (typeof id === "string" && id.length > 0) ids.push(id)
|
|
272
274
|
}
|
|
273
275
|
}
|
|
@@ -297,31 +299,26 @@ const make = Effect.gen(function*() {
|
|
|
297
299
|
|
|
298
300
|
const resolveFieldIds = (
|
|
299
301
|
name: string
|
|
300
|
-
): Effect.Effect<ReadonlyArray<string>, JiraApiError> =>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
client.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
for (const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
matches.push(obj["id"] as string)
|
|
313
|
-
}
|
|
302
|
+
): Effect.Effect<ReadonlyArray<string>, JiraApiError> =>
|
|
303
|
+
Effect.gen(function*() {
|
|
304
|
+
const cached = fieldIdsByName.get(name)
|
|
305
|
+
if (cached !== undefined) return cached
|
|
306
|
+
const result = yield* client.getFields(undefined).pipe(
|
|
307
|
+
Effect.mapError((cause) => new JiraApiError({ message: `Failed to list Jira fields`, cause }))
|
|
308
|
+
)
|
|
309
|
+
const matches: Array<string> = []
|
|
310
|
+
for (const field of rawArray(result)) {
|
|
311
|
+
const id = field["id"]
|
|
312
|
+
if (field["name"] === name && typeof id === "string") {
|
|
313
|
+
matches.push(id)
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}) as Effect.Effect<ReadonlyArray<string>, JiraApiError>)
|
|
316
|
+
fieldIdsByName.set(name, matches)
|
|
317
|
+
return matches
|
|
318
|
+
})
|
|
320
319
|
|
|
321
320
|
const fetchUser = (accountId: string): Effect.Effect<Person, never> =>
|
|
322
|
-
|
|
323
|
-
client.v3.client.GET("/rest/api/3/user", { params: { query: { accountId } } })
|
|
324
|
-
).pipe(
|
|
321
|
+
client.getUser({ params: { accountId } }).pipe(
|
|
325
322
|
Effect.map((u) => {
|
|
326
323
|
const obj = asRaw(u)
|
|
327
324
|
const person: Person = {
|
|
@@ -379,95 +376,92 @@ const make = Effect.gen(function*() {
|
|
|
379
376
|
versionName: string,
|
|
380
377
|
customFieldNames: ReadonlyArray<string>,
|
|
381
378
|
projectKey?: string
|
|
382
|
-
): Effect.Effect<ReadonlyArray<VersionTicket>, JiraApiError> =>
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
client.
|
|
379
|
+
): Effect.Effect<ReadonlyArray<VersionTicket>, JiraApiError> =>
|
|
380
|
+
Effect.gen(function*() {
|
|
381
|
+
const nameToIds = new Map<string, ReadonlyArray<string>>()
|
|
382
|
+
for (const name of customFieldNames) {
|
|
383
|
+
const ids = yield* resolveFieldIds(name)
|
|
384
|
+
nameToIds.set(name, ids)
|
|
385
|
+
}
|
|
386
|
+
const allFieldIds = new Set<string>()
|
|
387
|
+
for (const ids of nameToIds.values()) for (const id of ids) allFieldIds.add(id)
|
|
388
|
+
const requestedFields = ["assignee", "labels", "summary", ...allFieldIds]
|
|
389
|
+
|
|
390
|
+
const raws: Array<RawTicket> = []
|
|
391
|
+
const PAGE = 100
|
|
392
|
+
const MAX_PAGES = 100
|
|
393
|
+
let nextPageToken: string | undefined = undefined
|
|
394
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
395
|
+
const result = yield* client.searchIssuesUsingJql({
|
|
399
396
|
params: {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
...(nextPageToken ? { nextPageToken } : {})
|
|
405
|
-
}
|
|
397
|
+
jql: buildByVersionJql(versionName, projectKey),
|
|
398
|
+
fields: requestedFields,
|
|
399
|
+
maxResults: PAGE,
|
|
400
|
+
...(nextPageToken ? { nextPageToken } : {})
|
|
406
401
|
}
|
|
407
|
-
})
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
402
|
+
}).pipe(
|
|
403
|
+
Effect.mapError((cause) =>
|
|
404
|
+
new JiraApiError({ message: `Failed to fetch tickets for fixVersion "${versionName}"`, cause })
|
|
405
|
+
)
|
|
411
406
|
)
|
|
412
|
-
)
|
|
413
407
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
408
|
+
const resObj = asRaw(result)
|
|
409
|
+
const issues = rawArray(resObj["issues"])
|
|
410
|
+
for (const issue of issues) {
|
|
411
|
+
const key = stringOrNull(issue["key"]) ?? ""
|
|
412
|
+
const fields = asRaw(issue["fields"])
|
|
413
|
+
const assignee = fields["assignee"]
|
|
414
|
+
let assigneeId: string | null = null
|
|
415
|
+
if (isRaw(assignee)) {
|
|
416
|
+
const accountId = assignee["accountId"]
|
|
417
|
+
if (typeof accountId === "string" && accountId.length > 0) assigneeId = accountId
|
|
418
|
+
}
|
|
419
|
+
const labelsRaw = fields["labels"]
|
|
420
|
+
const labels: Array<string> = []
|
|
421
|
+
if (Array.isArray(labelsRaw)) {
|
|
422
|
+
for (const l of labelsRaw) if (typeof l === "string" && l.length > 0) labels.push(l)
|
|
423
|
+
}
|
|
424
|
+
const customFields: Record<string, string | null> = {}
|
|
425
|
+
for (const name of customFieldNames) {
|
|
426
|
+
const ids = nameToIds.get(name) ?? []
|
|
427
|
+
let resolved: string | null = null
|
|
428
|
+
for (const id of ids) {
|
|
429
|
+
const v = renderCustomFieldValue(fields[id])
|
|
430
|
+
if (v !== null) {
|
|
431
|
+
resolved = v
|
|
432
|
+
break
|
|
433
|
+
}
|
|
439
434
|
}
|
|
435
|
+
customFields[name] = resolved
|
|
440
436
|
}
|
|
441
|
-
|
|
437
|
+
raws.push({
|
|
438
|
+
key,
|
|
439
|
+
summary: stringOrNull(fields["summary"]),
|
|
440
|
+
assigneeId,
|
|
441
|
+
labels,
|
|
442
|
+
customFields
|
|
443
|
+
})
|
|
442
444
|
}
|
|
443
|
-
raws.push({
|
|
444
|
-
key,
|
|
445
|
-
summary: stringOrNull(fields["summary"]),
|
|
446
|
-
assigneeId,
|
|
447
|
-
labels,
|
|
448
|
-
customFields
|
|
449
|
-
})
|
|
450
|
-
}
|
|
451
445
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const uniqueAssignees = Array.from(
|
|
459
|
-
new Set(raws.map((t) => t.assigneeId).filter((id): id is string => !!id))
|
|
460
|
-
)
|
|
461
|
-
yield* Effect.forEach(uniqueAssignees, (id) => resolveUser(id), { concurrency: 4 })
|
|
446
|
+
const isLast = resObj["isLast"]
|
|
447
|
+
const next = resObj["nextPageToken"]
|
|
448
|
+
if (isLast === true || typeof next !== "string" || next.length === 0) break
|
|
449
|
+
nextPageToken = next
|
|
450
|
+
}
|
|
462
451
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
452
|
+
const uniqueAssignees = Array.from(
|
|
453
|
+
new Set(raws.map((t) => t.assigneeId).filter((id): id is string => !!id))
|
|
454
|
+
)
|
|
455
|
+
yield* Effect.forEach(uniqueAssignees, (id) => resolveUser(id), { concurrency: 4 })
|
|
456
|
+
|
|
457
|
+
return raws.map((t) => ({
|
|
458
|
+
key: t.key,
|
|
459
|
+
summary: t.summary,
|
|
460
|
+
assignee: t.assigneeId ? userCache.get(t.assigneeId) ?? null : null,
|
|
461
|
+
labels: t.labels,
|
|
462
|
+
customFields: t.customFields
|
|
463
|
+
}))
|
|
464
|
+
})
|
|
471
465
|
|
|
472
466
|
const mapVersion = (
|
|
473
467
|
raw: Raw,
|
|
@@ -479,7 +473,7 @@ const make = Effect.gen(function*() {
|
|
|
479
473
|
const name = String(raw["name"] ?? "")
|
|
480
474
|
const driverId = stringOrNull(raw["driver"])
|
|
481
475
|
const declared = extractContributorIds(raw)
|
|
482
|
-
const approversRaw = (
|
|
476
|
+
const approversRaw = rawArray(raw["approvers"])
|
|
483
477
|
|
|
484
478
|
const tickets = yield* ticketsForVersion(name, customFieldNames, projectKey)
|
|
485
479
|
|
|
@@ -548,75 +542,64 @@ const make = Effect.gen(function*() {
|
|
|
548
542
|
const listProjectVersions = (
|
|
549
543
|
projectKey: string,
|
|
550
544
|
options?: ListVersionsOptions
|
|
551
|
-
): Effect.Effect<ReadonlyArray<Version>, JiraApiError> =>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
client.
|
|
545
|
+
): Effect.Effect<ReadonlyArray<Version>, JiraApiError> =>
|
|
546
|
+
Effect.gen(function*() {
|
|
547
|
+
const all: Array<Raw> = []
|
|
548
|
+
let startAt = 0
|
|
549
|
+
const cap = options?.maxResults
|
|
550
|
+
const customFieldNames = options?.customFieldNames ?? []
|
|
551
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
552
|
+
const result = yield* client.getProjectVersionsPaginated(projectKey, {
|
|
559
553
|
params: {
|
|
560
|
-
|
|
561
|
-
|
|
554
|
+
startAt,
|
|
555
|
+
maxResults: PAGE_SIZE,
|
|
556
|
+
expand: EXPAND,
|
|
557
|
+
orderBy: "-releaseDate"
|
|
562
558
|
}
|
|
563
|
-
})
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
)
|
|
559
|
+
}).pipe(
|
|
560
|
+
Effect.mapError((cause) => new JiraApiError({ message: `Failed to list versions for ${projectKey}`, cause }))
|
|
561
|
+
)
|
|
567
562
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
563
|
+
const resObj = asRaw(result)
|
|
564
|
+
const values = rawArray(resObj["values"])
|
|
565
|
+
for (const v of values) {
|
|
566
|
+
if (options?.released === true && v["released"] !== true) continue
|
|
567
|
+
if (options?.unreleased === true && v["released"] === true) continue
|
|
568
|
+
all.push(v)
|
|
569
|
+
if (cap !== undefined && all.length >= cap) break
|
|
570
|
+
}
|
|
574
571
|
if (cap !== undefined && all.length >= cap) break
|
|
572
|
+
const isLast = resObj["isLast"]
|
|
573
|
+
if (isLast === true || values.length < PAGE_SIZE) break
|
|
574
|
+
startAt += values.length
|
|
575
575
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (isLast === true || values.length < PAGE_SIZE) break
|
|
579
|
-
startAt += values.length
|
|
580
|
-
}
|
|
581
|
-
return yield* Effect.forEach(all, (r) => mapVersion(r, customFieldNames, projectKey), { concurrency: 4 })
|
|
582
|
-
}) as Effect.Effect<ReadonlyArray<Version>, JiraApiError>)
|
|
576
|
+
return yield* Effect.forEach(all, (r) => mapVersion(r, customFieldNames, projectKey), { concurrency: 4 })
|
|
577
|
+
})
|
|
583
578
|
|
|
584
579
|
const getVersion = (id: string): Effect.Effect<Version, JiraApiError> =>
|
|
585
|
-
|
|
586
|
-
client.v3.client.GET("/rest/api/3/version/{id}", {
|
|
587
|
-
params: { path: { id }, query: { expand: EXPAND } }
|
|
588
|
-
})
|
|
589
|
-
).pipe(
|
|
580
|
+
client.getVersion(id, { params: { expand: EXPAND } }).pipe(
|
|
590
581
|
Effect.mapError((cause) => new JiraApiError({ message: `Failed to get version ${id}`, cause })),
|
|
591
582
|
Effect.flatMap((raw) => mapVersion(asRaw(raw), []))
|
|
592
583
|
)
|
|
593
584
|
|
|
594
585
|
const updateVersion = (id: string, input: UpdateVersionInput): Effect.Effect<Version, JiraApiError> =>
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
body: { ...(input.description !== undefined ? { description: input.description } : {}) }
|
|
599
|
-
})
|
|
600
|
-
).pipe(
|
|
586
|
+
client.updateVersion(id, {
|
|
587
|
+
payload: { ...(input.description !== undefined ? { description: input.description } : {}) }
|
|
588
|
+
}).pipe(
|
|
601
589
|
Effect.mapError((cause) => new JiraApiError({ message: `Failed to update version ${id}`, cause })),
|
|
602
590
|
Effect.map((raw) => mapVersionScalar(asRaw(raw)))
|
|
603
591
|
)
|
|
604
592
|
|
|
605
593
|
const listRelatedWork = (id: string): Effect.Effect<ReadonlyArray<RelatedWork>, JiraApiError> =>
|
|
606
|
-
|
|
607
|
-
client.v3.client.GET("/rest/api/3/version/{id}/relatedwork", { params: { path: { id } } })
|
|
608
|
-
).pipe(
|
|
594
|
+
client.getRelatedWork(id, undefined).pipe(
|
|
609
595
|
Effect.mapError((cause) => new JiraApiError({ message: `Failed to list related work for version ${id}`, cause })),
|
|
610
596
|
Effect.map((raw) => (Array.isArray(raw) ? raw : []).map(toRelatedWork))
|
|
611
597
|
)
|
|
612
598
|
|
|
613
599
|
const addRelatedWork = (id: string, input: AddRelatedWorkInput): Effect.Effect<RelatedWork, JiraApiError> =>
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
body: { title: input.title, category: input.category, url: input.url }
|
|
618
|
-
})
|
|
619
|
-
).pipe(
|
|
600
|
+
client.createRelatedWork(id, {
|
|
601
|
+
payload: { title: input.title, category: input.category, url: input.url }
|
|
602
|
+
}).pipe(
|
|
620
603
|
Effect.mapError((cause) => new JiraApiError({ message: `Failed to add related work to version ${id}`, cause })),
|
|
621
604
|
Effect.map((raw) => {
|
|
622
605
|
const w = toRelatedWork(raw)
|
package/src/bin.ts
CHANGED
|
@@ -66,7 +66,7 @@ const program = Effect.gen(function*() {
|
|
|
66
66
|
)
|
|
67
67
|
}).pipe(
|
|
68
68
|
Effect.provide(NodeStdio.layer),
|
|
69
|
-
Effect.catchCause((cause) => handleError(cause))
|
|
69
|
+
Effect.catchCause((cause) => handleError(cause).pipe(Effect.andThen(Effect.failCause(cause))))
|
|
70
70
|
)
|
|
71
71
|
|
|
72
|
-
NodeRuntime.runMain(program)
|
|
72
|
+
NodeRuntime.runMain(program, { disableErrorReporting: true })
|
package/src/commands/index.ts
CHANGED
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
export { authCommand } from "./auth.js"
|
|
8
8
|
export { handleError } from "./errorHandler.js"
|
|
9
9
|
export { issueCommand } from "./issue.js"
|
|
10
|
+
export { attachmentCommand } from "./issueAttachment.js"
|
|
10
11
|
export { AppLayer, AuthOnlyLayer, getLayerType, MinimalLayer } from "./layers.js"
|
|
11
12
|
export { versionCommand } from "./version.js"
|
package/src/commands/issue.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Command } from "effect/unstable/cli"
|
|
7
7
|
import { getCommand } from "./get.js"
|
|
8
|
+
import { attachmentCommand } from "./issueAttachment.js"
|
|
8
9
|
import { searchCommand } from "./search.js"
|
|
9
10
|
|
|
10
11
|
export const issueCommand = Command.make("issue").pipe(
|
|
11
|
-
Command.withDescription("
|
|
12
|
-
Command.withSubcommands([getCommand, searchCommand])
|
|
12
|
+
Command.withDescription("Jira issue resource commands"),
|
|
13
|
+
Command.withSubcommands([attachmentCommand, getCommand, searchCommand])
|
|
13
14
|
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jira issue attachment` resource commands.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { renderAttachmentMarkdown } from "@knpkv/atlassian-common/attachments"
|
|
7
|
+
import * as Console from "effect/Console"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as FileSystem from "effect/FileSystem"
|
|
10
|
+
import * as Option from "effect/Option"
|
|
11
|
+
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
12
|
+
import { AttachmentService } from "../AttachmentService.js"
|
|
13
|
+
import { insertJiraAttachmentReference } from "../internal/attachmentInsertion.js"
|
|
14
|
+
import { JiraApiError, WriteError } from "../JiraCliError.js"
|
|
15
|
+
|
|
16
|
+
const issueArg = Args.string("issue").pipe(
|
|
17
|
+
Args.withDescription("Issue key or id, for example PROJ-123")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const fileArg = Args.string("file").pipe(
|
|
21
|
+
Args.withDescription("Local file to upload")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const documentOption = Options.file("document").pipe(
|
|
25
|
+
Options.withDescription("Local Jira Markdown document containing the attachment placeholder"),
|
|
26
|
+
Options.optional
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const noInsertOption = Options.boolean("no-insert").pipe(
|
|
30
|
+
Options.withDescription("Only upload and print the Attachment Reference")
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const dryRunOption = Options.boolean("dry-run").pipe(
|
|
34
|
+
Options.withAlias("n"),
|
|
35
|
+
Options.withDescription("Validate local insertion input without uploading")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
39
|
+
Options.withDescription("Write exactly one JSON value to stdout")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const readDocument = (documentPath: string) =>
|
|
43
|
+
Effect.gen(function*() {
|
|
44
|
+
const fs = yield* FileSystem.FileSystem
|
|
45
|
+
return yield* fs.readFileString(documentPath).pipe(
|
|
46
|
+
Effect.mapError((cause) =>
|
|
47
|
+
new WriteError({ path: documentPath, message: "Failed to read Jira Markdown document", cause })
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const writeDocument = (documentPath: string, content: string) =>
|
|
53
|
+
Effect.gen(function*() {
|
|
54
|
+
const fs = yield* FileSystem.FileSystem
|
|
55
|
+
return yield* fs.writeFileString(documentPath, content).pipe(
|
|
56
|
+
Effect.mapError((cause) =>
|
|
57
|
+
new WriteError({ path: documentPath, message: "Failed to write Jira Markdown document", cause })
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const fileExists = (filePath: string) =>
|
|
63
|
+
Effect.gen(function*() {
|
|
64
|
+
const fs = yield* FileSystem.FileSystem
|
|
65
|
+
return yield* fs.exists(filePath).pipe(
|
|
66
|
+
Effect.mapError((cause) => new WriteError({ path: filePath, message: "Failed to check attachment file", cause }))
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const uploadCommand = Command.make(
|
|
71
|
+
"upload",
|
|
72
|
+
{
|
|
73
|
+
issue: issueArg,
|
|
74
|
+
file: fileArg,
|
|
75
|
+
document: documentOption,
|
|
76
|
+
noInsert: noInsertOption,
|
|
77
|
+
dryRun: dryRunOption,
|
|
78
|
+
json: jsonOption
|
|
79
|
+
},
|
|
80
|
+
({ document, dryRun, file, issue, json, noInsert }) =>
|
|
81
|
+
Effect.gen(function*() {
|
|
82
|
+
const documentPath = Option.isSome(document) ? document.value : null
|
|
83
|
+
const shouldInsert = !noInsert && documentPath !== null
|
|
84
|
+
const documentInput = shouldInsert
|
|
85
|
+
? { path: documentPath, content: yield* readDocument(documentPath) }
|
|
86
|
+
: null
|
|
87
|
+
|
|
88
|
+
if (documentInput !== null) {
|
|
89
|
+
const matches = countPlaceholderMatches(documentInput.content, file)
|
|
90
|
+
if (matches !== 1) {
|
|
91
|
+
return yield* Effect.fail(
|
|
92
|
+
new JiraApiError({ message: `Expected exactly one attachment placeholder for ${file}, found ${matches}` })
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
const exists = yield* fileExists(file)
|
|
99
|
+
if (!exists) {
|
|
100
|
+
return yield* Effect.fail(new JiraApiError({ message: `Attachment file does not exist: ${file}` }))
|
|
101
|
+
}
|
|
102
|
+
const result = { dryRun: true, issue, file, insert: shouldInsert }
|
|
103
|
+
yield* Console.log(json ? JSON.stringify(result) : `Dry run: ${file} can be uploaded to ${issue}`)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const service = yield* AttachmentService
|
|
108
|
+
const attachment = yield* service.uploadToIssue(issue, { filePath: file })
|
|
109
|
+
let inserted = false
|
|
110
|
+
|
|
111
|
+
if (documentInput !== null) {
|
|
112
|
+
const result = insertJiraAttachmentReference(documentInput.content, file, attachment)
|
|
113
|
+
if (result.replacements !== 1) {
|
|
114
|
+
return yield* Effect.fail(
|
|
115
|
+
new JiraApiError({
|
|
116
|
+
message:
|
|
117
|
+
`Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
yield* writeDocument(documentInput.path, result.content)
|
|
122
|
+
inserted = true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (json) {
|
|
126
|
+
yield* Console.log(JSON.stringify({ attachment, inserted }))
|
|
127
|
+
} else {
|
|
128
|
+
yield* Console.log(renderAttachmentMarkdown(attachment))
|
|
129
|
+
if (inserted) yield* Console.log(`Inserted into ${documentPath}`)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
).pipe(Command.withDescription("Remote write: upload a local file as a Jira issue attachment"))
|
|
133
|
+
|
|
134
|
+
export const attachmentCommand = Command.make(
|
|
135
|
+
"attachment",
|
|
136
|
+
{},
|
|
137
|
+
() => Console.log("Usage: jira issue attachment upload <issue> <file>")
|
|
138
|
+
).pipe(
|
|
139
|
+
Command.withDescription("Jira issue attachment commands"),
|
|
140
|
+
Command.withSubcommands([uploadCommand])
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const countPlaceholderMatches = (content: string, placeholderPath: string): number => {
|
|
144
|
+
const escapedPath = placeholderPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
145
|
+
return [...content.matchAll(new RegExp(`!?\\[[^\\]]*\\]\\(${escapedPath}\\)`, "g"))].length
|
|
146
|
+
}
|
package/src/commands/layers.ts
CHANGED
|
@@ -16,6 +16,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
|
16
16
|
import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
17
17
|
import * as Effect from "effect/Effect"
|
|
18
18
|
import * as Layer from "effect/Layer"
|
|
19
|
+
import { AttachmentService, layer as AttachmentServiceLayer } from "../AttachmentService.js"
|
|
19
20
|
import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../IssueService.js"
|
|
20
21
|
import { JiraAuth, layer as JiraAuthLayer } from "../JiraAuth.js"
|
|
21
22
|
import { layer as MarkdownWriterLayer, MarkdownWriter } from "../MarkdownWriter.js"
|
|
@@ -31,6 +32,13 @@ const DummyIssueServiceLayer = Layer.succeed(
|
|
|
31
32
|
})
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
const DummyAttachmentServiceLayer = Layer.succeed(
|
|
36
|
+
AttachmentService,
|
|
37
|
+
AttachmentService.of({
|
|
38
|
+
uploadToIssue: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
|
|
34
42
|
const DummyMarkdownWriterLayer = Layer.succeed(
|
|
35
43
|
MarkdownWriter,
|
|
36
44
|
MarkdownWriter.of({
|
|
@@ -82,15 +90,15 @@ const JiraConfigLive = Layer.unwrap(
|
|
|
82
90
|
return Layer.succeed(JiraApiConfig, {
|
|
83
91
|
baseUrl: "",
|
|
84
92
|
auth: {
|
|
85
|
-
type: "oauth2"
|
|
93
|
+
type: "oauth2",
|
|
86
94
|
accessToken,
|
|
87
95
|
cloudId
|
|
88
|
-
}
|
|
96
|
+
} satisfies { readonly type: "oauth2"; readonly accessToken: typeof accessToken; readonly cloudId: string }
|
|
89
97
|
})
|
|
90
98
|
})
|
|
91
99
|
)
|
|
92
100
|
|
|
93
|
-
// Build Jira
|
|
101
|
+
// Build the generated Jira client with auth config and the shared Effect HttpClient.
|
|
94
102
|
const JiraClientLive = JiraApiClient.layer.pipe(
|
|
95
103
|
Layer.provide(JiraConfigLive)
|
|
96
104
|
)
|
|
@@ -110,6 +118,7 @@ const SiteUrlLive = Layer.unwrap(
|
|
|
110
118
|
* @category Layers
|
|
111
119
|
*/
|
|
112
120
|
export const AppLayer = MarkdownWriterLayer.pipe(
|
|
121
|
+
Layer.provideMerge(AttachmentServiceLayer),
|
|
113
122
|
Layer.provideMerge(IssueServiceLayer),
|
|
114
123
|
Layer.provideMerge(VersionServiceLayer),
|
|
115
124
|
Layer.provideMerge(SiteUrlLive),
|
|
@@ -125,6 +134,7 @@ export const AppLayer = MarkdownWriterLayer.pipe(
|
|
|
125
134
|
* @category Layers
|
|
126
135
|
*/
|
|
127
136
|
export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
137
|
+
Layer.provideMerge(DummyAttachmentServiceLayer),
|
|
128
138
|
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
129
139
|
Layer.provideMerge(DummyVersionServiceLayer),
|
|
130
140
|
Layer.provideMerge(AuthLive),
|
|
@@ -138,6 +148,7 @@ export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
|
138
148
|
* @category Layers
|
|
139
149
|
*/
|
|
140
150
|
export const MinimalLayer = DummyIssueServiceLayer.pipe(
|
|
151
|
+
Layer.provideMerge(DummyAttachmentServiceLayer),
|
|
141
152
|
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
142
153
|
Layer.provideMerge(DummyVersionServiceLayer),
|
|
143
154
|
Layer.provideMerge(DummyJiraAuthLayer),
|
|
@@ -157,6 +168,12 @@ export const getLayerType = (args: ReadonlyArray<string>): "full" | "auth" | "mi
|
|
|
157
168
|
if (cmd === "auth") {
|
|
158
169
|
return "auth"
|
|
159
170
|
}
|
|
171
|
+
if (
|
|
172
|
+
cmd === "issue" && args[1] === "attachment" && args[2] === "upload" &&
|
|
173
|
+
(args.includes("--dry-run") || args.includes("-n"))
|
|
174
|
+
) {
|
|
175
|
+
return "minimal"
|
|
176
|
+
}
|
|
160
177
|
if (!cmd || cmd === "skills" || cmd === "--help" || cmd === "-h" || cmd === "--version") {
|
|
161
178
|
return "minimal"
|
|
162
179
|
}
|