@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/IssueService.d.ts +144 -0
- package/dist/IssueService.d.ts.map +1 -0
- package/dist/IssueService.js +250 -0
- package/dist/IssueService.js.map +1 -0
- package/dist/JiraAuth.d.ts +84 -0
- package/dist/JiraAuth.d.ts.map +1 -0
- package/dist/JiraAuth.js +246 -0
- package/dist/JiraAuth.js.map +1 -0
- package/dist/JiraCliError.d.ts +42 -0
- package/dist/JiraCliError.d.ts.map +1 -0
- package/dist/JiraCliError.js +35 -0
- package/dist/JiraCliError.js.map +1 -0
- package/dist/MarkdownWriter.d.ts +56 -0
- package/dist/MarkdownWriter.d.ts.map +1 -0
- package/dist/MarkdownWriter.js +66 -0
- package/dist/MarkdownWriter.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +39 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/auth.d.ts +22 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +89 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/errorHandler.d.ts +13 -0
- package/dist/commands/errorHandler.d.ts.map +1 -0
- package/dist/commands/errorHandler.js +13 -0
- package/dist/commands/errorHandler.js.map +1 -0
- package/dist/commands/get.d.ts +13 -0
- package/dist/commands/get.d.ts.map +1 -0
- package/dist/commands/get.js +25 -0
- package/dist/commands/get.js.map +1 -0
- package/dist/commands/index.d.ts +11 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +11 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/layers.d.ts +44 -0
- package/dist/commands/layers.d.ts.map +1 -0
- package/dist/commands/layers.js +100 -0
- package/dist/commands/layers.js.map +1 -0
- package/dist/commands/search.d.ts +18 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/search.js +64 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/NodeLayers.d.ts +7 -0
- package/dist/internal/NodeLayers.d.ts.map +1 -0
- package/dist/internal/NodeLayers.js +15 -0
- package/dist/internal/NodeLayers.js.map +1 -0
- package/dist/internal/frontmatter.d.ts +60 -0
- package/dist/internal/frontmatter.d.ts.map +1 -0
- package/dist/internal/frontmatter.js +130 -0
- package/dist/internal/frontmatter.js.map +1 -0
- package/dist/internal/jqlBuilder.d.ts +39 -0
- package/dist/internal/jqlBuilder.d.ts.map +1 -0
- package/dist/internal/jqlBuilder.js +47 -0
- package/dist/internal/jqlBuilder.js.map +1 -0
- package/dist/internal/oauthServer.d.ts +55 -0
- package/dist/internal/oauthServer.d.ts.map +1 -0
- package/dist/internal/oauthServer.js +113 -0
- package/dist/internal/oauthServer.js.map +1 -0
- package/package.json +86 -0
- package/src/IssueService.ts +378 -0
- package/src/JiraAuth.ts +476 -0
- package/src/JiraCliError.ts +44 -0
- package/src/MarkdownWriter.ts +112 -0
- package/src/bin.ts +62 -0
- package/src/commands/auth.ts +124 -0
- package/src/commands/errorHandler.ts +14 -0
- package/src/commands/get.ts +42 -0
- package/src/commands/index.ts +11 -0
- package/src/commands/layers.ts +142 -0
- package/src/commands/search.ts +102 -0
- package/src/index.ts +26 -0
- package/src/internal/NodeLayers.ts +17 -0
- package/src/internal/frontmatter.ts +170 -0
- package/src/internal/jqlBuilder.ts +49 -0
- package/src/internal/oauthServer.ts +203 -0
- package/test/jqlBuilder.test.ts +45 -0
- package/tsconfig.json +32 -0
- 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)
|