@knpkv/jira-cli 0.1.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 (87) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -0
  4. package/dist/IssueService.d.ts +144 -0
  5. package/dist/IssueService.d.ts.map +1 -0
  6. package/dist/IssueService.js +250 -0
  7. package/dist/IssueService.js.map +1 -0
  8. package/dist/JiraAuth.d.ts +84 -0
  9. package/dist/JiraAuth.d.ts.map +1 -0
  10. package/dist/JiraAuth.js +246 -0
  11. package/dist/JiraAuth.js.map +1 -0
  12. package/dist/JiraCliError.d.ts +42 -0
  13. package/dist/JiraCliError.d.ts.map +1 -0
  14. package/dist/JiraCliError.js +35 -0
  15. package/dist/JiraCliError.js.map +1 -0
  16. package/dist/MarkdownWriter.d.ts +56 -0
  17. package/dist/MarkdownWriter.d.ts.map +1 -0
  18. package/dist/MarkdownWriter.js +66 -0
  19. package/dist/MarkdownWriter.js.map +1 -0
  20. package/dist/bin.d.ts +3 -0
  21. package/dist/bin.d.ts.map +1 -0
  22. package/dist/bin.js +39 -0
  23. package/dist/bin.js.map +1 -0
  24. package/dist/commands/auth.d.ts +22 -0
  25. package/dist/commands/auth.d.ts.map +1 -0
  26. package/dist/commands/auth.js +89 -0
  27. package/dist/commands/auth.js.map +1 -0
  28. package/dist/commands/errorHandler.d.ts +13 -0
  29. package/dist/commands/errorHandler.d.ts.map +1 -0
  30. package/dist/commands/errorHandler.js +13 -0
  31. package/dist/commands/errorHandler.js.map +1 -0
  32. package/dist/commands/get.d.ts +13 -0
  33. package/dist/commands/get.d.ts.map +1 -0
  34. package/dist/commands/get.js +25 -0
  35. package/dist/commands/get.js.map +1 -0
  36. package/dist/commands/index.d.ts +11 -0
  37. package/dist/commands/index.d.ts.map +1 -0
  38. package/dist/commands/index.js +11 -0
  39. package/dist/commands/index.js.map +1 -0
  40. package/dist/commands/layers.d.ts +44 -0
  41. package/dist/commands/layers.d.ts.map +1 -0
  42. package/dist/commands/layers.js +100 -0
  43. package/dist/commands/layers.js.map +1 -0
  44. package/dist/commands/search.d.ts +18 -0
  45. package/dist/commands/search.d.ts.map +1 -0
  46. package/dist/commands/search.js +64 -0
  47. package/dist/commands/search.js.map +1 -0
  48. package/dist/index.d.ts +10 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +10 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/internal/NodeLayers.d.ts +7 -0
  53. package/dist/internal/NodeLayers.d.ts.map +1 -0
  54. package/dist/internal/NodeLayers.js +15 -0
  55. package/dist/internal/NodeLayers.js.map +1 -0
  56. package/dist/internal/frontmatter.d.ts +60 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -0
  58. package/dist/internal/frontmatter.js +130 -0
  59. package/dist/internal/frontmatter.js.map +1 -0
  60. package/dist/internal/jqlBuilder.d.ts +39 -0
  61. package/dist/internal/jqlBuilder.d.ts.map +1 -0
  62. package/dist/internal/jqlBuilder.js +47 -0
  63. package/dist/internal/jqlBuilder.js.map +1 -0
  64. package/dist/internal/oauthServer.d.ts +55 -0
  65. package/dist/internal/oauthServer.d.ts.map +1 -0
  66. package/dist/internal/oauthServer.js +113 -0
  67. package/dist/internal/oauthServer.js.map +1 -0
  68. package/package.json +86 -0
  69. package/src/IssueService.ts +378 -0
  70. package/src/JiraAuth.ts +476 -0
  71. package/src/JiraCliError.ts +44 -0
  72. package/src/MarkdownWriter.ts +112 -0
  73. package/src/bin.ts +62 -0
  74. package/src/commands/auth.ts +124 -0
  75. package/src/commands/errorHandler.ts +14 -0
  76. package/src/commands/get.ts +42 -0
  77. package/src/commands/index.ts +11 -0
  78. package/src/commands/layers.ts +142 -0
  79. package/src/commands/search.ts +102 -0
  80. package/src/index.ts +26 -0
  81. package/src/internal/NodeLayers.ts +17 -0
  82. package/src/internal/frontmatter.ts +170 -0
  83. package/src/internal/jqlBuilder.ts +49 -0
  84. package/src/internal/oauthServer.ts +203 -0
  85. package/test/jqlBuilder.test.ts +45 -0
  86. package/tsconfig.json +32 -0
  87. package/vitest.config.ts +12 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Jira issue fetching with field extraction and cursor-based pagination.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **API → domain mapping**: {@link IssueService} wraps the generated V3 client, extracting
7
+ * typed {@link Issue} objects from the loosely-typed API response via helper functions
8
+ * like `extractDisplayName` and `extractNameArray`.
9
+ * - **Rendered fields**: Requests include `expand: "renderedFields"` to get HTML-rendered
10
+ * descriptions and comments, falling back to plain text.
11
+ * - **Pagination guard**: {@link IssueServiceShape.searchAll} iterates pages using
12
+ * `nextPageToken` with a MAX_PAGES (1000) safety limit.
13
+ *
14
+ * **Common tasks**
15
+ *
16
+ * - Fetch single issue: `service.getByKey("PROJ-123")`
17
+ * - Search with pagination: `service.searchAll(jql)`
18
+ *
19
+ * @module
20
+ */
21
+ import { JiraApiClient, toEffect } from "@knpkv/jira-api-client"
22
+ import * as Context from "effect/Context"
23
+ import * as Effect from "effect/Effect"
24
+ import * as Layer from "effect/Layer"
25
+ import { JiraApiError } from "./JiraCliError.js"
26
+
27
+ /**
28
+ * Site URL configuration for issue links.
29
+ *
30
+ * @category Config
31
+ */
32
+ export class SiteUrl extends Context.Tag("@knpkv/jira-cli/SiteUrl")<SiteUrl, string>() {}
33
+
34
+ /**
35
+ * Attachment metadata.
36
+ *
37
+ * @category Types
38
+ */
39
+ export interface Attachment {
40
+ readonly id: string
41
+ readonly filename: string
42
+ readonly url: string
43
+ readonly mimeType: string
44
+ readonly size: number
45
+ }
46
+
47
+ /**
48
+ * Comment on an issue.
49
+ *
50
+ * @category Types
51
+ */
52
+ export interface Comment {
53
+ readonly id: string
54
+ readonly author: string
55
+ readonly body: string
56
+ readonly created: Date
57
+ readonly updated: Date
58
+ }
59
+
60
+ /**
61
+ * Jira issue with relevant fields.
62
+ *
63
+ * @category Types
64
+ */
65
+ export interface Issue {
66
+ readonly key: string
67
+ readonly id: string
68
+ readonly summary: string
69
+ readonly status: string
70
+ readonly type: string
71
+ readonly priority: string | null
72
+ readonly assignee: string | null
73
+ readonly reporter: string | null
74
+ readonly created: Date
75
+ readonly updated: Date
76
+ readonly fixVersions: ReadonlyArray<string>
77
+ readonly labels: ReadonlyArray<string>
78
+ readonly components: ReadonlyArray<string>
79
+ readonly description: string
80
+ readonly attachments: ReadonlyArray<Attachment>
81
+ readonly comments: ReadonlyArray<Comment>
82
+ readonly url: string
83
+ }
84
+
85
+ /**
86
+ * Search options for issue queries.
87
+ *
88
+ * @category Types
89
+ */
90
+ export interface SearchOptions {
91
+ readonly maxResults?: number
92
+ }
93
+
94
+ /**
95
+ * Search result with pagination info.
96
+ *
97
+ * @category Types
98
+ */
99
+ export interface SearchResult {
100
+ readonly issues: ReadonlyArray<Issue>
101
+ /** True if this is the last page (API uses cursor pagination, no total count) */
102
+ readonly isLast: boolean
103
+ readonly nextPageToken: string | null
104
+ readonly maxResults: number
105
+ }
106
+
107
+ /**
108
+ * IssueService interface.
109
+ *
110
+ * @category Services
111
+ */
112
+ export interface IssueServiceShape {
113
+ /** Get a single issue by key */
114
+ readonly getByKey: (key: string) => Effect.Effect<Issue, JiraApiError>
115
+ /** Search issues by JQL query */
116
+ readonly search: (jql: string, options?: SearchOptions) => Effect.Effect<SearchResult, JiraApiError>
117
+ /** Search all issues by JQL query (handles pagination) */
118
+ readonly searchAll: (
119
+ jql: string,
120
+ options?: { readonly maxResults?: number }
121
+ ) => Effect.Effect<ReadonlyArray<Issue>, JiraApiError>
122
+ }
123
+
124
+ /**
125
+ * IssueService tag.
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import { Effect } from "effect"
130
+ * import { IssueService } from "@knpkv/jira-cli/IssueService"
131
+ *
132
+ * Effect.gen(function* () {
133
+ * const service = yield* IssueService
134
+ * const issues = yield* service.searchAll('fixVersion = "1.0.0"')
135
+ * console.log(`Found ${issues.length} issues`)
136
+ * })
137
+ * ```
138
+ *
139
+ * @category Services
140
+ */
141
+ export class IssueService extends Context.Tag("@knpkv/jira-cli/IssueService")<
142
+ IssueService,
143
+ IssueServiceShape
144
+ >() {}
145
+
146
+ const FIELDS = [
147
+ "summary",
148
+ "description",
149
+ "status",
150
+ "issuetype",
151
+ "priority",
152
+ "assignee",
153
+ "reporter",
154
+ "created",
155
+ "updated",
156
+ "fixVersions",
157
+ "labels",
158
+ "components",
159
+ "attachment",
160
+ "comment"
161
+ ]
162
+
163
+ /**
164
+ * Extract string from a field that may be an object with displayName/name.
165
+ */
166
+ const extractDisplayName = (field: unknown): string | null => {
167
+ if (field === null || field === undefined) return null
168
+ if (typeof field === "string") return field
169
+ if (typeof field === "object") {
170
+ const obj = field as Record<string, unknown>
171
+ if (typeof obj["displayName"] === "string") return obj["displayName"]
172
+ if (typeof obj["name"] === "string") return obj["name"]
173
+ }
174
+ return null
175
+ }
176
+
177
+ /**
178
+ * Extract array of strings from a field that may be array of objects with name.
179
+ */
180
+ const extractNameArray = (field: unknown): ReadonlyArray<string> => {
181
+ if (!Array.isArray(field)) return []
182
+ return field
183
+ .map((item) => {
184
+ if (typeof item === "string") return item
185
+ if (typeof item === "object" && item !== null) {
186
+ const obj = item as Record<string, unknown>
187
+ if (typeof obj["name"] === "string") return obj["name"]
188
+ }
189
+ return null
190
+ })
191
+ .filter((x): x is string => x !== null)
192
+ }
193
+
194
+ /**
195
+ * Parse date from unknown value, returning epoch date if invalid.
196
+ */
197
+ const parseDate = (val: unknown): Date => {
198
+ const str = String(val ?? "")
199
+ if (!str) return new Date(0)
200
+ const date = new Date(str)
201
+ return isNaN(date.getTime()) ? new Date(0) : date
202
+ }
203
+
204
+ /**
205
+ * Map IssueBean from API to our Issue type.
206
+ */
207
+ const mapIssue = (bean: Record<string, unknown>, baseUrl: string): Issue => {
208
+ const fields = (bean["fields"] ?? {}) as Record<string, unknown>
209
+ const renderedFields = (bean["renderedFields"] ?? {}) as Record<string, unknown>
210
+ const key = String(bean["key"] ?? "")
211
+ const id = String(bean["id"] ?? "")
212
+
213
+ // Extract attachments
214
+ const attachmentField = fields["attachment"]
215
+ const attachments: Array<Attachment> = Array.isArray(attachmentField)
216
+ ? attachmentField.map((a) => {
217
+ const att = a as Record<string, unknown>
218
+ return {
219
+ id: String(att["id"] ?? ""),
220
+ filename: String(att["filename"] ?? ""),
221
+ url: String(att["content"] ?? ""),
222
+ mimeType: String(att["mimeType"] ?? ""),
223
+ size: Number(att["size"] ?? 0)
224
+ }
225
+ })
226
+ : []
227
+
228
+ // Extract comments
229
+ const commentField = fields["comment"] as Record<string, unknown> | undefined
230
+ const commentList = (commentField?.["comments"] ?? []) as Array<Record<string, unknown>>
231
+ const renderedComments =
232
+ ((renderedFields["comment"] as Record<string, unknown> | undefined)?.["comments"] ?? []) as Array<
233
+ Record<string, unknown>
234
+ >
235
+
236
+ // Build map of rendered comments by ID for accurate matching
237
+ const renderedMap = new Map<string, Record<string, unknown>>()
238
+ for (const r of renderedComments) {
239
+ const rId = String(r["id"] ?? "")
240
+ if (rId) renderedMap.set(rId, r)
241
+ }
242
+
243
+ const comments: Array<Comment> = commentList.map((c) => {
244
+ const author = c["author"] as Record<string, unknown> | undefined
245
+ const commentId = String(c["id"] ?? "")
246
+ const rendered = renderedMap.get(commentId)
247
+ const renderedBody = rendered?.["body"]
248
+ return {
249
+ id: commentId,
250
+ author: extractDisplayName(author) ?? "Unknown",
251
+ body: typeof renderedBody === "string" ? renderedBody : String(c["body"] ?? ""),
252
+ created: parseDate(c["created"]),
253
+ updated: parseDate(c["updated"])
254
+ }
255
+ })
256
+
257
+ // Use rendered description (HTML) if available
258
+ const description = typeof renderedFields["description"] === "string"
259
+ ? renderedFields["description"]
260
+ : String(fields["description"] ?? "")
261
+
262
+ return {
263
+ key,
264
+ id,
265
+ summary: String(fields["summary"] ?? ""),
266
+ status: extractDisplayName(fields["status"]) ?? "Unknown",
267
+ type: extractDisplayName(fields["issuetype"]) ?? "Unknown",
268
+ priority: extractDisplayName(fields["priority"]),
269
+ assignee: extractDisplayName(fields["assignee"]),
270
+ reporter: extractDisplayName(fields["reporter"]),
271
+ created: parseDate(fields["created"]),
272
+ updated: parseDate(fields["updated"]),
273
+ fixVersions: extractNameArray(fields["fixVersions"]),
274
+ labels: extractNameArray(fields["labels"]),
275
+ components: extractNameArray(fields["components"]),
276
+ description,
277
+ attachments,
278
+ comments,
279
+ url: `${baseUrl}/browse/${key}`
280
+ }
281
+ }
282
+
283
+ const make = Effect.gen(function*() {
284
+ const client = yield* JiraApiClient
285
+ const siteUrl = yield* SiteUrl
286
+
287
+ const getByKey = (key: string): Effect.Effect<Issue, JiraApiError> =>
288
+ toEffect(client.v3.client.GET("/rest/api/3/issue/{issueIdOrKey}", {
289
+ params: {
290
+ path: { issueIdOrKey: key },
291
+ query: { fields: FIELDS, expand: "renderedFields" }
292
+ }
293
+ })).pipe(
294
+ Effect.map((result) => mapIssue(result as unknown as Record<string, unknown>, siteUrl)),
295
+ Effect.mapError((cause) => new JiraApiError({ message: `Failed to get issue ${key}`, cause }))
296
+ )
297
+
298
+ const searchJql = (
299
+ jql: string,
300
+ maxResults: number,
301
+ nextPageToken?: string
302
+ ) =>
303
+ toEffect(client.v3.client.GET("/rest/api/3/search/jql", {
304
+ params: {
305
+ query: {
306
+ jql,
307
+ maxResults,
308
+ ...(nextPageToken ? { nextPageToken } : {}),
309
+ fields: FIELDS,
310
+ expand: "renderedFields"
311
+ }
312
+ }
313
+ })).pipe(
314
+ Effect.mapError((cause) => new JiraApiError({ message: "Failed to search issues", cause }))
315
+ )
316
+
317
+ const search = (jql: string, options?: SearchOptions): Effect.Effect<SearchResult, JiraApiError> =>
318
+ searchJql(jql, options?.maxResults ?? 50).pipe(
319
+ Effect.map((result) => {
320
+ const issues = result.issues ?? []
321
+ const mappedIssues: Array<Issue> = []
322
+ for (const bean of issues) {
323
+ mappedIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
324
+ }
325
+ return {
326
+ issues: mappedIssues,
327
+ isLast: result.isLast ?? true,
328
+ nextPageToken: result.nextPageToken ?? null,
329
+ maxResults: options?.maxResults ?? 50
330
+ }
331
+ })
332
+ )
333
+
334
+ const MAX_PAGES = 1000
335
+
336
+ const searchAll = (
337
+ jql: string,
338
+ options?: { readonly maxResults?: number }
339
+ ): Effect.Effect<ReadonlyArray<Issue>, JiraApiError> =>
340
+ Effect.gen(function*() {
341
+ const allIssues: Array<Issue> = []
342
+ const maxResults = options?.maxResults ?? 100
343
+ let nextPageToken: string | undefined = undefined
344
+ let pageCount = 0
345
+
346
+ // Fetch first page
347
+ let result = yield* searchJql(jql, maxResults, nextPageToken)
348
+ let issues = result.issues ?? []
349
+ pageCount++
350
+
351
+ for (const bean of issues) {
352
+ allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
353
+ }
354
+
355
+ // Fetch remaining pages with iteration guard
356
+ while (!result.isLast && result.nextPageToken && pageCount < MAX_PAGES) {
357
+ nextPageToken = result.nextPageToken
358
+ result = yield* searchJql(jql, maxResults, nextPageToken)
359
+ issues = result.issues ?? []
360
+ pageCount++
361
+
362
+ for (const bean of issues) {
363
+ allIssues.push(mapIssue(bean as unknown as Record<string, unknown>, siteUrl))
364
+ }
365
+ }
366
+
367
+ return allIssues
368
+ })
369
+
370
+ return IssueService.of({ getByKey, search, searchAll })
371
+ })
372
+
373
+ /**
374
+ * Layer for IssueService.
375
+ *
376
+ * @category Layers
377
+ */
378
+ export const layer = Layer.effect(IssueService, make)