@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +23 -7
  3. package/dist/AttachmentService.d.ts +22 -0
  4. package/dist/AttachmentService.d.ts.map +1 -0
  5. package/dist/AttachmentService.js +67 -0
  6. package/dist/AttachmentService.js.map +1 -0
  7. package/dist/IssueService.d.ts +2 -20
  8. package/dist/IssueService.d.ts.map +1 -1
  9. package/dist/IssueService.js +47 -36
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts +1 -1
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js.map +1 -1
  14. package/dist/VersionService.d.ts.map +1 -1
  15. package/dist/VersionService.js +39 -45
  16. package/dist/VersionService.js.map +1 -1
  17. package/dist/bin.js +2 -2
  18. package/dist/bin.js.map +1 -1
  19. package/dist/commands/auth.d.ts +1 -1
  20. package/dist/commands/index.d.ts +1 -0
  21. package/dist/commands/index.d.ts.map +1 -1
  22. package/dist/commands/index.js +1 -0
  23. package/dist/commands/index.js.map +1 -1
  24. package/dist/commands/issue.d.ts.map +1 -1
  25. package/dist/commands/issue.js +2 -1
  26. package/dist/commands/issue.js.map +1 -1
  27. package/dist/commands/issueAttachment.d.ts +6 -0
  28. package/dist/commands/issueAttachment.d.ts.map +1 -0
  29. package/dist/commands/issueAttachment.js +88 -0
  30. package/dist/commands/issueAttachment.js.map +1 -0
  31. package/dist/commands/layers.d.ts +4 -3
  32. package/dist/commands/layers.d.ts.map +1 -1
  33. package/dist/commands/layers.js +12 -4
  34. package/dist/commands/layers.js.map +1 -1
  35. package/dist/commands/search.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/attachmentInsertion.d.ts +10 -0
  41. package/dist/internal/attachmentInsertion.d.ts.map +1 -0
  42. package/dist/internal/attachmentInsertion.js +22 -0
  43. package/dist/internal/attachmentInsertion.js.map +1 -0
  44. package/dist/internal/frontmatter.d.ts.map +1 -1
  45. package/dist/internal/frontmatter.js +15 -3
  46. package/dist/internal/frontmatter.js.map +1 -1
  47. package/dist/internal/sync/baseline.js +2 -1
  48. package/dist/internal/sync/baseline.js.map +1 -1
  49. package/dist/internal/sync/changes.js.map +1 -1
  50. package/dist/internal/sync/config.d.ts.map +1 -1
  51. package/dist/internal/sync/config.js +1 -1
  52. package/dist/internal/sync/config.js.map +1 -1
  53. package/dist/internal/sync/document.d.ts.map +1 -1
  54. package/dist/internal/sync/document.js +88 -29
  55. package/dist/internal/sync/document.js.map +1 -1
  56. package/dist/internal/sync/fieldValues.d.ts +0 -5
  57. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  58. package/dist/internal/sync/fieldValues.js +9 -4
  59. package/dist/internal/sync/fieldValues.js.map +1 -1
  60. package/dist/internal/sync/manifest.js.map +1 -1
  61. package/dist/internal/sync/schemas.d.ts +5 -5
  62. package/dist/internal/sync/schemas.d.ts.map +1 -1
  63. package/dist/internal/sync/schemas.js +2 -1
  64. package/dist/internal/sync/schemas.js.map +1 -1
  65. package/dist/internal/sync/types.d.ts +5 -2
  66. package/dist/internal/sync/types.d.ts.map +1 -1
  67. package/dist/internal/sync/types.js.map +1 -1
  68. package/package.json +13 -9
  69. package/src/AttachmentService.ts +106 -0
  70. package/src/IssueService.ts +81 -61
  71. package/src/JiraAuth.ts +7 -11
  72. package/src/VersionService.ts +142 -159
  73. package/src/bin.ts +2 -2
  74. package/src/commands/index.ts +1 -0
  75. package/src/commands/issue.ts +3 -2
  76. package/src/commands/issueAttachment.ts +146 -0
  77. package/src/commands/layers.ts +20 -3
  78. package/src/commands/search.ts +1 -1
  79. package/src/index.ts +6 -0
  80. package/src/internal/attachmentInsertion.ts +44 -0
  81. package/src/internal/frontmatter.ts +17 -3
  82. package/src/internal/sync/baseline.ts +3 -3
  83. package/src/internal/sync/changes.ts +1 -1
  84. package/src/internal/sync/config.ts +1 -2
  85. package/src/internal/sync/document.ts +98 -29
  86. package/src/internal/sync/fieldValues.ts +4 -6
  87. package/src/internal/sync/manifest.ts +1 -1
  88. package/src/internal/sync/schemas.ts +3 -2
  89. package/src/internal/sync/types.ts +16 -4
  90. package/test/IssueService.test.ts +94 -0
  91. package/test/VersionService.test.ts +41 -20
  92. package/test/attachmentInsertion.test.ts +25 -0
  93. package/test/commandTree.test.ts +38 -0
  94. package/test/frontmatter.test.ts +20 -1
  95. package/test/integration.test.ts +94 -3
  96. package/test/layers.test.ts +12 -0
  97. package/test/syncDocument.test.ts +30 -1
  98. package/vitest.config.integration.ts +4 -0
  99. package/vitest.config.ts +4 -0
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * @module
28
28
  */
29
- import { JiraApiClient, toEffect } from "@knpkv/jira-api-client"
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 asRaw = (v: unknown): Raw => (v && typeof v === "object" ? v as Raw : {})
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 (typeof raw === "object") {
223
- const obj = raw as 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 && typeof child === "object") {
228
- const childValue = stringOrNull((child as Raw)["value"])
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 && typeof raw === "object") {
245
- const obj = raw as 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 && typeof c === "object") {
270
- const id = (c as Raw)["accountId"]
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> => (Effect.gen(function*() {
301
- const cached = fieldIdsByName.get(name)
302
- if (cached !== undefined) return cached
303
- const result = yield* toEffect(
304
- client.v3.client.GET("/rest/api/3/field")
305
- ).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to list Jira fields`, cause })))
306
- const matches: Array<string> = []
307
- if (Array.isArray(result)) {
308
- for (const f of result) {
309
- if (f && typeof f === "object") {
310
- const obj = f as Raw
311
- if (obj["name"] === name && typeof obj["id"] === "string") {
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
- fieldIdsByName.set(name, matches)
318
- return matches
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
- toEffect(
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> => (Effect.gen(function*() {
383
- const nameToIds = new Map<string, ReadonlyArray<string>>()
384
- for (const name of customFieldNames) {
385
- const ids = yield* resolveFieldIds(name)
386
- nameToIds.set(name, ids)
387
- }
388
- const allFieldIds = new Set<string>()
389
- for (const ids of nameToIds.values()) for (const id of ids) allFieldIds.add(id)
390
- const requestedFields = ["assignee", "labels", "summary", ...allFieldIds]
391
-
392
- const raws: Array<RawTicket> = []
393
- const PAGE = 100
394
- const MAX_PAGES = 100
395
- let nextPageToken: string | undefined = undefined
396
- for (let page = 0; page < MAX_PAGES; page++) {
397
- const result = yield* toEffect(
398
- client.v3.client.GET("/rest/api/3/search/jql", {
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
- query: {
401
- jql: buildByVersionJql(versionName, projectKey),
402
- fields: requestedFields,
403
- maxResults: PAGE,
404
- ...(nextPageToken ? { nextPageToken } : {})
405
- }
397
+ jql: buildByVersionJql(versionName, projectKey),
398
+ fields: requestedFields,
399
+ maxResults: PAGE,
400
+ ...(nextPageToken ? { nextPageToken } : {})
406
401
  }
407
- })
408
- ).pipe(
409
- Effect.mapError((cause) =>
410
- new JiraApiError({ message: `Failed to fetch tickets for fixVersion "${versionName}"`, cause })
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
- const resObj = asRaw(result)
415
- const issues = (Array.isArray(resObj["issues"]) ? resObj["issues"] : []) as Array<Raw>
416
- for (const issue of issues) {
417
- const key = typeof issue["key"] === "string" ? issue["key"] as string : ""
418
- const fields = asRaw(issue["fields"])
419
- const assignee = fields["assignee"]
420
- let assigneeId: string | null = null
421
- if (assignee && typeof assignee === "object") {
422
- const accountId = (assignee as Raw)["accountId"]
423
- if (typeof accountId === "string" && accountId.length > 0) assigneeId = accountId
424
- }
425
- const labelsRaw = fields["labels"]
426
- const labels: Array<string> = []
427
- if (Array.isArray(labelsRaw)) {
428
- for (const l of labelsRaw) if (typeof l === "string" && l.length > 0) labels.push(l)
429
- }
430
- const customFields: Record<string, string | null> = {}
431
- for (const name of customFieldNames) {
432
- const ids = nameToIds.get(name) ?? []
433
- let resolved: string | null = null
434
- for (const id of ids) {
435
- const v = renderCustomFieldValue(fields[id])
436
- if (v !== null) {
437
- resolved = v
438
- break
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
- customFields[name] = resolved
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
- const isLast = resObj["isLast"]
453
- const next = resObj["nextPageToken"]
454
- if (isLast === true || typeof next !== "string" || next.length === 0) break
455
- nextPageToken = next
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
- return raws.map((t) => ({
464
- key: t.key,
465
- summary: t.summary,
466
- assignee: t.assigneeId ? userCache.get(t.assigneeId) ?? null : null,
467
- labels: t.labels,
468
- customFields: t.customFields
469
- }))
470
- }) as Effect.Effect<ReadonlyArray<VersionTicket>, JiraApiError>)
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 = (Array.isArray(raw["approvers"]) ? raw["approvers"] : []) as Array<Raw>
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> => (Effect.gen(function*() {
552
- const all: Array<Raw> = []
553
- let startAt = 0
554
- const cap = options?.maxResults
555
- const customFieldNames = options?.customFieldNames ?? []
556
- for (let page = 0; page < MAX_PAGES; page++) {
557
- const result = yield* toEffect(
558
- client.v3.client.GET("/rest/api/3/project/{projectIdOrKey}/version", {
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
- path: { projectIdOrKey: projectKey },
561
- query: { startAt, maxResults: PAGE_SIZE, expand: EXPAND, orderBy: "-releaseDate" }
554
+ startAt,
555
+ maxResults: PAGE_SIZE,
556
+ expand: EXPAND,
557
+ orderBy: "-releaseDate"
562
558
  }
563
- })
564
- ).pipe(
565
- Effect.mapError((cause) => new JiraApiError({ message: `Failed to list versions for ${projectKey}`, cause }))
566
- )
559
+ }).pipe(
560
+ Effect.mapError((cause) => new JiraApiError({ message: `Failed to list versions for ${projectKey}`, cause }))
561
+ )
567
562
 
568
- const resObj = asRaw(result)
569
- const values = (Array.isArray(resObj["values"]) ? resObj["values"] : []) as Array<Raw>
570
- for (const v of values) {
571
- if (options?.released === true && v["released"] !== true) continue
572
- if (options?.unreleased === true && v["released"] === true) continue
573
- all.push(v)
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
- if (cap !== undefined && all.length >= cap) break
577
- const isLast = resObj["isLast"]
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
- toEffect(
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
- toEffect(
596
- client.v3.client.PUT("/rest/api/3/version/{id}", {
597
- params: { path: { id } },
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
- toEffect(
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
- toEffect(
615
- client.v3.client.POST("/rest/api/3/version/{id}/relatedwork", {
616
- params: { path: { id } },
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 })
@@ -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"
@@ -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("Read-only Jira issue commands"),
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
+ }
@@ -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" as const,
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 API client layer with config (no HttpClient needed — uses openapi-fetch)
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
  }