@knpkv/jira-cli 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/IssueService.d.ts +144 -0
- package/dist/IssueService.d.ts.map +1 -0
- package/dist/IssueService.js +250 -0
- package/dist/IssueService.js.map +1 -0
- package/dist/JiraAuth.d.ts +84 -0
- package/dist/JiraAuth.d.ts.map +1 -0
- package/dist/JiraAuth.js +246 -0
- package/dist/JiraAuth.js.map +1 -0
- package/dist/JiraCliError.d.ts +42 -0
- package/dist/JiraCliError.d.ts.map +1 -0
- package/dist/JiraCliError.js +35 -0
- package/dist/JiraCliError.js.map +1 -0
- package/dist/MarkdownWriter.d.ts +56 -0
- package/dist/MarkdownWriter.d.ts.map +1 -0
- package/dist/MarkdownWriter.js +66 -0
- package/dist/MarkdownWriter.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +39 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/auth.d.ts +22 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +89 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/errorHandler.d.ts +13 -0
- package/dist/commands/errorHandler.d.ts.map +1 -0
- package/dist/commands/errorHandler.js +13 -0
- package/dist/commands/errorHandler.js.map +1 -0
- package/dist/commands/get.d.ts +13 -0
- package/dist/commands/get.d.ts.map +1 -0
- package/dist/commands/get.js +25 -0
- package/dist/commands/get.js.map +1 -0
- package/dist/commands/index.d.ts +11 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +11 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/layers.d.ts +44 -0
- package/dist/commands/layers.d.ts.map +1 -0
- package/dist/commands/layers.js +100 -0
- package/dist/commands/layers.js.map +1 -0
- package/dist/commands/search.d.ts +18 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/search.js +64 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/NodeLayers.d.ts +7 -0
- package/dist/internal/NodeLayers.d.ts.map +1 -0
- package/dist/internal/NodeLayers.js +15 -0
- package/dist/internal/NodeLayers.js.map +1 -0
- package/dist/internal/frontmatter.d.ts +60 -0
- package/dist/internal/frontmatter.d.ts.map +1 -0
- package/dist/internal/frontmatter.js +130 -0
- package/dist/internal/frontmatter.js.map +1 -0
- package/dist/internal/jqlBuilder.d.ts +39 -0
- package/dist/internal/jqlBuilder.d.ts.map +1 -0
- package/dist/internal/jqlBuilder.js +47 -0
- package/dist/internal/jqlBuilder.js.map +1 -0
- package/dist/internal/oauthServer.d.ts +55 -0
- package/dist/internal/oauthServer.d.ts.map +1 -0
- package/dist/internal/oauthServer.js +113 -0
- package/dist/internal/oauthServer.js.map +1 -0
- package/package.json +86 -0
- package/src/IssueService.ts +378 -0
- package/src/JiraAuth.ts +476 -0
- package/src/JiraCliError.ts +44 -0
- package/src/MarkdownWriter.ts +112 -0
- package/src/bin.ts +62 -0
- package/src/commands/auth.ts +124 -0
- package/src/commands/errorHandler.ts +14 -0
- package/src/commands/get.ts +42 -0
- package/src/commands/index.ts +11 -0
- package/src/commands/layers.ts +142 -0
- package/src/commands/search.ts +102 -0
- package/src/index.ts +26 -0
- package/src/internal/NodeLayers.ts +17 -0
- package/src/internal/frontmatter.ts +170 -0
- package/src/internal/jqlBuilder.ts +49 -0
- package/src/internal/oauthServer.ts +203 -0
- package/test/jqlBuilder.test.ts +45 -0
- package/tsconfig.json +32 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth subcommands: create, configure, login, logout, status.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - Each command is a standalone `Command.make` that yields into `JiraAuth` service methods.
|
|
7
|
+
* - `create` opens the Atlassian Developer Console; `configure` prompts for client ID/secret.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
import { Command, Options, Prompt } from "@effect/cli"
|
|
12
|
+
import * as PlatformCommand from "@effect/platform/Command"
|
|
13
|
+
import * as Console from "effect/Console"
|
|
14
|
+
import * as Effect from "effect/Effect"
|
|
15
|
+
import * as Option from "effect/Option"
|
|
16
|
+
import { JiraAuth } from "../JiraAuth.js"
|
|
17
|
+
|
|
18
|
+
// === Auth create command ===
|
|
19
|
+
const createCommand = Command.make("create", {}, () =>
|
|
20
|
+
Effect.gen(function*() {
|
|
21
|
+
yield* Console.log(`
|
|
22
|
+
Creating OAuth app in Atlassian Developer Console...
|
|
23
|
+
|
|
24
|
+
1. Browser will open to create a new OAuth 2.0 (3LO) app
|
|
25
|
+
2. Enter app name (e.g., "Jira CLI")
|
|
26
|
+
3. After creation, go to "Permissions" and add:
|
|
27
|
+
|
|
28
|
+
Jira API:
|
|
29
|
+
- read:jira-work Search and read issues, comments, attachments
|
|
30
|
+
- read:jira-user Read user info for assignee/reporter fields
|
|
31
|
+
|
|
32
|
+
User Identity API:
|
|
33
|
+
- read:me Get your account ID and email for auth
|
|
34
|
+
|
|
35
|
+
4. Go to "Authorization" and set callback URL:
|
|
36
|
+
http://localhost:8585/callback
|
|
37
|
+
5. Go to "Settings" and copy Client ID and Secret
|
|
38
|
+
6. Run: jira auth configure --client-id <ID> --client-secret <SECRET>
|
|
39
|
+
`)
|
|
40
|
+
const url = "https://developer.atlassian.com/console/myapps/create-3lo-app/"
|
|
41
|
+
yield* PlatformCommand.make("open", url).pipe(
|
|
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
|
+
)
|
|
48
|
+
})).pipe(Command.withDescription("Create OAuth app in Atlassian Developer Console"))
|
|
49
|
+
|
|
50
|
+
// === Auth configure command ===
|
|
51
|
+
const clientIdOption = Options.text("client-id").pipe(
|
|
52
|
+
Options.withDescription("OAuth client ID from Atlassian Developer Console"),
|
|
53
|
+
Options.optional
|
|
54
|
+
)
|
|
55
|
+
const clientSecretOption = Options.text("client-secret").pipe(
|
|
56
|
+
Options.withDescription("OAuth client secret"),
|
|
57
|
+
Options.optional
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const configureCommand = Command.make(
|
|
61
|
+
"configure",
|
|
62
|
+
{ clientId: clientIdOption, clientSecret: clientSecretOption },
|
|
63
|
+
({ clientId, clientSecret }) =>
|
|
64
|
+
Effect.gen(function*() {
|
|
65
|
+
const auth = yield* JiraAuth
|
|
66
|
+
|
|
67
|
+
const rawClientId = Option.isSome(clientId)
|
|
68
|
+
? clientId.value
|
|
69
|
+
: yield* Prompt.text({ message: "Enter OAuth client ID:" })
|
|
70
|
+
const rawClientSecret = Option.isSome(clientSecret)
|
|
71
|
+
? clientSecret.value
|
|
72
|
+
: yield* Prompt.text({ message: "Enter OAuth client secret:" })
|
|
73
|
+
|
|
74
|
+
yield* auth.configure({ clientId: rawClientId, clientSecret: rawClientSecret })
|
|
75
|
+
yield* Console.log("OAuth configured. Run 'jira auth login' to authenticate.")
|
|
76
|
+
})
|
|
77
|
+
).pipe(Command.withDescription("Configure OAuth client credentials"))
|
|
78
|
+
|
|
79
|
+
// === Auth login command ===
|
|
80
|
+
const siteOption = Options.text("site").pipe(
|
|
81
|
+
Options.withDescription("Jira site URL to use (for accounts with multiple sites)"),
|
|
82
|
+
Options.optional
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const loginCommand = Command.make("login", { site: siteOption }, ({ site }) =>
|
|
86
|
+
Effect.gen(function*() {
|
|
87
|
+
const auth = yield* JiraAuth
|
|
88
|
+
const result = yield* auth.login(Option.isSome(site) ? { siteUrl: site.value } : undefined)
|
|
89
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
90
|
+
yield* Console.log("\nRe-run with --site to select a specific site.")
|
|
91
|
+
}
|
|
92
|
+
})).pipe(Command.withDescription("Authenticate with Atlassian via OAuth"))
|
|
93
|
+
|
|
94
|
+
// === Auth logout command ===
|
|
95
|
+
const logoutCommand = Command.make("logout", {}, () =>
|
|
96
|
+
Effect.gen(function*() {
|
|
97
|
+
const auth = yield* JiraAuth
|
|
98
|
+
yield* auth.logout()
|
|
99
|
+
yield* Console.log("Logged out")
|
|
100
|
+
})).pipe(Command.withDescription("Remove stored authentication"))
|
|
101
|
+
|
|
102
|
+
// === Auth status command ===
|
|
103
|
+
const statusCommand = Command.make("status", {}, () =>
|
|
104
|
+
Effect.gen(function*() {
|
|
105
|
+
const auth = yield* JiraAuth
|
|
106
|
+
const user = yield* auth.getCurrentUser()
|
|
107
|
+
if (user) {
|
|
108
|
+
yield* Console.log(`Logged in as: ${user.name} (${user.email})`)
|
|
109
|
+
} else {
|
|
110
|
+
yield* Console.log("Not logged in. Use 'jira auth login' to authenticate.")
|
|
111
|
+
}
|
|
112
|
+
})).pipe(Command.withDescription("Show authentication status"))
|
|
113
|
+
|
|
114
|
+
// === Auth command group ===
|
|
115
|
+
export const authCommand = Command.make("auth").pipe(
|
|
116
|
+
Command.withDescription("Manage OAuth authentication"),
|
|
117
|
+
Command.withSubcommands([
|
|
118
|
+
createCommand,
|
|
119
|
+
configureCommand,
|
|
120
|
+
loginCommand,
|
|
121
|
+
logoutCommand,
|
|
122
|
+
statusCommand
|
|
123
|
+
])
|
|
124
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect-idiomatic error handler — uses Cause.pretty for structured failure output.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as Cause from "effect/Cause"
|
|
7
|
+
import * as Console from "effect/Console"
|
|
8
|
+
import type * as Effect from "effect/Effect"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Handle errors from CLI execution.
|
|
12
|
+
* Logs the pretty-printed cause to stderr via Console.error.
|
|
13
|
+
*/
|
|
14
|
+
export const handleError = <E>(cause: Cause.Cause<E>): Effect.Effect<void> => Console.error(Cause.pretty(cause))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jira get <key>` command — fetches a single issue and writes to Markdown.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { Args, Command, Options } from "@effect/cli"
|
|
7
|
+
import * as Console from "effect/Console"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import { IssueService } from "../IssueService.js"
|
|
10
|
+
import { MarkdownWriter } from "../MarkdownWriter.js"
|
|
11
|
+
|
|
12
|
+
const keyArg = Args.text({ name: "key" }).pipe(
|
|
13
|
+
Args.withDescription("Issue key (e.g., PROJ-123)")
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const outputDirOption = Options.directory("output-dir").pipe(
|
|
17
|
+
Options.withAlias("o"),
|
|
18
|
+
Options.withDescription("Output directory for markdown file"),
|
|
19
|
+
Options.withDefault("./jira-tickets")
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const getCommand = Command.make(
|
|
23
|
+
"get",
|
|
24
|
+
{
|
|
25
|
+
key: keyArg,
|
|
26
|
+
outputDir: outputDirOption
|
|
27
|
+
},
|
|
28
|
+
({ key, outputDir }) =>
|
|
29
|
+
Effect.gen(function*() {
|
|
30
|
+
const issueService = yield* IssueService
|
|
31
|
+
const writer = yield* MarkdownWriter
|
|
32
|
+
|
|
33
|
+
yield* Console.log(`Fetching ${key}...`)
|
|
34
|
+
|
|
35
|
+
const issue = yield* issueService.getByKey(key)
|
|
36
|
+
|
|
37
|
+
yield* Console.log(`Writing to ${outputDir}/${key}.md...`)
|
|
38
|
+
yield* writer.writeMulti([issue], outputDir)
|
|
39
|
+
|
|
40
|
+
yield* Console.log(`Done.`)
|
|
41
|
+
})
|
|
42
|
+
).pipe(Command.withDescription("Get a single Jira issue by key"))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel export for CLI commands and layer definitions.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { authCommand } from "./auth.js"
|
|
8
|
+
export { handleError } from "./errorHandler.js"
|
|
9
|
+
export { getCommand } from "./get.js"
|
|
10
|
+
export { AppLayer, AuthOnlyLayer, getLayerType, MinimalLayer } from "./layers.js"
|
|
11
|
+
export { searchCommand } from "./search.js"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer composition for CLI commands with three tiers: full, auth-only, minimal.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Lazy layer selection**: {@link getLayerType} inspects `process.argv[2]` to pick
|
|
7
|
+
* the smallest layer needed — `"minimal"` for help/version, `"auth"` for auth commands,
|
|
8
|
+
* `"full"` for search/get (which needs API client + issue service).
|
|
9
|
+
* - **Dummy services**: Auth-only and minimal layers provide `Effect.dieMessage` stubs
|
|
10
|
+
* for unused services to satisfy the type system without initialization cost.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
import * as NodeContext from "@effect/platform-node/NodeContext"
|
|
15
|
+
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
|
|
16
|
+
import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
17
|
+
import * as Effect from "effect/Effect"
|
|
18
|
+
import * as Layer from "effect/Layer"
|
|
19
|
+
import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../IssueService.js"
|
|
20
|
+
import { JiraAuth, layer as JiraAuthLayer } from "../JiraAuth.js"
|
|
21
|
+
import { layer as MarkdownWriterLayer, MarkdownWriter } from "../MarkdownWriter.js"
|
|
22
|
+
|
|
23
|
+
// Dummy services for auth-only commands
|
|
24
|
+
const DummyIssueServiceLayer = Layer.succeed(
|
|
25
|
+
IssueService,
|
|
26
|
+
IssueService.of({
|
|
27
|
+
getByKey: () => Effect.dieMessage("Not configured - run 'jira auth login' first"),
|
|
28
|
+
search: () => Effect.dieMessage("Not configured - run 'jira auth login' first"),
|
|
29
|
+
searchAll: () => Effect.dieMessage("Not configured - run 'jira auth login' first")
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const DummyMarkdownWriterLayer = Layer.succeed(
|
|
34
|
+
MarkdownWriter,
|
|
35
|
+
MarkdownWriter.of({
|
|
36
|
+
writeMulti: () => Effect.dieMessage("Not configured"),
|
|
37
|
+
writeSingle: () => Effect.dieMessage("Not configured")
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const DummyJiraAuthLayer = Layer.succeed(
|
|
42
|
+
JiraAuth,
|
|
43
|
+
JiraAuth.of({
|
|
44
|
+
configure: () => Effect.dieMessage("Not configured"),
|
|
45
|
+
isConfigured: () => Effect.succeed(false),
|
|
46
|
+
login: () => Effect.dieMessage("Not configured"),
|
|
47
|
+
logout: () => Effect.dieMessage("Not configured"),
|
|
48
|
+
getAccessToken: () => Effect.dieMessage("Not configured"),
|
|
49
|
+
getCloudId: () => Effect.dieMessage("Not configured"),
|
|
50
|
+
getSiteUrl: () => Effect.dieMessage("Not configured"),
|
|
51
|
+
getCurrentUser: () => Effect.succeed(null),
|
|
52
|
+
isLoggedIn: () => Effect.succeed(false)
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Auth layer with HTTP client
|
|
57
|
+
const AuthLive = JiraAuthLayer.pipe(Layer.provide(NodeHttpClient.layer))
|
|
58
|
+
|
|
59
|
+
// Build Jira API config layer dynamically based on auth
|
|
60
|
+
const JiraConfigLive = Layer.unwrapEffect(
|
|
61
|
+
Effect.gen(function*() {
|
|
62
|
+
const auth = yield* JiraAuth
|
|
63
|
+
const accessToken = yield* auth.getAccessToken()
|
|
64
|
+
const cloudId = yield* auth.getCloudId()
|
|
65
|
+
|
|
66
|
+
return Layer.succeed(JiraApiConfig, {
|
|
67
|
+
baseUrl: "",
|
|
68
|
+
auth: {
|
|
69
|
+
type: "oauth2" as const,
|
|
70
|
+
accessToken,
|
|
71
|
+
cloudId
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Build Jira API client layer with config (no HttpClient needed — uses openapi-fetch)
|
|
78
|
+
const JiraClientLive = JiraApiClient.layer.pipe(
|
|
79
|
+
Layer.provide(JiraConfigLive)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Build SiteUrl layer from auth
|
|
83
|
+
const SiteUrlLive = Layer.unwrapEffect(
|
|
84
|
+
Effect.gen(function*() {
|
|
85
|
+
const auth = yield* JiraAuth
|
|
86
|
+
const siteUrl = yield* auth.getSiteUrl()
|
|
87
|
+
return Layer.succeed(SiteUrl, siteUrl)
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Full app layer with all services for search commands.
|
|
93
|
+
*
|
|
94
|
+
* @category Layers
|
|
95
|
+
*/
|
|
96
|
+
export const AppLayer = MarkdownWriterLayer.pipe(
|
|
97
|
+
Layer.provideMerge(IssueServiceLayer),
|
|
98
|
+
Layer.provideMerge(SiteUrlLive),
|
|
99
|
+
Layer.provideMerge(JiraClientLive),
|
|
100
|
+
Layer.provideMerge(AuthLive),
|
|
101
|
+
Layer.provideMerge(NodeHttpClient.layer),
|
|
102
|
+
Layer.provideMerge(NodeContext.layer)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Auth-only layer for auth commands.
|
|
107
|
+
*
|
|
108
|
+
* @category Layers
|
|
109
|
+
*/
|
|
110
|
+
export const AuthOnlyLayer = DummyIssueServiceLayer.pipe(
|
|
111
|
+
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
112
|
+
Layer.provideMerge(AuthLive),
|
|
113
|
+
Layer.provideMerge(NodeHttpClient.layer),
|
|
114
|
+
Layer.provideMerge(NodeContext.layer)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Minimal layer for help/version commands.
|
|
119
|
+
*
|
|
120
|
+
* @category Layers
|
|
121
|
+
*/
|
|
122
|
+
export const MinimalLayer = DummyIssueServiceLayer.pipe(
|
|
123
|
+
Layer.provideMerge(DummyMarkdownWriterLayer),
|
|
124
|
+
Layer.provideMerge(DummyJiraAuthLayer),
|
|
125
|
+
Layer.provideMerge(NodeContext.layer)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Determine which layer to use based on command.
|
|
130
|
+
*
|
|
131
|
+
* @category Utilities
|
|
132
|
+
*/
|
|
133
|
+
export const getLayerType = (argv: ReadonlyArray<string>): "full" | "auth" | "minimal" => {
|
|
134
|
+
const cmd = argv[2]
|
|
135
|
+
if (cmd === "auth") {
|
|
136
|
+
return "auth"
|
|
137
|
+
}
|
|
138
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "--version") {
|
|
139
|
+
return "minimal"
|
|
140
|
+
}
|
|
141
|
+
return "full"
|
|
142
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jira search` command — JQL search with multi/single Markdown output.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import { Args, Command, Options } from "@effect/cli"
|
|
7
|
+
import * as Console from "effect/Console"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Option from "effect/Option"
|
|
10
|
+
import { buildByVersionJql } from "../internal/jqlBuilder.js"
|
|
11
|
+
import { IssueService } from "../IssueService.js"
|
|
12
|
+
import { MarkdownWriter } from "../MarkdownWriter.js"
|
|
13
|
+
|
|
14
|
+
// === Options ===
|
|
15
|
+
const jqlArg = Args.text({ name: "jql" }).pipe(
|
|
16
|
+
Args.withDescription("JQL query to search for issues"),
|
|
17
|
+
Args.optional
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const byVersionOption = Options.text("by-version").pipe(
|
|
21
|
+
Options.withAlias("v"),
|
|
22
|
+
Options.withDescription("Search by fix version (pre-defined query)"),
|
|
23
|
+
Options.optional
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const projectOption = Options.text("project").pipe(
|
|
27
|
+
Options.withAlias("p"),
|
|
28
|
+
Options.withDescription("Filter by project key"),
|
|
29
|
+
Options.optional
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const outputDirOption = Options.directory("output-dir").pipe(
|
|
33
|
+
Options.withAlias("o"),
|
|
34
|
+
Options.withDescription("Output directory for markdown files"),
|
|
35
|
+
Options.withDefault("./jira-tickets")
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const formatOption = Options.choice("format", ["multi", "single"]).pipe(
|
|
39
|
+
Options.withAlias("f"),
|
|
40
|
+
Options.withDescription("Output format: multi (one file per issue) or single (combined file)"),
|
|
41
|
+
Options.withDefault("multi" as const)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const maxResultsOption = Options.integer("max-results").pipe(
|
|
45
|
+
Options.withAlias("m"),
|
|
46
|
+
Options.withDescription("Maximum number of results to fetch"),
|
|
47
|
+
Options.withDefault(100)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// === Search command ===
|
|
51
|
+
export const searchCommand = Command.make(
|
|
52
|
+
"search",
|
|
53
|
+
{
|
|
54
|
+
jql: jqlArg,
|
|
55
|
+
byVersion: byVersionOption,
|
|
56
|
+
project: projectOption,
|
|
57
|
+
outputDir: outputDirOption,
|
|
58
|
+
format: formatOption,
|
|
59
|
+
maxResults: maxResultsOption
|
|
60
|
+
},
|
|
61
|
+
({ byVersion, format, jql, maxResults, outputDir, project }) =>
|
|
62
|
+
Effect.gen(function*() {
|
|
63
|
+
const issueService = yield* IssueService
|
|
64
|
+
const writer = yield* MarkdownWriter
|
|
65
|
+
|
|
66
|
+
// Build JQL query
|
|
67
|
+
let query: string
|
|
68
|
+
|
|
69
|
+
if (Option.isSome(byVersion)) {
|
|
70
|
+
const projectKey = Option.isSome(project) ? project.value : undefined
|
|
71
|
+
query = buildByVersionJql(byVersion.value, projectKey)
|
|
72
|
+
yield* Console.log(`Searching by fix version: ${byVersion.value}`)
|
|
73
|
+
} else if (Option.isSome(jql)) {
|
|
74
|
+
query = jql.value
|
|
75
|
+
} else {
|
|
76
|
+
yield* Console.log("Error: Either a JQL query or --by-version must be provided.")
|
|
77
|
+
yield* Console.log("Usage: jira search <jql>")
|
|
78
|
+
yield* Console.log(" jira search --by-version <version>")
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
yield* Console.log(`Query: ${query}`)
|
|
83
|
+
yield* Console.log("Fetching issues...")
|
|
84
|
+
|
|
85
|
+
const issues = yield* issueService.searchAll(query, { maxResults })
|
|
86
|
+
|
|
87
|
+
if (issues.length === 0) {
|
|
88
|
+
yield* Console.log("No issues found.")
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
yield* Console.log(`Found ${issues.length} issue(s). Writing to ${outputDir}...`)
|
|
93
|
+
|
|
94
|
+
if (format === "single") {
|
|
95
|
+
yield* writer.writeSingle(issues, outputDir, query)
|
|
96
|
+
yield* Console.log(`Exported to ${outputDir}/jira-export.md`)
|
|
97
|
+
} else {
|
|
98
|
+
yield* writer.writeMulti(issues, outputDir)
|
|
99
|
+
yield* Console.log(`Exported ${issues.length} file(s) to ${outputDir}/`)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
).pipe(Command.withDescription("Search Jira issues and export to markdown"))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root barrel export for `@knpkv/jira-cli`.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
type Attachment,
|
|
9
|
+
type Comment,
|
|
10
|
+
type Issue,
|
|
11
|
+
IssueService,
|
|
12
|
+
type IssueServiceShape,
|
|
13
|
+
layer as IssueServiceLayer,
|
|
14
|
+
type SearchOptions,
|
|
15
|
+
type SearchResult,
|
|
16
|
+
SiteUrl
|
|
17
|
+
} from "./IssueService.js"
|
|
18
|
+
export {
|
|
19
|
+
type AccessibleSite,
|
|
20
|
+
JiraAuth,
|
|
21
|
+
type JiraAuthService,
|
|
22
|
+
layer as JiraAuthLayer,
|
|
23
|
+
type LoginOptions
|
|
24
|
+
} from "./JiraAuth.js"
|
|
25
|
+
export * from "./JiraCliError.js"
|
|
26
|
+
export { layer as MarkdownWriterLayer, MarkdownWriter, type MarkdownWriterShape } from "./MarkdownWriter.js"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js-specific HTTP server factory — the only file importing `@effect/platform-node` server.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
|
|
7
|
+
import { createServer } from "node:http"
|
|
8
|
+
import { makeHttpServerFactory } from "./oauthServer.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* HTTP Server factory layer using Node.js http module.
|
|
12
|
+
*
|
|
13
|
+
* @category Layers
|
|
14
|
+
*/
|
|
15
|
+
export const HttpServerFactoryLive = makeHttpServerFactory(
|
|
16
|
+
(port) => NodeHttpServer.layerServer(createServer, { port })
|
|
17
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML frontmatter serialization for Jira issues using `gray-matter`.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - {@link serializeIssue} produces `---\nyaml\n---\nmarkdown` for a single issue.
|
|
7
|
+
* - {@link buildCombinedMarkdown} concatenates all issues with a JQL header for single-file export.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
import matter from "gray-matter"
|
|
12
|
+
import type { Issue } from "../IssueService.js"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Front-matter data for a Jira issue.
|
|
16
|
+
*
|
|
17
|
+
* @category Types
|
|
18
|
+
*/
|
|
19
|
+
export interface IssueFrontMatter {
|
|
20
|
+
readonly key: string
|
|
21
|
+
readonly id: string
|
|
22
|
+
readonly summary: string
|
|
23
|
+
readonly status: string
|
|
24
|
+
readonly type: string
|
|
25
|
+
readonly priority: string | null
|
|
26
|
+
readonly assignee: string | null
|
|
27
|
+
readonly reporter: string | null
|
|
28
|
+
readonly created: string
|
|
29
|
+
readonly updated: string
|
|
30
|
+
readonly fixVersions: ReadonlyArray<string>
|
|
31
|
+
readonly labels: ReadonlyArray<string>
|
|
32
|
+
readonly components: ReadonlyArray<string>
|
|
33
|
+
readonly url: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract front-matter data from an issue.
|
|
38
|
+
*
|
|
39
|
+
* @param issue - The issue to extract from
|
|
40
|
+
* @returns Front-matter object
|
|
41
|
+
*
|
|
42
|
+
* @category Utilities
|
|
43
|
+
*/
|
|
44
|
+
export const extractFrontMatter = (issue: Issue): IssueFrontMatter => ({
|
|
45
|
+
key: issue.key,
|
|
46
|
+
id: issue.id,
|
|
47
|
+
summary: issue.summary,
|
|
48
|
+
status: issue.status,
|
|
49
|
+
type: issue.type,
|
|
50
|
+
priority: issue.priority,
|
|
51
|
+
assignee: issue.assignee,
|
|
52
|
+
reporter: issue.reporter,
|
|
53
|
+
created: issue.created.toISOString(),
|
|
54
|
+
updated: issue.updated.toISOString(),
|
|
55
|
+
fixVersions: issue.fixVersions,
|
|
56
|
+
labels: issue.labels,
|
|
57
|
+
components: issue.components,
|
|
58
|
+
url: issue.url
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serialize an issue to markdown with front-matter.
|
|
63
|
+
*
|
|
64
|
+
* @param issue - The issue to serialize
|
|
65
|
+
* @returns Markdown string with YAML front-matter
|
|
66
|
+
*
|
|
67
|
+
* @category Serialization
|
|
68
|
+
*/
|
|
69
|
+
export const serializeIssue = (issue: Issue): string => {
|
|
70
|
+
const frontMatter = extractFrontMatter(issue)
|
|
71
|
+
const content = buildMarkdownContent(issue)
|
|
72
|
+
return matter.stringify(content, frontMatter)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build markdown content for an issue (without front-matter).
|
|
77
|
+
*
|
|
78
|
+
* @param issue - The issue to build content for
|
|
79
|
+
* @returns Markdown content string
|
|
80
|
+
*
|
|
81
|
+
* @category Serialization
|
|
82
|
+
*/
|
|
83
|
+
export const buildMarkdownContent = (issue: Issue): string => {
|
|
84
|
+
const parts: Array<string> = []
|
|
85
|
+
|
|
86
|
+
// Title
|
|
87
|
+
parts.push(`# ${issue.key}: ${issue.summary}`)
|
|
88
|
+
parts.push("")
|
|
89
|
+
|
|
90
|
+
// Description
|
|
91
|
+
if (issue.description) {
|
|
92
|
+
parts.push("## Description")
|
|
93
|
+
parts.push("")
|
|
94
|
+
parts.push(issue.description)
|
|
95
|
+
parts.push("")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Attachments
|
|
99
|
+
if (issue.attachments.length > 0) {
|
|
100
|
+
parts.push("## Attachments")
|
|
101
|
+
parts.push("")
|
|
102
|
+
for (const attachment of issue.attachments) {
|
|
103
|
+
parts.push(`- [${attachment.filename}](${attachment.url})`)
|
|
104
|
+
}
|
|
105
|
+
parts.push("")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Comments
|
|
109
|
+
if (issue.comments.length > 0) {
|
|
110
|
+
parts.push("## Comments")
|
|
111
|
+
parts.push("")
|
|
112
|
+
for (const comment of issue.comments) {
|
|
113
|
+
const date = comment.created.toISOString().split("T")[0]
|
|
114
|
+
parts.push(`### ${comment.author} (${date})`)
|
|
115
|
+
parts.push("")
|
|
116
|
+
parts.push(comment.body)
|
|
117
|
+
parts.push("")
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return parts.join("\n")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a combined markdown file for multiple issues.
|
|
126
|
+
*
|
|
127
|
+
* @param issues - The issues to include
|
|
128
|
+
* @param jql - The JQL query used (for header)
|
|
129
|
+
* @returns Combined markdown string
|
|
130
|
+
*
|
|
131
|
+
* @category Serialization
|
|
132
|
+
*/
|
|
133
|
+
export const buildCombinedMarkdown = (issues: ReadonlyArray<Issue>, jql: string): string => {
|
|
134
|
+
const parts: Array<string> = []
|
|
135
|
+
|
|
136
|
+
// Header
|
|
137
|
+
parts.push("# Jira Export")
|
|
138
|
+
parts.push("")
|
|
139
|
+
parts.push(`Query: \`${jql}\``)
|
|
140
|
+
parts.push(`Exported: ${new Date().toISOString()}`)
|
|
141
|
+
parts.push(`Total: ${issues.length} tickets`)
|
|
142
|
+
parts.push("")
|
|
143
|
+
parts.push("---")
|
|
144
|
+
parts.push("")
|
|
145
|
+
|
|
146
|
+
// Issues
|
|
147
|
+
for (const issue of issues) {
|
|
148
|
+
parts.push(`## ${issue.key}: ${issue.summary}`)
|
|
149
|
+
parts.push("")
|
|
150
|
+
parts.push(
|
|
151
|
+
`**Status:** ${issue.status} | **Type:** ${issue.type}${
|
|
152
|
+
issue.priority ? ` | **Priority:** ${issue.priority}` : ""
|
|
153
|
+
}`
|
|
154
|
+
)
|
|
155
|
+
if (issue.assignee) {
|
|
156
|
+
parts.push(`**Assignee:** ${issue.assignee}`)
|
|
157
|
+
}
|
|
158
|
+
parts.push("")
|
|
159
|
+
|
|
160
|
+
if (issue.description) {
|
|
161
|
+
parts.push(issue.description)
|
|
162
|
+
parts.push("")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
parts.push("---")
|
|
166
|
+
parts.push("")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return parts.join("\n")
|
|
170
|
+
}
|