@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.
- package/CHANGELOG.md +25 -0
- package/README.md +21 -1
- package/dist/AttachmentService.d.ts +22 -0
- package/dist/AttachmentService.d.ts.map +1 -0
- package/dist/AttachmentService.js +72 -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 +37 -24
- package/dist/IssueService.js.map +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 +16 -18
- 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 +11 -3
- 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/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 +12 -8
- package/src/AttachmentService.ts +112 -0
- package/src/IssueService.ts +70 -47
- package/src/JiraAuth.ts +7 -11
- package/src/VersionService.ts +139 -138
- 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 +19 -2
- 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 +100 -29
- 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 +78 -0
- package/test/VersionService.test.ts +21 -16
- 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 +92 -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/IssueService.ts
CHANGED
|
@@ -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 (
|
|
176
|
-
|
|
177
|
-
if (typeof
|
|
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 (
|
|
192
|
-
|
|
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<
|
|
214
|
-
const fields = (bean
|
|
215
|
-
const renderedFields = (bean
|
|
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((
|
|
223
|
-
const
|
|
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
|
-
|
|
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"]
|
|
236
|
-
const commentList = (commentField
|
|
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
|
|
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) =>
|
|
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(
|
|
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(
|
|
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> =>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(
|
|
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
|
-
|
|
374
|
-
|
|
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]
|
|
432
|
-
: [deferred, Option.some(deferred)]
|
|
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) {
|
package/src/VersionService.ts
CHANGED
|
@@ -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,26 +299,23 @@ 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
|
-
|
|
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* 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
|
-
|
|
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
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> =>
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 })
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 = (
|
|
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> =>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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>)
|
|
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 })
|
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
|
)
|