@knpkv/jira-cli 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +66 -4
  3. package/dist/IssueService.d.ts +2 -2
  4. package/dist/IssueService.d.ts.map +1 -1
  5. package/dist/IssueService.js +3 -3
  6. package/dist/IssueService.js.map +1 -1
  7. package/dist/JiraAuth.d.ts +14 -14
  8. package/dist/JiraAuth.d.ts.map +1 -1
  9. package/dist/JiraAuth.js +18 -10
  10. package/dist/JiraAuth.js.map +1 -1
  11. package/dist/JiraCliError.d.ts +30 -0
  12. package/dist/JiraCliError.d.ts.map +1 -1
  13. package/dist/JiraCliError.js +14 -0
  14. package/dist/JiraCliError.js.map +1 -1
  15. package/dist/MarkdownWriter.d.ts +4 -4
  16. package/dist/MarkdownWriter.d.ts.map +1 -1
  17. package/dist/MarkdownWriter.js +6 -6
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +34 -0
  20. package/dist/SyncWorkspace.d.ts.map +1 -0
  21. package/dist/SyncWorkspace.js +105 -0
  22. package/dist/SyncWorkspace.js.map +1 -0
  23. package/dist/VersionService.d.ts +206 -0
  24. package/dist/VersionService.d.ts.map +1 -0
  25. package/dist/VersionService.js +426 -0
  26. package/dist/VersionService.js.map +1 -0
  27. package/dist/bin.js +29 -22
  28. package/dist/bin.js.map +1 -1
  29. package/dist/commands/auth.d.ts +2 -21
  30. package/dist/commands/auth.d.ts.map +1 -1
  31. package/dist/commands/auth.js +6 -6
  32. package/dist/commands/auth.js.map +1 -1
  33. package/dist/commands/get.d.ts +3 -8
  34. package/dist/commands/get.d.ts.map +1 -1
  35. package/dist/commands/get.js +4 -4
  36. package/dist/commands/get.js.map +1 -1
  37. package/dist/commands/index.d.ts +2 -2
  38. package/dist/commands/index.d.ts.map +1 -1
  39. package/dist/commands/index.js +2 -2
  40. package/dist/commands/index.js.map +1 -1
  41. package/dist/commands/issue.d.ts +8 -0
  42. package/dist/commands/issue.d.ts.map +1 -0
  43. package/dist/commands/issue.js +10 -0
  44. package/dist/commands/issue.js.map +1 -0
  45. package/dist/commands/layers.d.ts +6 -18
  46. package/dist/commands/layers.d.ts.map +1 -1
  47. package/dist/commands/layers.js +35 -24
  48. package/dist/commands/layers.js.map +1 -1
  49. package/dist/commands/search.d.ts +3 -8
  50. package/dist/commands/search.d.ts.map +1 -1
  51. package/dist/commands/search.js +8 -8
  52. package/dist/commands/search.js.map +1 -1
  53. package/dist/commands/version.d.ts +12 -0
  54. package/dist/commands/version.d.ts.map +1 -0
  55. package/dist/commands/version.js +179 -0
  56. package/dist/commands/version.js.map +1 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -1
  58. package/dist/internal/frontmatter.js +14 -1
  59. package/dist/internal/frontmatter.js.map +1 -1
  60. package/dist/internal/oauthServer.d.ts +17 -5
  61. package/dist/internal/oauthServer.d.ts.map +1 -1
  62. package/dist/internal/oauthServer.js +23 -40
  63. package/dist/internal/oauthServer.js.map +1 -1
  64. package/dist/internal/openBrowser.d.ts +10 -0
  65. package/dist/internal/openBrowser.d.ts.map +1 -0
  66. package/dist/internal/openBrowser.js +17 -0
  67. package/dist/internal/openBrowser.js.map +1 -0
  68. package/dist/internal/sync/baseline.d.ts +11 -0
  69. package/dist/internal/sync/baseline.d.ts.map +1 -0
  70. package/dist/internal/sync/baseline.js +18 -0
  71. package/dist/internal/sync/baseline.js.map +1 -0
  72. package/dist/internal/sync/changes.d.ts +15 -0
  73. package/dist/internal/sync/changes.d.ts.map +1 -0
  74. package/dist/internal/sync/changes.js +72 -0
  75. package/dist/internal/sync/changes.js.map +1 -0
  76. package/dist/internal/sync/config.d.ts +12 -0
  77. package/dist/internal/sync/config.d.ts.map +1 -0
  78. package/dist/internal/sync/config.js +53 -0
  79. package/dist/internal/sync/config.js.map +1 -0
  80. package/dist/internal/sync/document.d.ts +9 -0
  81. package/dist/internal/sync/document.d.ts.map +1 -0
  82. package/dist/internal/sync/document.js +173 -0
  83. package/dist/internal/sync/document.js.map +1 -0
  84. package/dist/internal/sync/fieldValues.d.ts +30 -0
  85. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  86. package/dist/internal/sync/fieldValues.js +91 -0
  87. package/dist/internal/sync/fieldValues.js.map +1 -0
  88. package/dist/internal/sync/manifest.d.ts +12 -0
  89. package/dist/internal/sync/manifest.d.ts.map +1 -0
  90. package/dist/internal/sync/manifest.js +23 -0
  91. package/dist/internal/sync/manifest.js.map +1 -0
  92. package/dist/internal/sync/paths.d.ts +26 -0
  93. package/dist/internal/sync/paths.d.ts.map +1 -0
  94. package/dist/internal/sync/paths.js +22 -0
  95. package/dist/internal/sync/paths.js.map +1 -0
  96. package/dist/internal/sync/schemas.d.ts +128 -0
  97. package/dist/internal/sync/schemas.d.ts.map +1 -0
  98. package/dist/internal/sync/schemas.js +82 -0
  99. package/dist/internal/sync/schemas.js.map +1 -0
  100. package/dist/internal/sync/types.d.ts +144 -0
  101. package/dist/internal/sync/types.d.ts.map +1 -0
  102. package/dist/internal/sync/types.js +17 -0
  103. package/dist/internal/sync/types.js.map +1 -0
  104. package/package.json +13 -12
  105. package/skills/jira/SKILL.md +90 -0
  106. package/skills/jira/agents/openai.yaml +4 -0
  107. package/src/IssueService.ts +34 -28
  108. package/src/JiraAuth.ts +53 -39
  109. package/src/JiraCliError.ts +24 -0
  110. package/src/MarkdownWriter.ts +7 -11
  111. package/src/SyncWorkspace.ts +185 -0
  112. package/src/VersionService.ts +647 -0
  113. package/src/bin.ts +39 -29
  114. package/src/commands/auth.ts +6 -12
  115. package/src/commands/get.ts +4 -4
  116. package/src/commands/index.ts +2 -2
  117. package/src/commands/issue.ts +13 -0
  118. package/src/commands/layers.ts +44 -26
  119. package/src/commands/search.ts +8 -8
  120. package/src/commands/version.ts +267 -0
  121. package/src/internal/frontmatter.ts +15 -1
  122. package/src/internal/oauthServer.ts +43 -70
  123. package/src/internal/openBrowser.ts +31 -0
  124. package/src/internal/sync/baseline.ts +27 -0
  125. package/src/internal/sync/changes.ts +118 -0
  126. package/src/internal/sync/config.ts +76 -0
  127. package/src/internal/sync/document.ts +201 -0
  128. package/src/internal/sync/fieldValues.ts +145 -0
  129. package/src/internal/sync/manifest.ts +32 -0
  130. package/src/internal/sync/paths.ts +48 -0
  131. package/src/internal/sync/schemas.ts +103 -0
  132. package/src/internal/sync/types.ts +192 -0
  133. package/test/SyncWorkspace.test.ts +76 -0
  134. package/test/VersionService.test.ts +266 -0
  135. package/test/commandTree.test.ts +266 -0
  136. package/test/frontmatter.test.ts +69 -0
  137. package/test/integration.test.ts +187 -0
  138. package/test/syncChanges.test.ts +106 -0
  139. package/test/syncConfig.test.ts +138 -0
  140. package/test/syncDocument.test.ts +69 -0
  141. package/test/syncFieldValues.test.ts +101 -0
  142. package/vitest.config.integration.ts +17 -0
  143. package/vitest.config.ts +6 -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)