@knpkv/jira-cli 1.1.1 → 1.2.0

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 (93) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +21 -1
  3. package/dist/AttachmentService.d.ts +22 -0
  4. package/dist/AttachmentService.d.ts.map +1 -0
  5. package/dist/AttachmentService.js +72 -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 +37 -24
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts.map +1 -1
  12. package/dist/JiraAuth.js.map +1 -1
  13. package/dist/VersionService.d.ts.map +1 -1
  14. package/dist/VersionService.js +16 -18
  15. package/dist/VersionService.js.map +1 -1
  16. package/dist/bin.js +2 -2
  17. package/dist/bin.js.map +1 -1
  18. package/dist/commands/auth.d.ts +1 -1
  19. package/dist/commands/index.d.ts +1 -0
  20. package/dist/commands/index.d.ts.map +1 -1
  21. package/dist/commands/index.js +1 -0
  22. package/dist/commands/index.js.map +1 -1
  23. package/dist/commands/issue.d.ts.map +1 -1
  24. package/dist/commands/issue.js +2 -1
  25. package/dist/commands/issue.js.map +1 -1
  26. package/dist/commands/issueAttachment.d.ts +6 -0
  27. package/dist/commands/issueAttachment.d.ts.map +1 -0
  28. package/dist/commands/issueAttachment.js +88 -0
  29. package/dist/commands/issueAttachment.js.map +1 -0
  30. package/dist/commands/layers.d.ts +4 -3
  31. package/dist/commands/layers.d.ts.map +1 -1
  32. package/dist/commands/layers.js +11 -3
  33. package/dist/commands/layers.js.map +1 -1
  34. package/dist/commands/search.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal/attachmentInsertion.d.ts +10 -0
  40. package/dist/internal/attachmentInsertion.d.ts.map +1 -0
  41. package/dist/internal/attachmentInsertion.js +22 -0
  42. package/dist/internal/attachmentInsertion.js.map +1 -0
  43. package/dist/internal/frontmatter.d.ts.map +1 -1
  44. package/dist/internal/frontmatter.js +15 -3
  45. package/dist/internal/frontmatter.js.map +1 -1
  46. package/dist/internal/sync/baseline.js +2 -1
  47. package/dist/internal/sync/baseline.js.map +1 -1
  48. package/dist/internal/sync/changes.js.map +1 -1
  49. package/dist/internal/sync/config.d.ts.map +1 -1
  50. package/dist/internal/sync/config.js +1 -1
  51. package/dist/internal/sync/config.js.map +1 -1
  52. package/dist/internal/sync/document.d.ts.map +1 -1
  53. package/dist/internal/sync/document.js +88 -29
  54. package/dist/internal/sync/document.js.map +1 -1
  55. package/dist/internal/sync/manifest.js.map +1 -1
  56. package/dist/internal/sync/schemas.d.ts +5 -5
  57. package/dist/internal/sync/schemas.d.ts.map +1 -1
  58. package/dist/internal/sync/schemas.js +2 -1
  59. package/dist/internal/sync/schemas.js.map +1 -1
  60. package/dist/internal/sync/types.d.ts +5 -2
  61. package/dist/internal/sync/types.d.ts.map +1 -1
  62. package/dist/internal/sync/types.js.map +1 -1
  63. package/package.json +12 -8
  64. package/src/AttachmentService.ts +112 -0
  65. package/src/IssueService.ts +70 -47
  66. package/src/JiraAuth.ts +7 -11
  67. package/src/VersionService.ts +139 -138
  68. package/src/bin.ts +2 -2
  69. package/src/commands/index.ts +1 -0
  70. package/src/commands/issue.ts +3 -2
  71. package/src/commands/issueAttachment.ts +146 -0
  72. package/src/commands/layers.ts +19 -2
  73. package/src/commands/search.ts +1 -1
  74. package/src/index.ts +6 -0
  75. package/src/internal/attachmentInsertion.ts +44 -0
  76. package/src/internal/frontmatter.ts +17 -3
  77. package/src/internal/sync/baseline.ts +3 -3
  78. package/src/internal/sync/changes.ts +1 -1
  79. package/src/internal/sync/config.ts +1 -2
  80. package/src/internal/sync/document.ts +100 -29
  81. package/src/internal/sync/manifest.ts +1 -1
  82. package/src/internal/sync/schemas.ts +3 -2
  83. package/src/internal/sync/types.ts +16 -4
  84. package/test/IssueService.test.ts +78 -0
  85. package/test/VersionService.test.ts +21 -16
  86. package/test/attachmentInsertion.test.ts +25 -0
  87. package/test/commandTree.test.ts +38 -0
  88. package/test/frontmatter.test.ts +20 -1
  89. package/test/integration.test.ts +92 -3
  90. package/test/layers.test.ts +12 -0
  91. package/test/syncDocument.test.ts +30 -1
  92. package/vitest.config.integration.ts +4 -0
  93. package/vitest.config.ts +4 -0
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * @module
20
20
  */
21
+ import { normalizeAttachmentMediaType } from "@knpkv/atlassian-common/attachments"
21
22
  import { JiraApiClient, toEffect } from "@knpkv/jira-api-client"
22
23
  import * as Context from "effect/Context"
23
24
  import * as Effect from "effect/Effect"
@@ -40,6 +41,8 @@ export interface Attachment {
40
41
  readonly id: string
41
42
  readonly filename: string
42
43
  readonly url: string
44
+ readonly mediaType: string | null
45
+ /** Backwards-compatible alias for consumers that still read Jira's MIME field name. */
43
46
  readonly mimeType: string
44
47
  readonly size: number
45
48
  }
@@ -110,6 +113,24 @@ interface SearchJqlResponse {
110
113
  readonly nextPageToken?: string
111
114
  }
112
115
 
116
+ const isRecord = (value: unknown): value is Readonly<Record<PropertyKey, unknown>> =>
117
+ typeof value === "object" && value !== null && !Array.isArray(value)
118
+
119
+ const recordOrEmpty = (value: unknown): Readonly<Record<PropertyKey, unknown>> => isRecord(value) ? value : {}
120
+
121
+ const recordArray = (value: unknown): ReadonlyArray<Readonly<Record<PropertyKey, unknown>>> =>
122
+ Array.isArray(value) ? value.filter(isRecord) : []
123
+
124
+ const parseSearchJqlResponse = (value: unknown): SearchJqlResponse => {
125
+ const record = recordOrEmpty(value)
126
+ const nextPageToken = typeof record.nextPageToken === "string" ? record.nextPageToken : undefined
127
+ return {
128
+ issues: Array.isArray(record.issues) ? record.issues : [],
129
+ isLast: typeof record.isLast === "boolean" ? record.isLast : nextPageToken === undefined,
130
+ ...(nextPageToken !== undefined ? { nextPageToken } : {})
131
+ }
132
+ }
133
+
113
134
  /**
114
135
  * IssueService interface.
115
136
  *
@@ -172,10 +193,9 @@ const FIELDS = [
172
193
  const extractDisplayName = (field: unknown): string | null => {
173
194
  if (field === null || field === undefined) return null
174
195
  if (typeof field === "string") return field
175
- if (typeof field === "object") {
176
- const obj = field as Record<string, unknown>
177
- if (typeof obj["displayName"] === "string") return obj["displayName"]
178
- if (typeof obj["name"] === "string") return obj["name"]
196
+ if (isRecord(field)) {
197
+ if (typeof field.displayName === "string") return field.displayName
198
+ if (typeof field.name === "string") return field.name
179
199
  }
180
200
  return null
181
201
  }
@@ -188,9 +208,8 @@ const extractNameArray = (field: unknown): ReadonlyArray<string> => {
188
208
  return field
189
209
  .map((item) => {
190
210
  if (typeof item === "string") return item
191
- if (typeof item === "object" && item !== null) {
192
- const obj = item as Record<string, unknown>
193
- if (typeof obj["name"] === "string") return obj["name"]
211
+ if (isRecord(item)) {
212
+ if (typeof item.name === "string") return item.name
194
213
  }
195
214
  return null
196
215
  })
@@ -210,34 +229,35 @@ const parseDate = (val: unknown): Date => {
210
229
  /**
211
230
  * Map IssueBean from API to our Issue type.
212
231
  */
213
- const mapIssue = (bean: Record<string, unknown>, baseUrl: string): Issue => {
214
- const fields = (bean["fields"] ?? {}) as Record<string, unknown>
215
- const renderedFields = (bean["renderedFields"] ?? {}) as Record<string, unknown>
232
+ const mapIssue = (bean: Readonly<Record<PropertyKey, unknown>>, baseUrl: string): Issue => {
233
+ const fields = recordOrEmpty(bean.fields)
234
+ const renderedFields = recordOrEmpty(bean.renderedFields)
216
235
  const key = String(bean["key"] ?? "")
217
236
  const id = String(bean["id"] ?? "")
218
237
 
219
238
  // Extract attachments
220
239
  const attachmentField = fields["attachment"]
221
240
  const attachments: Array<Attachment> = Array.isArray(attachmentField)
222
- ? attachmentField.map((a) => {
223
- const att = a as Record<string, unknown>
241
+ ? recordArray(attachmentField).map((att) => {
242
+ const mediaType = normalizeAttachmentMediaType(
243
+ undefined,
244
+ typeof att["mimeType"] === "string" ? att["mimeType"] : null
245
+ )
224
246
  return {
225
247
  id: String(att["id"] ?? ""),
226
248
  filename: String(att["filename"] ?? ""),
227
249
  url: String(att["content"] ?? ""),
228
- mimeType: String(att["mimeType"] ?? ""),
250
+ mediaType,
251
+ mimeType: mediaType ?? "",
229
252
  size: Number(att["size"] ?? 0)
230
253
  }
231
254
  })
232
255
  : []
233
256
 
234
257
  // Extract comments
235
- const commentField = fields["comment"] as Record<string, unknown> | undefined
236
- const commentList = (commentField?.["comments"] ?? []) as Array<Record<string, unknown>>
237
- const renderedComments =
238
- ((renderedFields["comment"] as Record<string, unknown> | undefined)?.["comments"] ?? []) as Array<
239
- Record<string, unknown>
240
- >
258
+ const commentField = recordOrEmpty(fields["comment"])
259
+ const commentList = recordArray(commentField.comments)
260
+ const renderedComments = recordArray(recordOrEmpty(renderedFields.comment).comments)
241
261
 
242
262
  // Build map of rendered comments by ID for accurate matching
243
263
  const renderedMap = new Map<string, Record<string, unknown>>()
@@ -247,7 +267,7 @@ const mapIssue = (bean: Record<string, unknown>, baseUrl: string): Issue => {
247
267
  }
248
268
 
249
269
  const comments: Array<Comment> = commentList.map((c) => {
250
- const author = c["author"] as Record<string, unknown> | undefined
270
+ const author = recordOrEmpty(c.author)
251
271
  const commentId = String(c["id"] ?? "")
252
272
  const rendered = renderedMap.get(commentId)
253
273
  const renderedBody = rendered?.["body"]
@@ -286,6 +306,8 @@ const mapIssue = (bean: Record<string, unknown>, baseUrl: string): Issue => {
286
306
  }
287
307
  }
288
308
 
309
+ const mapIssueUnknown = (bean: unknown, baseUrl: string): Issue => mapIssue(recordOrEmpty(bean), baseUrl)
310
+
289
311
  const make = Effect.gen(function*() {
290
312
  const client = yield* JiraApiClient
291
313
  const siteUrl = yield* SiteUrl
@@ -297,7 +319,7 @@ const make = Effect.gen(function*() {
297
319
  query: { fields: FIELDS, expand: "renderedFields" }
298
320
  }
299
321
  })).pipe(
300
- Effect.map((result) => mapIssue(result as unknown as Record<string, unknown>, siteUrl)),
322
+ Effect.map((result) => mapIssueUnknown(result, siteUrl)),
301
323
  Effect.mapError((cause) => new JiraApiError({ message: `Failed to get issue ${key}`, cause }))
302
324
  )
303
325
 
@@ -317,7 +339,7 @@ const make = Effect.gen(function*() {
317
339
  }
318
340
  }
319
341
  })).pipe(
320
- Effect.map((result) => result as SearchJqlResponse),
342
+ Effect.map(parseSearchJqlResponse),
321
343
  Effect.mapError((cause) => new JiraApiError({ message: "Failed to search issues", cause }))
322
344
  )
323
345
 
@@ -327,7 +349,7 @@ const make = Effect.gen(function*() {
327
349
  const issues = result.issues ?? []
328
350
  const mappedIssues: Array<Issue> = []
329
351
  for (const bean of issues) {
330
- mappedIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
352
+ mappedIssues.push(mapIssueUnknown(bean, siteUrl))
331
353
  }
332
354
  return {
333
355
  issues: mappedIssues,
@@ -343,35 +365,36 @@ const make = Effect.gen(function*() {
343
365
  const searchAll = (
344
366
  jql: string,
345
367
  options?: { readonly maxResults?: number }
346
- ): Effect.Effect<ReadonlyArray<Issue>, JiraApiError> => (Effect.gen(function*() {
347
- const allIssues: Array<Issue> = []
348
- const maxResults = options?.maxResults ?? 100
349
- let nextPageToken: string | undefined = undefined
350
- let pageCount = 0
351
-
352
- // Fetch first page
353
- let result = yield* searchJql(jql, maxResults, nextPageToken)
354
- let issues = result.issues ?? []
355
- pageCount++
356
-
357
- for (const bean of issues) {
358
- allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
359
- }
360
-
361
- // Fetch remaining pages with iteration guard
362
- while (!result.isLast && result.nextPageToken && pageCount < MAX_PAGES) {
363
- nextPageToken = result.nextPageToken
364
- result = yield* searchJql(jql, maxResults, nextPageToken)
365
- issues = result.issues ?? []
368
+ ): Effect.Effect<ReadonlyArray<Issue>, JiraApiError> =>
369
+ Effect.gen(function*() {
370
+ const allIssues: Array<Issue> = []
371
+ const maxResults = options?.maxResults ?? 100
372
+ let nextPageToken: string | undefined = undefined
373
+ let pageCount = 0
374
+
375
+ // Fetch first page
376
+ let result = yield* searchJql(jql, maxResults, nextPageToken)
377
+ let issues = result.issues ?? []
366
378
  pageCount++
367
379
 
368
380
  for (const bean of issues) {
369
- allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
381
+ allIssues.push(mapIssueUnknown(bean, siteUrl))
382
+ }
383
+
384
+ // Fetch remaining pages with iteration guard
385
+ while (!result.isLast && result.nextPageToken && pageCount < MAX_PAGES) {
386
+ nextPageToken = result.nextPageToken
387
+ result = yield* searchJql(jql, maxResults, nextPageToken)
388
+ issues = result.issues ?? []
389
+ pageCount++
390
+
391
+ for (const bean of issues) {
392
+ allIssues.push(mapIssueUnknown(bean, siteUrl))
393
+ }
370
394
  }
371
- }
372
395
 
373
- return allIssues
374
- }) as Effect.Effect<ReadonlyArray<Issue>, JiraApiError>)
396
+ return allIssues
397
+ })
375
398
 
376
399
  return IssueService.of({ getByKey, search, searchAll })
377
400
  })
package/src/JiraAuth.ts CHANGED
@@ -226,17 +226,16 @@ export class JiraAuth extends Context.Service<
226
226
  JiraAuthService
227
227
  >()("@knpkv/jira-cli/JiraAuth") {}
228
228
 
229
+ type RefreshError = OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
230
+ type RefreshDeferred = Deferred.Deferred<OAuthToken, RefreshError>
231
+
229
232
  const make = Effect.gen(function*() {
230
233
  const httpClient = yield* HttpClient.HttpClient
231
234
  const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner
232
235
  const cryptoService = yield* Crypto.Crypto
233
236
 
234
237
  // Ref to track ongoing refresh operation to prevent concurrent refreshes
235
- const refreshLock = yield* Ref.make<
236
- Option.Option<
237
- Deferred.Deferred<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError>
238
- >
239
- >(
238
+ const refreshLock = yield* Ref.make<Option.Option<RefreshDeferred>>(
240
239
  Option.none()
241
240
  )
242
241
 
@@ -422,14 +421,11 @@ const make = Effect.gen(function*() {
422
421
  }
423
422
 
424
423
  // Atomically check-then-set refresh lock to avoid TOCTOU race
425
- const deferred = yield* Deferred.make<
426
- OAuthToken,
427
- OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError
428
- >()
424
+ const deferred = yield* Deferred.make<OAuthToken, RefreshError>()
429
425
  const existing = yield* Ref.modify(refreshLock, (current) =>
430
426
  Option.isSome(current)
431
- ? [current.value, current] as const
432
- : [deferred, Option.some(deferred)] as const)
427
+ ? ([current.value, current] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>])
428
+ : ([deferred, Option.some(deferred)] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>]))
433
429
 
434
430
  // Another fiber is already refreshing — just await its result
435
431
  if (existing !== deferred) {
@@ -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,26 +299,23 @@ 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* toEffect(
307
+ client.v3.client.GET("/rest/api/3/field")
308
+ ).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to list Jira fields`, cause })))
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
321
  toEffect(
@@ -379,95 +378,96 @@ const make = Effect.gen(function*() {
379
378
  versionName: string,
380
379
  customFieldNames: ReadonlyArray<string>,
381
380
  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", {
399
- params: {
400
- query: {
401
- jql: buildByVersionJql(versionName, projectKey),
402
- fields: requestedFields,
403
- maxResults: PAGE,
404
- ...(nextPageToken ? { nextPageToken } : {})
381
+ ): Effect.Effect<ReadonlyArray<VersionTicket>, JiraApiError> =>
382
+ 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", {
399
+ params: {
400
+ query: {
401
+ jql: buildByVersionJql(versionName, projectKey),
402
+ fields: requestedFields,
403
+ maxResults: PAGE,
404
+ ...(nextPageToken ? { nextPageToken } : {})
405
+ }
405
406
  }
406
- }
407
- })
408
- ).pipe(
409
- Effect.mapError((cause) =>
410
- new JiraApiError({ message: `Failed to fetch tickets for fixVersion "${versionName}"`, cause })
407
+ })
408
+ ).pipe(
409
+ Effect.mapError((cause) =>
410
+ new JiraApiError({ message: `Failed to fetch tickets for fixVersion "${versionName}"`, cause })
411
+ )
411
412
  )
412
- )
413
413
 
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
414
+ const resObj = asRaw(result)
415
+ const issues = rawArray(resObj["issues"])
416
+ for (const issue of issues) {
417
+ const key = stringOrNull(issue["key"]) ?? ""
418
+ const fields = asRaw(issue["fields"])
419
+ const assignee = fields["assignee"]
420
+ let assigneeId: string | null = null
421
+ if (isRaw(assignee)) {
422
+ const accountId = assignee["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
439
+ }
439
440
  }
441
+ customFields[name] = resolved
440
442
  }
441
- customFields[name] = resolved
443
+ raws.push({
444
+ key,
445
+ summary: stringOrNull(fields["summary"]),
446
+ assigneeId,
447
+ labels,
448
+ customFields
449
+ })
442
450
  }
443
- raws.push({
444
- key,
445
- summary: stringOrNull(fields["summary"]),
446
- assigneeId,
447
- labels,
448
- customFields
449
- })
450
- }
451
451
 
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 })
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
+ }
462
457
 
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>)
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 })
462
+
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
+ })
471
471
 
472
472
  const mapVersion = (
473
473
  raw: Raw,
@@ -479,7 +479,7 @@ const make = Effect.gen(function*() {
479
479
  const name = String(raw["name"] ?? "")
480
480
  const driverId = stringOrNull(raw["driver"])
481
481
  const declared = extractContributorIds(raw)
482
- const approversRaw = (Array.isArray(raw["approvers"]) ? raw["approvers"] : []) as Array<Raw>
482
+ const approversRaw = rawArray(raw["approvers"])
483
483
 
484
484
  const tickets = yield* ticketsForVersion(name, customFieldNames, projectKey)
485
485
 
@@ -548,38 +548,39 @@ const make = Effect.gen(function*() {
548
548
  const listProjectVersions = (
549
549
  projectKey: string,
550
550
  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", {
559
- params: {
560
- path: { projectIdOrKey: projectKey },
561
- query: { startAt, maxResults: PAGE_SIZE, expand: EXPAND, orderBy: "-releaseDate" }
562
- }
563
- })
564
- ).pipe(
565
- Effect.mapError((cause) => new JiraApiError({ message: `Failed to list versions for ${projectKey}`, cause }))
566
- )
551
+ ): Effect.Effect<ReadonlyArray<Version>, JiraApiError> =>
552
+ Effect.gen(function*() {
553
+ const all: Array<Raw> = []
554
+ let startAt = 0
555
+ const cap = options?.maxResults
556
+ const customFieldNames = options?.customFieldNames ?? []
557
+ for (let page = 0; page < MAX_PAGES; page++) {
558
+ const result = yield* toEffect(
559
+ client.v3.client.GET("/rest/api/3/project/{projectIdOrKey}/version", {
560
+ params: {
561
+ path: { projectIdOrKey: projectKey },
562
+ query: { startAt, maxResults: PAGE_SIZE, expand: EXPAND, orderBy: "-releaseDate" }
563
+ }
564
+ })
565
+ ).pipe(
566
+ Effect.mapError((cause) => new JiraApiError({ message: `Failed to list versions for ${projectKey}`, cause }))
567
+ )
567
568
 
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)
569
+ const resObj = asRaw(result)
570
+ const values = rawArray(resObj["values"])
571
+ for (const v of values) {
572
+ if (options?.released === true && v["released"] !== true) continue
573
+ if (options?.unreleased === true && v["released"] === true) continue
574
+ all.push(v)
575
+ if (cap !== undefined && all.length >= cap) break
576
+ }
574
577
  if (cap !== undefined && all.length >= cap) break
578
+ const isLast = resObj["isLast"]
579
+ if (isLast === true || values.length < PAGE_SIZE) break
580
+ startAt += values.length
575
581
  }
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>)
582
+ return yield* Effect.forEach(all, (r) => mapVersion(r, customFieldNames, projectKey), { concurrency: 4 })
583
+ })
583
584
 
584
585
  const getVersion = (id: string): Effect.Effect<Version, JiraApiError> =>
585
586
  toEffect(
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
  )