@knpkv/jira-cli 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +64 -0
  3. package/dist/AttachmentService.d.ts +2 -2
  4. package/dist/AttachmentService.d.ts.map +1 -1
  5. package/dist/AttachmentService.js +1 -1
  6. package/dist/AttachmentService.js.map +1 -1
  7. package/dist/IssueService.d.ts +82 -3
  8. package/dist/IssueService.d.ts.map +1 -1
  9. package/dist/IssueService.js +111 -16
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts +1 -1
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js +97 -21
  14. package/dist/JiraAuth.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 +2 -2
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +2 -2
  20. package/dist/SyncWorkspace.d.ts.map +1 -1
  21. package/dist/SyncWorkspace.js.map +1 -1
  22. package/dist/VersionService.d.ts +81 -9
  23. package/dist/VersionService.d.ts.map +1 -1
  24. package/dist/VersionService.js +113 -17
  25. package/dist/VersionService.js.map +1 -1
  26. package/dist/commands/auth.d.ts +3 -2
  27. package/dist/commands/auth.d.ts.map +1 -1
  28. package/dist/commands/edit.d.ts +13 -0
  29. package/dist/commands/edit.d.ts.map +1 -0
  30. package/dist/commands/edit.js +49 -0
  31. package/dist/commands/edit.js.map +1 -0
  32. package/dist/commands/errorHandler.d.ts.map +1 -1
  33. package/dist/commands/issue.d.ts +1 -1
  34. package/dist/commands/issue.d.ts.map +1 -1
  35. package/dist/commands/issue.js +2 -1
  36. package/dist/commands/issue.js.map +1 -1
  37. package/dist/commands/layers.d.ts +3 -3
  38. package/dist/commands/layers.d.ts.map +1 -1
  39. package/dist/commands/layers.js +5 -2
  40. package/dist/commands/layers.js.map +1 -1
  41. package/dist/commands/version.d.ts +8 -1
  42. package/dist/commands/version.d.ts.map +1 -1
  43. package/dist/commands/version.js +116 -4
  44. package/dist/commands/version.js.map +1 -1
  45. package/dist/index.d.ts +4 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/NodeLayers.js +1 -1
  50. package/dist/internal/NodeLayers.js.map +1 -1
  51. package/dist/internal/attachmentInsertion.d.ts.map +1 -1
  52. package/dist/internal/frontmatter.d.ts.map +1 -1
  53. package/dist/internal/frontmatter.js +2 -1
  54. package/dist/internal/frontmatter.js.map +1 -1
  55. package/dist/internal/jqlBuilder.d.ts.map +1 -1
  56. package/dist/internal/oauthServer.d.ts +14 -9
  57. package/dist/internal/oauthServer.d.ts.map +1 -1
  58. package/dist/internal/oauthServer.js +21 -21
  59. package/dist/internal/oauthServer.js.map +1 -1
  60. package/dist/internal/openBrowser.d.ts.map +1 -1
  61. package/dist/internal/sync/baseline.d.ts.map +1 -1
  62. package/dist/internal/sync/baseline.js +2 -2
  63. package/dist/internal/sync/baseline.js.map +1 -1
  64. package/dist/internal/sync/changes.d.ts.map +1 -1
  65. package/dist/internal/sync/config.d.ts.map +1 -1
  66. package/dist/internal/sync/config.js +1 -1
  67. package/dist/internal/sync/config.js.map +1 -1
  68. package/dist/internal/sync/document.d.ts.map +1 -1
  69. package/dist/internal/sync/document.js +8 -8
  70. package/dist/internal/sync/document.js.map +1 -1
  71. package/dist/internal/sync/fieldValues.d.ts +9 -9
  72. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  73. package/dist/internal/sync/fieldValues.js +12 -16
  74. package/dist/internal/sync/fieldValues.js.map +1 -1
  75. package/dist/internal/sync/manifest.d.ts.map +1 -1
  76. package/dist/internal/sync/manifest.js +1 -1
  77. package/dist/internal/sync/manifest.js.map +1 -1
  78. package/dist/internal/sync/paths.d.ts.map +1 -1
  79. package/dist/internal/sync/schemas.d.ts +17 -9
  80. package/dist/internal/sync/schemas.d.ts.map +1 -1
  81. package/dist/internal/sync/schemas.js +6 -6
  82. package/dist/internal/sync/schemas.js.map +1 -1
  83. package/dist/internal/sync/types.d.ts +4 -4
  84. package/dist/internal/sync/types.d.ts.map +1 -1
  85. package/dist/internal/sync/types.js +1 -1
  86. package/dist/internal/sync/types.js.map +1 -1
  87. package/package.json +14 -10
  88. package/skills/jira/SKILL.md +17 -1
  89. package/src/AttachmentService.ts +6 -6
  90. package/src/IssueService.ts +214 -27
  91. package/src/JiraAuth.ts +120 -35
  92. package/src/MarkdownWriter.ts +6 -6
  93. package/src/SyncWorkspace.ts +12 -11
  94. package/src/VersionService.ts +199 -27
  95. package/src/commands/edit.ts +88 -0
  96. package/src/commands/issue.ts +2 -1
  97. package/src/commands/layers.ts +5 -2
  98. package/src/commands/version.ts +161 -5
  99. package/src/index.ts +13 -3
  100. package/src/internal/NodeLayers.ts +1 -1
  101. package/src/internal/frontmatter.ts +3 -2
  102. package/src/internal/oauthServer.ts +46 -36
  103. package/src/internal/sync/baseline.ts +3 -2
  104. package/src/internal/sync/config.ts +2 -1
  105. package/src/internal/sync/document.ts +15 -14
  106. package/src/internal/sync/fieldValues.ts +21 -22
  107. package/src/internal/sync/manifest.ts +1 -1
  108. package/src/internal/sync/schemas.ts +6 -6
  109. package/src/internal/sync/types.ts +4 -4
  110. package/test/JiraAuthRefresh.test.ts +269 -0
  111. package/test/commandTree.test.ts +157 -1
  112. package/test/editIssuePayload.test.ts +103 -0
  113. package/test/frontmatter.test.ts +4 -2
  114. package/test/integration.test.ts +5 -3
  115. package/test/oauthServer.test.ts +171 -0
  116. package/test/publicApi.test.ts +34 -0
  117. package/test/relatedWorkSync.test.ts +179 -0
  118. package/test/remoteWrites.test.ts +220 -0
  119. package/test/syncChanges.test.ts +2 -2
  120. package/test/syncConfig.test.ts +2 -2
  121. package/tsconfig.json +3 -0
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * **Mental model**
5
5
  *
6
- * - **Two output modes**: {@link MarkdownWriterShape.writeMulti} creates one `.md` per issue;
7
- * {@link MarkdownWriterShape.writeSingle} combines all into `jira-export.md`.
6
+ * - **Two output modes**: {@link MarkdownWriterContract.writeMulti} creates one `.md` per issue;
7
+ * {@link MarkdownWriterContract.writeSingle} combines all into `jira-export.md`.
8
8
  * - **Effect service**: Requires `FileSystem` and `Path` from `effect`.
9
9
  *
10
10
  * @module
@@ -23,7 +23,7 @@ import { WriteError } from "./JiraCliError.js"
23
23
  *
24
24
  * @category Services
25
25
  */
26
- export interface MarkdownWriterShape {
26
+ export interface MarkdownWriterContract {
27
27
  /** Write each issue to a separate markdown file */
28
28
  readonly writeMulti: (
29
29
  issues: ReadonlyArray<Issue>,
@@ -55,7 +55,7 @@ export interface MarkdownWriterShape {
55
55
  */
56
56
  export class MarkdownWriter extends Context.Service<
57
57
  MarkdownWriter,
58
- MarkdownWriterShape
58
+ MarkdownWriterContract
59
59
  >()("@knpkv/jira-cli/MarkdownWriter") {}
60
60
 
61
61
  const make = Effect.gen(function*() {
@@ -72,7 +72,7 @@ const make = Effect.gen(function*() {
72
72
  Effect.catch((cause) => Effect.fail(new WriteError({ path: filePath, message: "Failed to write file", cause })))
73
73
  )
74
74
 
75
- const writeMulti: MarkdownWriterShape["writeMulti"] = (issues, outputDir) =>
75
+ const writeMulti: MarkdownWriterContract["writeMulti"] = (issues, outputDir) =>
76
76
  Effect.gen(function*() {
77
77
  yield* ensureDir(outputDir)
78
78
 
@@ -84,7 +84,7 @@ const make = Effect.gen(function*() {
84
84
  }
85
85
  })
86
86
 
87
- const writeSingle: MarkdownWriterShape["writeSingle"] = (issues, outputDir, jql) =>
87
+ const writeSingle: MarkdownWriterContract["writeSingle"] = (issues, outputDir, jql) =>
88
88
  Effect.gen(function*() {
89
89
  yield* ensureDir(outputDir)
90
90
 
@@ -26,7 +26,7 @@ export interface InitWorkspaceInput {
26
26
  readonly siteUrl: string
27
27
  }
28
28
 
29
- export interface SyncWorkspaceShape {
29
+ export interface SyncWorkspaceContract {
30
30
  readonly init: (input: InitWorkspaceInput) => Effect.Effect<SyncWorkspacePaths, SyncWorkspaceError>
31
31
  readonly paths: (root: string, config?: Pick<WorkspaceConfig, "documentsDir">) => Effect.Effect<SyncWorkspacePaths>
32
32
  readonly readConfig: (root: string) => Effect.Effect<WorkspaceConfig, SyncWorkspaceError>
@@ -43,7 +43,7 @@ export interface SyncWorkspaceShape {
43
43
 
44
44
  export class SyncWorkspace extends Context.Service<
45
45
  SyncWorkspace,
46
- SyncWorkspaceShape
46
+ SyncWorkspaceContract
47
47
  >()("@knpkv/jira-cli/SyncWorkspace") {}
48
48
 
49
49
  const mapWorkspaceError = (message: string, path?: string | undefined) => (cause: unknown) =>
@@ -53,7 +53,8 @@ const make = Effect.gen(function*() {
53
53
  const fs = yield* FileSystem.FileSystem
54
54
  const path = yield* Path.Path
55
55
 
56
- const paths: SyncWorkspaceShape["paths"] = (root, config) => Effect.succeed(resolveWorkspacePaths(path, root, config))
56
+ const paths: SyncWorkspaceContract["paths"] = (root, config) =>
57
+ Effect.succeed(resolveWorkspacePaths(path, root, config))
57
58
 
58
59
  const ensureDir = (dir: string): Effect.Effect<void, SyncWorkspaceError> =>
59
60
  fs.makeDirectory(dir, { recursive: true }).pipe(
@@ -80,7 +81,7 @@ const make = Effect.gen(function*() {
80
81
  Effect.mapError(mapWorkspaceError("Failed to read sync workspace file", filePath))
81
82
  )
82
83
 
83
- const init: SyncWorkspaceShape["init"] = ({ root, siteUrl }) =>
84
+ const init: SyncWorkspaceContract["init"] = ({ root, siteUrl }) =>
84
85
  Effect.gen(function*() {
85
86
  const config = makeDefaultWorkspaceConfig(siteUrl)
86
87
  const workspacePaths = resolveWorkspacePaths(path, root, config)
@@ -93,7 +94,7 @@ const make = Effect.gen(function*() {
93
94
  return workspacePaths
94
95
  })
95
96
 
96
- const readConfig: SyncWorkspaceShape["readConfig"] = (root) =>
97
+ const readConfig: SyncWorkspaceContract["readConfig"] = (root) =>
97
98
  Effect.gen(function*() {
98
99
  const workspacePaths = resolveWorkspacePaths(path, root)
99
100
  const content = yield* readFile(workspacePaths.configFile)
@@ -108,14 +109,14 @@ const make = Effect.gen(function*() {
108
109
  )
109
110
  })
110
111
 
111
- const writeConfig: SyncWorkspaceShape["writeConfig"] = (root, config) =>
112
+ const writeConfig: SyncWorkspaceContract["writeConfig"] = (root, config) =>
112
113
  Effect.gen(function*() {
113
114
  const workspacePaths = resolveWorkspacePaths(path, root, config)
114
115
  yield* ensureDir(workspacePaths.metadataDir)
115
116
  yield* writeFile(workspacePaths.configFile, serializeWorkspaceConfig(config))
116
117
  })
117
118
 
118
- const readManifest: SyncWorkspaceShape["readManifest"] = (root) =>
119
+ const readManifest: SyncWorkspaceContract["readManifest"] = (root) =>
119
120
  Effect.gen(function*() {
120
121
  const workspacePaths = resolveWorkspacePaths(path, root)
121
122
  const content = yield* readFile(workspacePaths.manifestFile)
@@ -130,14 +131,14 @@ const make = Effect.gen(function*() {
130
131
  )
131
132
  })
132
133
 
133
- const writeManifest: SyncWorkspaceShape["writeManifest"] = (root, manifest) =>
134
+ const writeManifest: SyncWorkspaceContract["writeManifest"] = (root, manifest) =>
134
135
  Effect.gen(function*() {
135
136
  const workspacePaths = resolveWorkspacePaths(path, root)
136
137
  yield* ensureDir(workspacePaths.metadataDir)
137
138
  yield* writeFile(workspacePaths.manifestFile, serializeSyncManifest(manifest))
138
139
  })
139
140
 
140
- const readBaseline: SyncWorkspaceShape["readBaseline"] = (root, issueId) =>
141
+ const readBaseline: SyncWorkspaceContract["readBaseline"] = (root, issueId) =>
141
142
  Effect.gen(function*() {
142
143
  const workspacePaths = resolveWorkspacePaths(path, root)
143
144
  const filePath = baselineFilePath(path, workspacePaths, issueId)
@@ -153,7 +154,7 @@ const make = Effect.gen(function*() {
153
154
  )
154
155
  })
155
156
 
156
- const writeBaseline: SyncWorkspaceShape["writeBaseline"] = (root, baseline) =>
157
+ const writeBaseline: SyncWorkspaceContract["writeBaseline"] = (root, baseline) =>
157
158
  Effect.gen(function*() {
158
159
  const workspacePaths = resolveWorkspacePaths(path, root)
159
160
  const filePath = baselineFilePath(path, workspacePaths, baseline.issueId)
@@ -161,7 +162,7 @@ const make = Effect.gen(function*() {
161
162
  yield* writeFile(filePath, serializeSyncBaseline(baseline))
162
163
  })
163
164
 
164
- const getConventionDocumentPath: SyncWorkspaceShape["conventionDocumentPath"] = (root, issueKey) =>
165
+ const getConventionDocumentPath: SyncWorkspaceContract["conventionDocumentPath"] = (root, issueKey) =>
165
166
  Effect.gen(function*() {
166
167
  const config = yield* readConfig(root)
167
168
  const workspacePaths = resolveWorkspacePaths(path, root, config)
@@ -11,16 +11,21 @@
11
11
  * though it is not in the public OpenAPI spec).
12
12
  * - **Account-id resolution**: account IDs are looked up against
13
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
14
+ * - **Mutations**: {@link VersionServiceContract.createVersion} opens a new release,
15
+ * {@link VersionServiceContract.updateVersion} edits version fields (e.g. description)
16
+ * and {@link VersionServiceContract.addRelatedWork} /
17
+ * {@link VersionServiceContract.listRelatedWork} manage the "Related work" links that
17
18
  * surface as Confluence pages on a release report. Mutations require the
18
19
  * `manage:jira-project` OAuth scope (see `JiraAuth`).
20
+ * - **Project id resolution**: the create endpoint takes a numeric `projectId`
21
+ * (`project` is deprecated), so {@link VersionServiceContract.createVersion} accepts the
22
+ * project *key* callers already use elsewhere and resolves it via `/project/{key}`.
19
23
  *
20
24
  * **Common tasks**
21
25
  *
22
26
  * - List versions for a project: `service.listProjectVersions("RPS", { released: true })`
23
27
  * - Get a single version: `service.getVersion("12345")`
28
+ * - Open a new release: `service.createVersion({ projectKey: "RPS", name: "OOB 100" })`
24
29
  * - Set the description: `service.updateVersion("12345", { description: "..." })`
25
30
  * - Attach a Confluence page: `service.addRelatedWork("12345", { title, category, url })`
26
31
  *
@@ -30,6 +35,8 @@ import { JiraApiClient } from "@knpkv/jira-api-client"
30
35
  import * as Context from "effect/Context"
31
36
  import * as Effect from "effect/Effect"
32
37
  import * as Layer from "effect/Layer"
38
+ import * as Predicate from "effect/Predicate"
39
+ import type * as Schema from "effect/Schema"
33
40
  import { buildByVersionJql } from "./internal/jqlBuilder.js"
34
41
  import { JiraApiError } from "./JiraCliError.js"
35
42
 
@@ -132,6 +139,21 @@ export interface UpdateVersionInput {
132
139
  readonly description?: string
133
140
  }
134
141
 
142
+ /**
143
+ * Fields for a new version. `projectKey` is the key (e.g. `RPS`) — it is resolved
144
+ * to the numeric `projectId` the create endpoint requires. Dates are ISO 8601
145
+ * `yyyy-mm-dd`.
146
+ *
147
+ * @category Types
148
+ */
149
+ export interface CreateVersionInput {
150
+ readonly projectKey: string
151
+ readonly name: string
152
+ readonly description?: string
153
+ readonly startDate?: string
154
+ readonly releaseDate?: string
155
+ }
156
+
135
157
  /**
136
158
  * Filters for listing versions.
137
159
  *
@@ -158,18 +180,25 @@ export interface ListVersionsOptions {
158
180
  *
159
181
  * @category Services
160
182
  */
161
- export interface VersionServiceShape {
183
+ export interface VersionServiceContract {
162
184
  readonly listProjectVersions: (
163
185
  projectKey: string,
164
186
  options?: ListVersionsOptions
165
187
  ) => Effect.Effect<ReadonlyArray<Version>, JiraApiError>
166
188
  readonly getVersion: (id: string) => Effect.Effect<Version, JiraApiError>
189
+ /**
190
+ * Create a new (unreleased) version on a project. Needs `manage:jira-project`.
191
+ * Resolves `projectKey` to the numeric project id the API requires.
192
+ */
193
+ readonly createVersion: (input: CreateVersionInput) => Effect.Effect<Version, JiraApiError>
167
194
  /** Update editable fields (currently description) on a version. Needs `manage:jira-project`. */
168
195
  readonly updateVersion: (id: string, input: UpdateVersionInput) => Effect.Effect<Version, JiraApiError>
169
196
  /** List the "Related work" links attached to a version. */
170
197
  readonly listRelatedWork: (id: string) => Effect.Effect<ReadonlyArray<RelatedWork>, JiraApiError>
171
198
  /** Attach a "Related work" link (e.g. a Confluence page) to a version. Needs `manage:jira-project`. */
172
199
  readonly addRelatedWork: (id: string, input: AddRelatedWorkInput) => Effect.Effect<RelatedWork, JiraApiError>
200
+ /** Remove a "Related work" link from a version. Needs `manage:jira-project`. */
201
+ readonly deleteRelatedWork: (id: string, relatedWorkId: string) => Effect.Effect<void, JiraApiError>
173
202
  }
174
203
 
175
204
  /**
@@ -191,16 +220,18 @@ export interface VersionServiceShape {
191
220
  */
192
221
  export class VersionService extends Context.Service<
193
222
  VersionService,
194
- VersionServiceShape
223
+ VersionServiceContract
195
224
  >()("@knpkv/jira-cli/VersionService") {}
196
225
 
197
226
  const EXPAND = "approvers,driver,operations,issuesstatus,contributors"
198
227
 
199
228
  /** Loosely-typed record helper for navigating untyped API JSON. */
200
- type Raw = Record<string, unknown>
201
- const isRaw = (value: unknown): value is Raw => typeof value === "object" && value !== null && !Array.isArray(value)
202
- const asRaw = (value: unknown): Raw => isRaw(value) ? value : {}
203
- const rawArray = (value: unknown): ReadonlyArray<Raw> => Array.isArray(value) ? value.filter(isRaw) : []
229
+ type Raw = Record<string, Schema.Json>
230
+ const isRaw = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & Raw =>
231
+ Predicate.isObjectOrArray(value) && value !== null && !Array.isArray(value)
232
+ const asRaw = <UnparsedInput>(value: UnparsedInput): Raw => isRaw(value) ? value : {}
233
+ const rawArray = <UnparsedInput>(value: UnparsedInput): ReadonlyArray<Raw> =>
234
+ Array.isArray(value) ? value.filter(isRaw) : []
204
235
 
205
236
  /**
206
237
  * Render a Jira custom-field value as a flat string.
@@ -213,10 +244,10 @@ const rawArray = (value: unknown): ReadonlyArray<Raw> => Array.isArray(value) ?
213
244
  * - array of any of the above → values joined with `, `
214
245
  * - null / unset / unknown shape → `null`
215
246
  */
216
- export const renderCustomFieldValue = (raw: unknown): string | null => {
247
+ export const renderCustomFieldValue = <UnparsedInput>(raw: UnparsedInput): string | null => {
217
248
  if (raw === null || raw === undefined) return null
218
- if (typeof raw === "string") return raw.length > 0 ? raw : null
219
- if (typeof raw === "number" || typeof raw === "boolean") return String(raw)
249
+ if (Predicate.isString(raw)) return raw.length > 0 ? raw : null
250
+ if (Predicate.isNumber(raw) || Predicate.isBoolean(raw)) return String(raw)
220
251
  if (Array.isArray(raw)) {
221
252
  const parts = raw.map(renderCustomFieldValue).filter((v): v is string => !!v)
222
253
  return parts.length > 0 ? parts.join(", ") : null
@@ -240,9 +271,10 @@ export const renderCustomFieldValue = (raw: unknown): string | null => {
240
271
  return null
241
272
  }
242
273
 
243
- const stringOrNull = (v: unknown): string | null => typeof v === "string" && v.length > 0 ? v : null
274
+ const stringOrNull = <UnparsedInput>(v: UnparsedInput): string | null =>
275
+ Predicate.isString(v) && v.length > 0 ? v : null
244
276
 
245
- export const personFromObject = (raw: unknown, fallbackId?: string): Person | null => {
277
+ export const personFromObject = <UnparsedInput>(raw: UnparsedInput, fallbackId?: string): Person | null => {
246
278
  if (isRaw(raw)) {
247
279
  const obj = raw
248
280
  const accountId = stringOrNull(obj["accountId"]) ?? fallbackId ?? null
@@ -253,7 +285,7 @@ export const personFromObject = (raw: unknown, fallbackId?: string): Person | nu
253
285
  emailAddress: stringOrNull(obj["emailAddress"])
254
286
  }
255
287
  }
256
- if (typeof raw === "string" && raw.length > 0) {
288
+ if (Predicate.isString(raw) && raw.length > 0) {
257
289
  return { accountId: raw, displayName: raw, emailAddress: null }
258
290
  }
259
291
  return null
@@ -267,17 +299,17 @@ export const extractContributorIds = (raw: Raw): ReadonlyArray<string> => {
267
299
  if (!Array.isArray(field)) return []
268
300
  const ids: Array<string> = []
269
301
  for (const c of field) {
270
- if (typeof c === "string" && c.length > 0) ids.push(c)
302
+ if (Predicate.isString(c) && c.length > 0) ids.push(c)
271
303
  else if (isRaw(c)) {
272
304
  const id = c["accountId"]
273
- if (typeof id === "string" && id.length > 0) ids.push(id)
305
+ if (Predicate.isString(id) && id.length > 0) ids.push(id)
274
306
  }
275
307
  }
276
308
  return ids
277
309
  }
278
310
 
279
311
  /** Normalise a Jira "Related work" entry into a {@link RelatedWork}. */
280
- export const toRelatedWork = (raw: unknown): RelatedWork => {
312
+ export const toRelatedWork = <UnparsedInput>(raw: UnparsedInput): RelatedWork => {
281
313
  const o = asRaw(raw)
282
314
  return {
283
315
  relatedWorkId: stringOrNull(o["relatedWorkId"]),
@@ -309,7 +341,7 @@ const make = Effect.gen(function*() {
309
341
  const matches: Array<string> = []
310
342
  for (const field of rawArray(result)) {
311
343
  const id = field["id"]
312
- if (field["name"] === name && typeof id === "string") {
344
+ if (field["name"] === name && Predicate.isString(id)) {
313
345
  matches.push(id)
314
346
  }
315
347
  }
@@ -392,12 +424,12 @@ const make = Effect.gen(function*() {
392
424
  const MAX_PAGES = 100
393
425
  let nextPageToken: string | undefined = undefined
394
426
  for (let page = 0; page < MAX_PAGES; page++) {
395
- const result = yield* client.searchIssuesUsingJql({
427
+ const result: unknown = yield* client.searchIssuesUsingJql({
396
428
  params: {
397
429
  jql: buildByVersionJql(versionName, projectKey),
398
430
  fields: requestedFields,
399
431
  maxResults: PAGE,
400
- ...(nextPageToken ? { nextPageToken } : {})
432
+ ...(nextPageToken && { nextPageToken })
401
433
  }
402
434
  }).pipe(
403
435
  Effect.mapError((cause) =>
@@ -405,7 +437,7 @@ const make = Effect.gen(function*() {
405
437
  )
406
438
  )
407
439
 
408
- const resObj = asRaw(result)
440
+ const resObj: Raw = asRaw(result)
409
441
  const issues = rawArray(resObj["issues"])
410
442
  for (const issue of issues) {
411
443
  const key = stringOrNull(issue["key"]) ?? ""
@@ -414,12 +446,12 @@ const make = Effect.gen(function*() {
414
446
  let assigneeId: string | null = null
415
447
  if (isRaw(assignee)) {
416
448
  const accountId = assignee["accountId"]
417
- if (typeof accountId === "string" && accountId.length > 0) assigneeId = accountId
449
+ if (Predicate.isString(accountId) && accountId.length > 0) assigneeId = accountId
418
450
  }
419
451
  const labelsRaw = fields["labels"]
420
452
  const labels: Array<string> = []
421
453
  if (Array.isArray(labelsRaw)) {
422
- for (const l of labelsRaw) if (typeof l === "string" && l.length > 0) labels.push(l)
454
+ for (const l of labelsRaw) if (Predicate.isString(l) && l.length > 0) labels.push(l)
423
455
  }
424
456
  const customFields: Record<string, string | null> = {}
425
457
  for (const name of customFieldNames) {
@@ -445,7 +477,7 @@ const make = Effect.gen(function*() {
445
477
 
446
478
  const isLast = resObj["isLast"]
447
479
  const next = resObj["nextPageToken"]
448
- if (isLast === true || typeof next !== "string" || next.length === 0) break
480
+ if (isLast === true || !Predicate.isString(next) || next.length === 0) break
449
481
  nextPageToken = next
450
482
  }
451
483
 
@@ -582,9 +614,56 @@ const make = Effect.gen(function*() {
582
614
  Effect.flatMap((raw) => mapVersion(asRaw(raw), []))
583
615
  )
584
616
 
617
+ /**
618
+ * Resolve a project key to its numeric id.
619
+ *
620
+ * `POST /version` requires `projectId`; its `project` (key) field is deprecated
621
+ * and ignored by newer instances, so sending the key straight through creates
622
+ * nothing and reports a confusing 400. Callers pass the key because that is
623
+ * what every other command takes.
624
+ */
625
+ const resolveProjectId = (projectKey: string): Effect.Effect<number, JiraApiError> =>
626
+ client.getProject(projectKey, undefined).pipe(
627
+ Effect.mapError((cause) => new JiraApiError({ message: `Failed to look up project ${projectKey}`, cause })),
628
+ Effect.flatMap((raw) => {
629
+ const id = asRaw(raw)["id"]
630
+ // Jira serialises project ids as strings here even though the version
631
+ // payload wants a number.
632
+ const numeric = Predicate.isNumber(id) ? id : Predicate.isString(id) ? Number(id) : Number.NaN
633
+ return Number.isInteger(numeric)
634
+ ? Effect.succeed(numeric)
635
+ : Effect.fail(
636
+ new JiraApiError({
637
+ message: `Project ${projectKey} returned no usable numeric id; cannot create a version against it.`
638
+ })
639
+ )
640
+ })
641
+ )
642
+
643
+ const createVersion = (input: CreateVersionInput): Effect.Effect<Version, JiraApiError> =>
644
+ Effect.gen(function*() {
645
+ const projectId = yield* resolveProjectId(input.projectKey)
646
+ const raw = yield* client.createVersion({
647
+ payload: {
648
+ name: input.name,
649
+ projectId,
650
+ ...((input.description !== undefined) && { description: input.description }),
651
+ ...((input.startDate !== undefined) && { startDate: input.startDate }),
652
+ ...((input.releaseDate !== undefined) && { releaseDate: input.releaseDate })
653
+ }
654
+ }).pipe(
655
+ Effect.mapError((cause) =>
656
+ new JiraApiError({ message: `Failed to create version "${input.name}" on ${input.projectKey}`, cause })
657
+ )
658
+ )
659
+ // The 201 body carries no `expand`, so map scalars only — same reasoning as
660
+ // updateVersion.
661
+ return mapVersionScalar(asRaw(raw))
662
+ })
663
+
585
664
  const updateVersion = (id: string, input: UpdateVersionInput): Effect.Effect<Version, JiraApiError> =>
586
665
  client.updateVersion(id, {
587
- payload: { ...(input.description !== undefined ? { description: input.description } : {}) }
666
+ payload: { ...((input.description !== undefined) && { description: input.description }) }
588
667
  }).pipe(
589
668
  Effect.mapError((cause) => new JiraApiError({ message: `Failed to update version ${id}`, cause })),
590
669
  Effect.map((raw) => mapVersionScalar(asRaw(raw)))
@@ -613,15 +692,108 @@ const make = Effect.gen(function*() {
613
692
  })
614
693
  )
615
694
 
695
+ const deleteRelatedWork = (id: string, relatedWorkId: string): Effect.Effect<void, JiraApiError> =>
696
+ client.deleteRelatedWork(id, relatedWorkId, undefined).pipe(
697
+ Effect.mapError((cause) =>
698
+ new JiraApiError({ message: `Failed to remove related work ${relatedWorkId} from version ${id}`, cause })
699
+ ),
700
+ Effect.asVoid
701
+ )
702
+
616
703
  return VersionService.of({
617
704
  listProjectVersions,
618
705
  getVersion,
706
+ createVersion,
619
707
  updateVersion,
620
708
  listRelatedWork,
621
- addRelatedWork
709
+ addRelatedWork,
710
+ deleteRelatedWork
622
711
  })
623
712
  })
624
713
 
714
+ /**
715
+ * Desired related-work link for {@link planRelatedWorkSync}.
716
+ *
717
+ * @category Types
718
+ */
719
+ export interface DesiredRelatedWork {
720
+ readonly title: string
721
+ readonly url: string
722
+ }
723
+
724
+ /**
725
+ * Plan for reconciling a version's related-work links.
726
+ *
727
+ * @category Types
728
+ */
729
+ export interface RelatedWorkSyncPlan {
730
+ readonly toAdd: ReadonlyArray<DesiredRelatedWork>
731
+ readonly kept: ReadonlyArray<string>
732
+ // `url` is nullable because Jira can return a related-work entry without one;
733
+ // pruning still has to be able to remove it.
734
+ readonly toRemove: ReadonlyArray<{ readonly relatedWorkId: string; readonly url: string | null }>
735
+ }
736
+
737
+ /**
738
+ * Work out which related-work links to add, keep and remove.
739
+ *
740
+ * Matching is by URL: Jira assigns the id, and the title is editable, so the
741
+ * URL is the only stable identity a link has. Scoped to one category so a
742
+ * `Communication` reconcile cannot disturb `Testing` links. Removal is opt-in
743
+ * (`prune`) because links added by hand in the Jira UI are legitimate.
744
+ *
745
+ * URL identity is applied on both sides, which is what makes "exactly the given
746
+ * set" true: repeated desired entries collapse to one, and `prune` removes
747
+ * surplus copies of a desired URL as well as links to undesired ones — a pile-up
748
+ * of identical `Release notes` links is the case this exists to clean up, so
749
+ * keeping every copy merely because its URL is wanted would defeat it.
750
+ *
751
+ * Pure so that repeated scaffolding runs can be tested without the API — the
752
+ * property that matters is that running it twice adds nothing the second time.
753
+ *
754
+ * @category Utilities
755
+ */
756
+ export const planRelatedWorkSync = (
757
+ existing: ReadonlyArray<RelatedWork>,
758
+ desired: ReadonlyArray<DesiredRelatedWork>,
759
+ options: { readonly category: string; readonly prune: boolean }
760
+ ): RelatedWorkSyncPlan => {
761
+ const inCategory = existing.filter((w) => w.category === options.category)
762
+ const existingUrls = new Set(inCategory.flatMap((w) => (w.url === null ? [] : [w.url])))
763
+
764
+ const desiredByUrl = new Map<string, DesiredRelatedWork>()
765
+ for (const entry of desired) {
766
+ if (!desiredByUrl.has(entry.url)) desiredByUrl.set(entry.url, entry)
767
+ }
768
+ const unique = [...desiredByUrl.values()]
769
+
770
+ const toAdd = unique.filter((d) => !existingUrls.has(d.url))
771
+ const kept = unique.filter((d) => existingUrls.has(d.url)).map((d) => d.url)
772
+
773
+ const keptUrls = new Set<string>()
774
+ const toRemove = options.prune
775
+ ? inCategory.flatMap((w) => {
776
+ // Designate the keeper before asking whether this copy is deletable. An
777
+ // undeletable first copy that skipped straight out never claimed the
778
+ // URL, so the next duplicate claimed it instead and *both* survived a
779
+ // reconcile that reported success.
780
+ if (w.url !== null && desiredByUrl.has(w.url) && !keptUrls.has(w.url)) {
781
+ keptUrls.add(w.url)
782
+ return []
783
+ }
784
+ // Nothing to delete with; leave it alone whatever its url.
785
+ if (w.relatedWorkId === null) return []
786
+ // A url-less entry can never match a desired link — every desired link
787
+ // has one — so pruning must remove it rather than skip it, or the
788
+ // category is not reconciled to the requested set and the command
789
+ // reports success while the stale entry stays.
790
+ return [{ relatedWorkId: w.relatedWorkId, url: w.url }]
791
+ })
792
+ : []
793
+
794
+ return { toAdd, kept, toRemove }
795
+ }
796
+
625
797
  /**
626
798
  * Layer for VersionService.
627
799
  *
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `jira issue edit <key>` command — edit an issue's list-valued fields
3
+ * (fixVersions, labels).
4
+ *
5
+ * Incremental flags (`--add-*`, `--remove-*`) are the ones to reach for: both
6
+ * fields are sets, so the replacing form silently drops anything not listed.
7
+ *
8
+ * @internal
9
+ */
10
+ import * as Console from "effect/Console"
11
+ import * as Effect from "effect/Effect"
12
+ import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
13
+ import { IssueService } from "../IssueService.js"
14
+
15
+ const keyArg = Args.string("key").pipe(
16
+ Args.withDescription("Issue key (e.g., PROJ-123)")
17
+ )
18
+
19
+ const addFixVersionOption = Options.string("add-fix-version").pipe(
20
+ Options.withDescription("Add a fix version by name, keeping existing ones (repeatable)"),
21
+ Options.atLeast(0)
22
+ )
23
+ const removeFixVersionOption = Options.string("remove-fix-version").pipe(
24
+ Options.withDescription("Remove a fix version by name (repeatable)"),
25
+ Options.atLeast(0)
26
+ )
27
+ const fixVersionOption = Options.string("fix-version").pipe(
28
+ Options.withDescription(
29
+ "Replace the fix versions with exactly these names (repeatable). Drops any not listed — prefer --add-fix-version"
30
+ ),
31
+ Options.atLeast(0)
32
+ )
33
+ const addLabelOption = Options.string("add-label").pipe(
34
+ Options.withDescription("Add a label, keeping existing ones (repeatable)"),
35
+ Options.atLeast(0)
36
+ )
37
+ const removeLabelOption = Options.string("remove-label").pipe(
38
+ Options.withDescription("Remove a label (repeatable)"),
39
+ Options.atLeast(0)
40
+ )
41
+ const labelOption = Options.string("label").pipe(
42
+ Options.withDescription(
43
+ "Replace the labels with exactly these (repeatable). Drops any not listed — prefer --add-label"
44
+ ),
45
+ Options.atLeast(0)
46
+ )
47
+ const jsonOption = Options.boolean("json").pipe(
48
+ Options.withDescription("Output as JSON"),
49
+ Options.withDefault(false)
50
+ )
51
+
52
+ export const editCommand = Command.make(
53
+ "edit",
54
+ {
55
+ key: keyArg,
56
+ addFixVersion: addFixVersionOption,
57
+ removeFixVersion: removeFixVersionOption,
58
+ fixVersion: fixVersionOption,
59
+ addLabel: addLabelOption,
60
+ removeLabel: removeLabelOption,
61
+ label: labelOption,
62
+ json: jsonOption
63
+ },
64
+ ({ addFixVersion, addLabel, fixVersion, json, key, label, removeFixVersion, removeLabel }) =>
65
+ Effect.gen(function*() {
66
+ const issueService = yield* IssueService
67
+ const issue = yield* issueService.edit(key, {
68
+ addFixVersions: addFixVersion,
69
+ removeFixVersions: removeFixVersion,
70
+ setFixVersions: fixVersion,
71
+ addLabels: addLabel,
72
+ removeLabels: removeLabel,
73
+ setLabels: label
74
+ })
75
+
76
+ if (json) {
77
+ yield* Console.log(JSON.stringify(issue, null, 2))
78
+ return
79
+ }
80
+ yield* Console.log(`Updated ${issue.key}`)
81
+ yield* Console.log(`fixVersions: ${issue.fixVersions.join(", ") || "-"}`)
82
+ yield* Console.log(`labels: ${issue.labels.join(", ") || "-"}`)
83
+ })
84
+ ).pipe(
85
+ Command.withDescription(
86
+ "Remote write: edit an issue's fix versions and labels (requires write:jira-work scope)"
87
+ )
88
+ )
@@ -4,11 +4,12 @@
4
4
  * @internal
5
5
  */
6
6
  import { Command } from "effect/unstable/cli"
7
+ import { editCommand } from "./edit.js"
7
8
  import { getCommand } from "./get.js"
8
9
  import { attachmentCommand } from "./issueAttachment.js"
9
10
  import { searchCommand } from "./search.js"
10
11
 
11
12
  export const issueCommand = Command.make("issue").pipe(
12
13
  Command.withDescription("Jira issue resource commands"),
13
- Command.withSubcommands([attachmentCommand, getCommand, searchCommand])
14
+ Command.withSubcommands([attachmentCommand, getCommand, searchCommand, editCommand])
14
15
  )
@@ -28,7 +28,8 @@ const DummyIssueServiceLayer = Layer.succeed(
28
28
  IssueService.of({
29
29
  getByKey: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
30
30
  search: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
31
- searchAll: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
31
+ searchAll: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
32
+ edit: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
32
33
  })
33
34
  )
34
35
 
@@ -52,9 +53,11 @@ const DummyVersionServiceLayer = Layer.succeed(
52
53
  VersionService.of({
53
54
  listProjectVersions: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
54
55
  getVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
56
+ createVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
55
57
  updateVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
56
58
  listRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
57
- addRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
59
+ addRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
60
+ deleteRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
58
61
  })
59
62
  )
60
63