@knpkv/jira-cli 0.1.1 → 0.3.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 +47 -0
- package/README.md +63 -1
- package/dist/IssueService.d.ts +2 -2
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +3 -3
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +14 -14
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +18 -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 +6 -6
- package/dist/MarkdownWriter.js.map +1 -1
- package/dist/VersionService.d.ts +206 -0
- package/dist/VersionService.d.ts.map +1 -0
- package/dist/VersionService.js +426 -0
- package/dist/VersionService.js.map +1 -0
- package/dist/bin.js +28 -20
- package/dist/bin.js.map +1 -1
- package/dist/commands/auth.d.ts +2 -21
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +6 -6
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/get.d.ts +3 -8
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +2 -2
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +1 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/layers.d.ts +6 -18
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +31 -23
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.d.ts +3 -8
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +4 -4
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.d.ts +12 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +179 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/internal/oauthServer.d.ts +17 -5
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +23 -40
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts +10 -0
- package/dist/internal/openBrowser.d.ts.map +1 -0
- package/dist/internal/openBrowser.js +17 -0
- package/dist/internal/openBrowser.js.map +1 -0
- package/package.json +10 -12
- package/skills/jira/SKILL.md +90 -0
- package/skills/jira/agents/openai.yaml +4 -0
- package/src/IssueService.ts +34 -28
- package/src/JiraAuth.ts +53 -39
- package/src/MarkdownWriter.ts +7 -11
- package/src/VersionService.ts +647 -0
- package/src/bin.ts +38 -26
- package/src/commands/auth.ts +6 -12
- package/src/commands/get.ts +2 -2
- package/src/commands/index.ts +1 -0
- package/src/commands/layers.ts +40 -25
- package/src/commands/search.ts +4 -4
- package/src/commands/version.ts +267 -0
- package/src/internal/oauthServer.ts +43 -70
- package/src/internal/openBrowser.ts +31 -0
- package/test/VersionService.test.ts +266 -0
- package/vitest.config.ts +5 -0
package/src/bin.ts
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* CLI entry point — assembles commands, selects layer by subcommand, runs via `NodeRuntime`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Arguments are read from Effect's `Stdio` service at the runtime edge.
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { NodeRuntime, NodeStdio } from "@effect/platform-node"
|
|
10
|
+
import { makeInstallCommand } from "@knpkv/agent-skills"
|
|
11
|
+
import * as Console from "effect/Console"
|
|
11
12
|
import * as Effect from "effect/Effect"
|
|
12
|
-
import * as
|
|
13
|
-
import
|
|
13
|
+
import * as Stdio from "effect/Stdio"
|
|
14
|
+
import { Command } from "effect/unstable/cli"
|
|
14
15
|
import pkg from "../package.json" with { type: "json" }
|
|
15
16
|
import {
|
|
16
17
|
AppLayer,
|
|
@@ -20,43 +21,54 @@ import {
|
|
|
20
21
|
getLayerType,
|
|
21
22
|
handleError,
|
|
22
23
|
MinimalLayer,
|
|
23
|
-
searchCommand
|
|
24
|
+
searchCommand,
|
|
25
|
+
versionCommand
|
|
24
26
|
} from "./commands/index.js"
|
|
25
27
|
|
|
28
|
+
const skillsInstall = makeInstallCommand({
|
|
29
|
+
description: "Install the Jira agent skill",
|
|
30
|
+
name: "install",
|
|
31
|
+
skills: ["jira"]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const skillsCommand = Command.make("skills", {}, () => Console.log("Usage: jira skills install")).pipe(
|
|
35
|
+
Command.withDescription("Agent skill commands"),
|
|
36
|
+
Command.withSubcommands([skillsInstall])
|
|
37
|
+
)
|
|
38
|
+
|
|
26
39
|
// === Main command ===
|
|
27
40
|
const jira = Command.make("jira").pipe(
|
|
28
41
|
Command.withDescription("Fetch Jira tickets and export to markdown"),
|
|
29
42
|
Command.withSubcommands([
|
|
30
43
|
authCommand,
|
|
31
44
|
getCommand,
|
|
32
|
-
searchCommand
|
|
45
|
+
searchCommand,
|
|
46
|
+
skillsCommand,
|
|
47
|
+
versionCommand
|
|
33
48
|
])
|
|
34
49
|
)
|
|
35
50
|
|
|
36
51
|
// === Run CLI ===
|
|
37
|
-
const cli = Command.
|
|
38
|
-
name: pkg.name,
|
|
52
|
+
const cli = Command.runWith(jira, {
|
|
39
53
|
version: pkg.version
|
|
40
54
|
})
|
|
41
55
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const layerType = getLayerType(
|
|
46
|
-
const layer = layerType === "full"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// Suppress verbose Effect logs
|
|
53
|
-
const SilentLogger = Logger.replace(Logger.defaultLogger, Logger.none)
|
|
56
|
+
const program = Effect.gen(function*() {
|
|
57
|
+
const stdio = yield* Stdio.Stdio
|
|
58
|
+
const args = yield* stdio.args
|
|
59
|
+
const layerType = getLayerType(args)
|
|
60
|
+
const layer = layerType === "full"
|
|
61
|
+
? AppLayer
|
|
62
|
+
: layerType === "auth"
|
|
63
|
+
? AuthOnlyLayer
|
|
64
|
+
: MinimalLayer
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Effect.
|
|
66
|
+
return yield* cli(args).pipe(
|
|
67
|
+
Effect.provide(layer)
|
|
68
|
+
)
|
|
69
|
+
}).pipe(
|
|
70
|
+
Effect.provide(NodeStdio.layer),
|
|
71
|
+
Effect.catchCause((cause) => handleError(cause))
|
|
60
72
|
)
|
|
61
73
|
|
|
62
74
|
NodeRuntime.runMain(program)
|
package/src/commands/auth.ts
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @internal
|
|
10
10
|
*/
|
|
11
|
-
import { Command, Options, Prompt } from "@effect/cli"
|
|
12
|
-
import * as PlatformCommand from "@effect/platform/Command"
|
|
13
11
|
import * as Console from "effect/Console"
|
|
14
12
|
import * as Effect from "effect/Effect"
|
|
15
13
|
import * as Option from "effect/Option"
|
|
14
|
+
import { Command, Flag as Options, Prompt } from "effect/unstable/cli"
|
|
15
|
+
import { openBrowser } from "../internal/openBrowser.js"
|
|
16
16
|
import { JiraAuth } from "../JiraAuth.js"
|
|
17
17
|
|
|
18
18
|
// === Auth create command ===
|
|
@@ -38,21 +38,15 @@ Creating OAuth app in Atlassian Developer Console...
|
|
|
38
38
|
6. Run: jira auth configure --client-id <ID> --client-secret <SECRET>
|
|
39
39
|
`)
|
|
40
40
|
const url = "https://developer.atlassian.com/console/myapps/create-3lo-app/"
|
|
41
|
-
yield*
|
|
42
|
-
PlatformCommand.exitCode,
|
|
43
|
-
Effect.catchAll(() => PlatformCommand.make("xdg-open", url).pipe(PlatformCommand.exitCode)),
|
|
44
|
-
Effect.catchAll(() => PlatformCommand.make("cmd", "/c", "start", "", url).pipe(PlatformCommand.exitCode)),
|
|
45
|
-
Effect.asVoid,
|
|
46
|
-
Effect.catchAll(() => Effect.void)
|
|
47
|
-
)
|
|
41
|
+
yield* openBrowser(url).pipe(Effect.catch(() => Effect.void))
|
|
48
42
|
})).pipe(Command.withDescription("Create OAuth app in Atlassian Developer Console"))
|
|
49
43
|
|
|
50
44
|
// === Auth configure command ===
|
|
51
|
-
const clientIdOption = Options.
|
|
45
|
+
const clientIdOption = Options.string("client-id").pipe(
|
|
52
46
|
Options.withDescription("OAuth client ID from Atlassian Developer Console"),
|
|
53
47
|
Options.optional
|
|
54
48
|
)
|
|
55
|
-
const clientSecretOption = Options.
|
|
49
|
+
const clientSecretOption = Options.string("client-secret").pipe(
|
|
56
50
|
Options.withDescription("OAuth client secret"),
|
|
57
51
|
Options.optional
|
|
58
52
|
)
|
|
@@ -77,7 +71,7 @@ const configureCommand = Command.make(
|
|
|
77
71
|
).pipe(Command.withDescription("Configure OAuth client credentials"))
|
|
78
72
|
|
|
79
73
|
// === Auth login command ===
|
|
80
|
-
const siteOption = Options.
|
|
74
|
+
const siteOption = Options.string("site").pipe(
|
|
81
75
|
Options.withDescription("Jira site URL to use (for accounts with multiple sites)"),
|
|
82
76
|
Options.optional
|
|
83
77
|
)
|
package/src/commands/get.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
-
import { Args, Command, Options } from "@effect/cli"
|
|
7
6
|
import * as Console from "effect/Console"
|
|
8
7
|
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
|
|
package/src/commands/index.ts
CHANGED
package/src/commands/layers.ts
CHANGED
|
@@ -3,61 +3,73 @@
|
|
|
3
3
|
*
|
|
4
4
|
* **Mental model**
|
|
5
5
|
*
|
|
6
|
-
* - **Lazy layer selection**: {@link getLayerType} inspects `
|
|
6
|
+
* - **Lazy layer selection**: {@link getLayerType} inspects CLI arguments from `Stdio` to pick
|
|
7
7
|
* the smallest layer needed — `"minimal"` for help/version, `"auth"` for auth commands,
|
|
8
8
|
* `"full"` for search/get (which needs API client + issue service).
|
|
9
|
-
* - **Dummy services**: Auth-only and minimal layers provide
|
|
9
|
+
* - **Dummy services**: Auth-only and minimal layers provide dying stubs
|
|
10
10
|
* for unused services to satisfy the type system without initialization cost.
|
|
11
11
|
*
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
|
-
import * as NodeContext from "@effect/platform-node/NodeContext"
|
|
15
14
|
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
|
|
15
|
+
import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
16
16
|
import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
17
17
|
import * as Effect from "effect/Effect"
|
|
18
18
|
import * as Layer from "effect/Layer"
|
|
19
19
|
import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../IssueService.js"
|
|
20
20
|
import { JiraAuth, layer as JiraAuthLayer } from "../JiraAuth.js"
|
|
21
21
|
import { layer as MarkdownWriterLayer, MarkdownWriter } from "../MarkdownWriter.js"
|
|
22
|
+
import { layer as VersionServiceLayer, VersionService } from "../VersionService.js"
|
|
22
23
|
|
|
23
24
|
// Dummy services for auth-only commands
|
|
24
25
|
const DummyIssueServiceLayer = Layer.succeed(
|
|
25
26
|
IssueService,
|
|
26
27
|
IssueService.of({
|
|
27
|
-
getByKey: () => Effect.
|
|
28
|
-
search: () => Effect.
|
|
29
|
-
searchAll: () => Effect.
|
|
28
|
+
getByKey: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
|
|
29
|
+
search: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
|
|
30
|
+
searchAll: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
|
|
30
31
|
})
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
const DummyMarkdownWriterLayer = Layer.succeed(
|
|
34
35
|
MarkdownWriter,
|
|
35
36
|
MarkdownWriter.of({
|
|
36
|
-
writeMulti: () => Effect.
|
|
37
|
-
writeSingle: () => Effect.
|
|
37
|
+
writeMulti: () => Effect.die(new Error("Not configured")),
|
|
38
|
+
writeSingle: () => Effect.die(new Error("Not configured"))
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const DummyVersionServiceLayer = Layer.succeed(
|
|
43
|
+
VersionService,
|
|
44
|
+
VersionService.of({
|
|
45
|
+
listProjectVersions: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
|
|
46
|
+
getVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
|
|
47
|
+
updateVersion: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
|
|
48
|
+
listRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first")),
|
|
49
|
+
addRelatedWork: () => Effect.die(new Error("Not configured - run 'jira auth login' first"))
|
|
38
50
|
})
|
|
39
51
|
)
|
|
40
52
|
|
|
41
53
|
const DummyJiraAuthLayer = Layer.succeed(
|
|
42
54
|
JiraAuth,
|
|
43
55
|
JiraAuth.of({
|
|
44
|
-
configure: () => Effect.
|
|
56
|
+
configure: () => Effect.die(new Error("Not configured")),
|
|
45
57
|
isConfigured: () => Effect.succeed(false),
|
|
46
|
-
login: () => Effect.
|
|
47
|
-
logout: () => Effect.
|
|
48
|
-
getAccessToken: () => Effect.
|
|
49
|
-
getCloudId: () => Effect.
|
|
50
|
-
getSiteUrl: () => Effect.
|
|
58
|
+
login: () => Effect.die(new Error("Not configured")),
|
|
59
|
+
logout: () => Effect.die(new Error("Not configured")),
|
|
60
|
+
getAccessToken: () => Effect.die(new Error("Not configured")),
|
|
61
|
+
getCloudId: () => Effect.die(new Error("Not configured")),
|
|
62
|
+
getSiteUrl: () => Effect.die(new Error("Not configured")),
|
|
51
63
|
getCurrentUser: () => Effect.succeed(null),
|
|
52
64
|
isLoggedIn: () => Effect.succeed(false)
|
|
53
65
|
})
|
|
54
66
|
)
|
|
55
67
|
|
|
56
68
|
// Auth layer with HTTP client
|
|
57
|
-
const AuthLive = JiraAuthLayer.pipe(Layer.provide(NodeHttpClient.layer))
|
|
69
|
+
const AuthLive = JiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(NodeHttpClient.layerUndici, NodeServices.layer)))
|
|
58
70
|
|
|
59
71
|
// Build Jira API config layer dynamically based on auth
|
|
60
|
-
const JiraConfigLive = Layer.
|
|
72
|
+
const JiraConfigLive = Layer.unwrap(
|
|
61
73
|
Effect.gen(function*() {
|
|
62
74
|
const auth = yield* JiraAuth
|
|
63
75
|
const accessToken = yield* auth.getAccessToken()
|
|
@@ -80,7 +92,7 @@ const JiraClientLive = JiraApiClient.layer.pipe(
|
|
|
80
92
|
)
|
|
81
93
|
|
|
82
94
|
// Build SiteUrl layer from auth
|
|
83
|
-
const SiteUrlLive = Layer.
|
|
95
|
+
const SiteUrlLive = Layer.unwrap(
|
|
84
96
|
Effect.gen(function*() {
|
|
85
97
|
const auth = yield* JiraAuth
|
|
86
98
|
const siteUrl = yield* auth.getSiteUrl()
|
|
@@ -95,11 +107,12 @@ const SiteUrlLive = Layer.unwrapEffect(
|
|
|
95
107
|
*/
|
|
96
108
|
export const AppLayer = MarkdownWriterLayer.pipe(
|
|
97
109
|
Layer.provideMerge(IssueServiceLayer),
|
|
110
|
+
Layer.provideMerge(VersionServiceLayer),
|
|
98
111
|
Layer.provideMerge(SiteUrlLive),
|
|
99
112
|
Layer.provideMerge(JiraClientLive),
|
|
100
113
|
Layer.provideMerge(AuthLive),
|
|
101
|
-
Layer.provideMerge(NodeHttpClient.
|
|
102
|
-
Layer.provideMerge(
|
|
114
|
+
Layer.provideMerge(NodeHttpClient.layerUndici),
|
|
115
|
+
Layer.provideMerge(NodeServices.layer)
|
|
103
116
|
)
|
|
104
117
|
|
|
105
118
|
/**
|
|
@@ -109,9 +122,10 @@ export const AppLayer = MarkdownWriterLayer.pipe(
|
|
|
109
122
|
*/
|
|
110
123
|
export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
111
124
|
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
125
|
+
Layer.provideMerge(DummyVersionServiceLayer),
|
|
112
126
|
Layer.provideMerge(AuthLive),
|
|
113
|
-
Layer.provideMerge(NodeHttpClient.
|
|
114
|
-
Layer.provideMerge(
|
|
127
|
+
Layer.provideMerge(NodeHttpClient.layerUndici),
|
|
128
|
+
Layer.provideMerge(NodeServices.layer)
|
|
115
129
|
)
|
|
116
130
|
|
|
117
131
|
/**
|
|
@@ -121,8 +135,9 @@ export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
|
121
135
|
*/
|
|
122
136
|
export const MinimalLayer = DummyIssueServiceLayer.pipe(
|
|
123
137
|
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
138
|
+
Layer.provideMerge(DummyVersionServiceLayer),
|
|
124
139
|
Layer.provideMerge(DummyJiraAuthLayer),
|
|
125
|
-
Layer.provideMerge(
|
|
140
|
+
Layer.provideMerge(NodeServices.layer)
|
|
126
141
|
)
|
|
127
142
|
|
|
128
143
|
/**
|
|
@@ -130,12 +145,12 @@ export const MinimalLayer = DummyIssueServiceLayer.pipe(
|
|
|
130
145
|
*
|
|
131
146
|
* @category Utilities
|
|
132
147
|
*/
|
|
133
|
-
export const getLayerType = (
|
|
134
|
-
const cmd =
|
|
148
|
+
export const getLayerType = (args: ReadonlyArray<string>): "full" | "auth" | "minimal" => {
|
|
149
|
+
const cmd = args[0]
|
|
135
150
|
if (cmd === "auth") {
|
|
136
151
|
return "auth"
|
|
137
152
|
}
|
|
138
|
-
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "--version") {
|
|
153
|
+
if (!cmd || cmd === "skills" || cmd === "--help" || cmd === "-h" || cmd === "--version") {
|
|
139
154
|
return "minimal"
|
|
140
155
|
}
|
|
141
156
|
return "full"
|
package/src/commands/search.ts
CHANGED
|
@@ -3,27 +3,27 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
-
import { Args, Command, Options } from "@effect/cli"
|
|
7
6
|
import * as Console from "effect/Console"
|
|
8
7
|
import * as Effect from "effect/Effect"
|
|
9
8
|
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
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jira version` command — list / view Jira project versions (releases) with
|
|
3
|
+
* Driver, Contributors and Approver fields resolved to display names, plus
|
|
4
|
+
* mutations: edit the description and manage "Related work" links (the
|
|
5
|
+
* Confluence pages surfaced on a release report).
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
import * as Console from "effect/Console"
|
|
10
|
+
import * as Effect from "effect/Effect"
|
|
11
|
+
import * as Option from "effect/Option"
|
|
12
|
+
import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"
|
|
13
|
+
import { JiraApiError } from "../JiraCliError.js"
|
|
14
|
+
import type { Person, Version } from "../VersionService.js"
|
|
15
|
+
import { VersionService } from "../VersionService.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return a copy of `version` with every resolved {@link Person.emailAddress}
|
|
19
|
+
* (PII) set to null — covering driver, contributors, approvers[].person and
|
|
20
|
+
* tickets[].assignee. Used to keep emails out of `--json` output unless the
|
|
21
|
+
* caller opts in with `--emails`.
|
|
22
|
+
*/
|
|
23
|
+
export const stripEmails = (version: Version): Version => {
|
|
24
|
+
const stripPerson = <P extends Person>(person: P): P => ({ ...person, emailAddress: null })
|
|
25
|
+
return {
|
|
26
|
+
...version,
|
|
27
|
+
driver: version.driver ? stripPerson(version.driver) : null,
|
|
28
|
+
contributors: version.contributors.map(stripPerson),
|
|
29
|
+
approvers: version.approvers.map((a) => ({ ...a, person: stripPerson(a.person) })),
|
|
30
|
+
tickets: version.tickets.map((t) => ({
|
|
31
|
+
...t,
|
|
32
|
+
assignee: t.assignee ? stripPerson(t.assignee) : null
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Jira version ids are numeric (e.g. `10042`). Passing a name/key 404s with an
|
|
39
|
+
* opaque error, so validate early and emit a hint pointing at `version list`.
|
|
40
|
+
*/
|
|
41
|
+
const ensureNumericId = (id: string): Effect.Effect<void, JiraApiError> =>
|
|
42
|
+
/^\d+$/.test(id)
|
|
43
|
+
? Effect.void
|
|
44
|
+
: Effect.fail(
|
|
45
|
+
new JiraApiError({
|
|
46
|
+
message: `Invalid version id "${id}". The version id is numeric (e.g. 10042); ` +
|
|
47
|
+
`use 'jira version list --project <KEY>' to find it.`
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const projectOption = Options.string("project").pipe(
|
|
52
|
+
Options.withAlias("p"),
|
|
53
|
+
Options.withDescription("Jira project key (e.g. RPS)")
|
|
54
|
+
)
|
|
55
|
+
const releasedOption = Options.boolean("released").pipe(
|
|
56
|
+
Options.withDescription("Only list released versions"),
|
|
57
|
+
Options.withDefault(false)
|
|
58
|
+
)
|
|
59
|
+
const unreleasedOption = Options.boolean("unreleased").pipe(
|
|
60
|
+
Options.withDescription("Only list unreleased versions"),
|
|
61
|
+
Options.withDefault(false)
|
|
62
|
+
)
|
|
63
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
64
|
+
Options.withDescription("Output as JSON"),
|
|
65
|
+
Options.withDefault(false)
|
|
66
|
+
)
|
|
67
|
+
const emailsOption = Options.boolean("emails").pipe(
|
|
68
|
+
Options.withDescription("Include resolved user email addresses in --json output"),
|
|
69
|
+
Options.withDefault(false)
|
|
70
|
+
)
|
|
71
|
+
const customFieldOption = Options.string("custom-field").pipe(
|
|
72
|
+
Options.withDescription(
|
|
73
|
+
"Custom field display name to include on each ticket (repeatable, e.g. " +
|
|
74
|
+
"--custom-field \"Security & Compliance Impact\"). Values are exposed in " +
|
|
75
|
+
"ticket.customFields[<name>]."
|
|
76
|
+
),
|
|
77
|
+
Options.atLeast(0)
|
|
78
|
+
)
|
|
79
|
+
const maxOption = Options.integer("max").pipe(
|
|
80
|
+
Options.withAlias("m"),
|
|
81
|
+
Options.withDescription("Maximum number of versions to fetch (default: all)"),
|
|
82
|
+
Options.optional
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const idArg = Args.string("id").pipe(Args.withDescription("Version id (numeric)"))
|
|
86
|
+
|
|
87
|
+
const listCommand = Command.make("list", {
|
|
88
|
+
project: projectOption,
|
|
89
|
+
released: releasedOption,
|
|
90
|
+
unreleased: unreleasedOption,
|
|
91
|
+
customFields: customFieldOption,
|
|
92
|
+
max: maxOption,
|
|
93
|
+
json: jsonOption,
|
|
94
|
+
emails: emailsOption
|
|
95
|
+
}, ({ customFields, emails, json, max, project, released, unreleased }) =>
|
|
96
|
+
Effect.gen(function*() {
|
|
97
|
+
if (released && unreleased) {
|
|
98
|
+
return yield* Effect.fail(
|
|
99
|
+
new JiraApiError({
|
|
100
|
+
message: "--released and --unreleased are mutually exclusive; pass at most one (omit both to list all)."
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
const service = yield* VersionService
|
|
105
|
+
const versions = yield* service.listProjectVersions(project, {
|
|
106
|
+
released,
|
|
107
|
+
unreleased,
|
|
108
|
+
...(Option.isSome(max) ? { maxResults: max.value } : {}),
|
|
109
|
+
customFieldNames: customFields
|
|
110
|
+
})
|
|
111
|
+
if (json) {
|
|
112
|
+
const output = emails ? versions : versions.map(stripEmails)
|
|
113
|
+
yield* Console.log(JSON.stringify(output, null, 2))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const sep = " "
|
|
117
|
+
yield* Console.log(["id", "name", "released", "releaseDate", "driver", "contributors", "approvers"].join(sep))
|
|
118
|
+
for (const v of versions) {
|
|
119
|
+
yield* Console.log([
|
|
120
|
+
v.id,
|
|
121
|
+
v.name,
|
|
122
|
+
String(v.released),
|
|
123
|
+
v.releaseDate ?? "-",
|
|
124
|
+
v.driver?.displayName ?? "-",
|
|
125
|
+
v.contributors.map((c) => c.displayName).join("|") || "-",
|
|
126
|
+
v.approvers.map((a) => `${a.person.displayName}:${a.status}`).join("|") || "-"
|
|
127
|
+
].join(sep))
|
|
128
|
+
}
|
|
129
|
+
})).pipe(Command.withDescription("List versions for a Jira project"))
|
|
130
|
+
|
|
131
|
+
/** Cap on the number of ticket keys listed in the human `view` output. */
|
|
132
|
+
const TICKET_KEYS_LIMIT = 20
|
|
133
|
+
|
|
134
|
+
const viewCommand = Command.make(
|
|
135
|
+
"view",
|
|
136
|
+
{ id: idArg, json: jsonOption, emails: emailsOption },
|
|
137
|
+
({ emails, id, json }) =>
|
|
138
|
+
Effect.gen(function*() {
|
|
139
|
+
yield* ensureNumericId(id)
|
|
140
|
+
const service = yield* VersionService
|
|
141
|
+
const version = yield* service.getVersion(id)
|
|
142
|
+
if (json) {
|
|
143
|
+
const output = emails ? version : stripEmails(version)
|
|
144
|
+
yield* Console.log(JSON.stringify(output, null, 2))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
yield* Console.log(`# ${version.name} (${version.id})`)
|
|
148
|
+
yield* Console.log(`released: ${version.released}`)
|
|
149
|
+
yield* Console.log(`releaseDate: ${version.releaseDate ?? "-"}`)
|
|
150
|
+
yield* Console.log(`driver: ${version.driver?.displayName ?? "-"}`)
|
|
151
|
+
yield* Console.log(`contributors: ${version.contributors.map((c) => c.displayName).join(", ") || "-"}`)
|
|
152
|
+
yield* Console.log(
|
|
153
|
+
`approvers: ${version.approvers.map((a) => `${a.person.displayName}:${a.status}`).join(", ") || "-"}`
|
|
154
|
+
)
|
|
155
|
+
yield* Console.log(`tickets (${version.tickets.length}): ${formatTicketKeys(version.tickets)}`)
|
|
156
|
+
})
|
|
157
|
+
).pipe(Command.withDescription("Show a single Jira version"))
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Render a version's ticket keys for the human `view`: the first
|
|
161
|
+
* {@link TICKET_KEYS_LIMIT} keys, with a `(+M more)` suffix when truncated, or
|
|
162
|
+
* `-` when there are none.
|
|
163
|
+
*/
|
|
164
|
+
const formatTicketKeys = (tickets: Version["tickets"]): string => {
|
|
165
|
+
if (tickets.length === 0) return "-"
|
|
166
|
+
const keys = tickets.map((t) => t.key)
|
|
167
|
+
const shown = keys.slice(0, TICKET_KEYS_LIMIT).join(", ")
|
|
168
|
+
const remaining = keys.length - TICKET_KEYS_LIMIT
|
|
169
|
+
return remaining > 0 ? `${shown} (+${remaining} more)` : shown
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const descriptionOption = Options.string("description").pipe(
|
|
173
|
+
Options.withAlias("d"),
|
|
174
|
+
Options.withDescription("New version description")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
const setCommand = Command.make("set", { id: idArg, description: descriptionOption, json: jsonOption }, ({
|
|
178
|
+
description,
|
|
179
|
+
id,
|
|
180
|
+
json
|
|
181
|
+
}) =>
|
|
182
|
+
Effect.gen(function*() {
|
|
183
|
+
yield* ensureNumericId(id)
|
|
184
|
+
const service = yield* VersionService
|
|
185
|
+
const version = yield* service.updateVersion(id, { description })
|
|
186
|
+
if (json) {
|
|
187
|
+
yield* Console.log(JSON.stringify(version, null, 2))
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
yield* Console.log(`Updated version ${version.name} (${version.id})`)
|
|
191
|
+
yield* Console.log(`description: ${version.description ?? "-"}`)
|
|
192
|
+
})).pipe(
|
|
193
|
+
Command.withDescription("Update a version's description (requires manage:jira-project scope)")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// === relatedwork ===
|
|
197
|
+
|
|
198
|
+
const titleOption = Options.string("title").pipe(
|
|
199
|
+
Options.withAlias("t"),
|
|
200
|
+
Options.withDescription("Related-work link title (e.g. \"Release notes\")")
|
|
201
|
+
)
|
|
202
|
+
const urlOption = Options.string("url").pipe(
|
|
203
|
+
Options.withAlias("u"),
|
|
204
|
+
Options.withDescription("Related-work link URL (e.g. a Confluence page)")
|
|
205
|
+
)
|
|
206
|
+
const categoryOption = Options.string("category").pipe(
|
|
207
|
+
Options.withAlias("c"),
|
|
208
|
+
Options.withDescription("Related-work category (Jira groups by this; e.g. Communication, Testing, Design)"),
|
|
209
|
+
Options.withDefault("Communication")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const relatedWorkListCommand = Command.make(
|
|
213
|
+
"list",
|
|
214
|
+
{ id: idArg, json: jsonOption },
|
|
215
|
+
({ id, json }) =>
|
|
216
|
+
Effect.gen(function*() {
|
|
217
|
+
yield* ensureNumericId(id)
|
|
218
|
+
const service = yield* VersionService
|
|
219
|
+
const items = yield* service.listRelatedWork(id)
|
|
220
|
+
if (json) {
|
|
221
|
+
yield* Console.log(JSON.stringify(items, null, 2))
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (items.length === 0) {
|
|
225
|
+
yield* Console.log("(no related work)")
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const sep = " "
|
|
229
|
+
yield* Console.log(["category", "title", "url"].join(sep))
|
|
230
|
+
for (const w of items) {
|
|
231
|
+
yield* Console.log([w.category || "-", w.title ?? "-", w.url ?? "-"].join(sep))
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
).pipe(Command.withDescription("List a version's related-work links"))
|
|
235
|
+
|
|
236
|
+
const relatedWorkAddCommand = Command.make("add", {
|
|
237
|
+
id: idArg,
|
|
238
|
+
title: titleOption,
|
|
239
|
+
url: urlOption,
|
|
240
|
+
category: categoryOption,
|
|
241
|
+
json: jsonOption
|
|
242
|
+
}, ({ category, id, json, title, url }) =>
|
|
243
|
+
Effect.gen(function*() {
|
|
244
|
+
yield* ensureNumericId(id)
|
|
245
|
+
const service = yield* VersionService
|
|
246
|
+
const created = yield* service.addRelatedWork(id, { title, category, url })
|
|
247
|
+
if (json) {
|
|
248
|
+
yield* Console.log(JSON.stringify(created, null, 2))
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
yield* Console.log(`Attached "${created.title ?? title}" (${created.category}) to version ${id}`)
|
|
252
|
+
yield* Console.log(`url: ${created.url ?? url}`)
|
|
253
|
+
})).pipe(
|
|
254
|
+
Command.withDescription(
|
|
255
|
+
"Attach a related-work link (e.g. a Confluence page) to a version (requires manage:jira-project scope)"
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
const relatedWorkCommand = Command.make("relatedwork").pipe(
|
|
260
|
+
Command.withDescription("List or attach version related-work links (Confluence pages on the release report)"),
|
|
261
|
+
Command.withSubcommands([relatedWorkListCommand, relatedWorkAddCommand])
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
export const versionCommand = Command.make("version").pipe(
|
|
265
|
+
Command.withDescription("List, view, or edit Jira project versions (releases)"),
|
|
266
|
+
Command.withSubcommands([listCommand, viewCommand, setCommand, relatedWorkCommand])
|
|
267
|
+
)
|