@knpkv/jira-cli 1.2.3 → 1.4.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 (147) hide show
  1. package/CHANGELOG.md +133 -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 +3 -3
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js +87 -23
  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/bin.js +1 -1
  27. package/dist/bin.js.map +1 -1
  28. package/dist/commands/auth.d.ts +13 -2
  29. package/dist/commands/auth.d.ts.map +1 -1
  30. package/dist/commands/auth.js +7 -7
  31. package/dist/commands/auth.js.map +1 -1
  32. package/dist/commands/edit.d.ts +22 -0
  33. package/dist/commands/edit.d.ts.map +1 -0
  34. package/dist/commands/edit.js +49 -0
  35. package/dist/commands/edit.js.map +1 -0
  36. package/dist/commands/errorHandler.d.ts.map +1 -1
  37. package/dist/commands/get.d.ts +6 -1
  38. package/dist/commands/get.d.ts.map +1 -1
  39. package/dist/commands/get.js +3 -3
  40. package/dist/commands/get.js.map +1 -1
  41. package/dist/commands/issue.d.ts +2 -2
  42. package/dist/commands/issue.d.ts.map +1 -1
  43. package/dist/commands/issue.js +3 -2
  44. package/dist/commands/issue.js.map +1 -1
  45. package/dist/commands/issueAttachment.d.ts +1 -1
  46. package/dist/commands/issueAttachment.d.ts.map +1 -1
  47. package/dist/commands/issueAttachment.js +13 -11
  48. package/dist/commands/issueAttachment.js.map +1 -1
  49. package/dist/commands/layers.d.ts +3 -3
  50. package/dist/commands/layers.d.ts.map +1 -1
  51. package/dist/commands/layers.js +5 -2
  52. package/dist/commands/layers.js.map +1 -1
  53. package/dist/commands/search.d.ts +6 -1
  54. package/dist/commands/search.d.ts.map +1 -1
  55. package/dist/commands/search.js +7 -7
  56. package/dist/commands/search.js.map +1 -1
  57. package/dist/commands/version.d.ts +17 -2
  58. package/dist/commands/version.d.ts.map +1 -1
  59. package/dist/commands/version.js +131 -19
  60. package/dist/commands/version.js.map +1 -1
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/internal/NodeLayers.js +1 -1
  66. package/dist/internal/NodeLayers.js.map +1 -1
  67. package/dist/internal/attachmentInsertion.d.ts.map +1 -1
  68. package/dist/internal/frontmatter.d.ts.map +1 -1
  69. package/dist/internal/frontmatter.js +2 -1
  70. package/dist/internal/frontmatter.js.map +1 -1
  71. package/dist/internal/jqlBuilder.d.ts.map +1 -1
  72. package/dist/internal/oauthServer.d.ts +10 -4
  73. package/dist/internal/oauthServer.d.ts.map +1 -1
  74. package/dist/internal/oauthServer.js +13 -8
  75. package/dist/internal/oauthServer.js.map +1 -1
  76. package/dist/internal/openBrowser.d.ts +1 -1
  77. package/dist/internal/openBrowser.d.ts.map +1 -1
  78. package/dist/internal/openBrowser.js +1 -1
  79. package/dist/internal/openBrowser.js.map +1 -1
  80. package/dist/internal/sync/baseline.d.ts.map +1 -1
  81. package/dist/internal/sync/baseline.js +2 -2
  82. package/dist/internal/sync/baseline.js.map +1 -1
  83. package/dist/internal/sync/changes.d.ts.map +1 -1
  84. package/dist/internal/sync/config.d.ts.map +1 -1
  85. package/dist/internal/sync/config.js +1 -1
  86. package/dist/internal/sync/config.js.map +1 -1
  87. package/dist/internal/sync/document.d.ts.map +1 -1
  88. package/dist/internal/sync/document.js +8 -8
  89. package/dist/internal/sync/document.js.map +1 -1
  90. package/dist/internal/sync/fieldValues.d.ts +9 -9
  91. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  92. package/dist/internal/sync/fieldValues.js +12 -16
  93. package/dist/internal/sync/fieldValues.js.map +1 -1
  94. package/dist/internal/sync/manifest.d.ts.map +1 -1
  95. package/dist/internal/sync/manifest.js +1 -1
  96. package/dist/internal/sync/manifest.js.map +1 -1
  97. package/dist/internal/sync/paths.d.ts.map +1 -1
  98. package/dist/internal/sync/schemas.d.ts +17 -9
  99. package/dist/internal/sync/schemas.d.ts.map +1 -1
  100. package/dist/internal/sync/schemas.js +6 -6
  101. package/dist/internal/sync/schemas.js.map +1 -1
  102. package/dist/internal/sync/types.d.ts +4 -4
  103. package/dist/internal/sync/types.d.ts.map +1 -1
  104. package/dist/internal/sync/types.js +1 -1
  105. package/dist/internal/sync/types.js.map +1 -1
  106. package/package.json +15 -11
  107. package/skills/jira/SKILL.md +17 -1
  108. package/src/AttachmentService.ts +6 -6
  109. package/src/IssueService.ts +214 -27
  110. package/src/JiraAuth.ts +106 -42
  111. package/src/MarkdownWriter.ts +6 -6
  112. package/src/SyncWorkspace.ts +12 -11
  113. package/src/VersionService.ts +199 -27
  114. package/src/bin.ts +1 -1
  115. package/src/commands/auth.ts +7 -7
  116. package/src/commands/edit.ts +88 -0
  117. package/src/commands/get.ts +3 -3
  118. package/src/commands/issue.ts +3 -2
  119. package/src/commands/issueAttachment.ts +21 -20
  120. package/src/commands/layers.ts +5 -2
  121. package/src/commands/search.ts +7 -7
  122. package/src/commands/version.ts +176 -23
  123. package/src/index.ts +13 -3
  124. package/src/internal/NodeLayers.ts +1 -1
  125. package/src/internal/frontmatter.ts +3 -2
  126. package/src/internal/oauthServer.ts +29 -16
  127. package/src/internal/openBrowser.ts +1 -1
  128. package/src/internal/sync/baseline.ts +3 -2
  129. package/src/internal/sync/config.ts +2 -1
  130. package/src/internal/sync/document.ts +15 -14
  131. package/src/internal/sync/fieldValues.ts +21 -22
  132. package/src/internal/sync/manifest.ts +1 -1
  133. package/src/internal/sync/schemas.ts +6 -6
  134. package/src/internal/sync/types.ts +4 -4
  135. package/test/IssueService.test.ts +2 -2
  136. package/test/JiraAuthRefresh.test.ts +269 -0
  137. package/test/VersionService.test.ts +2 -2
  138. package/test/commandTree.test.ts +158 -2
  139. package/test/editIssuePayload.test.ts +103 -0
  140. package/test/frontmatter.test.ts +4 -2
  141. package/test/integration.test.ts +12 -10
  142. package/test/oauthServer.test.ts +77 -4
  143. package/test/publicApi.test.ts +34 -0
  144. package/test/relatedWorkSync.test.ts +179 -0
  145. package/test/remoteWrites.test.ts +220 -0
  146. package/test/syncChanges.test.ts +2 -2
  147. package/test/syncConfig.test.ts +2 -2
@@ -20,7 +20,7 @@ export interface UploadAttachmentInput {
20
20
  readonly mediaType?: string | undefined
21
21
  }
22
22
 
23
- export interface AttachmentServiceShape {
23
+ export interface AttachmentServiceContract {
24
24
  readonly uploadToIssue: (
25
25
  issueIdOrKey: string,
26
26
  input: UploadAttachmentInput
@@ -29,7 +29,7 @@ export interface AttachmentServiceShape {
29
29
 
30
30
  export class AttachmentService extends Context.Service<
31
31
  AttachmentService,
32
- AttachmentServiceShape
32
+ AttachmentServiceContract
33
33
  >()("@knpkv/jira-cli/AttachmentService") {}
34
34
 
35
35
  const UploadedAttachmentSchema = Schema.Struct({
@@ -41,7 +41,7 @@ const UploadedAttachmentSchema = Schema.Struct({
41
41
  size: Schema.optional(Schema.NullOr(Schema.Number))
42
42
  })
43
43
 
44
- const decodeUploadedAttachment = (raw: unknown): Effect.Effect<Attachment, JiraApiError> =>
44
+ const decodeUploadedAttachment = <UnparsedInput>(raw: UnparsedInput): Effect.Effect<Attachment, JiraApiError> =>
45
45
  Schema.decodeUnknownEffect(UploadedAttachmentSchema)(raw).pipe(
46
46
  Effect.mapError((cause) => new JiraApiError({ message: "Jira returned an invalid attachment response", cause })),
47
47
  Effect.flatMap((record) => {
@@ -62,7 +62,7 @@ const decodeUploadedAttachment = (raw: unknown): Effect.Effect<Attachment, JiraA
62
62
  })
63
63
  )
64
64
 
65
- const decodeFirstUploadedAttachment = (raw: unknown): Effect.Effect<Attachment, JiraApiError> => {
65
+ const decodeFirstUploadedAttachment = <UnparsedInput>(raw: UnparsedInput): Effect.Effect<Attachment, JiraApiError> => {
66
66
  if (!Array.isArray(raw)) {
67
67
  return Effect.fail(new JiraApiError({ message: "Jira did not return an attachment array" }))
68
68
  }
@@ -78,7 +78,7 @@ const make = Effect.gen(function*() {
78
78
  const fs = yield* FileSystem.FileSystem
79
79
  const path = yield* Path.Path
80
80
 
81
- const uploadToIssue: AttachmentServiceShape["uploadToIssue"] = (issueIdOrKey, input) =>
81
+ const uploadToIssue: AttachmentServiceContract["uploadToIssue"] = (issueIdOrKey, input) =>
82
82
  Effect.gen(function*() {
83
83
  const bytes = yield* fs.readFile(input.filePath).pipe(
84
84
  Effect.mapError((cause) =>
@@ -89,7 +89,7 @@ const make = Effect.gen(function*() {
89
89
  const result = yield* client.uploadAttachment(issueIdOrKey, {
90
90
  bytes,
91
91
  filename,
92
- ...(input.mediaType === undefined ? {} : { mediaType: input.mediaType })
92
+ ...(!(input.mediaType === undefined) && { mediaType: input.mediaType })
93
93
  }).pipe(
94
94
  Effect.mapError((cause) =>
95
95
  new JiraApiError({ message: `Failed to upload attachment to ${issueIdOrKey}`, cause })
@@ -8,7 +8,7 @@
8
8
  * like `extractDisplayName` and `extractNameArray`.
9
9
  * - **Rendered fields**: Requests include `expand: "renderedFields"` to get HTML-rendered
10
10
  * descriptions and comments, falling back to plain text.
11
- * - **Pagination guard**: {@link IssueServiceShape.searchAll} iterates pages using
11
+ * - **Pagination guard**: {@link IssueServiceContract.searchAll} iterates pages using
12
12
  * `nextPageToken` with a MAX_PAGES (1000) safety limit.
13
13
  *
14
14
  * **Common tasks**
@@ -24,6 +24,7 @@ import * as Context from "effect/Context"
24
24
  import * as Effect from "effect/Effect"
25
25
  import * as Layer from "effect/Layer"
26
26
  import * as Predicate from "effect/Predicate"
27
+ import * as Schema from "effect/Schema"
27
28
  import { JiraApiError } from "./JiraCliError.js"
28
29
 
29
30
  /**
@@ -114,19 +115,174 @@ interface SearchJqlResponse {
114
115
  readonly nextPageToken?: string
115
116
  }
116
117
 
117
- const recordOrEmpty = (value: unknown): Readonly<Record<PropertyKey, unknown>> =>
118
- Predicate.isReadonlyObject(value) ? value : {}
118
+ const JsonObject = Schema.Record(Schema.String, Schema.Json)
119
+ const isJsonObject = Schema.is(JsonObject)
119
120
 
120
- const recordArray = (value: unknown): ReadonlyArray<Readonly<Record<PropertyKey, unknown>>> =>
121
- Array.isArray(value) ? value.filter(Predicate.isReadonlyObject) : []
121
+ const recordOrEmpty = <UnparsedInput>(value: UnparsedInput): Readonly<Record<string, Schema.Json>> =>
122
+ isJsonObject(value) ? value : {}
122
123
 
123
- const parseSearchJqlResponse = (value: unknown): SearchJqlResponse => {
124
+ const recordArray = <UnparsedInput>(value: UnparsedInput): ReadonlyArray<Readonly<Record<string, Schema.Json>>> =>
125
+ Array.isArray(value) ? value.filter(isJsonObject) : []
126
+
127
+ const parseSearchJqlResponse = <UnparsedInput>(value: UnparsedInput): SearchJqlResponse => {
124
128
  const record = recordOrEmpty(value)
125
- const nextPageToken = typeof record.nextPageToken === "string" ? record.nextPageToken : undefined
129
+ const nextPageToken = Predicate.isString(record.nextPageToken) ? record.nextPageToken : undefined
126
130
  return {
127
131
  issues: Array.isArray(record.issues) ? record.issues : [],
128
- isLast: typeof record.isLast === "boolean" ? record.isLast : nextPageToken === undefined,
129
- ...(nextPageToken !== undefined ? { nextPageToken } : {})
132
+ isLast: Predicate.isBoolean(record.isLast) ? record.isLast : nextPageToken === undefined,
133
+ ...((nextPageToken !== undefined) && { nextPageToken })
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Edits to apply to an issue's list-valued fields.
139
+ *
140
+ * `add`/`remove` are incremental and `set` replaces. Prefer the incremental pair:
141
+ * `fixVersions` and `labels` are **sets**, so a `set` that forgets an existing
142
+ * value silently drops it — the failure mode when scripting a release scope.
143
+ *
144
+ * An empty array reads as "not supplied", not as "clear the field". The CLI's
145
+ * repeatable flags use `Options.atLeast(0)`, which reports an absent flag as
146
+ * `[]`, so treating `[]` as a replacement would turn every unpassed flag into a
147
+ * destructive clear. The consequence is that clearing a field outright is not
148
+ * currently expressible; it needs an explicit `--clear-*` flag (or a
149
+ * presence-aware option type) rather than a reinterpretation of `[]`.
150
+ *
151
+ * @category Types
152
+ */
153
+ export interface EditIssueInput {
154
+ readonly addFixVersions?: ReadonlyArray<string>
155
+ readonly removeFixVersions?: ReadonlyArray<string>
156
+ readonly setFixVersions?: ReadonlyArray<string>
157
+ readonly addLabels?: ReadonlyArray<string>
158
+ readonly removeLabels?: ReadonlyArray<string>
159
+ readonly setLabels?: ReadonlyArray<string>
160
+ }
161
+
162
+ /** Any JSON value, matching what the generated client accepts for field values. */
163
+ type JsonValue =
164
+ | string
165
+ | number
166
+ | boolean
167
+ | null
168
+ | ReadonlyArray<JsonValue>
169
+ | { readonly [key: string]: JsonValue }
170
+
171
+ /** One entry in Jira's `update` map. */
172
+ interface FieldOperation {
173
+ readonly add?: JsonValue
174
+ readonly remove?: JsonValue
175
+ }
176
+
177
+ /** A `PUT /issue/{key}` body: `fields` replaces, `update` applies operations. */
178
+ interface EditIssuePayload {
179
+ readonly fields?: { readonly [field: string]: JsonValue }
180
+ readonly update?: { readonly [field: string]: ReadonlyArray<FieldOperation> }
181
+ }
182
+
183
+ /**
184
+ * One list-valued field's edits, plus how to render a value for it — Jira wants
185
+ * `{ name }` objects for fixVersions but bare strings for labels.
186
+ */
187
+ interface FieldEditSpec {
188
+ readonly field: string
189
+ /** Flag stem, so an error can quote the flag the caller actually typed. */
190
+ readonly flag: string
191
+ readonly set: ReadonlyArray<string> | undefined
192
+ readonly add: ReadonlyArray<string> | undefined
193
+ readonly remove: ReadonlyArray<string> | undefined
194
+ readonly wrap: (value: string) => JsonValue
195
+ }
196
+
197
+ /**
198
+ * Outcome of {@link buildEditIssuePayload}: either a body to send, or the
199
+ * reason the requested edit is not expressible as one.
200
+ *
201
+ * Tagged rather than discriminated by property presence, so a caller matches on
202
+ * `_tag` and a future third outcome cannot narrow silently.
203
+ *
204
+ * @category Types
205
+ */
206
+ export type EditIssuePayloadResult =
207
+ | { readonly _tag: "Payload"; readonly payload: EditIssuePayload }
208
+ | { readonly _tag: "Invalid"; readonly reason: string }
209
+
210
+ /**
211
+ * Build the `PUT /issue/{key}` body for an {@link EditIssueInput}.
212
+ *
213
+ * Incremental edits go through Jira's `update` verb rather than a
214
+ * read-modify-write on `fields`: the server applies them atomically, so a
215
+ * concurrent edit cannot be clobbered and no extra GET is needed.
216
+ *
217
+ * Jira rejects a field that appears in both `fields` and `update`, and the error
218
+ * it returns does not name the field — so that combination is caught here with a
219
+ * message that does.
220
+ *
221
+ * Pure so the payload shape can be tested without the API.
222
+ *
223
+ * @category Utilities
224
+ */
225
+ export const buildEditIssuePayload = (
226
+ input: EditIssueInput
227
+ ): EditIssuePayloadResult => {
228
+ const fields: Record<string, JsonValue> = {}
229
+ const update: Record<string, ReadonlyArray<FieldOperation>> = {}
230
+
231
+ const specs: ReadonlyArray<FieldEditSpec> = [
232
+ {
233
+ field: "fixVersions",
234
+ flag: "fix-version",
235
+ set: input.setFixVersions,
236
+ add: input.addFixVersions,
237
+ remove: input.removeFixVersions,
238
+ // fixVersions entries are objects keyed by name.
239
+ wrap: (name) => ({ name })
240
+ },
241
+ {
242
+ field: "labels",
243
+ flag: "label",
244
+ set: input.setLabels,
245
+ add: input.addLabels,
246
+ remove: input.removeLabels,
247
+ // Labels are bare strings.
248
+ wrap: (label) => label
249
+ }
250
+ ]
251
+
252
+ for (const spec of specs) {
253
+ // An empty repeatable flag arrives as `[]`, which means "not passed" rather
254
+ // than "replace with nothing" — treating it as a set would clear the field.
255
+ const set = spec.set !== undefined && spec.set.length > 0 ? spec.set : undefined
256
+ const increments = [
257
+ ...(spec.add ?? []).map((value) => ({ add: spec.wrap(value) })),
258
+ ...(spec.remove ?? []).map((value) => ({ remove: spec.wrap(value) }))
259
+ ]
260
+ if (set !== undefined && increments.length > 0) {
261
+ return {
262
+ _tag: "Invalid",
263
+ reason: `--${spec.flag} cannot be combined with --add-${spec.flag}/--remove-${spec.flag}; ` +
264
+ `--${spec.flag} replaces the whole list.`
265
+ }
266
+ }
267
+ if (set !== undefined) fields[spec.field] = set.map(spec.wrap)
268
+ else if (increments.length > 0) update[spec.field] = increments
269
+ }
270
+
271
+ const hasFields = Object.keys(fields).length > 0
272
+ const hasUpdate = Object.keys(update).length > 0
273
+ if (!hasFields && !hasUpdate) {
274
+ return {
275
+ _tag: "Invalid",
276
+ reason:
277
+ "Nothing to edit. Pass at least one of --add-fix-version, --remove-fix-version, --fix-version, --add-label, --remove-label or --label."
278
+ }
279
+ }
280
+ return {
281
+ _tag: "Payload",
282
+ payload: {
283
+ ...(hasFields && { fields }),
284
+ ...(hasUpdate && { update })
285
+ }
130
286
  }
131
287
  }
132
288
 
@@ -135,7 +291,7 @@ const parseSearchJqlResponse = (value: unknown): SearchJqlResponse => {
135
291
  *
136
292
  * @category Services
137
293
  */
138
- export interface IssueServiceShape {
294
+ export interface IssueServiceContract {
139
295
  /** Get a single issue by key */
140
296
  readonly getByKey: (key: string) => Effect.Effect<Issue, JiraApiError>
141
297
  /** Search issues by JQL query */
@@ -145,6 +301,11 @@ export interface IssueServiceShape {
145
301
  jql: string,
146
302
  options?: { readonly maxResults?: number }
147
303
  ) => Effect.Effect<ReadonlyArray<Issue>, JiraApiError>
304
+ /**
305
+ * Edit an issue's list-valued fields (fixVersions, labels) and return the
306
+ * issue as it stands afterwards. Needs `write:jira-work`.
307
+ */
308
+ readonly edit: (key: string, input: EditIssueInput) => Effect.Effect<Issue, JiraApiError>
148
309
  }
149
310
 
150
311
  /**
@@ -166,7 +327,7 @@ export interface IssueServiceShape {
166
327
  */
167
328
  export class IssueService extends Context.Service<
168
329
  IssueService,
169
- IssueServiceShape
330
+ IssueServiceContract
170
331
  >()("@knpkv/jira-cli/IssueService") {}
171
332
 
172
333
  const FIELDS = [
@@ -189,12 +350,12 @@ const FIELDS = [
189
350
  /**
190
351
  * Extract string from a field that may be an object with displayName/name.
191
352
  */
192
- const extractDisplayName = (field: unknown): string | null => {
353
+ const extractDisplayName = <UnparsedInput>(field: UnparsedInput): string | null => {
193
354
  if (field === null || field === undefined) return null
194
- if (typeof field === "string") return field
355
+ if (Predicate.isString(field)) return field
195
356
  if (Predicate.isReadonlyObject(field)) {
196
- if (typeof field.displayName === "string") return field.displayName
197
- if (typeof field.name === "string") return field.name
357
+ if (Predicate.isString(field.displayName)) return field.displayName
358
+ if (Predicate.isString(field.name)) return field.name
198
359
  }
199
360
  return null
200
361
  }
@@ -202,13 +363,13 @@ const extractDisplayName = (field: unknown): string | null => {
202
363
  /**
203
364
  * Extract array of strings from a field that may be array of objects with name.
204
365
  */
205
- const extractNameArray = (field: unknown): ReadonlyArray<string> => {
366
+ const extractNameArray = <UnparsedInput>(field: UnparsedInput): ReadonlyArray<string> => {
206
367
  if (!Array.isArray(field)) return []
207
368
  return field
208
369
  .map((item) => {
209
- if (typeof item === "string") return item
370
+ if (Predicate.isString(item)) return item
210
371
  if (Predicate.isReadonlyObject(item)) {
211
- if (typeof item.name === "string") return item.name
372
+ if (Predicate.isString(item.name)) return item.name
212
373
  }
213
374
  return null
214
375
  })
@@ -218,7 +379,7 @@ const extractNameArray = (field: unknown): ReadonlyArray<string> => {
218
379
  /**
219
380
  * Parse date from unknown value, returning epoch date if invalid.
220
381
  */
221
- const parseDate = (val: unknown): Date => {
382
+ const parseDate = <UnparsedInput>(val: UnparsedInput): Date => {
222
383
  const str = String(val ?? "")
223
384
  if (!str) return new Date(0)
224
385
  const date = new Date(str)
@@ -228,7 +389,7 @@ const parseDate = (val: unknown): Date => {
228
389
  /**
229
390
  * Map IssueBean from API to our Issue type.
230
391
  */
231
- const mapIssue = (bean: Readonly<Record<PropertyKey, unknown>>, baseUrl: string): Issue => {
392
+ const mapIssue = (bean: Readonly<Record<string, Schema.Json>>, baseUrl: string): Issue => {
232
393
  const fields = recordOrEmpty(bean.fields)
233
394
  const renderedFields = recordOrEmpty(bean.renderedFields)
234
395
  const key = String(bean["key"] ?? "")
@@ -240,7 +401,7 @@ const mapIssue = (bean: Readonly<Record<PropertyKey, unknown>>, baseUrl: string)
240
401
  ? recordArray(attachmentField).map((att) => {
241
402
  const mediaType = normalizeAttachmentMediaType(
242
403
  undefined,
243
- typeof att["mimeType"] === "string" ? att["mimeType"] : null
404
+ Predicate.isString(att["mimeType"]) ? att["mimeType"] : null
244
405
  )
245
406
  return {
246
407
  id: String(att["id"] ?? ""),
@@ -259,7 +420,7 @@ const mapIssue = (bean: Readonly<Record<PropertyKey, unknown>>, baseUrl: string)
259
420
  const renderedComments = recordArray(recordOrEmpty(renderedFields.comment).comments)
260
421
 
261
422
  // Build map of rendered comments by ID for accurate matching
262
- const renderedMap = new Map<string, Record<string, unknown>>()
423
+ const renderedMap = new Map<string, Record<string, Schema.Json>>()
263
424
  for (const r of renderedComments) {
264
425
  const rId = String(r["id"] ?? "")
265
426
  if (rId) renderedMap.set(rId, r)
@@ -273,14 +434,14 @@ const mapIssue = (bean: Readonly<Record<PropertyKey, unknown>>, baseUrl: string)
273
434
  return {
274
435
  id: commentId,
275
436
  author: extractDisplayName(author) ?? "Unknown",
276
- body: typeof renderedBody === "string" ? renderedBody : String(c["body"] ?? ""),
437
+ body: Predicate.isString(renderedBody) ? renderedBody : String(c["body"] ?? ""),
277
438
  created: parseDate(c["created"]),
278
439
  updated: parseDate(c["updated"])
279
440
  }
280
441
  })
281
442
 
282
443
  // Use rendered description (HTML) if available
283
- const description = typeof renderedFields["description"] === "string"
444
+ const description = Predicate.isString(renderedFields["description"])
284
445
  ? renderedFields["description"]
285
446
  : String(fields["description"] ?? "")
286
447
 
@@ -305,7 +466,8 @@ const mapIssue = (bean: Readonly<Record<PropertyKey, unknown>>, baseUrl: string)
305
466
  }
306
467
  }
307
468
 
308
- const mapIssueUnknown = (bean: unknown, baseUrl: string): Issue => mapIssue(recordOrEmpty(bean), baseUrl)
469
+ const mapIssueUnknown = <UnparsedInput>(bean: UnparsedInput, baseUrl: string): Issue =>
470
+ mapIssue(recordOrEmpty(bean), baseUrl)
309
471
 
310
472
  const make = Effect.gen(function*() {
311
473
  const client = yield* JiraApiClient
@@ -331,7 +493,7 @@ const make = Effect.gen(function*() {
331
493
  params: {
332
494
  jql,
333
495
  maxResults,
334
- ...(nextPageToken ? { nextPageToken } : {}),
496
+ ...(nextPageToken && { nextPageToken }),
335
497
  fields: FIELDS,
336
498
  expand: "renderedFields"
337
499
  }
@@ -393,7 +555,32 @@ const make = Effect.gen(function*() {
393
555
  return allIssues
394
556
  })
395
557
 
396
- return IssueService.of({ getByKey, search, searchAll })
558
+ const edit = (key: string, input: EditIssueInput): Effect.Effect<Issue, JiraApiError> =>
559
+ Effect.gen(function*() {
560
+ const built = buildEditIssuePayload(input)
561
+ if (built._tag === "Invalid") return yield* Effect.fail(new JiraApiError({ message: built.reason }))
562
+ yield* client.editIssue(key, { payload: built.payload }).pipe(
563
+ Effect.mapError((cause) => new JiraApiError({ message: `Failed to edit issue ${key}`, cause }))
564
+ )
565
+ // The 204 carries no body, so re-read to report the resulting state.
566
+ //
567
+ // Past this point the edit is already committed, so a failed read-back must
568
+ // not be reported as a failed edit: a caller told "edit failed" may redo a
569
+ // change that already landed. `returnIssue` would close the window, but the
570
+ // endpoint takes no `fields`, so the returned issue can silently omit what
571
+ // `getByKey` asks for — quietly wrong data in place of a loud error.
572
+ return yield* getByKey(key).pipe(
573
+ Effect.mapError((cause) =>
574
+ new JiraApiError({
575
+ message: `Edited issue ${key}, but reading it back failed, so the result below is unverified. ` +
576
+ `The edit was applied — do not repeat it.`,
577
+ cause
578
+ })
579
+ )
580
+ )
581
+ })
582
+
583
+ return IssueService.of({ getByKey, search, searchAll, edit })
397
584
  })
398
585
 
399
586
  /**
package/src/JiraAuth.ts CHANGED
@@ -59,15 +59,15 @@ import * as Context from "effect/Context"
59
59
  import * as Crypto from "effect/Crypto"
60
60
  import * as Deferred from "effect/Deferred"
61
61
  import * as Effect from "effect/Effect"
62
+ import * as HttpClient from "effect/http/HttpClient"
62
63
  import * as Layer from "effect/Layer"
63
64
  import * as Option from "effect/Option"
64
65
  import type * as PlatformError from "effect/PlatformError"
66
+ import { ChildProcessSpawner } from "effect/process"
65
67
  import * as Redacted from "effect/Redacted"
66
68
  import * as Ref from "effect/Ref"
67
- import * as HttpClient from "effect/unstable/http/HttpClient"
68
- import { ChildProcessSpawner } from "effect/unstable/process"
69
69
  import { HttpServerFactoryLive } from "./internal/NodeLayers.js"
70
- import { startCallbackServer } from "./internal/oauthServer.js"
70
+ import { callbackUrl, startCallbackServer } from "./internal/oauthServer.js"
71
71
  import { openBrowser } from "./internal/openBrowser.js"
72
72
  import type { AuthMissingError } from "./JiraCliError.js"
73
73
  import { authMissing } from "./JiraCliError.js"
@@ -78,8 +78,9 @@ const JIRA_CLI_SCOPES = [
78
78
  "read:jira-work",
79
79
  // Edit issues and write worklogs.
80
80
  "write:jira-work",
81
- // Edit a version (e.g. its description) via `PUT /rest/api/3/version/{id}` and
82
- // manage version "Related work" links (`/rest/api/3/version/{id}/relatedwork`).
81
+ // Create a version (`POST /rest/api/3/version`), edit one (e.g. its
82
+ // description) via `PUT /rest/api/3/version/{id}`, and manage version
83
+ // "Related work" links (`/rest/api/3/version/{id}/relatedwork`).
83
84
  "manage:jira-project",
84
85
  // Resolve account IDs to display names for Driver/Contributors/Approvers.
85
86
  "read:jira-user",
@@ -253,27 +254,58 @@ const make = Effect.gen(function*() {
253
254
  Effect.gen(function*() {
254
255
  const config = yield* loadOAuthConfigOp()
255
256
  if (config === null) {
256
- return yield* Effect.fail(
257
- new OAuthError({
258
- step: "authorize",
259
- cause: "OAuth not configured. Run 'jira auth configure' first."
260
- })
261
- )
257
+ return yield* new OAuthError({
258
+ step: "authorize",
259
+ cause: "OAuth not configured. Run 'jira auth configure' first."
260
+ })
262
261
  }
263
262
  return config
264
263
  })
265
264
 
265
+ // Atlassian rotates refresh tokens: the response carries a replacement and
266
+ // the one we sent is consumed server-side. Interrupting between the
267
+ // round-trip and `saveTokenOp` therefore destroys the credential — the next
268
+ // refresh fails and `getAccessToken` below reacts by deleting the token file,
269
+ // so the user is silently logged out and has to run `jira auth login` again.
270
+ //
271
+ // Interruption is routine here, not hypothetical: this runs during layer
272
+ // construction on every CLI invocation, and jcf's nvim statusline kills the
273
+ // process on `VimLeave`. So the grant and the persist are atomic.
274
+ //
275
+ // The region carries its own deadline rather than relying on a caller's.
276
+ // `Effect.timeout` is a race, and racing an uninterruptible loser means
277
+ // waiting for it — an outer bound would go inert and, worse, an
278
+ // uninterruptible region with no deadline of its own absorbs SIGINT/SIGTERM
279
+ // entirely (`NodeRuntime.runMain`'s signal handlers only interrupt the main
280
+ // fiber), leaving a process that ignores Ctrl-C. The deadline forked inside
281
+ // the region is itself interruptible, so it does bound this.
282
+ //
283
+ // Abandoning the round-trip cannot prove the grant did not land: Atlassian
284
+ // may consume the refresh token and rotate it after we have stopped
285
+ // listening, and no client-side deadline changes that. So the deadline is
286
+ // paired with the rule in `getAccessToken` below — a refresh that fails
287
+ // without a 4xx never deletes the stored token. The credential survives to be
288
+ // retried, and only a real rejection ends the session.
289
+ const REFRESH_TIMEOUT = "30 seconds"
290
+
266
291
  const refreshTokenImpl = (
267
292
  token: OAuthToken,
268
293
  config: OAuthConfig
269
294
  ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
270
- Effect.gen(function*() {
271
- const updated = yield* refreshToken(token, config).pipe(
272
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
273
- )
274
- yield* saveTokenOp(updated)
275
- return updated
276
- })
295
+ Effect.uninterruptible(
296
+ Effect.gen(function*() {
297
+ const updated = yield* refreshToken(token, config).pipe(
298
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient)),
299
+ Effect.timeout(REFRESH_TIMEOUT),
300
+ Effect.catchTag(
301
+ "TimeoutError",
302
+ () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
303
+ )
304
+ )
305
+ yield* saveTokenOp(updated)
306
+ return updated
307
+ })
308
+ )
277
309
 
278
310
  const revokeTokenImpl = (
279
311
  token: OAuthToken,
@@ -317,6 +349,7 @@ const make = Effect.gen(function*() {
317
349
  clientId: config.clientId,
318
350
  state,
319
351
  port,
352
+ redirectUri: callbackUrl(port),
320
353
  scopes: JIRA_CLI_SCOPES,
321
354
  codeChallenge
322
355
  })
@@ -338,7 +371,11 @@ const make = Effect.gen(function*() {
338
371
  )
339
372
 
340
373
  yield* Console.log("Exchanging code for tokens...")
341
- const tokens = yield* exchangeCodeForTokens(code, config, { port, codeVerifier }).pipe(
374
+ const tokens = yield* exchangeCodeForTokens(code, config, {
375
+ port,
376
+ redirectUri: callbackUrl(port),
377
+ codeVerifier
378
+ }).pipe(
342
379
  Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
343
380
  )
344
381
 
@@ -348,12 +385,10 @@ const make = Effect.gen(function*() {
348
385
  )
349
386
 
350
387
  if (sites.length === 0) {
351
- return yield* Effect.fail(
352
- new OAuthError({
353
- step: "authorize",
354
- cause: "No Jira sites found for this account"
355
- })
356
- )
388
+ return yield* new OAuthError({
389
+ step: "authorize",
390
+ cause: "No Jira sites found for this account"
391
+ })
357
392
  }
358
393
 
359
394
  let site: (typeof sites)[number]
@@ -363,12 +398,10 @@ const make = Effect.gen(function*() {
363
398
  const matched = sites.find((s) => s.url === options.siteUrl)
364
399
  if (!matched) {
365
400
  const available = sites.map((s) => ` - ${s.name}: ${s.url}`).join("\n")
366
- return yield* Effect.fail(
367
- new OAuthError({
368
- step: "authorize",
369
- cause: `Site '${options.siteUrl}' not found. Available sites:\n${available}`
370
- })
371
- )
401
+ return yield* new OAuthError({
402
+ step: "authorize",
403
+ cause: `Site '${options.siteUrl}' not found. Available sites:\n${available}`
404
+ })
372
405
  }
373
406
  site = matched
374
407
  } else {
@@ -425,7 +458,7 @@ const make = Effect.gen(function*() {
425
458
  Effect.gen(function*() {
426
459
  const token = yield* loadTokenOp()
427
460
  if (token === null) {
428
- return yield* Effect.fail(authMissing())
461
+ return yield* authMissing()
429
462
  }
430
463
 
431
464
  const nowMs = yield* Clock.currentTimeMillis
@@ -448,19 +481,50 @@ const make = Effect.gen(function*() {
448
481
 
449
482
  const refresh = Effect.gen(function*() {
450
483
  const config = yield* getConfig()
451
- yield* Console.log("Token expired, refreshing...")
484
+ // stderr, not stdout: this fires from inside layer construction, so it
485
+ // lands ahead of whatever the command prints. On `--json` that used to
486
+ // put a line of prose in front of the document, and the release
487
+ // automation parsing it failed *after* the remote writes had happened.
488
+ yield* Console.error("Token expired, refreshing...")
452
489
  return yield* refreshTokenImpl(token, config)
453
490
  }).pipe(
454
491
  Effect.catchTag("OAuthError", (error) => {
455
- if (error.step === "refresh") {
492
+ // Discard the stored credential only when Atlassian actually rejected
493
+ // it. A refresh can fail without saying anything about the token —
494
+ // transport error, timeout, interruption — and in those cases the
495
+ // grant may even have been consumed server-side, so the one thing we
496
+ // must not do is delete the replacement's only trail. Deleting on any
497
+ // `step === "refresh"` failure turned a flaky network into a silent
498
+ // logout, which is worse than retrying and is unrecoverable. On a 4xx
499
+ // the token is genuinely dead and re-login is the only way forward.
500
+ // Not every failure is a verdict on the token, and not even every
501
+ // 4xx is. `429` is the one this most needs to survive — several
502
+ // `jira`/`jcf` processes on an expired token hit the endpoint
503
+ // together, one wins the rotation and the rest are rate-limited —
504
+ // while `408`/`425` restate the timeout case and `407` and other
505
+ // middlebox replies never came from Atlassian at all.
506
+ //
507
+ // Status alone is still too coarse. `refreshToken` sends the client
508
+ // credentials in the body, so a wrong or rotated `clientSecret` comes
509
+ // back as `400 invalid_client`; deleting the token there destroys a
510
+ // working credential over a config problem, and the re-login it forces
511
+ // would fail the same way until `jira auth configure` is run. Only
512
+ // `invalid_grant` means the stored token itself is spent, and that is
513
+ // the only thing that ends the session — on a `403` too, since a bare
514
+ // 403 is just as likely to come from a proxy or WAF as from Atlassian
515
+ // revoking anything. An unparseable body leaves `errorCode` absent and
516
+ // the token in place.
517
+ const { errorCode, status } = error
518
+ const rejected = errorCode === "invalid_grant" && (status === 400 || status === 403)
519
+ if (error.step === "refresh" && rejected) {
456
520
  return Effect.gen(function*() {
457
521
  yield* deleteTokenOp()
458
- return yield* Effect.fail(
459
- new OAuthError({
460
- step: "refresh",
461
- cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate."
462
- })
463
- )
522
+ return yield* new OAuthError({
523
+ step: "refresh",
524
+ cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate.",
525
+ status,
526
+ errorCode
527
+ })
464
528
  })
465
529
  }
466
530
  return Effect.fail(error)
@@ -486,7 +550,7 @@ const make = Effect.gen(function*() {
486
550
  Effect.gen(function*() {
487
551
  const token = yield* loadTokenOp()
488
552
  if (token === null) {
489
- return yield* Effect.fail(authMissing())
553
+ return yield* authMissing()
490
554
  }
491
555
  return token.cloud_id
492
556
  })
@@ -498,7 +562,7 @@ const make = Effect.gen(function*() {
498
562
  Effect.gen(function*() {
499
563
  const token = yield* loadTokenOp()
500
564
  if (token === null) {
501
- return yield* Effect.fail(authMissing())
565
+ return yield* authMissing()
502
566
  }
503
567
  return token.site_url
504
568
  })