@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +63 -1
  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/MarkdownWriter.d.ts +4 -4
  12. package/dist/MarkdownWriter.d.ts.map +1 -1
  13. package/dist/MarkdownWriter.js +6 -6
  14. package/dist/MarkdownWriter.js.map +1 -1
  15. package/dist/VersionService.d.ts +206 -0
  16. package/dist/VersionService.d.ts.map +1 -0
  17. package/dist/VersionService.js +426 -0
  18. package/dist/VersionService.js.map +1 -0
  19. package/dist/bin.js +28 -20
  20. package/dist/bin.js.map +1 -1
  21. package/dist/commands/auth.d.ts +2 -21
  22. package/dist/commands/auth.d.ts.map +1 -1
  23. package/dist/commands/auth.js +6 -6
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/get.d.ts +3 -8
  26. package/dist/commands/get.d.ts.map +1 -1
  27. package/dist/commands/get.js +2 -2
  28. package/dist/commands/get.js.map +1 -1
  29. package/dist/commands/index.d.ts +1 -0
  30. package/dist/commands/index.d.ts.map +1 -1
  31. package/dist/commands/index.js +1 -0
  32. package/dist/commands/index.js.map +1 -1
  33. package/dist/commands/layers.d.ts +6 -18
  34. package/dist/commands/layers.d.ts.map +1 -1
  35. package/dist/commands/layers.js +31 -23
  36. package/dist/commands/layers.js.map +1 -1
  37. package/dist/commands/search.d.ts +3 -8
  38. package/dist/commands/search.d.ts.map +1 -1
  39. package/dist/commands/search.js +4 -4
  40. package/dist/commands/search.js.map +1 -1
  41. package/dist/commands/version.d.ts +12 -0
  42. package/dist/commands/version.d.ts.map +1 -0
  43. package/dist/commands/version.js +179 -0
  44. package/dist/commands/version.js.map +1 -0
  45. package/dist/internal/oauthServer.d.ts +17 -5
  46. package/dist/internal/oauthServer.d.ts.map +1 -1
  47. package/dist/internal/oauthServer.js +23 -40
  48. package/dist/internal/oauthServer.js.map +1 -1
  49. package/dist/internal/openBrowser.d.ts +10 -0
  50. package/dist/internal/openBrowser.d.ts.map +1 -0
  51. package/dist/internal/openBrowser.js +17 -0
  52. package/dist/internal/openBrowser.js.map +1 -0
  53. package/package.json +10 -12
  54. package/skills/jira/SKILL.md +90 -0
  55. package/skills/jira/agents/openai.yaml +4 -0
  56. package/src/IssueService.ts +34 -28
  57. package/src/JiraAuth.ts +53 -39
  58. package/src/MarkdownWriter.ts +7 -11
  59. package/src/VersionService.ts +647 -0
  60. package/src/bin.ts +38 -26
  61. package/src/commands/auth.ts +6 -12
  62. package/src/commands/get.ts +2 -2
  63. package/src/commands/index.ts +1 -0
  64. package/src/commands/layers.ts +40 -25
  65. package/src/commands/search.ts +4 -4
  66. package/src/commands/version.ts +267 -0
  67. package/src/internal/oauthServer.ts +43 -70
  68. package/src/internal/openBrowser.ts +31 -0
  69. package/test/VersionService.test.ts +266 -0
  70. package/vitest.config.ts +5 -0
@@ -0,0 +1,206 @@
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 } 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 { JiraApiError } from "./JiraCliError.js";
34
+ /**
35
+ * A resolved Jira user (account ID + display name).
36
+ *
37
+ * @category Types
38
+ */
39
+ export interface Person {
40
+ readonly accountId: string;
41
+ readonly displayName: string;
42
+ /** Resolved email address (PII). Stripped from `version --json` unless `--emails` is passed. */
43
+ readonly emailAddress: string | null;
44
+ }
45
+ /**
46
+ * One approval line on a version.
47
+ *
48
+ * @category Types
49
+ */
50
+ export interface Approver {
51
+ readonly person: Person;
52
+ /** APPROVED | DECLINED | PENDING (Jira returns it uppercase). */
53
+ readonly status: string;
54
+ readonly declineReason: string | null;
55
+ readonly description: string | null;
56
+ }
57
+ /**
58
+ * A ticket with the version set as its fixVersion. Carries the minimum metadata
59
+ * needed by SOC2-style audits: assignee (for contributor derivation), labels
60
+ * (for impact-tagging checks), and summary (for human-readable evidence).
61
+ *
62
+ * @category Types
63
+ */
64
+ export interface VersionTicket {
65
+ readonly key: string;
66
+ readonly summary: string | null;
67
+ readonly assignee: Person | null;
68
+ readonly labels: ReadonlyArray<string>;
69
+ /**
70
+ * Values of any custom fields the caller asked to include (see
71
+ * {@link ListVersionsOptions.customFieldNames}). Keyed by the field's display
72
+ * name (the same string the caller passed in).
73
+ */
74
+ readonly customFields: Readonly<Record<string, string | null>>;
75
+ }
76
+ /**
77
+ * A project version (release) with people fields resolved.
78
+ *
79
+ * @category Types
80
+ */
81
+ export interface Version {
82
+ readonly id: string;
83
+ readonly name: string;
84
+ readonly description: string | null;
85
+ readonly released: boolean;
86
+ readonly archived: boolean;
87
+ readonly startDate: string | null;
88
+ readonly releaseDate: string | null;
89
+ readonly driver: Person | null;
90
+ readonly contributors: ReadonlyArray<Person>;
91
+ readonly approvers: ReadonlyArray<Approver>;
92
+ readonly tickets: ReadonlyArray<VersionTicket>;
93
+ readonly url: string;
94
+ }
95
+ /**
96
+ * A "Related work" link on a version (e.g. a Confluence page surfaced on the
97
+ * release report). `category` is a free-form string Jira groups by — common
98
+ * values are `Communication`, `Testing`, `Design`.
99
+ *
100
+ * @category Types
101
+ */
102
+ export interface RelatedWork {
103
+ readonly relatedWorkId: string | null;
104
+ readonly title: string | null;
105
+ readonly category: string;
106
+ readonly url: string | null;
107
+ }
108
+ /**
109
+ * Input for attaching a new "Related work" link to a version.
110
+ *
111
+ * @category Types
112
+ */
113
+ export interface AddRelatedWorkInput {
114
+ readonly title: string;
115
+ readonly category: string;
116
+ readonly url: string;
117
+ }
118
+ /**
119
+ * Editable version fields. Only the provided keys are sent to Jira.
120
+ *
121
+ * @category Types
122
+ */
123
+ export interface UpdateVersionInput {
124
+ readonly description?: string;
125
+ }
126
+ /**
127
+ * Filters for listing versions.
128
+ *
129
+ * @category Types
130
+ */
131
+ export interface ListVersionsOptions {
132
+ /** Restrict to released versions. */
133
+ readonly released?: boolean;
134
+ /** Restrict to unreleased versions. */
135
+ readonly unreleased?: boolean;
136
+ /** Hard cap on the number of versions fetched (default: all). */
137
+ readonly maxResults?: number;
138
+ /**
139
+ * Custom field **display names** (e.g. `"Security & Compliance Impact"`)
140
+ * whose values should be populated on each {@link VersionTicket.customFields}
141
+ * map. Names are resolved to per-instance field IDs via `/rest/api/3/field`,
142
+ * cached per service instance.
143
+ */
144
+ readonly customFieldNames?: ReadonlyArray<string>;
145
+ }
146
+ /**
147
+ * VersionService interface.
148
+ *
149
+ * @category Services
150
+ */
151
+ export interface VersionServiceShape {
152
+ readonly listProjectVersions: (projectKey: string, options?: ListVersionsOptions) => Effect.Effect<ReadonlyArray<Version>, JiraApiError>;
153
+ readonly getVersion: (id: string) => Effect.Effect<Version, JiraApiError>;
154
+ /** Update editable fields (currently description) on a version. Needs `manage:jira-project`. */
155
+ readonly updateVersion: (id: string, input: UpdateVersionInput) => Effect.Effect<Version, JiraApiError>;
156
+ /** List the "Related work" links attached to a version. */
157
+ readonly listRelatedWork: (id: string) => Effect.Effect<ReadonlyArray<RelatedWork>, JiraApiError>;
158
+ /** Attach a "Related work" link (e.g. a Confluence page) to a version. Needs `manage:jira-project`. */
159
+ readonly addRelatedWork: (id: string, input: AddRelatedWorkInput) => Effect.Effect<RelatedWork, JiraApiError>;
160
+ }
161
+ declare const VersionService_base: Context.ServiceClass<VersionService, "@knpkv/jira-cli/VersionService", VersionServiceShape>;
162
+ /**
163
+ * VersionService tag.
164
+ *
165
+ * @example
166
+ * ```typescript
167
+ * import { Effect } from "effect"
168
+ * import { VersionService } from "@knpkv/jira-cli/VersionService"
169
+ *
170
+ * Effect.gen(function* () {
171
+ * const versions = yield* VersionService
172
+ * const list = yield* versions.listProjectVersions("RPS", { released: true })
173
+ * console.log(`Found ${list.length} released versions`)
174
+ * })
175
+ * ```
176
+ *
177
+ * @category Services
178
+ */
179
+ export declare class VersionService extends VersionService_base {
180
+ }
181
+ /** Loosely-typed record helper for navigating untyped API JSON. */
182
+ type Raw = Record<string, unknown>;
183
+ /**
184
+ * Render a Jira custom-field value as a flat string.
185
+ *
186
+ * Handles the common shapes returned by `/rest/api/3/search/jql`:
187
+ * - cascading select: `{ value, child: { value } }` → `"Parent > Child"`
188
+ * - single select / option: `{ value }` → `"Parent"`
189
+ * - user object: `{ displayName }` → display name
190
+ * - plain string/number → coerced to string
191
+ * - array of any of the above → values joined with `, `
192
+ * - null / unset / unknown shape → `null`
193
+ */
194
+ export declare const renderCustomFieldValue: (raw: unknown) => string | null;
195
+ export declare const personFromObject: (raw: unknown, fallbackId?: string) => Person | null;
196
+ export declare const extractContributorIds: (raw: Raw) => ReadonlyArray<string>;
197
+ /** Normalise a Jira "Related work" entry into a {@link RelatedWork}. */
198
+ export declare const toRelatedWork: (raw: unknown) => RelatedWork;
199
+ /**
200
+ * Layer for VersionService.
201
+ *
202
+ * @category Layers
203
+ */
204
+ export declare const layer: Layer.Layer<VersionService, never, JiraApiClient>;
205
+ export {};
206
+ //# sourceMappingURL=VersionService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VersionService.d.ts","sourceRoot":"","sources":["../src/VersionService.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,426 @@
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
+ * VersionService tag.
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * import { Effect } from "effect"
41
+ * import { VersionService } from "@knpkv/jira-cli/VersionService"
42
+ *
43
+ * Effect.gen(function* () {
44
+ * const versions = yield* VersionService
45
+ * const list = yield* versions.listProjectVersions("RPS", { released: true })
46
+ * console.log(`Found ${list.length} released versions`)
47
+ * })
48
+ * ```
49
+ *
50
+ * @category Services
51
+ */
52
+ export class VersionService extends Context.Service()("@knpkv/jira-cli/VersionService") {
53
+ }
54
+ const EXPAND = "approvers,driver,operations,issuesstatus,contributors";
55
+ const asRaw = (v) => (v && typeof v === "object" ? v : {});
56
+ /**
57
+ * Render a Jira custom-field value as a flat string.
58
+ *
59
+ * Handles the common shapes returned by `/rest/api/3/search/jql`:
60
+ * - cascading select: `{ value, child: { value } }` → `"Parent > Child"`
61
+ * - single select / option: `{ value }` → `"Parent"`
62
+ * - user object: `{ displayName }` → display name
63
+ * - plain string/number → coerced to string
64
+ * - array of any of the above → values joined with `, `
65
+ * - null / unset / unknown shape → `null`
66
+ */
67
+ export const renderCustomFieldValue = (raw) => {
68
+ if (raw === null || raw === undefined)
69
+ return null;
70
+ if (typeof raw === "string")
71
+ return raw.length > 0 ? raw : null;
72
+ if (typeof raw === "number" || typeof raw === "boolean")
73
+ return String(raw);
74
+ if (Array.isArray(raw)) {
75
+ const parts = raw.map(renderCustomFieldValue).filter((v) => !!v);
76
+ return parts.length > 0 ? parts.join(", ") : null;
77
+ }
78
+ if (typeof raw === "object") {
79
+ const obj = raw;
80
+ const parent = stringOrNull(obj["value"]);
81
+ if (parent) {
82
+ const child = obj["child"];
83
+ if (child && typeof child === "object") {
84
+ const childValue = stringOrNull(child["value"]);
85
+ if (childValue)
86
+ return `${parent} > ${childValue}`;
87
+ }
88
+ return parent;
89
+ }
90
+ const displayName = stringOrNull(obj["displayName"]);
91
+ if (displayName)
92
+ return displayName;
93
+ const name = stringOrNull(obj["name"]);
94
+ if (name)
95
+ return name;
96
+ }
97
+ return null;
98
+ };
99
+ const stringOrNull = (v) => typeof v === "string" && v.length > 0 ? v : null;
100
+ export const personFromObject = (raw, fallbackId) => {
101
+ if (raw && typeof raw === "object") {
102
+ const obj = raw;
103
+ const accountId = stringOrNull(obj["accountId"]) ?? fallbackId ?? null;
104
+ if (!accountId)
105
+ return null;
106
+ return {
107
+ accountId,
108
+ displayName: stringOrNull(obj["displayName"]) ?? accountId,
109
+ emailAddress: stringOrNull(obj["emailAddress"])
110
+ };
111
+ }
112
+ if (typeof raw === "string" && raw.length > 0) {
113
+ return { accountId: raw, displayName: raw, emailAddress: null };
114
+ }
115
+ return null;
116
+ };
117
+ export const extractContributorIds = (raw) => {
118
+ // Jira Premium *may* return `contributors` on the version (undocumented in the
119
+ // public OpenAPI spec) — read defensively. In practice we've observed it
120
+ // empty, hence the assignee-based fallback below.
121
+ const field = raw["contributors"];
122
+ if (!Array.isArray(field))
123
+ return [];
124
+ const ids = [];
125
+ for (const c of field) {
126
+ if (typeof c === "string" && c.length > 0)
127
+ ids.push(c);
128
+ else if (c && typeof c === "object") {
129
+ const id = c["accountId"];
130
+ if (typeof id === "string" && id.length > 0)
131
+ ids.push(id);
132
+ }
133
+ }
134
+ return ids;
135
+ };
136
+ /** Normalise a Jira "Related work" entry into a {@link RelatedWork}. */
137
+ export const toRelatedWork = (raw) => {
138
+ const o = asRaw(raw);
139
+ return {
140
+ relatedWorkId: stringOrNull(o["relatedWorkId"]),
141
+ title: stringOrNull(o["title"]),
142
+ category: stringOrNull(o["category"]) ?? "",
143
+ url: stringOrNull(o["url"])
144
+ };
145
+ };
146
+ const make = Effect.gen(function* () {
147
+ const client = yield* JiraApiClient;
148
+ const userCache = new Map();
149
+ // In-flight lookups keyed by accountId so concurrent callers (bounded by the
150
+ // `concurrency: 4` fan-outs) share a single request instead of duplicating it.
151
+ const userInFlight = new Map();
152
+ // Cached lookup of all custom field IDs sharing a given display name.
153
+ const fieldIdsByName = new Map();
154
+ const resolveFieldIds = (name) => Effect.gen(function* () {
155
+ const cached = fieldIdsByName.get(name);
156
+ if (cached !== undefined)
157
+ return cached;
158
+ const result = yield* toEffect(client.v3.client.GET("/rest/api/3/field")).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to list Jira fields`, cause })));
159
+ const matches = [];
160
+ if (Array.isArray(result)) {
161
+ for (const f of result) {
162
+ if (f && typeof f === "object") {
163
+ const obj = f;
164
+ if (obj["name"] === name && typeof obj["id"] === "string") {
165
+ matches.push(obj["id"]);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ fieldIdsByName.set(name, matches);
171
+ return matches;
172
+ });
173
+ const fetchUser = (accountId) => toEffect(client.v3.client.GET("/rest/api/3/user", { params: { query: { accountId } } })).pipe(Effect.map((u) => {
174
+ const obj = asRaw(u);
175
+ const person = {
176
+ accountId,
177
+ displayName: stringOrNull(obj["displayName"]) ?? accountId,
178
+ emailAddress: stringOrNull(obj["emailAddress"])
179
+ };
180
+ userCache.set(accountId, person);
181
+ return person;
182
+ }), Effect.catch(() => {
183
+ // User may be deleted / inaccessible — fall back to bare account id.
184
+ const fallback = { accountId, displayName: accountId, emailAddress: null };
185
+ userCache.set(accountId, fallback);
186
+ return Effect.succeed(fallback);
187
+ }),
188
+ // Drop the in-flight memo once resolved so a later miss can refetch.
189
+ Effect.ensuring(Effect.sync(() => userInFlight.delete(accountId))));
190
+ const resolveUser = (accountId) => Effect.gen(function* () {
191
+ const cached = userCache.get(accountId);
192
+ if (cached)
193
+ return cached;
194
+ const existing = userInFlight.get(accountId);
195
+ if (existing)
196
+ return yield* existing;
197
+ // `Effect.cached` shares one execution across all awaiters of the returned
198
+ // effect. Building it and storing it in `userInFlight` happens in
199
+ // synchronous effect steps (no async boundary), so concurrent uncached
200
+ // callers — bounded by the `concurrency: 4` fan-outs — dedupe to one
201
+ // request rather than each issuing their own.
202
+ const shared = yield* Effect.cached(fetchUser(accountId));
203
+ userInFlight.set(accountId, shared);
204
+ return yield* shared;
205
+ });
206
+ /**
207
+ * Fetch every ticket whose `fixVersion` matches `versionName`, returning the
208
+ * minimum metadata downstream audits need (key, summary, assignee, labels).
209
+ *
210
+ * `projectKey` scopes the query to a single project so version names that
211
+ * collide across projects (e.g. `"1.0.0"`) don't pull in unrelated issues.
212
+ * When omitted (e.g. {@link getVersion}, which has no project context), the
213
+ * query is instance-wide and may match same-named versions in other projects.
214
+ */
215
+ const ticketsForVersion = (versionName, customFieldNames, projectKey) => Effect.gen(function* () {
216
+ const nameToIds = new Map();
217
+ for (const name of customFieldNames) {
218
+ const ids = yield* resolveFieldIds(name);
219
+ nameToIds.set(name, ids);
220
+ }
221
+ const allFieldIds = new Set();
222
+ for (const ids of nameToIds.values())
223
+ for (const id of ids)
224
+ allFieldIds.add(id);
225
+ const requestedFields = ["assignee", "labels", "summary", ...allFieldIds];
226
+ const raws = [];
227
+ const PAGE = 100;
228
+ const MAX_PAGES = 100;
229
+ let nextPageToken = undefined;
230
+ for (let page = 0; page < MAX_PAGES; page++) {
231
+ const result = yield* toEffect(client.v3.client.GET("/rest/api/3/search/jql", {
232
+ params: {
233
+ query: {
234
+ jql: buildByVersionJql(versionName, projectKey),
235
+ fields: requestedFields,
236
+ maxResults: PAGE,
237
+ ...(nextPageToken ? { nextPageToken } : {})
238
+ }
239
+ }
240
+ })).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to fetch tickets for fixVersion "${versionName}"`, cause })));
241
+ const resObj = asRaw(result);
242
+ const issues = (Array.isArray(resObj["issues"]) ? resObj["issues"] : []);
243
+ for (const issue of issues) {
244
+ const key = typeof issue["key"] === "string" ? issue["key"] : "";
245
+ const fields = asRaw(issue["fields"]);
246
+ const assignee = fields["assignee"];
247
+ let assigneeId = null;
248
+ if (assignee && typeof assignee === "object") {
249
+ const accountId = assignee["accountId"];
250
+ if (typeof accountId === "string" && accountId.length > 0)
251
+ assigneeId = accountId;
252
+ }
253
+ const labelsRaw = fields["labels"];
254
+ const labels = [];
255
+ if (Array.isArray(labelsRaw)) {
256
+ for (const l of labelsRaw)
257
+ if (typeof l === "string" && l.length > 0)
258
+ labels.push(l);
259
+ }
260
+ const customFields = {};
261
+ for (const name of customFieldNames) {
262
+ const ids = nameToIds.get(name) ?? [];
263
+ let resolved = null;
264
+ for (const id of ids) {
265
+ const v = renderCustomFieldValue(fields[id]);
266
+ if (v !== null) {
267
+ resolved = v;
268
+ break;
269
+ }
270
+ }
271
+ customFields[name] = resolved;
272
+ }
273
+ raws.push({
274
+ key,
275
+ summary: stringOrNull(fields["summary"]),
276
+ assigneeId,
277
+ labels,
278
+ customFields
279
+ });
280
+ }
281
+ const isLast = resObj["isLast"];
282
+ const next = resObj["nextPageToken"];
283
+ if (isLast === true || typeof next !== "string" || next.length === 0)
284
+ break;
285
+ nextPageToken = next;
286
+ }
287
+ const uniqueAssignees = Array.from(new Set(raws.map((t) => t.assigneeId).filter((id) => !!id)));
288
+ yield* Effect.forEach(uniqueAssignees, (id) => resolveUser(id), { concurrency: 4 });
289
+ return raws.map((t) => ({
290
+ key: t.key,
291
+ summary: t.summary,
292
+ assignee: t.assigneeId ? userCache.get(t.assigneeId) ?? null : null,
293
+ labels: t.labels,
294
+ customFields: t.customFields
295
+ }));
296
+ });
297
+ const mapVersion = (raw, customFieldNames, projectKey) => Effect.gen(function* () {
298
+ const id = String(raw["id"] ?? "");
299
+ const name = String(raw["name"] ?? "");
300
+ const driverId = stringOrNull(raw["driver"]);
301
+ const declared = extractContributorIds(raw);
302
+ const approversRaw = (Array.isArray(raw["approvers"]) ? raw["approvers"] : []);
303
+ const tickets = yield* ticketsForVersion(name, customFieldNames, projectKey);
304
+ const contributorIds = declared.length > 0
305
+ ? declared
306
+ : Array.from(new Set(tickets.map((t) => t.assignee?.accountId).filter((v) => !!v)));
307
+ const driver = driverId ? yield* resolveUser(driverId) : null;
308
+ const contributors = yield* Effect.forEach(contributorIds, (id) => resolveUser(id), { concurrency: 4 });
309
+ const approvers = yield* Effect.forEach(approversRaw, (a) => Effect.gen(function* () {
310
+ const accountId = stringOrNull(a["accountId"]);
311
+ const person = accountId
312
+ ? yield* resolveUser(accountId)
313
+ : (personFromObject(a) ?? { accountId: "<unknown>", displayName: "<unknown>", emailAddress: null });
314
+ return {
315
+ person,
316
+ status: String(a["status"] ?? "UNKNOWN").toUpperCase(),
317
+ declineReason: stringOrNull(a["declineReason"]),
318
+ description: stringOrNull(a["description"])
319
+ };
320
+ }), { concurrency: 4 });
321
+ return {
322
+ id,
323
+ name,
324
+ description: stringOrNull(raw["description"]),
325
+ released: raw["released"] === true,
326
+ archived: raw["archived"] === true,
327
+ startDate: stringOrNull(raw["startDate"]),
328
+ releaseDate: stringOrNull(raw["releaseDate"]),
329
+ driver,
330
+ contributors,
331
+ approvers,
332
+ tickets,
333
+ url: stringOrNull(raw["self"]) ?? ""
334
+ };
335
+ });
336
+ /**
337
+ * Map a version's scalar fields only — no ticket scan, no people resolution.
338
+ * Used for mutation responses ({@link updateVersion}) whose PUT payload carries
339
+ * no `expand`, so the heavy {@link ticketsForVersion} fan-out would only ever
340
+ * feed an empty `contributors` fallback. `driver`/`contributors`/`approvers`/
341
+ * `tickets` are returned empty.
342
+ */
343
+ const mapVersionScalar = (raw) => ({
344
+ id: String(raw["id"] ?? ""),
345
+ name: String(raw["name"] ?? ""),
346
+ description: stringOrNull(raw["description"]),
347
+ released: raw["released"] === true,
348
+ archived: raw["archived"] === true,
349
+ startDate: stringOrNull(raw["startDate"]),
350
+ releaseDate: stringOrNull(raw["releaseDate"]),
351
+ driver: null,
352
+ contributors: [],
353
+ approvers: [],
354
+ tickets: [],
355
+ url: stringOrNull(raw["self"]) ?? ""
356
+ });
357
+ const PAGE_SIZE = 50;
358
+ const MAX_PAGES = 200;
359
+ const listProjectVersions = (projectKey, options) => Effect.gen(function* () {
360
+ const all = [];
361
+ let startAt = 0;
362
+ const cap = options?.maxResults;
363
+ const customFieldNames = options?.customFieldNames ?? [];
364
+ for (let page = 0; page < MAX_PAGES; page++) {
365
+ const result = yield* toEffect(client.v3.client.GET("/rest/api/3/project/{projectIdOrKey}/version", {
366
+ params: {
367
+ path: { projectIdOrKey: projectKey },
368
+ query: { startAt, maxResults: PAGE_SIZE, expand: EXPAND, orderBy: "-releaseDate" }
369
+ }
370
+ })).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to list versions for ${projectKey}`, cause })));
371
+ const resObj = asRaw(result);
372
+ const values = (Array.isArray(resObj["values"]) ? resObj["values"] : []);
373
+ for (const v of values) {
374
+ if (options?.released === true && v["released"] !== true)
375
+ continue;
376
+ if (options?.unreleased === true && v["released"] === true)
377
+ continue;
378
+ all.push(v);
379
+ if (cap !== undefined && all.length >= cap)
380
+ break;
381
+ }
382
+ if (cap !== undefined && all.length >= cap)
383
+ break;
384
+ const isLast = resObj["isLast"];
385
+ if (isLast === true || values.length < PAGE_SIZE)
386
+ break;
387
+ startAt += values.length;
388
+ }
389
+ return yield* Effect.forEach(all, (r) => mapVersion(r, customFieldNames, projectKey), { concurrency: 4 });
390
+ });
391
+ const getVersion = (id) => toEffect(client.v3.client.GET("/rest/api/3/version/{id}", {
392
+ params: { path: { id }, query: { expand: EXPAND } }
393
+ })).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to get version ${id}`, cause })), Effect.flatMap((raw) => mapVersion(asRaw(raw), [])));
394
+ const updateVersion = (id, input) => toEffect(client.v3.client.PUT("/rest/api/3/version/{id}", {
395
+ params: { path: { id } },
396
+ body: { ...(input.description !== undefined ? { description: input.description } : {}) }
397
+ })).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to update version ${id}`, cause })), Effect.map((raw) => mapVersionScalar(asRaw(raw))));
398
+ const listRelatedWork = (id) => toEffect(client.v3.client.GET("/rest/api/3/version/{id}/relatedwork", { params: { path: { id } } })).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to list related work for version ${id}`, cause })), Effect.map((raw) => (Array.isArray(raw) ? raw : []).map(toRelatedWork)));
399
+ const addRelatedWork = (id, input) => toEffect(client.v3.client.POST("/rest/api/3/version/{id}/relatedwork", {
400
+ params: { path: { id } },
401
+ body: { title: input.title, category: input.category, url: input.url }
402
+ })).pipe(Effect.mapError((cause) => new JiraApiError({ message: `Failed to add related work to version ${id}`, cause })), Effect.map((raw) => {
403
+ const w = toRelatedWork(raw);
404
+ // POST echoes the created entity; fall back to the input we sent.
405
+ return {
406
+ relatedWorkId: w.relatedWorkId,
407
+ title: w.title ?? input.title,
408
+ category: w.category || input.category,
409
+ url: w.url ?? input.url
410
+ };
411
+ }));
412
+ return VersionService.of({
413
+ listProjectVersions,
414
+ getVersion,
415
+ updateVersion,
416
+ listRelatedWork,
417
+ addRelatedWork
418
+ });
419
+ });
420
+ /**
421
+ * Layer for VersionService.
422
+ *
423
+ * @category Layers
424
+ */
425
+ export const layer = Layer.effect(VersionService, make);
426
+ //# sourceMappingURL=VersionService.js.map