@knpkv/jira-cli 1.2.3 → 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.
- package/CHANGELOG.md +113 -0
- package/README.md +64 -0
- package/dist/AttachmentService.d.ts +2 -2
- package/dist/AttachmentService.d.ts.map +1 -1
- package/dist/AttachmentService.js +1 -1
- package/dist/AttachmentService.js.map +1 -1
- package/dist/IssueService.d.ts +82 -3
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +111 -16
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +1 -1
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +74 -10
- package/dist/JiraAuth.js.map +1 -1
- package/dist/MarkdownWriter.d.ts +4 -4
- package/dist/MarkdownWriter.d.ts.map +1 -1
- package/dist/MarkdownWriter.js +2 -2
- package/dist/MarkdownWriter.js.map +1 -1
- package/dist/SyncWorkspace.d.ts +2 -2
- package/dist/SyncWorkspace.d.ts.map +1 -1
- package/dist/SyncWorkspace.js.map +1 -1
- package/dist/VersionService.d.ts +81 -9
- package/dist/VersionService.d.ts.map +1 -1
- package/dist/VersionService.js +113 -17
- package/dist/VersionService.js.map +1 -1
- package/dist/commands/auth.d.ts +3 -2
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/edit.d.ts +13 -0
- package/dist/commands/edit.d.ts.map +1 -0
- package/dist/commands/edit.js +49 -0
- package/dist/commands/edit.js.map +1 -0
- package/dist/commands/errorHandler.d.ts.map +1 -1
- package/dist/commands/issue.d.ts +1 -1
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +2 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/layers.d.ts +3 -3
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +5 -2
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/version.d.ts +8 -1
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +116 -4
- package/dist/commands/version.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/NodeLayers.js +1 -1
- package/dist/internal/NodeLayers.js.map +1 -1
- package/dist/internal/attachmentInsertion.d.ts.map +1 -1
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +2 -1
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/jqlBuilder.d.ts.map +1 -1
- package/dist/internal/oauthServer.d.ts +8 -2
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +10 -6
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts.map +1 -1
- package/dist/internal/sync/baseline.d.ts.map +1 -1
- package/dist/internal/sync/baseline.js +2 -2
- package/dist/internal/sync/baseline.js.map +1 -1
- package/dist/internal/sync/changes.d.ts.map +1 -1
- package/dist/internal/sync/config.d.ts.map +1 -1
- package/dist/internal/sync/config.js +1 -1
- package/dist/internal/sync/config.js.map +1 -1
- package/dist/internal/sync/document.d.ts.map +1 -1
- package/dist/internal/sync/document.js +8 -8
- package/dist/internal/sync/document.js.map +1 -1
- package/dist/internal/sync/fieldValues.d.ts +9 -9
- package/dist/internal/sync/fieldValues.d.ts.map +1 -1
- package/dist/internal/sync/fieldValues.js +12 -16
- package/dist/internal/sync/fieldValues.js.map +1 -1
- package/dist/internal/sync/manifest.d.ts.map +1 -1
- package/dist/internal/sync/manifest.js +1 -1
- package/dist/internal/sync/manifest.js.map +1 -1
- package/dist/internal/sync/paths.d.ts.map +1 -1
- package/dist/internal/sync/schemas.d.ts +17 -9
- package/dist/internal/sync/schemas.d.ts.map +1 -1
- package/dist/internal/sync/schemas.js +6 -6
- package/dist/internal/sync/schemas.js.map +1 -1
- package/dist/internal/sync/types.d.ts +4 -4
- package/dist/internal/sync/types.d.ts.map +1 -1
- package/dist/internal/sync/types.js +1 -1
- package/dist/internal/sync/types.js.map +1 -1
- package/package.json +14 -10
- package/skills/jira/SKILL.md +17 -1
- package/src/AttachmentService.ts +6 -6
- package/src/IssueService.ts +214 -27
- package/src/JiraAuth.ts +86 -14
- package/src/MarkdownWriter.ts +6 -6
- package/src/SyncWorkspace.ts +12 -11
- package/src/VersionService.ts +199 -27
- package/src/commands/edit.ts +88 -0
- package/src/commands/issue.ts +2 -1
- package/src/commands/layers.ts +5 -2
- package/src/commands/version.ts +161 -5
- package/src/index.ts +13 -3
- package/src/internal/NodeLayers.ts +1 -1
- package/src/internal/frontmatter.ts +3 -2
- package/src/internal/oauthServer.ts +25 -13
- package/src/internal/sync/baseline.ts +3 -2
- package/src/internal/sync/config.ts +2 -1
- package/src/internal/sync/document.ts +15 -14
- package/src/internal/sync/fieldValues.ts +21 -22
- package/src/internal/sync/manifest.ts +1 -1
- package/src/internal/sync/schemas.ts +6 -6
- package/src/internal/sync/types.ts +4 -4
- package/test/JiraAuthRefresh.test.ts +269 -0
- package/test/commandTree.test.ts +157 -1
- package/test/editIssuePayload.test.ts +103 -0
- package/test/frontmatter.test.ts +4 -2
- package/test/integration.test.ts +5 -3
- package/test/oauthServer.test.ts +75 -3
- package/test/publicApi.test.ts +34 -0
- package/test/relatedWorkSync.test.ts +179 -0
- package/test/remoteWrites.test.ts +220 -0
- package/test/syncChanges.test.ts +2 -2
- package/test/syncConfig.test.ts +2 -2
package/src/IssueService.ts
CHANGED
|
@@ -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
|
|
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
|
|
118
|
-
|
|
118
|
+
const JsonObject = Schema.Record(Schema.String, Schema.Json)
|
|
119
|
+
const isJsonObject = Schema.is(JsonObject)
|
|
119
120
|
|
|
120
|
-
const
|
|
121
|
-
|
|
121
|
+
const recordOrEmpty = <UnparsedInput>(value: UnparsedInput): Readonly<Record<string, Schema.Json>> =>
|
|
122
|
+
isJsonObject(value) ? value : {}
|
|
122
123
|
|
|
123
|
-
const
|
|
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 =
|
|
129
|
+
const nextPageToken = Predicate.isString(record.nextPageToken) ? record.nextPageToken : undefined
|
|
126
130
|
return {
|
|
127
131
|
issues: Array.isArray(record.issues) ? record.issues : [],
|
|
128
|
-
isLast:
|
|
129
|
-
...(nextPageToken !== undefined
|
|
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
|
|
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
|
-
|
|
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:
|
|
353
|
+
const extractDisplayName = <UnparsedInput>(field: UnparsedInput): string | null => {
|
|
193
354
|
if (field === null || field === undefined) return null
|
|
194
|
-
if (
|
|
355
|
+
if (Predicate.isString(field)) return field
|
|
195
356
|
if (Predicate.isReadonlyObject(field)) {
|
|
196
|
-
if (
|
|
197
|
-
if (
|
|
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:
|
|
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 (
|
|
370
|
+
if (Predicate.isString(item)) return item
|
|
210
371
|
if (Predicate.isReadonlyObject(item)) {
|
|
211
|
-
if (
|
|
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:
|
|
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<
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
@@ -67,7 +67,7 @@ import * as Ref from "effect/Ref"
|
|
|
67
67
|
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
68
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
|
-
//
|
|
82
|
-
//
|
|
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",
|
|
@@ -263,17 +264,50 @@ const make = Effect.gen(function*() {
|
|
|
263
264
|
return config
|
|
264
265
|
})
|
|
265
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
|
+
|
|
266
293
|
const refreshTokenImpl = (
|
|
267
294
|
token: OAuthToken,
|
|
268
295
|
config: OAuthConfig
|
|
269
296
|
): Effect.Effect<OAuthToken, OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError> =>
|
|
270
|
-
Effect.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
)
|
|
277
311
|
|
|
278
312
|
const revokeTokenImpl = (
|
|
279
313
|
token: OAuthToken,
|
|
@@ -317,6 +351,7 @@ const make = Effect.gen(function*() {
|
|
|
317
351
|
clientId: config.clientId,
|
|
318
352
|
state,
|
|
319
353
|
port,
|
|
354
|
+
redirectUri: callbackUrl(port),
|
|
320
355
|
scopes: JIRA_CLI_SCOPES,
|
|
321
356
|
codeChallenge
|
|
322
357
|
})
|
|
@@ -338,7 +373,11 @@ const make = Effect.gen(function*() {
|
|
|
338
373
|
)
|
|
339
374
|
|
|
340
375
|
yield* Console.log("Exchanging code for tokens...")
|
|
341
|
-
const tokens = yield* exchangeCodeForTokens(code, config, {
|
|
376
|
+
const tokens = yield* exchangeCodeForTokens(code, config, {
|
|
377
|
+
port,
|
|
378
|
+
redirectUri: callbackUrl(port),
|
|
379
|
+
codeVerifier
|
|
380
|
+
}).pipe(
|
|
342
381
|
Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
|
|
343
382
|
)
|
|
344
383
|
|
|
@@ -448,17 +487,50 @@ const make = Effect.gen(function*() {
|
|
|
448
487
|
|
|
449
488
|
const refresh = Effect.gen(function*() {
|
|
450
489
|
const config = yield* getConfig()
|
|
451
|
-
|
|
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...")
|
|
452
495
|
return yield* refreshTokenImpl(token, config)
|
|
453
496
|
}).pipe(
|
|
454
497
|
Effect.catchTag("OAuthError", (error) => {
|
|
455
|
-
|
|
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) {
|
|
456
526
|
return Effect.gen(function*() {
|
|
457
527
|
yield* deleteTokenOp()
|
|
458
528
|
return yield* Effect.fail(
|
|
459
529
|
new OAuthError({
|
|
460
530
|
step: "refresh",
|
|
461
|
-
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
|
|
462
534
|
})
|
|
463
535
|
)
|
|
464
536
|
})
|
package/src/MarkdownWriter.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* **Mental model**
|
|
5
5
|
*
|
|
6
|
-
* - **Two output modes**: {@link
|
|
7
|
-
* {@link
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
87
|
+
const writeSingle: MarkdownWriterContract["writeSingle"] = (issues, outputDir, jql) =>
|
|
88
88
|
Effect.gen(function*() {
|
|
89
89
|
yield* ensureDir(outputDir)
|
|
90
90
|
|
package/src/SyncWorkspace.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface InitWorkspaceInput {
|
|
|
26
26
|
readonly siteUrl: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export interface
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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)
|