@knpkv/jira-cli 0.1.1 → 0.3.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 +47 -0
- package/README.md +63 -1
- package/dist/IssueService.d.ts +2 -2
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +3 -3
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +14 -14
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +18 -10
- package/dist/JiraAuth.js.map +1 -1
- package/dist/MarkdownWriter.d.ts +4 -4
- package/dist/MarkdownWriter.d.ts.map +1 -1
- package/dist/MarkdownWriter.js +6 -6
- package/dist/MarkdownWriter.js.map +1 -1
- package/dist/VersionService.d.ts +206 -0
- package/dist/VersionService.d.ts.map +1 -0
- package/dist/VersionService.js +426 -0
- package/dist/VersionService.js.map +1 -0
- package/dist/bin.js +28 -20
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -21
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +6 -6
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/get.d.ts +3 -8
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +2 -2
- package/dist/commands/get.js.map +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/layers.d.ts +6 -18
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +31 -23
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.d.ts +3 -8
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +4 -4
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +179 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/internal/oauthServer.d.ts +17 -5
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +23 -40
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts +10 -0
- package/dist/internal/openBrowser.d.ts.map +1 -0
- package/dist/internal/openBrowser.js +17 -0
- package/dist/internal/openBrowser.js.map +1 -0
- package/package.json +10 -12
- package/skills/jira/SKILL.md +90 -0
- package/skills/jira/agents/openai.yaml +4 -0
- package/src/IssueService.ts +34 -28
- package/src/JiraAuth.ts +53 -39
- package/src/MarkdownWriter.ts +7 -11
- package/src/VersionService.ts +647 -0
- package/src/bin.ts +38 -26
- package/src/commands/auth.ts +6 -12
- package/src/commands/get.ts +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/layers.ts +40 -25
- package/src/commands/search.ts +4 -4
- package/src/commands/version.ts +267 -0
- package/src/internal/oauthServer.ts +43 -70
- package/src/internal/openBrowser.ts +31 -0
- package/test/VersionService.test.ts +266 -0
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira project version (release) fetching with people-field resolution.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **API → domain mapping**: {@link VersionService} wraps the generated V3 client,
|
|
7
|
+
* normalising a project version into a {@link Version} object with resolved Driver,
|
|
8
|
+
* Contributors, and Approvers (each rendered as a {@link Person}).
|
|
9
|
+
* - **Expand options**: `approvers,driver,operations,issuesstatus` plus a passthrough
|
|
10
|
+
* for any extra fields the API returns (`contributors` is sent by Jira Premium even
|
|
11
|
+
* though it is not in the public OpenAPI spec).
|
|
12
|
+
* - **Account-id resolution**: account IDs are looked up against
|
|
13
|
+
* `/rest/api/3/user?accountId={id}` and cached per service instance.
|
|
14
|
+
* - **Mutations**: {@link VersionServiceShape.updateVersion} edits version fields
|
|
15
|
+
* (e.g. description) and {@link VersionServiceShape.addRelatedWork} /
|
|
16
|
+
* {@link VersionServiceShape.listRelatedWork} manage the "Related work" links that
|
|
17
|
+
* surface as Confluence pages on a release report. Mutations require the
|
|
18
|
+
* `manage:jira-project` OAuth scope (see `JiraAuth`).
|
|
19
|
+
*
|
|
20
|
+
* **Common tasks**
|
|
21
|
+
*
|
|
22
|
+
* - List versions for a project: `service.listProjectVersions("RPS", { released: true })`
|
|
23
|
+
* - Get a single version: `service.getVersion("12345")`
|
|
24
|
+
* - Set the description: `service.updateVersion("12345", { description: "..." })`
|
|
25
|
+
* - Attach a Confluence page: `service.addRelatedWork("12345", { title, category, url })`
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
import { JiraApiClient, toEffect } from "@knpkv/jira-api-client"
|
|
30
|
+
import * as Context from "effect/Context"
|
|
31
|
+
import * as Effect from "effect/Effect"
|
|
32
|
+
import * as Layer from "effect/Layer"
|
|
33
|
+
import { buildByVersionJql } from "./internal/jqlBuilder.js"
|
|
34
|
+
import { JiraApiError } from "./JiraCliError.js"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A resolved Jira user (account ID + display name).
|
|
38
|
+
*
|
|
39
|
+
* @category Types
|
|
40
|
+
*/
|
|
41
|
+
export interface Person {
|
|
42
|
+
readonly accountId: string
|
|
43
|
+
readonly displayName: string
|
|
44
|
+
/** Resolved email address (PII). Stripped from `version --json` unless `--emails` is passed. */
|
|
45
|
+
readonly emailAddress: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One approval line on a version.
|
|
50
|
+
*
|
|
51
|
+
* @category Types
|
|
52
|
+
*/
|
|
53
|
+
export interface Approver {
|
|
54
|
+
readonly person: Person
|
|
55
|
+
/** APPROVED | DECLINED | PENDING (Jira returns it uppercase). */
|
|
56
|
+
readonly status: string
|
|
57
|
+
readonly declineReason: string | null
|
|
58
|
+
readonly description: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A ticket with the version set as its fixVersion. Carries the minimum metadata
|
|
63
|
+
* needed by SOC2-style audits: assignee (for contributor derivation), labels
|
|
64
|
+
* (for impact-tagging checks), and summary (for human-readable evidence).
|
|
65
|
+
*
|
|
66
|
+
* @category Types
|
|
67
|
+
*/
|
|
68
|
+
export interface VersionTicket {
|
|
69
|
+
readonly key: string
|
|
70
|
+
readonly summary: string | null
|
|
71
|
+
readonly assignee: Person | null
|
|
72
|
+
readonly labels: ReadonlyArray<string>
|
|
73
|
+
/**
|
|
74
|
+
* Values of any custom fields the caller asked to include (see
|
|
75
|
+
* {@link ListVersionsOptions.customFieldNames}). Keyed by the field's display
|
|
76
|
+
* name (the same string the caller passed in).
|
|
77
|
+
*/
|
|
78
|
+
readonly customFields: Readonly<Record<string, string | null>>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A project version (release) with people fields resolved.
|
|
83
|
+
*
|
|
84
|
+
* @category Types
|
|
85
|
+
*/
|
|
86
|
+
export interface Version {
|
|
87
|
+
readonly id: string
|
|
88
|
+
readonly name: string
|
|
89
|
+
readonly description: string | null
|
|
90
|
+
readonly released: boolean
|
|
91
|
+
readonly archived: boolean
|
|
92
|
+
readonly startDate: string | null
|
|
93
|
+
readonly releaseDate: string | null
|
|
94
|
+
readonly driver: Person | null
|
|
95
|
+
readonly contributors: ReadonlyArray<Person>
|
|
96
|
+
readonly approvers: ReadonlyArray<Approver>
|
|
97
|
+
readonly tickets: ReadonlyArray<VersionTicket>
|
|
98
|
+
readonly url: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A "Related work" link on a version (e.g. a Confluence page surfaced on the
|
|
103
|
+
* release report). `category` is a free-form string Jira groups by — common
|
|
104
|
+
* values are `Communication`, `Testing`, `Design`.
|
|
105
|
+
*
|
|
106
|
+
* @category Types
|
|
107
|
+
*/
|
|
108
|
+
export interface RelatedWork {
|
|
109
|
+
readonly relatedWorkId: string | null
|
|
110
|
+
readonly title: string | null
|
|
111
|
+
readonly category: string
|
|
112
|
+
readonly url: string | null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Input for attaching a new "Related work" link to a version.
|
|
117
|
+
*
|
|
118
|
+
* @category Types
|
|
119
|
+
*/
|
|
120
|
+
export interface AddRelatedWorkInput {
|
|
121
|
+
readonly title: string
|
|
122
|
+
readonly category: string
|
|
123
|
+
readonly url: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Editable version fields. Only the provided keys are sent to Jira.
|
|
128
|
+
*
|
|
129
|
+
* @category Types
|
|
130
|
+
*/
|
|
131
|
+
export interface UpdateVersionInput {
|
|
132
|
+
readonly description?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Filters for listing versions.
|
|
137
|
+
*
|
|
138
|
+
* @category Types
|
|
139
|
+
*/
|
|
140
|
+
export interface ListVersionsOptions {
|
|
141
|
+
/** Restrict to released versions. */
|
|
142
|
+
readonly released?: boolean
|
|
143
|
+
/** Restrict to unreleased versions. */
|
|
144
|
+
readonly unreleased?: boolean
|
|
145
|
+
/** Hard cap on the number of versions fetched (default: all). */
|
|
146
|
+
readonly maxResults?: number
|
|
147
|
+
/**
|
|
148
|
+
* Custom field **display names** (e.g. `"Security & Compliance Impact"`)
|
|
149
|
+
* whose values should be populated on each {@link VersionTicket.customFields}
|
|
150
|
+
* map. Names are resolved to per-instance field IDs via `/rest/api/3/field`,
|
|
151
|
+
* cached per service instance.
|
|
152
|
+
*/
|
|
153
|
+
readonly customFieldNames?: ReadonlyArray<string>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* VersionService interface.
|
|
158
|
+
*
|
|
159
|
+
* @category Services
|
|
160
|
+
*/
|
|
161
|
+
export interface VersionServiceShape {
|
|
162
|
+
readonly listProjectVersions: (
|
|
163
|
+
projectKey: string,
|
|
164
|
+
options?: ListVersionsOptions
|
|
165
|
+
) => Effect.Effect<ReadonlyArray<Version>, JiraApiError>
|
|
166
|
+
readonly getVersion: (id: string) => Effect.Effect<Version, JiraApiError>
|
|
167
|
+
/** Update editable fields (currently description) on a version. Needs `manage:jira-project`. */
|
|
168
|
+
readonly updateVersion: (id: string, input: UpdateVersionInput) => Effect.Effect<Version, JiraApiError>
|
|
169
|
+
/** List the "Related work" links attached to a version. */
|
|
170
|
+
readonly listRelatedWork: (id: string) => Effect.Effect<ReadonlyArray<RelatedWork>, JiraApiError>
|
|
171
|
+
/** Attach a "Related work" link (e.g. a Confluence page) to a version. Needs `manage:jira-project`. */
|
|
172
|
+
readonly addRelatedWork: (id: string, input: AddRelatedWorkInput) => Effect.Effect<RelatedWork, JiraApiError>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* VersionService tag.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* import { Effect } from "effect"
|
|
181
|
+
* import { VersionService } from "@knpkv/jira-cli/VersionService"
|
|
182
|
+
*
|
|
183
|
+
* Effect.gen(function* () {
|
|
184
|
+
* const versions = yield* VersionService
|
|
185
|
+
* const list = yield* versions.listProjectVersions("RPS", { released: true })
|
|
186
|
+
* console.log(`Found ${list.length} released versions`)
|
|
187
|
+
* })
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* @category Services
|
|
191
|
+
*/
|
|
192
|
+
export class VersionService extends Context.Service<
|
|
193
|
+
VersionService,
|
|
194
|
+
VersionServiceShape
|
|
195
|
+
>()("@knpkv/jira-cli/VersionService") {}
|
|
196
|
+
|
|
197
|
+
const EXPAND = "approvers,driver,operations,issuesstatus,contributors"
|
|
198
|
+
|
|
199
|
+
/** Loosely-typed record helper for navigating untyped API JSON. */
|
|
200
|
+
type Raw = Record<string, unknown>
|
|
201
|
+
const asRaw = (v: unknown): Raw => (v && typeof v === "object" ? v as Raw : {})
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render a Jira custom-field value as a flat string.
|
|
205
|
+
*
|
|
206
|
+
* Handles the common shapes returned by `/rest/api/3/search/jql`:
|
|
207
|
+
* - cascading select: `{ value, child: { value } }` → `"Parent > Child"`
|
|
208
|
+
* - single select / option: `{ value }` → `"Parent"`
|
|
209
|
+
* - user object: `{ displayName }` → display name
|
|
210
|
+
* - plain string/number → coerced to string
|
|
211
|
+
* - array of any of the above → values joined with `, `
|
|
212
|
+
* - null / unset / unknown shape → `null`
|
|
213
|
+
*/
|
|
214
|
+
export const renderCustomFieldValue = (raw: unknown): string | null => {
|
|
215
|
+
if (raw === null || raw === undefined) return null
|
|
216
|
+
if (typeof raw === "string") return raw.length > 0 ? raw : null
|
|
217
|
+
if (typeof raw === "number" || typeof raw === "boolean") return String(raw)
|
|
218
|
+
if (Array.isArray(raw)) {
|
|
219
|
+
const parts = raw.map(renderCustomFieldValue).filter((v): v is string => !!v)
|
|
220
|
+
return parts.length > 0 ? parts.join(", ") : null
|
|
221
|
+
}
|
|
222
|
+
if (typeof raw === "object") {
|
|
223
|
+
const obj = raw as Raw
|
|
224
|
+
const parent = stringOrNull(obj["value"])
|
|
225
|
+
if (parent) {
|
|
226
|
+
const child = obj["child"]
|
|
227
|
+
if (child && typeof child === "object") {
|
|
228
|
+
const childValue = stringOrNull((child as Raw)["value"])
|
|
229
|
+
if (childValue) return `${parent} > ${childValue}`
|
|
230
|
+
}
|
|
231
|
+
return parent
|
|
232
|
+
}
|
|
233
|
+
const displayName = stringOrNull(obj["displayName"])
|
|
234
|
+
if (displayName) return displayName
|
|
235
|
+
const name = stringOrNull(obj["name"])
|
|
236
|
+
if (name) return name
|
|
237
|
+
}
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const stringOrNull = (v: unknown): string | null => typeof v === "string" && v.length > 0 ? v : null
|
|
242
|
+
|
|
243
|
+
export const personFromObject = (raw: unknown, fallbackId?: string): Person | null => {
|
|
244
|
+
if (raw && typeof raw === "object") {
|
|
245
|
+
const obj = raw as Raw
|
|
246
|
+
const accountId = stringOrNull(obj["accountId"]) ?? fallbackId ?? null
|
|
247
|
+
if (!accountId) return null
|
|
248
|
+
return {
|
|
249
|
+
accountId,
|
|
250
|
+
displayName: stringOrNull(obj["displayName"]) ?? accountId,
|
|
251
|
+
emailAddress: stringOrNull(obj["emailAddress"])
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
255
|
+
return { accountId: raw, displayName: raw, emailAddress: null }
|
|
256
|
+
}
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const extractContributorIds = (raw: Raw): ReadonlyArray<string> => {
|
|
261
|
+
// Jira Premium *may* return `contributors` on the version (undocumented in the
|
|
262
|
+
// public OpenAPI spec) — read defensively. In practice we've observed it
|
|
263
|
+
// empty, hence the assignee-based fallback below.
|
|
264
|
+
const field = raw["contributors"]
|
|
265
|
+
if (!Array.isArray(field)) return []
|
|
266
|
+
const ids: Array<string> = []
|
|
267
|
+
for (const c of field) {
|
|
268
|
+
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
|
+
if (typeof id === "string" && id.length > 0) ids.push(id)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return ids
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Normalise a Jira "Related work" entry into a {@link RelatedWork}. */
|
|
278
|
+
export const toRelatedWork = (raw: unknown): RelatedWork => {
|
|
279
|
+
const o = asRaw(raw)
|
|
280
|
+
return {
|
|
281
|
+
relatedWorkId: stringOrNull(o["relatedWorkId"]),
|
|
282
|
+
title: stringOrNull(o["title"]),
|
|
283
|
+
category: stringOrNull(o["category"]) ?? "",
|
|
284
|
+
url: stringOrNull(o["url"])
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const make = Effect.gen(function*() {
|
|
289
|
+
const client = yield* JiraApiClient
|
|
290
|
+
const userCache = new Map<string, Person>()
|
|
291
|
+
// In-flight lookups keyed by accountId so concurrent callers (bounded by the
|
|
292
|
+
// `concurrency: 4` fan-outs) share a single request instead of duplicating it.
|
|
293
|
+
const userInFlight = new Map<string, Effect.Effect<Person, never>>()
|
|
294
|
+
|
|
295
|
+
// Cached lookup of all custom field IDs sharing a given display name.
|
|
296
|
+
const fieldIdsByName = new Map<string, ReadonlyArray<string>>()
|
|
297
|
+
|
|
298
|
+
const resolveFieldIds = (
|
|
299
|
+
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
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
fieldIdsByName.set(name, matches)
|
|
318
|
+
return matches
|
|
319
|
+
}) as Effect.Effect<ReadonlyArray<string>, JiraApiError>)
|
|
320
|
+
|
|
321
|
+
const fetchUser = (accountId: string): Effect.Effect<Person, never> =>
|
|
322
|
+
toEffect(
|
|
323
|
+
client.v3.client.GET("/rest/api/3/user", { params: { query: { accountId } } })
|
|
324
|
+
).pipe(
|
|
325
|
+
Effect.map((u) => {
|
|
326
|
+
const obj = asRaw(u)
|
|
327
|
+
const person: Person = {
|
|
328
|
+
accountId,
|
|
329
|
+
displayName: stringOrNull(obj["displayName"]) ?? accountId,
|
|
330
|
+
emailAddress: stringOrNull(obj["emailAddress"])
|
|
331
|
+
}
|
|
332
|
+
userCache.set(accountId, person)
|
|
333
|
+
return person
|
|
334
|
+
}),
|
|
335
|
+
Effect.catch(() => {
|
|
336
|
+
// User may be deleted / inaccessible — fall back to bare account id.
|
|
337
|
+
const fallback: Person = { accountId, displayName: accountId, emailAddress: null }
|
|
338
|
+
userCache.set(accountId, fallback)
|
|
339
|
+
return Effect.succeed(fallback)
|
|
340
|
+
}),
|
|
341
|
+
// Drop the in-flight memo once resolved so a later miss can refetch.
|
|
342
|
+
Effect.ensuring(Effect.sync(() => userInFlight.delete(accountId)))
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const resolveUser = (accountId: string): Effect.Effect<Person, never> =>
|
|
346
|
+
Effect.gen(function*() {
|
|
347
|
+
const cached = userCache.get(accountId)
|
|
348
|
+
if (cached) return cached
|
|
349
|
+
const existing = userInFlight.get(accountId)
|
|
350
|
+
if (existing) return yield* existing
|
|
351
|
+
// `Effect.cached` shares one execution across all awaiters of the returned
|
|
352
|
+
// effect. Building it and storing it in `userInFlight` happens in
|
|
353
|
+
// synchronous effect steps (no async boundary), so concurrent uncached
|
|
354
|
+
// callers — bounded by the `concurrency: 4` fan-outs — dedupe to one
|
|
355
|
+
// request rather than each issuing their own.
|
|
356
|
+
const shared = yield* Effect.cached(fetchUser(accountId))
|
|
357
|
+
userInFlight.set(accountId, shared)
|
|
358
|
+
return yield* shared
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
interface RawTicket {
|
|
362
|
+
readonly key: string
|
|
363
|
+
readonly summary: string | null
|
|
364
|
+
readonly assigneeId: string | null
|
|
365
|
+
readonly labels: ReadonlyArray<string>
|
|
366
|
+
readonly customFields: Record<string, string | null>
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Fetch every ticket whose `fixVersion` matches `versionName`, returning the
|
|
371
|
+
* minimum metadata downstream audits need (key, summary, assignee, labels).
|
|
372
|
+
*
|
|
373
|
+
* `projectKey` scopes the query to a single project so version names that
|
|
374
|
+
* collide across projects (e.g. `"1.0.0"`) don't pull in unrelated issues.
|
|
375
|
+
* When omitted (e.g. {@link getVersion}, which has no project context), the
|
|
376
|
+
* query is instance-wide and may match same-named versions in other projects.
|
|
377
|
+
*/
|
|
378
|
+
const ticketsForVersion = (
|
|
379
|
+
versionName: string,
|
|
380
|
+
customFieldNames: ReadonlyArray<string>,
|
|
381
|
+
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 } : {})
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
).pipe(
|
|
409
|
+
Effect.mapError((cause) =>
|
|
410
|
+
new JiraApiError({ message: `Failed to fetch tickets for fixVersion "${versionName}"`, cause })
|
|
411
|
+
)
|
|
412
|
+
)
|
|
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
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
customFields[name] = resolved
|
|
442
|
+
}
|
|
443
|
+
raws.push({
|
|
444
|
+
key,
|
|
445
|
+
summary: stringOrNull(fields["summary"]),
|
|
446
|
+
assigneeId,
|
|
447
|
+
labels,
|
|
448
|
+
customFields
|
|
449
|
+
})
|
|
450
|
+
}
|
|
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 })
|
|
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
|
+
}) as Effect.Effect<ReadonlyArray<VersionTicket>, JiraApiError>)
|
|
471
|
+
|
|
472
|
+
const mapVersion = (
|
|
473
|
+
raw: Raw,
|
|
474
|
+
customFieldNames: ReadonlyArray<string>,
|
|
475
|
+
projectKey?: string
|
|
476
|
+
): Effect.Effect<Version, JiraApiError> =>
|
|
477
|
+
Effect.gen(function*() {
|
|
478
|
+
const id = String(raw["id"] ?? "")
|
|
479
|
+
const name = String(raw["name"] ?? "")
|
|
480
|
+
const driverId = stringOrNull(raw["driver"])
|
|
481
|
+
const declared = extractContributorIds(raw)
|
|
482
|
+
const approversRaw = (Array.isArray(raw["approvers"]) ? raw["approvers"] : []) as Array<Raw>
|
|
483
|
+
|
|
484
|
+
const tickets = yield* ticketsForVersion(name, customFieldNames, projectKey)
|
|
485
|
+
|
|
486
|
+
const contributorIds = declared.length > 0
|
|
487
|
+
? declared
|
|
488
|
+
: Array.from(new Set(tickets.map((t) => t.assignee?.accountId).filter((v): v is string => !!v)))
|
|
489
|
+
|
|
490
|
+
const driver = driverId ? yield* resolveUser(driverId) : null
|
|
491
|
+
const contributors = yield* Effect.forEach(contributorIds, (id) => resolveUser(id), { concurrency: 4 })
|
|
492
|
+
|
|
493
|
+
const approvers = yield* Effect.forEach(approversRaw, (a) =>
|
|
494
|
+
Effect.gen(function*() {
|
|
495
|
+
const accountId = stringOrNull(a["accountId"])
|
|
496
|
+
const person = accountId
|
|
497
|
+
? yield* resolveUser(accountId)
|
|
498
|
+
: (personFromObject(a) ?? { accountId: "<unknown>", displayName: "<unknown>", emailAddress: null })
|
|
499
|
+
return {
|
|
500
|
+
person,
|
|
501
|
+
status: String(a["status"] ?? "UNKNOWN").toUpperCase(),
|
|
502
|
+
declineReason: stringOrNull(a["declineReason"]),
|
|
503
|
+
description: stringOrNull(a["description"])
|
|
504
|
+
}
|
|
505
|
+
}), { concurrency: 4 })
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
id,
|
|
509
|
+
name,
|
|
510
|
+
description: stringOrNull(raw["description"]),
|
|
511
|
+
released: raw["released"] === true,
|
|
512
|
+
archived: raw["archived"] === true,
|
|
513
|
+
startDate: stringOrNull(raw["startDate"]),
|
|
514
|
+
releaseDate: stringOrNull(raw["releaseDate"]),
|
|
515
|
+
driver,
|
|
516
|
+
contributors,
|
|
517
|
+
approvers,
|
|
518
|
+
tickets,
|
|
519
|
+
url: stringOrNull(raw["self"]) ?? ""
|
|
520
|
+
}
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Map a version's scalar fields only — no ticket scan, no people resolution.
|
|
525
|
+
* Used for mutation responses ({@link updateVersion}) whose PUT payload carries
|
|
526
|
+
* no `expand`, so the heavy {@link ticketsForVersion} fan-out would only ever
|
|
527
|
+
* feed an empty `contributors` fallback. `driver`/`contributors`/`approvers`/
|
|
528
|
+
* `tickets` are returned empty.
|
|
529
|
+
*/
|
|
530
|
+
const mapVersionScalar = (raw: Raw): Version => ({
|
|
531
|
+
id: String(raw["id"] ?? ""),
|
|
532
|
+
name: String(raw["name"] ?? ""),
|
|
533
|
+
description: stringOrNull(raw["description"]),
|
|
534
|
+
released: raw["released"] === true,
|
|
535
|
+
archived: raw["archived"] === true,
|
|
536
|
+
startDate: stringOrNull(raw["startDate"]),
|
|
537
|
+
releaseDate: stringOrNull(raw["releaseDate"]),
|
|
538
|
+
driver: null,
|
|
539
|
+
contributors: [],
|
|
540
|
+
approvers: [],
|
|
541
|
+
tickets: [],
|
|
542
|
+
url: stringOrNull(raw["self"]) ?? ""
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
const PAGE_SIZE = 50
|
|
546
|
+
const MAX_PAGES = 200
|
|
547
|
+
|
|
548
|
+
const listProjectVersions = (
|
|
549
|
+
projectKey: string,
|
|
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
|
+
)
|
|
567
|
+
|
|
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)
|
|
574
|
+
if (cap !== undefined && all.length >= cap) break
|
|
575
|
+
}
|
|
576
|
+
if (cap !== undefined && all.length >= cap) break
|
|
577
|
+
const isLast = resObj["isLast"]
|
|
578
|
+
if (isLast === true || values.length < PAGE_SIZE) break
|
|
579
|
+
startAt += values.length
|
|
580
|
+
}
|
|
581
|
+
return yield* Effect.forEach(all, (r) => mapVersion(r, customFieldNames, projectKey), { concurrency: 4 })
|
|
582
|
+
}) as Effect.Effect<ReadonlyArray<Version>, JiraApiError>)
|
|
583
|
+
|
|
584
|
+
const getVersion = (id: string): Effect.Effect<Version, JiraApiError> =>
|
|
585
|
+
toEffect(
|
|
586
|
+
client.v3.client.GET("/rest/api/3/version/{id}", {
|
|
587
|
+
params: { path: { id }, query: { expand: EXPAND } }
|
|
588
|
+
})
|
|
589
|
+
).pipe(
|
|
590
|
+
Effect.mapError((cause) => new JiraApiError({ message: `Failed to get version ${id}`, cause })),
|
|
591
|
+
Effect.flatMap((raw) => mapVersion(asRaw(raw), []))
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
const updateVersion = (id: string, input: UpdateVersionInput): Effect.Effect<Version, JiraApiError> =>
|
|
595
|
+
toEffect(
|
|
596
|
+
client.v3.client.PUT("/rest/api/3/version/{id}", {
|
|
597
|
+
params: { path: { id } },
|
|
598
|
+
body: { ...(input.description !== undefined ? { description: input.description } : {}) }
|
|
599
|
+
})
|
|
600
|
+
).pipe(
|
|
601
|
+
Effect.mapError((cause) => new JiraApiError({ message: `Failed to update version ${id}`, cause })),
|
|
602
|
+
Effect.map((raw) => mapVersionScalar(asRaw(raw)))
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
const listRelatedWork = (id: string): Effect.Effect<ReadonlyArray<RelatedWork>, JiraApiError> =>
|
|
606
|
+
toEffect(
|
|
607
|
+
client.v3.client.GET("/rest/api/3/version/{id}/relatedwork", { params: { path: { id } } })
|
|
608
|
+
).pipe(
|
|
609
|
+
Effect.mapError((cause) => new JiraApiError({ message: `Failed to list related work for version ${id}`, cause })),
|
|
610
|
+
Effect.map((raw) => (Array.isArray(raw) ? raw : []).map(toRelatedWork))
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
const addRelatedWork = (id: string, input: AddRelatedWorkInput): Effect.Effect<RelatedWork, JiraApiError> =>
|
|
614
|
+
toEffect(
|
|
615
|
+
client.v3.client.POST("/rest/api/3/version/{id}/relatedwork", {
|
|
616
|
+
params: { path: { id } },
|
|
617
|
+
body: { title: input.title, category: input.category, url: input.url }
|
|
618
|
+
})
|
|
619
|
+
).pipe(
|
|
620
|
+
Effect.mapError((cause) => new JiraApiError({ message: `Failed to add related work to version ${id}`, cause })),
|
|
621
|
+
Effect.map((raw) => {
|
|
622
|
+
const w = toRelatedWork(raw)
|
|
623
|
+
// POST echoes the created entity; fall back to the input we sent.
|
|
624
|
+
return {
|
|
625
|
+
relatedWorkId: w.relatedWorkId,
|
|
626
|
+
title: w.title ?? input.title,
|
|
627
|
+
category: w.category || input.category,
|
|
628
|
+
url: w.url ?? input.url
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return VersionService.of({
|
|
634
|
+
listProjectVersions,
|
|
635
|
+
getVersion,
|
|
636
|
+
updateVersion,
|
|
637
|
+
listRelatedWork,
|
|
638
|
+
addRelatedWork
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Layer for VersionService.
|
|
644
|
+
*
|
|
645
|
+
* @category Layers
|
|
646
|
+
*/
|
|
647
|
+
export const layer = Layer.effect(VersionService, make)
|