@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.
- package/CHANGELOG.md +133 -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 +3 -3
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +87 -23
- 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/bin.js +1 -1
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +13 -2
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +7 -7
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/edit.d.ts +22 -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/get.d.ts +6 -1
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +3 -3
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/issue.d.ts +2 -2
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +3 -2
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/issueAttachment.d.ts +1 -1
- package/dist/commands/issueAttachment.d.ts.map +1 -1
- package/dist/commands/issueAttachment.js +13 -11
- package/dist/commands/issueAttachment.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/search.d.ts +6 -1
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +7 -7
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +17 -2
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +131 -19
- 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 +10 -4
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +13 -8
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts +1 -1
- package/dist/internal/openBrowser.d.ts.map +1 -1
- package/dist/internal/openBrowser.js +1 -1
- package/dist/internal/openBrowser.js.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 +15 -11
- package/skills/jira/SKILL.md +17 -1
- package/src/AttachmentService.ts +6 -6
- package/src/IssueService.ts +214 -27
- package/src/JiraAuth.ts +106 -42
- package/src/MarkdownWriter.ts +6 -6
- package/src/SyncWorkspace.ts +12 -11
- package/src/VersionService.ts +199 -27
- package/src/bin.ts +1 -1
- package/src/commands/auth.ts +7 -7
- package/src/commands/edit.ts +88 -0
- package/src/commands/get.ts +3 -3
- package/src/commands/issue.ts +3 -2
- package/src/commands/issueAttachment.ts +21 -20
- package/src/commands/layers.ts +5 -2
- package/src/commands/search.ts +7 -7
- package/src/commands/version.ts +176 -23
- 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 +29 -16
- package/src/internal/openBrowser.ts +1 -1
- 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/IssueService.test.ts +2 -2
- package/test/JiraAuthRefresh.test.ts +269 -0
- package/test/VersionService.test.ts +2 -2
- package/test/commandTree.test.ts +158 -2
- package/test/editIssuePayload.test.ts +103 -0
- package/test/frontmatter.test.ts +4 -2
- package/test/integration.test.ts +12 -10
- package/test/oauthServer.test.ts +77 -4
- 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/AttachmentService.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface UploadAttachmentInput {
|
|
|
20
20
|
readonly mediaType?: string | undefined
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export interface
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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 })
|
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
|
@@ -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
|
-
//
|
|
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",
|
|
@@ -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*
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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, {
|
|
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*
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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*
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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*
|
|
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
|
-
|
|
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
|
-
|
|
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*
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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*
|
|
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*
|
|
565
|
+
return yield* authMissing()
|
|
502
566
|
}
|
|
503
567
|
return token.site_url
|
|
504
568
|
})
|