@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
@@ -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
@@ -23,7 +23,7 @@ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
23
23
  import * as NodePath from "@effect/platform-node/NodePath"
24
24
  import {
25
25
  buildAuthUrl,
26
- buildOAuthToken,
26
+ buildOAuthTokenAt,
27
27
  computeCodeChallenge,
28
28
  exchangeCodeForTokens,
29
29
  generateCodeVerifier,
@@ -41,7 +41,7 @@ import {
41
41
  type FileSystemError,
42
42
  type HomeDirectoryError,
43
43
  HomeDirectoryLive,
44
- isTokenExpired,
44
+ isTokenExpiredAt,
45
45
  loadActiveProfile,
46
46
  loadActiveProfileToken,
47
47
  loadOAuthConfig,
@@ -53,6 +53,7 @@ import {
53
53
  saveProfileToken,
54
54
  setActiveProfileBySelector
55
55
  } from "@knpkv/atlassian-common/config"
56
+ import * as Clock from "effect/Clock"
56
57
  import * as Console from "effect/Console"
57
58
  import * as Context from "effect/Context"
58
59
  import * as Crypto from "effect/Crypto"
@@ -66,7 +67,7 @@ import * as Ref from "effect/Ref"
66
67
  import * as HttpClient from "effect/unstable/http/HttpClient"
67
68
  import { ChildProcessSpawner } from "effect/unstable/process"
68
69
  import { HttpServerFactoryLive } from "./internal/NodeLayers.js"
69
- import { startCallbackServer } from "./internal/oauthServer.js"
70
+ import { callbackUrl, startCallbackServer } from "./internal/oauthServer.js"
70
71
  import { openBrowser } from "./internal/openBrowser.js"
71
72
  import type { AuthMissingError } from "./JiraCliError.js"
72
73
  import { authMissing } from "./JiraCliError.js"
@@ -77,8 +78,9 @@ const JIRA_CLI_SCOPES = [
77
78
  "read:jira-work",
78
79
  // Edit issues and write worklogs.
79
80
  "write:jira-work",
80
- // Edit a version (e.g. its description) via `PUT /rest/api/3/version/{id}` and
81
- // 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`).
82
84
  "manage:jira-project",
83
85
  // Resolve account IDs to display names for Driver/Contributors/Approvers.
84
86
  "read:jira-user",
@@ -262,17 +264,50 @@ const make = Effect.gen(function*() {
262
264
  return config
263
265
  })
264
266
 
267
+ // Atlassian rotates refresh tokens: the response carries a replacement and
268
+ // the one we sent is consumed server-side. Interrupting between the
269
+ // round-trip and `saveTokenOp` therefore destroys the credential — the next
270
+ // refresh fails and `getAccessToken` below reacts by deleting the token file,
271
+ // so the user is silently logged out and has to run `jira auth login` again.
272
+ //
273
+ // Interruption is routine here, not hypothetical: this runs during layer
274
+ // construction on every CLI invocation, and jcf's nvim statusline kills the
275
+ // process on `VimLeave`. So the grant and the persist are atomic.
276
+ //
277
+ // The region carries its own deadline rather than relying on a caller's.
278
+ // `Effect.timeout` is a race, and racing an uninterruptible loser means
279
+ // waiting for it — an outer bound would go inert and, worse, an
280
+ // uninterruptible region with no deadline of its own absorbs SIGINT/SIGTERM
281
+ // entirely (`NodeRuntime.runMain`'s signal handlers only interrupt the main
282
+ // fiber), leaving a process that ignores Ctrl-C. The deadline forked inside
283
+ // the region is itself interruptible, so it does bound this.
284
+ //
285
+ // Abandoning the round-trip cannot prove the grant did not land: Atlassian
286
+ // may consume the refresh token and rotate it after we have stopped
287
+ // listening, and no client-side deadline changes that. So the deadline is
288
+ // paired with the rule in `getAccessToken` below — a refresh that fails
289
+ // without a 4xx never deletes the stored token. The credential survives to be
290
+ // retried, and only a real rejection ends the session.
291
+ const REFRESH_TIMEOUT = "30 seconds"
292
+
265
293
  const refreshTokenImpl = (
266
294
  token: OAuthToken,
267
295
  config: OAuthConfig
268
296
  ): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
269
- Effect.gen(function*() {
270
- const updated = yield* refreshToken(token, config).pipe(
271
- Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
272
- )
273
- yield* saveTokenOp(updated)
274
- return updated
275
- })
297
+ Effect.uninterruptible(
298
+ Effect.gen(function*() {
299
+ const updated = yield* refreshToken(token, config).pipe(
300
+ Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient)),
301
+ Effect.timeout(REFRESH_TIMEOUT),
302
+ Effect.catchTag(
303
+ "TimeoutError",
304
+ () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
305
+ )
306
+ )
307
+ yield* saveTokenOp(updated)
308
+ return updated
309
+ })
310
+ )
276
311
 
277
312
  const revokeTokenImpl = (
278
313
  token: OAuthToken,
@@ -307,27 +342,42 @@ const make = Effect.gen(function*() {
307
342
  Effect.provideService(Crypto.Crypto, cryptoService)
308
343
  )
309
344
 
310
- const { codePromise, port, shutdown } = yield* startCallbackServer(state).pipe(
311
- Effect.provide(HttpServerFactoryLive)
312
- )
313
- const authUrl = buildAuthUrl({ clientId: config.clientId, state, port, scopes: JIRA_CLI_SCOPES, codeChallenge })
314
-
315
- yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
316
- yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
317
- yield* openBrowserImpl(authUrl)
318
- yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
319
-
320
- const code = yield* codePromise.pipe(
321
- Effect.timeout("5 minutes"),
322
- Effect.catchTag(
323
- "TimeoutError",
324
- () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
325
- ),
326
- Effect.ensuring(shutdown)
345
+ const { code, port } = yield* Effect.scoped(
346
+ Effect.gen(function*() {
347
+ const { codePromise, port } = yield* startCallbackServer(state).pipe(
348
+ Effect.provide(HttpServerFactoryLive)
349
+ )
350
+ const authUrl = buildAuthUrl({
351
+ clientId: config.clientId,
352
+ state,
353
+ port,
354
+ redirectUri: callbackUrl(port),
355
+ scopes: JIRA_CLI_SCOPES,
356
+ codeChallenge
357
+ })
358
+
359
+ yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
360
+ yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
361
+ yield* openBrowserImpl(authUrl)
362
+ yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
363
+
364
+ const code = yield* codePromise.pipe(
365
+ Effect.timeout("5 minutes"),
366
+ Effect.catchTag(
367
+ "TimeoutError",
368
+ () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
369
+ )
370
+ )
371
+ return { code, port }
372
+ })
327
373
  )
328
374
 
329
375
  yield* Console.log("Exchanging code for tokens...")
330
- const tokens = yield* exchangeCodeForTokens(code, config, { port, codeVerifier }).pipe(
376
+ const tokens = yield* exchangeCodeForTokens(code, config, {
377
+ port,
378
+ redirectUri: callbackUrl(port),
379
+ codeVerifier
380
+ }).pipe(
331
381
  Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
332
382
  )
333
383
 
@@ -377,7 +427,8 @@ const make = Effect.gen(function*() {
377
427
  Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
378
428
  )
379
429
 
380
- const tokenData = buildOAuthToken(tokens, site, user)
430
+ const nowMs = yield* Clock.currentTimeMillis
431
+ const tokenData = buildOAuthTokenAt(tokens, site, user, nowMs)
381
432
 
382
433
  yield* saveTokenOp(tokenData)
383
434
  yield* Console.log(`Logged in as ${user.name} (${user.email})`)
@@ -416,7 +467,8 @@ const make = Effect.gen(function*() {
416
467
  return yield* Effect.fail(authMissing())
417
468
  }
418
469
 
419
- if (!isTokenExpired(token)) {
470
+ const nowMs = yield* Clock.currentTimeMillis
471
+ if (!isTokenExpiredAt(token, nowMs)) {
420
472
  return Redacted.make(token.access_token)
421
473
  }
422
474
 
@@ -435,17 +487,50 @@ const make = Effect.gen(function*() {
435
487
 
436
488
  const refresh = Effect.gen(function*() {
437
489
  const config = yield* getConfig()
438
- yield* Console.log("Token expired, refreshing...")
490
+ // stderr, not stdout: this fires from inside layer construction, so it
491
+ // lands ahead of whatever the command prints. On `--json` that used to
492
+ // put a line of prose in front of the document, and the release
493
+ // automation parsing it failed *after* the remote writes had happened.
494
+ yield* Console.error("Token expired, refreshing...")
439
495
  return yield* refreshTokenImpl(token, config)
440
496
  }).pipe(
441
497
  Effect.catchTag("OAuthError", (error) => {
442
- if (error.step === "refresh") {
498
+ // Discard the stored credential only when Atlassian actually rejected
499
+ // it. A refresh can fail without saying anything about the token —
500
+ // transport error, timeout, interruption — and in those cases the
501
+ // grant may even have been consumed server-side, so the one thing we
502
+ // must not do is delete the replacement's only trail. Deleting on any
503
+ // `step === "refresh"` failure turned a flaky network into a silent
504
+ // logout, which is worse than retrying and is unrecoverable. On a 4xx
505
+ // the token is genuinely dead and re-login is the only way forward.
506
+ // Not every failure is a verdict on the token, and not even every
507
+ // 4xx is. `429` is the one this most needs to survive — several
508
+ // `jira`/`jcf` processes on an expired token hit the endpoint
509
+ // together, one wins the rotation and the rest are rate-limited —
510
+ // while `408`/`425` restate the timeout case and `407` and other
511
+ // middlebox replies never came from Atlassian at all.
512
+ //
513
+ // Status alone is still too coarse. `refreshToken` sends the client
514
+ // credentials in the body, so a wrong or rotated `clientSecret` comes
515
+ // back as `400 invalid_client`; deleting the token there destroys a
516
+ // working credential over a config problem, and the re-login it forces
517
+ // would fail the same way until `jira auth configure` is run. Only
518
+ // `invalid_grant` means the stored token itself is spent, and that is
519
+ // the only thing that ends the session — on a `403` too, since a bare
520
+ // 403 is just as likely to come from a proxy or WAF as from Atlassian
521
+ // revoking anything. An unparseable body leaves `errorCode` absent and
522
+ // the token in place.
523
+ const { errorCode, status } = error
524
+ const rejected = errorCode === "invalid_grant" && (status === 400 || status === 403)
525
+ if (error.step === "refresh" && rejected) {
443
526
  return Effect.gen(function*() {
444
527
  yield* deleteTokenOp()
445
528
  return yield* Effect.fail(
446
529
  new OAuthError({
447
530
  step: "refresh",
448
- cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate."
531
+ cause: "Refresh token expired. Please run 'jira auth login' to re-authenticate.",
532
+ status,
533
+ errorCode
449
534
  })
450
535
  )
451
536
  })