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