@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
|
@@ -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 { Argument as Args, Command, Flag as Options } from "effect/cli"
|
|
11
|
+
import * as Console from "effect/Console"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
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/get.ts
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
+
import { Argument as Args, Command, Flag as Options } from "effect/cli"
|
|
6
7
|
import * as Console from "effect/Console"
|
|
7
8
|
import * as Effect from "effect/Effect"
|
|
8
|
-
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
9
9
|
import { IssueService } from "../IssueService.js"
|
|
10
10
|
import { MarkdownWriter } from "../MarkdownWriter.js"
|
|
11
11
|
|
|
12
|
-
const keyArg = Args.
|
|
12
|
+
const keyArg = Args.String("key").pipe(
|
|
13
13
|
Args.withDescription("Issue key (e.g., PROJ-123)")
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
const outputDirOption = Options.
|
|
16
|
+
const outputDirOption = Options.Directory("output-dir").pipe(
|
|
17
17
|
Options.withAlias("o"),
|
|
18
18
|
Options.withDescription("Output directory for markdown file"),
|
|
19
19
|
Options.withDefault("./jira-tickets")
|
package/src/commands/issue.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
-
import { Command } from "effect/
|
|
6
|
+
import { Command } from "effect/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
|
)
|
|
@@ -4,39 +4,42 @@
|
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
6
|
import { renderAttachmentMarkdown } from "@knpkv/atlassian-common/attachments"
|
|
7
|
+
import { Argument as Args, Command, Flag as Options } from "effect/cli"
|
|
7
8
|
import * as Console from "effect/Console"
|
|
8
9
|
import * as Effect from "effect/Effect"
|
|
9
10
|
import * as FileSystem from "effect/FileSystem"
|
|
10
11
|
import * as Option from "effect/Option"
|
|
11
|
-
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
12
12
|
import { AttachmentService } from "../AttachmentService.js"
|
|
13
13
|
import { insertJiraAttachmentReference } from "../internal/attachmentInsertion.js"
|
|
14
14
|
import { JiraApiError, WriteError } from "../JiraCliError.js"
|
|
15
15
|
|
|
16
|
-
const issueArg = Args.
|
|
16
|
+
const issueArg = Args.String("issue").pipe(
|
|
17
17
|
Args.withDescription("Issue key or id, for example PROJ-123")
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
-
const fileArg = Args.
|
|
20
|
+
const fileArg = Args.String("file").pipe(
|
|
21
21
|
Args.withDescription("Local file to upload")
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
const documentOption = Options.
|
|
24
|
+
const documentOption = Options.File("document").pipe(
|
|
25
25
|
Options.withDescription("Local Jira Markdown document containing the attachment placeholder"),
|
|
26
26
|
Options.optional
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
-
const noInsertOption = Options.
|
|
30
|
-
Options.withDescription("Only upload and print the Attachment Reference")
|
|
29
|
+
const noInsertOption = Options.Boolean("no-insert").pipe(
|
|
30
|
+
Options.withDescription("Only upload and print the Attachment Reference"),
|
|
31
|
+
Options.withDefault(false)
|
|
31
32
|
)
|
|
32
33
|
|
|
33
|
-
const dryRunOption = Options.
|
|
34
|
+
const dryRunOption = Options.Boolean("dry-run").pipe(
|
|
34
35
|
Options.withAlias("n"),
|
|
35
|
-
Options.withDescription("Validate local insertion input without uploading")
|
|
36
|
+
Options.withDescription("Validate local insertion input without uploading"),
|
|
37
|
+
Options.withDefault(false)
|
|
36
38
|
)
|
|
37
39
|
|
|
38
|
-
const jsonOption = Options.
|
|
39
|
-
Options.withDescription("Write exactly one JSON value to stdout")
|
|
40
|
+
const jsonOption = Options.Boolean("json").pipe(
|
|
41
|
+
Options.withDescription("Write exactly one JSON value to stdout"),
|
|
42
|
+
Options.withDefault(false)
|
|
40
43
|
)
|
|
41
44
|
|
|
42
45
|
const readDocument = (documentPath: string) =>
|
|
@@ -88,16 +91,16 @@ const uploadCommand = Command.make(
|
|
|
88
91
|
if (documentInput !== null) {
|
|
89
92
|
const matches = countPlaceholderMatches(documentInput.content, file)
|
|
90
93
|
if (matches !== 1) {
|
|
91
|
-
return yield*
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
+
return yield* new JiraApiError({
|
|
95
|
+
message: `Expected exactly one attachment placeholder for ${file}, found ${matches}`
|
|
96
|
+
})
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
if (dryRun) {
|
|
98
101
|
const exists = yield* fileExists(file)
|
|
99
102
|
if (!exists) {
|
|
100
|
-
return yield*
|
|
103
|
+
return yield* new JiraApiError({ message: `Attachment file does not exist: ${file}` })
|
|
101
104
|
}
|
|
102
105
|
const result = { dryRun: true, issue, file, insert: shouldInsert }
|
|
103
106
|
yield* Console.log(json ? JSON.stringify(result) : `Dry run: ${file} can be uploaded to ${issue}`)
|
|
@@ -111,12 +114,10 @@ const uploadCommand = Command.make(
|
|
|
111
114
|
if (documentInput !== null) {
|
|
112
115
|
const result = insertJiraAttachmentReference(documentInput.content, file, attachment)
|
|
113
116
|
if (result.replacements !== 1) {
|
|
114
|
-
return yield*
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
})
|
|
119
|
-
)
|
|
117
|
+
return yield* new JiraApiError({
|
|
118
|
+
message:
|
|
119
|
+
`Uploaded attachment ${attachment.id}, but expected exactly one local placeholder for ${file}; found ${result.replacements}`
|
|
120
|
+
})
|
|
120
121
|
}
|
|
121
122
|
yield* writeDocument(documentInput.path, result.content)
|
|
122
123
|
inserted = true
|
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
|
|
package/src/commands/search.ts
CHANGED
|
@@ -3,45 +3,45 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
+
import { Argument as Args, Command, Flag as Options } from "effect/cli"
|
|
6
7
|
import * as Console from "effect/Console"
|
|
7
8
|
import * as Effect from "effect/Effect"
|
|
8
9
|
import * as Option from "effect/Option"
|
|
9
|
-
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
10
10
|
import { buildByVersionJql } from "../internal/jqlBuilder.js"
|
|
11
11
|
import { IssueService } from "../IssueService.js"
|
|
12
12
|
import { MarkdownWriter } from "../MarkdownWriter.js"
|
|
13
13
|
|
|
14
14
|
// === Options ===
|
|
15
|
-
const jqlArg = Args.
|
|
15
|
+
const jqlArg = Args.String("jql").pipe(
|
|
16
16
|
Args.withDescription("JQL query to search for issues"),
|
|
17
17
|
Args.optional
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
-
const byVersionOption = Options.
|
|
20
|
+
const byVersionOption = Options.String("by-version").pipe(
|
|
21
21
|
Options.withAlias("v"),
|
|
22
22
|
Options.withDescription("Search by fix version (pre-defined query)"),
|
|
23
23
|
Options.optional
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
const projectOption = Options.
|
|
26
|
+
const projectOption = Options.String("project").pipe(
|
|
27
27
|
Options.withAlias("p"),
|
|
28
28
|
Options.withDescription("Filter by project key"),
|
|
29
29
|
Options.optional
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
const outputDirOption = Options.
|
|
32
|
+
const outputDirOption = Options.Directory("output-dir").pipe(
|
|
33
33
|
Options.withAlias("o"),
|
|
34
34
|
Options.withDescription("Output directory for markdown files"),
|
|
35
35
|
Options.withDefault("./jira-tickets")
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
-
const formatOption = Options.
|
|
38
|
+
const formatOption = Options.Literals("format", ["multi", "single"]).pipe(
|
|
39
39
|
Options.withAlias("f"),
|
|
40
40
|
Options.withDescription("Output format: multi (one file per issue) or single (combined file)"),
|
|
41
41
|
Options.withDefault("multi")
|
|
42
42
|
)
|
|
43
43
|
|
|
44
|
-
const maxResultsOption = Options.
|
|
44
|
+
const maxResultsOption = Options.Int("max-results").pipe(
|
|
45
45
|
Options.withAlias("m"),
|
|
46
46
|
Options.withDescription("Maximum number of results to fetch"),
|
|
47
47
|
Options.withDefault(100)
|
package/src/commands/version.ts
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @internal
|
|
8
8
|
*/
|
|
9
|
+
import { Argument as Args, Command, Flag as Options } from "effect/cli"
|
|
9
10
|
import * as Console from "effect/Console"
|
|
10
11
|
import * as Effect from "effect/Effect"
|
|
11
12
|
import * as Option from "effect/Option"
|
|
12
|
-
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
13
13
|
import { JiraApiError } from "../JiraCliError.js"
|
|
14
14
|
import type { Person, Version } from "../VersionService.js"
|
|
15
|
-
import { VersionService } from "../VersionService.js"
|
|
15
|
+
import { planRelatedWorkSync, VersionService } from "../VersionService.js"
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Return a copy of `version` with every resolved {@link Person.emailAddress}
|
|
@@ -48,27 +48,27 @@ const ensureNumericId = (id: string): Effect.Effect<void, JiraApiError> =>
|
|
|
48
48
|
})
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
-
const projectOption = Options.
|
|
51
|
+
const projectOption = Options.String("project").pipe(
|
|
52
52
|
Options.withAlias("p"),
|
|
53
53
|
Options.withDescription("Jira project key (e.g. RPS)")
|
|
54
54
|
)
|
|
55
|
-
const releasedOption = Options.
|
|
55
|
+
const releasedOption = Options.Boolean("released").pipe(
|
|
56
56
|
Options.withDescription("Only list released versions"),
|
|
57
57
|
Options.withDefault(false)
|
|
58
58
|
)
|
|
59
|
-
const unreleasedOption = Options.
|
|
59
|
+
const unreleasedOption = Options.Boolean("unreleased").pipe(
|
|
60
60
|
Options.withDescription("Only list unreleased versions"),
|
|
61
61
|
Options.withDefault(false)
|
|
62
62
|
)
|
|
63
|
-
const jsonOption = Options.
|
|
63
|
+
const jsonOption = Options.Boolean("json").pipe(
|
|
64
64
|
Options.withDescription("Output as JSON"),
|
|
65
65
|
Options.withDefault(false)
|
|
66
66
|
)
|
|
67
|
-
const emailsOption = Options.
|
|
67
|
+
const emailsOption = Options.Boolean("emails").pipe(
|
|
68
68
|
Options.withDescription("Include resolved user email addresses in --json output"),
|
|
69
69
|
Options.withDefault(false)
|
|
70
70
|
)
|
|
71
|
-
const customFieldOption = Options.
|
|
71
|
+
const customFieldOption = Options.String("custom-field").pipe(
|
|
72
72
|
Options.withDescription(
|
|
73
73
|
"Custom field display name to include on each ticket (repeatable, e.g. " +
|
|
74
74
|
"--custom-field \"Security & Compliance Impact\"). Values are exposed in " +
|
|
@@ -76,13 +76,13 @@ const customFieldOption = Options.string("custom-field").pipe(
|
|
|
76
76
|
),
|
|
77
77
|
Options.atLeast(0)
|
|
78
78
|
)
|
|
79
|
-
const maxOption = Options.
|
|
79
|
+
const maxOption = Options.Int("max").pipe(
|
|
80
80
|
Options.withAlias("m"),
|
|
81
81
|
Options.withDescription("Maximum number of versions to fetch (default: all)"),
|
|
82
82
|
Options.optional
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
-
const idArg = Args.
|
|
85
|
+
const idArg = Args.String("id").pipe(Args.withDescription("Version id (numeric)"))
|
|
86
86
|
|
|
87
87
|
const listCommand = Command.make("list", {
|
|
88
88
|
project: projectOption,
|
|
@@ -95,17 +95,15 @@ const listCommand = Command.make("list", {
|
|
|
95
95
|
}, ({ customFields, emails, json, max, project, released, unreleased }) =>
|
|
96
96
|
Effect.gen(function*() {
|
|
97
97
|
if (released && unreleased) {
|
|
98
|
-
return yield*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
})
|
|
102
|
-
)
|
|
98
|
+
return yield* new JiraApiError({
|
|
99
|
+
message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
|
|
100
|
+
})
|
|
103
101
|
}
|
|
104
102
|
const service = yield* VersionService
|
|
105
103
|
const versions = yield* service.listProjectVersions(project, {
|
|
106
104
|
released,
|
|
107
105
|
unreleased,
|
|
108
|
-
...(Option.isSome(max)
|
|
106
|
+
...((Option.isSome(max)) && { maxResults: max.value }),
|
|
109
107
|
customFieldNames: customFields
|
|
110
108
|
})
|
|
111
109
|
if (json) {
|
|
@@ -169,11 +167,85 @@ const formatTicketKeys = (tickets: Version["tickets"]): string => {
|
|
|
169
167
|
return remaining > 0 ? `${shown} (+${remaining} more)` : shown
|
|
170
168
|
}
|
|
171
169
|
|
|
172
|
-
const descriptionOption = Options.
|
|
170
|
+
const descriptionOption = Options.String("description").pipe(
|
|
173
171
|
Options.withAlias("d"),
|
|
174
172
|
Options.withDescription("New version description")
|
|
175
173
|
)
|
|
176
174
|
|
|
175
|
+
// === create ===
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Jira accepts version dates only as ISO 8601 `yyyy-mm-dd`. Anything else — a
|
|
179
|
+
* locale format, a timestamp — comes back as a generic 400 that does not name the
|
|
180
|
+
* offending field, so reject it locally where the message can.
|
|
181
|
+
*/
|
|
182
|
+
export const isIsoDate = (value: string): boolean => {
|
|
183
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
184
|
+
// Reject real-looking but non-existent dates (2026-02-30): round-tripping
|
|
185
|
+
// through Date is the cheapest calendar check.
|
|
186
|
+
const parsed = new Date(`${value}T00:00:00Z`)
|
|
187
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const ensureIsoDate = (flag: string, value: string): Effect.Effect<void, JiraApiError> =>
|
|
191
|
+
isIsoDate(value) ? Effect.void : Effect.fail(
|
|
192
|
+
new JiraApiError({ message: `Invalid --${flag} "${value}". Expected an ISO 8601 date (yyyy-mm-dd).` })
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
// No `-n` alias: it means `--dry-run` everywhere else in these CLIs, and both
|
|
196
|
+
// `getLayerType` implementations route on `argv.includes("-n")`. Reusing it for
|
|
197
|
+
// a value on a remote-write command is the wrong default.
|
|
198
|
+
const nameOption = Options.String("name").pipe(
|
|
199
|
+
Options.withDescription("Version name (e.g. \"OOB 100\")")
|
|
200
|
+
)
|
|
201
|
+
const optionalDescriptionOption = Options.String("description").pipe(
|
|
202
|
+
Options.withAlias("d"),
|
|
203
|
+
Options.withDescription("Version description"),
|
|
204
|
+
Options.optional
|
|
205
|
+
)
|
|
206
|
+
const startDateOption = Options.String("start-date").pipe(
|
|
207
|
+
Options.withDescription("Start date, ISO 8601 (yyyy-mm-dd)"),
|
|
208
|
+
Options.optional
|
|
209
|
+
)
|
|
210
|
+
const releaseDateOption = Options.String("release-date").pipe(
|
|
211
|
+
Options.withDescription("Release date, ISO 8601 (yyyy-mm-dd)"),
|
|
212
|
+
Options.optional
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
const createCommand = Command.make("create", {
|
|
216
|
+
project: projectOption,
|
|
217
|
+
name: nameOption,
|
|
218
|
+
description: optionalDescriptionOption,
|
|
219
|
+
startDate: startDateOption,
|
|
220
|
+
releaseDate: releaseDateOption,
|
|
221
|
+
json: jsonOption
|
|
222
|
+
}, ({ description, json, name, project, releaseDate, startDate }) =>
|
|
223
|
+
Effect.gen(function*() {
|
|
224
|
+
if (Option.isSome(startDate)) yield* ensureIsoDate("start-date", startDate.value)
|
|
225
|
+
if (Option.isSome(releaseDate)) yield* ensureIsoDate("release-date", releaseDate.value)
|
|
226
|
+
|
|
227
|
+
const service = yield* VersionService
|
|
228
|
+
const version = yield* service.createVersion({
|
|
229
|
+
projectKey: project,
|
|
230
|
+
name,
|
|
231
|
+
...((Option.isSome(description)) && { description: description.value }),
|
|
232
|
+
...((Option.isSome(startDate)) && { startDate: startDate.value }),
|
|
233
|
+
...((Option.isSome(releaseDate)) && { releaseDate: releaseDate.value })
|
|
234
|
+
})
|
|
235
|
+
if (json) {
|
|
236
|
+
yield* Console.log(JSON.stringify(version, null, 2))
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
yield* Console.log(`Created version ${version.name} (${version.id}) on ${project}`)
|
|
240
|
+
yield* Console.log(`startDate: ${version.startDate ?? "-"}`)
|
|
241
|
+
yield* Console.log(`releaseDate: ${version.releaseDate ?? "-"}`)
|
|
242
|
+
yield* Console.log(`description: ${version.description ?? "-"}`)
|
|
243
|
+
})).pipe(
|
|
244
|
+
Command.withDescription(
|
|
245
|
+
"Remote write: create a new unreleased version on a project (requires manage:jira-project scope)"
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
177
249
|
const updateCommand = Command.make("update", { id: idArg, description: descriptionOption, json: jsonOption }, ({
|
|
178
250
|
description,
|
|
179
251
|
id,
|
|
@@ -195,15 +267,15 @@ const updateCommand = Command.make("update", { id: idArg, description: descripti
|
|
|
195
267
|
|
|
196
268
|
// === related-work ===
|
|
197
269
|
|
|
198
|
-
const titleOption = Options.
|
|
270
|
+
const titleOption = Options.String("title").pipe(
|
|
199
271
|
Options.withAlias("t"),
|
|
200
272
|
Options.withDescription("Related-work link title (e.g. \"Release notes\")")
|
|
201
273
|
)
|
|
202
|
-
const urlOption = Options.
|
|
274
|
+
const urlOption = Options.String("url").pipe(
|
|
203
275
|
Options.withAlias("u"),
|
|
204
276
|
Options.withDescription("Related-work link URL (e.g. a Confluence page)")
|
|
205
277
|
)
|
|
206
|
-
const categoryOption = Options.
|
|
278
|
+
const categoryOption = Options.String("category").pipe(
|
|
207
279
|
Options.withAlias("c"),
|
|
208
280
|
Options.withDescription("Related-work category (Jira groups by this; e.g. Communication, Testing, Design)"),
|
|
209
281
|
Options.withDefault("Communication")
|
|
@@ -256,12 +328,93 @@ const relatedWorkAddCommand = Command.make("add", {
|
|
|
256
328
|
)
|
|
257
329
|
)
|
|
258
330
|
|
|
331
|
+
const linkOption = Options.String("link").pipe(
|
|
332
|
+
Options.withAlias("l"),
|
|
333
|
+
Options.withDescription("Desired link as `title=url` (repeatable). Category comes from --category."),
|
|
334
|
+
Options.atLeast(0)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const pruneOption = Options.Boolean("prune").pipe(
|
|
338
|
+
Options.withDescription("Also remove links in the category that are not in the desired set"),
|
|
339
|
+
Options.withDefault(false)
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Reconcile a version's related-work links against a desired set.
|
|
344
|
+
*
|
|
345
|
+
* Re-running a release scaffold should not pile up duplicate "Release notes"
|
|
346
|
+
* links, which is what repeated `add` calls produce. Matching is by URL, the
|
|
347
|
+
* only stable identity a link has — Jira assigns the id and the title is
|
|
348
|
+
* editable.
|
|
349
|
+
*/
|
|
350
|
+
const relatedWorkSyncCommand = Command.make("sync", {
|
|
351
|
+
id: idArg,
|
|
352
|
+
link: linkOption,
|
|
353
|
+
category: categoryOption,
|
|
354
|
+
prune: pruneOption,
|
|
355
|
+
json: jsonOption
|
|
356
|
+
}, ({ category, id, json, link, prune }) =>
|
|
357
|
+
Effect.gen(function*() {
|
|
358
|
+
yield* ensureNumericId(id)
|
|
359
|
+
|
|
360
|
+
const desired: Array<{ readonly title: string; readonly url: string }> = []
|
|
361
|
+
for (const raw of link) {
|
|
362
|
+
const separator = raw.indexOf("=")
|
|
363
|
+
// Check the trimmed halves, not the separator position: `" =url"` and
|
|
364
|
+
// `"title= "` both put a non-empty span either side of the `=` and would
|
|
365
|
+
// otherwise reach Jira as a link with an empty title or url.
|
|
366
|
+
const title = separator < 0 ? "" : raw.slice(0, separator).trim()
|
|
367
|
+
const url = separator < 0 ? "" : raw.slice(separator + 1).trim()
|
|
368
|
+
if (separator < 0 || title.length === 0 || url.length === 0) {
|
|
369
|
+
return yield* new JiraApiError({ message: `Invalid --link ${JSON.stringify(raw)}. Expected title=url.` })
|
|
370
|
+
}
|
|
371
|
+
desired.push({ title, url })
|
|
372
|
+
}
|
|
373
|
+
if (desired.length === 0) {
|
|
374
|
+
return yield* new JiraApiError({ message: "Pass at least one --link title=url." })
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const service = yield* VersionService
|
|
378
|
+
const existing = yield* service.listRelatedWork(id)
|
|
379
|
+
const plan = planRelatedWorkSync(existing, desired, { category, prune })
|
|
380
|
+
|
|
381
|
+
for (const item of plan.toAdd) {
|
|
382
|
+
yield* service.addRelatedWork(id, { title: item.title, category, url: item.url })
|
|
383
|
+
}
|
|
384
|
+
for (const item of plan.toRemove) {
|
|
385
|
+
yield* service.deleteRelatedWork(id, item.relatedWorkId)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const added = plan.toAdd.map((d) => d.url)
|
|
389
|
+
const removed = plan.toRemove.map((d) => d.url)
|
|
390
|
+
if (json) {
|
|
391
|
+
yield* Console.log(JSON.stringify({ added, kept: plan.kept, removed }, null, 2))
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
yield* Console.log(
|
|
395
|
+
`Version ${id} (${category}): ${added.length} added, ${plan.kept.length} unchanged, ${removed.length} removed`
|
|
396
|
+
)
|
|
397
|
+
for (const url of added) yield* Console.log(` + ${url}`)
|
|
398
|
+
for (const url of removed) yield* Console.log(` - ${url}`)
|
|
399
|
+
})).pipe(
|
|
400
|
+
// The help text is what a user reads before a remote write, so it has to
|
|
401
|
+
// say that pruning is opt-in: without --prune nothing is ever removed, and
|
|
402
|
+
// "reconcile to exactly the given set" reads as a promise that stale links
|
|
403
|
+
// were cleaned up.
|
|
404
|
+
Command.withDescription(
|
|
405
|
+
"Remote write: add any missing related-work links in a category, matched by URL (idempotent); " +
|
|
406
|
+
"pass --prune to also remove links that are not in the given set"
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
259
410
|
const relatedWorkCommand = Command.make("related-work").pipe(
|
|
260
|
-
Command.withDescription(
|
|
261
|
-
|
|
411
|
+
Command.withDescription(
|
|
412
|
+
"List, attach or reconcile version related-work links (Confluence pages on the release report)"
|
|
413
|
+
),
|
|
414
|
+
Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand, relatedWorkSyncCommand])
|
|
262
415
|
)
|
|
263
416
|
|
|
264
417
|
export const versionCommand = Command.make("version").pipe(
|
|
265
418
|
Command.withDescription("Jira version commands"),
|
|
266
|
-
Command.withSubcommands([listCommand, getCommand, updateCommand, relatedWorkCommand])
|
|
419
|
+
Command.withSubcommands([listCommand, getCommand, createCommand, updateCommand, relatedWorkCommand])
|
|
267
420
|
)
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
8
|
AttachmentService,
|
|
9
|
-
type
|
|
9
|
+
type AttachmentServiceContract,
|
|
10
10
|
layer as AttachmentServiceLayer,
|
|
11
11
|
type UploadAttachmentInput
|
|
12
12
|
} from "./AttachmentService.js"
|
|
@@ -15,7 +15,7 @@ export {
|
|
|
15
15
|
type Comment,
|
|
16
16
|
type Issue,
|
|
17
17
|
IssueService,
|
|
18
|
-
type
|
|
18
|
+
type IssueServiceContract,
|
|
19
19
|
layer as IssueServiceLayer,
|
|
20
20
|
type SearchOptions,
|
|
21
21
|
type SearchResult,
|
|
@@ -29,4 +29,14 @@ export {
|
|
|
29
29
|
type LoginOptions
|
|
30
30
|
} from "./JiraAuth.js"
|
|
31
31
|
export * from "./JiraCliError.js"
|
|
32
|
-
export { layer as MarkdownWriterLayer, MarkdownWriter, type
|
|
32
|
+
export { layer as MarkdownWriterLayer, MarkdownWriter, type MarkdownWriterContract } from "./MarkdownWriter.js"
|
|
33
|
+
export {
|
|
34
|
+
type DesiredRelatedWork,
|
|
35
|
+
layer as VersionServiceLayer,
|
|
36
|
+
planRelatedWorkSync,
|
|
37
|
+
type RelatedWork,
|
|
38
|
+
type RelatedWorkSyncPlan,
|
|
39
|
+
type Version,
|
|
40
|
+
VersionService,
|
|
41
|
+
type VersionServiceContract
|
|
42
|
+
} from "./VersionService.js"
|
|
@@ -13,5 +13,5 @@ import { makeHttpServerFactory } from "./oauthServer.js"
|
|
|
13
13
|
* @category Layers
|
|
14
14
|
*/
|
|
15
15
|
export const HttpServerFactoryLive = makeHttpServerFactory(
|
|
16
|
-
(
|
|
16
|
+
(options) => NodeHttpServer.layerServer(createServer, options)
|
|
17
17
|
)
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @internal
|
|
10
10
|
*/
|
|
11
11
|
import { isPreviewableAttachment } from "@knpkv/atlassian-common/attachments"
|
|
12
|
+
import * as Predicate from "effect/Predicate"
|
|
12
13
|
import matter from "gray-matter"
|
|
13
14
|
import * as yaml from "js-yaml"
|
|
14
15
|
import type { Issue } from "../IssueService.js"
|
|
@@ -24,9 +25,9 @@ import type { Issue } from "../IssueService.js"
|
|
|
24
25
|
const yamlEngine = {
|
|
25
26
|
parse: (str: string): object => {
|
|
26
27
|
const parsed = yaml.load(str)
|
|
27
|
-
return parsed !== null &&
|
|
28
|
+
return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed) ? parsed : {}
|
|
28
29
|
},
|
|
29
|
-
stringify: (data:
|
|
30
|
+
stringify: <Data extends object>(data: Data): string => yaml.dump(data)
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|