@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
|
@@ -10,21 +10,20 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @internal
|
|
12
12
|
*/
|
|
13
|
-
import * as HttpRouter from "@effect/platform/HttpRouter"
|
|
14
|
-
import * as HttpServer from "@effect/platform/HttpServer"
|
|
15
|
-
import type { ServeError } from "@effect/platform/HttpServerError"
|
|
16
|
-
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
|
|
17
|
-
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
|
|
18
13
|
import { OAuthError } from "@knpkv/atlassian-common/auth"
|
|
19
14
|
import * as Context from "effect/Context"
|
|
20
15
|
import * as Deferred from "effect/Deferred"
|
|
21
16
|
import * as Effect from "effect/Effect"
|
|
17
|
+
import * as Exit from "effect/Exit"
|
|
22
18
|
import * as Fiber from "effect/Fiber"
|
|
23
19
|
import * as Layer from "effect/Layer"
|
|
20
|
+
import * as Scope from "effect/Scope"
|
|
21
|
+
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
|
|
22
|
+
import type * as HttpServerError from "effect/unstable/http/HttpServerError"
|
|
24
23
|
|
|
25
24
|
const DEFAULT_PORT = 8585
|
|
26
|
-
const
|
|
27
|
-
|
|
25
|
+
const MAX_PORT = 8594
|
|
26
|
+
type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
|
|
28
27
|
/**
|
|
29
28
|
* Factory service for creating HTTP servers.
|
|
30
29
|
* This allows mocking the server creation in tests.
|
|
@@ -34,7 +33,7 @@ const MAX_PORT_ATTEMPTS = 10
|
|
|
34
33
|
export interface HttpServerFactory {
|
|
35
34
|
readonly createServerLayer: (port: number) => Layer.Layer<
|
|
36
35
|
HttpServer.HttpServer,
|
|
37
|
-
ServeError,
|
|
36
|
+
HttpServerError.ServeError,
|
|
38
37
|
never
|
|
39
38
|
>
|
|
40
39
|
}
|
|
@@ -44,10 +43,10 @@ export interface HttpServerFactory {
|
|
|
44
43
|
*
|
|
45
44
|
* @category Services
|
|
46
45
|
*/
|
|
47
|
-
export class HttpServerFactoryTag extends Context.
|
|
46
|
+
export class HttpServerFactoryTag extends Context.Service<
|
|
48
47
|
HttpServerFactoryTag,
|
|
49
48
|
HttpServerFactory
|
|
50
|
-
>() {}
|
|
49
|
+
>()("@knpkv/jira-cli/HttpServerFactory") {}
|
|
51
50
|
|
|
52
51
|
/**
|
|
53
52
|
* Create a HttpServerFactory layer from a layer factory function.
|
|
@@ -59,50 +58,12 @@ export class HttpServerFactoryTag extends Context.Tag("@knpkv/jira-cli/HttpServe
|
|
|
59
58
|
* @category Layers
|
|
60
59
|
*/
|
|
61
60
|
export const makeHttpServerFactory = (
|
|
62
|
-
createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, ServeError, never>
|
|
61
|
+
createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
|
|
63
62
|
): Layer.Layer<HttpServerFactoryTag> =>
|
|
64
63
|
Layer.succeed(HttpServerFactoryTag, {
|
|
65
64
|
createServerLayer: createLayerFn
|
|
66
65
|
})
|
|
67
66
|
|
|
68
|
-
/**
|
|
69
|
-
* Check if a port is available by attempting to start a server.
|
|
70
|
-
*/
|
|
71
|
-
const isPortAvailable = (port: number): Effect.Effect<boolean, never, HttpServerFactoryTag> =>
|
|
72
|
-
Effect.gen(function*() {
|
|
73
|
-
const factory = yield* HttpServerFactoryTag
|
|
74
|
-
const serverLayer = factory.createServerLayer(port)
|
|
75
|
-
|
|
76
|
-
const result = yield* Layer.build(serverLayer).pipe(
|
|
77
|
-
Effect.scoped,
|
|
78
|
-
Effect.as(true),
|
|
79
|
-
Effect.catchAll(() => Effect.succeed(false))
|
|
80
|
-
)
|
|
81
|
-
return result
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Find an available port starting from the default.
|
|
86
|
-
*/
|
|
87
|
-
const findAvailablePort = (): Effect.Effect<number, OAuthError, HttpServerFactoryTag> =>
|
|
88
|
-
Effect.gen(function*() {
|
|
89
|
-
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
|
90
|
-
const port = DEFAULT_PORT + attempt
|
|
91
|
-
const available = yield* isPortAvailable(port)
|
|
92
|
-
if (available) {
|
|
93
|
-
return port
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return yield* Effect.fail(
|
|
97
|
-
new OAuthError({
|
|
98
|
-
step: "authorize",
|
|
99
|
-
cause: `Could not find available port (tried ${DEFAULT_PORT}-${
|
|
100
|
-
DEFAULT_PORT + MAX_PORT_ATTEMPTS - 1
|
|
101
|
-
}). Close other applications using these ports.`
|
|
102
|
-
})
|
|
103
|
-
)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
67
|
/**
|
|
107
68
|
* Result from the OAuth callback server.
|
|
108
69
|
*/
|
|
@@ -128,15 +89,32 @@ export const startCallbackServer = (
|
|
|
128
89
|
): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag> =>
|
|
129
90
|
Effect.gen(function*() {
|
|
130
91
|
const factory = yield* HttpServerFactoryTag
|
|
131
|
-
const port = yield* findAvailablePort()
|
|
132
92
|
const deferred = yield* Deferred.make<string, OAuthError>()
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
93
|
+
const serverScope = yield* Scope.make()
|
|
94
|
+
const buildServerContext = (port: number): Effect.Effect<
|
|
95
|
+
{ readonly context: Context.Context<HttpServer.HttpServer>; readonly port: number },
|
|
96
|
+
OAuthError
|
|
97
|
+
> =>
|
|
98
|
+
Layer.buildWithScope(factory.createServerLayer(port), serverScope).pipe(
|
|
99
|
+
Effect.map((context) => ({ context, port })),
|
|
100
|
+
Effect.catchCause((cause) =>
|
|
101
|
+
port < MAX_PORT
|
|
102
|
+
? buildServerContext(port + 1)
|
|
103
|
+
: Effect.fail(new OAuthError({ step: "authorize", cause }))
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
const { context: serverContext } = yield* buildServerContext(DEFAULT_PORT)
|
|
107
|
+
const server: HttpServerInstance = Context.get(serverContext, HttpServer.HttpServer)
|
|
108
|
+
const port = yield* (server.address._tag === "TcpAddress"
|
|
109
|
+
? Effect.succeed(server.address.port)
|
|
110
|
+
: Effect.fail(new OAuthError({ step: "authorize", cause: "OAuth callback server must listen on a TCP port" })))
|
|
111
|
+
|
|
112
|
+
const router = yield* HttpRouter.make
|
|
113
|
+
yield* router.add(
|
|
114
|
+
"GET",
|
|
115
|
+
"/callback",
|
|
116
|
+
(req) =>
|
|
138
117
|
Effect.gen(function*() {
|
|
139
|
-
const req = yield* HttpServerRequest.HttpServerRequest
|
|
140
118
|
const url = new URL(req.url, `http://localhost:${port}`)
|
|
141
119
|
const code = url.searchParams.get("code")
|
|
142
120
|
const state = url.searchParams.get("state")
|
|
@@ -178,26 +156,21 @@ export const startCallbackServer = (
|
|
|
178
156
|
"<html><body><h1>Success!</h1><p>You can close this window and return to the terminal.</p></body></html>"
|
|
179
157
|
)
|
|
180
158
|
})
|
|
181
|
-
)
|
|
182
159
|
)
|
|
160
|
+
const app = router.asHttpEffect()
|
|
183
161
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
Layer.build,
|
|
189
|
-
Effect.tap(() => Deferred.succeed(readyDeferred, undefined)),
|
|
190
|
-
Effect.tapError((err) => Deferred.fail(readyDeferred, new OAuthError({ step: "authorize", cause: err }))),
|
|
191
|
-
Effect.flatMap(() => Effect.never),
|
|
192
|
-
Effect.scoped,
|
|
193
|
-
Effect.fork
|
|
162
|
+
const serverFiber = yield* HttpServer.serveEffect(app).pipe(
|
|
163
|
+
Effect.provide(serverContext),
|
|
164
|
+
Effect.provideService(Scope.Scope, serverScope),
|
|
165
|
+
Effect.forkIn(serverScope)
|
|
194
166
|
)
|
|
195
167
|
|
|
196
|
-
yield* Deferred.await(readyDeferred)
|
|
197
|
-
|
|
198
168
|
return {
|
|
199
169
|
codePromise: Deferred.await(deferred),
|
|
200
|
-
shutdown:
|
|
170
|
+
shutdown: Effect.gen(function*() {
|
|
171
|
+
yield* Fiber.interrupt(serverFiber)
|
|
172
|
+
yield* Scope.close(serverScope, Exit.void)
|
|
173
|
+
}),
|
|
201
174
|
port
|
|
202
175
|
}
|
|
203
176
|
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform browser launcher backed by Effect v4 child process services.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import type * as PlatformError from "effect/PlatformError"
|
|
8
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
9
|
+
|
|
10
|
+
const run = (
|
|
11
|
+
command: string,
|
|
12
|
+
args: ReadonlyArray<string>
|
|
13
|
+
): Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
14
|
+
Effect.gen(function*() {
|
|
15
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
16
|
+
yield* spawner.exitCode(
|
|
17
|
+
ChildProcess.make(command, args, {
|
|
18
|
+
stdin: "ignore",
|
|
19
|
+
stdout: "ignore",
|
|
20
|
+
stderr: "ignore"
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export const openBrowser = (
|
|
26
|
+
url: string
|
|
27
|
+
): Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
28
|
+
run("open", [url]).pipe(
|
|
29
|
+
Effect.catch(() => run("xdg-open", [url])),
|
|
30
|
+
Effect.catch(() => run("rundll32.exe", ["url.dll,FileProtocolHandler", url]))
|
|
31
|
+
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { JiraApiClient } from "@knpkv/jira-api-client"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Layer from "effect/Layer"
|
|
5
|
+
import { stripEmails } from "../src/commands/version.js"
|
|
6
|
+
import type { Version } from "../src/VersionService.js"
|
|
7
|
+
import {
|
|
8
|
+
extractContributorIds,
|
|
9
|
+
layer as VersionServiceLayer,
|
|
10
|
+
personFromObject,
|
|
11
|
+
renderCustomFieldValue,
|
|
12
|
+
toRelatedWork,
|
|
13
|
+
VersionService
|
|
14
|
+
} from "../src/VersionService.js"
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Pure helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe("renderCustomFieldValue", () => {
|
|
21
|
+
it("returns null for null/undefined/empty string", () => {
|
|
22
|
+
expect(renderCustomFieldValue(null)).toBeNull()
|
|
23
|
+
expect(renderCustomFieldValue(undefined)).toBeNull()
|
|
24
|
+
expect(renderCustomFieldValue("")).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("returns plain strings and coerces numbers/booleans", () => {
|
|
28
|
+
expect(renderCustomFieldValue("hello")).toBe("hello")
|
|
29
|
+
expect(renderCustomFieldValue(42)).toBe("42")
|
|
30
|
+
expect(renderCustomFieldValue(true)).toBe("true")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("renders cascading select as 'Parent > Child'", () => {
|
|
34
|
+
expect(renderCustomFieldValue({ value: "High", child: { value: "Confidential" } })).toBe(
|
|
35
|
+
"High > Confidential"
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("renders a single select / option as its value", () => {
|
|
40
|
+
expect(renderCustomFieldValue({ value: "High" })).toBe("High")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("renders a user object as its display name", () => {
|
|
44
|
+
expect(renderCustomFieldValue({ displayName: "Jane Doe" })).toBe("Jane Doe")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("falls back to name when no value/displayName", () => {
|
|
48
|
+
expect(renderCustomFieldValue({ name: "Backend" })).toBe("Backend")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("joins arrays with ', ' and drops empties", () => {
|
|
52
|
+
expect(renderCustomFieldValue([{ value: "A" }, { value: "B" }])).toBe("A, B")
|
|
53
|
+
expect(renderCustomFieldValue([])).toBeNull()
|
|
54
|
+
expect(renderCustomFieldValue([null, ""])).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("returns null for an unknown object shape", () => {
|
|
58
|
+
expect(renderCustomFieldValue({ foo: "bar" })).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("personFromObject", () => {
|
|
63
|
+
it("builds a Person from an object with accountId", () => {
|
|
64
|
+
expect(personFromObject({ accountId: "abc", displayName: "Jane", emailAddress: "jane@example.com" })).toEqual({
|
|
65
|
+
accountId: "abc",
|
|
66
|
+
displayName: "Jane",
|
|
67
|
+
emailAddress: "jane@example.com"
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("falls back displayName to accountId and email to null", () => {
|
|
72
|
+
expect(personFromObject({ accountId: "abc" })).toEqual({
|
|
73
|
+
accountId: "abc",
|
|
74
|
+
displayName: "abc",
|
|
75
|
+
emailAddress: null
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("uses fallbackId when the object has no accountId", () => {
|
|
80
|
+
expect(personFromObject({ displayName: "Jane" }, "fid")).toEqual({
|
|
81
|
+
accountId: "fid",
|
|
82
|
+
displayName: "Jane",
|
|
83
|
+
emailAddress: null
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("treats a bare string as an accountId", () => {
|
|
88
|
+
expect(personFromObject("abc")).toEqual({ accountId: "abc", displayName: "abc", emailAddress: null })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("returns null with neither accountId nor fallback", () => {
|
|
92
|
+
expect(personFromObject({ displayName: "Jane" })).toBeNull()
|
|
93
|
+
expect(personFromObject(null)).toBeNull()
|
|
94
|
+
expect(personFromObject("")).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("extractContributorIds", () => {
|
|
99
|
+
it("returns [] when contributors is missing or not an array", () => {
|
|
100
|
+
expect(extractContributorIds({})).toEqual([])
|
|
101
|
+
expect(extractContributorIds({ contributors: "nope" })).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("extracts ids from strings and objects, skipping empties", () => {
|
|
105
|
+
expect(
|
|
106
|
+
extractContributorIds({
|
|
107
|
+
contributors: ["a", { accountId: "b" }, { accountId: "" }, {}, ""]
|
|
108
|
+
})
|
|
109
|
+
).toEqual(["a", "b"])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("toRelatedWork", () => {
|
|
114
|
+
it("normalises a related-work entry", () => {
|
|
115
|
+
expect(
|
|
116
|
+
toRelatedWork({
|
|
117
|
+
relatedWorkId: "rw-1",
|
|
118
|
+
title: "Release notes",
|
|
119
|
+
category: "Communication",
|
|
120
|
+
url: "https://example.com"
|
|
121
|
+
})
|
|
122
|
+
).toEqual({
|
|
123
|
+
relatedWorkId: "rw-1",
|
|
124
|
+
title: "Release notes",
|
|
125
|
+
category: "Communication",
|
|
126
|
+
url: "https://example.com"
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("defaults category to empty string and missing fields to null", () => {
|
|
131
|
+
expect(toRelatedWork({})).toEqual({
|
|
132
|
+
relatedWorkId: null,
|
|
133
|
+
title: null,
|
|
134
|
+
category: "",
|
|
135
|
+
url: null
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("stripEmails", () => {
|
|
141
|
+
const versionWithEmails: Version = {
|
|
142
|
+
id: "10",
|
|
143
|
+
name: "1.0.0",
|
|
144
|
+
description: null,
|
|
145
|
+
released: true,
|
|
146
|
+
archived: false,
|
|
147
|
+
startDate: null,
|
|
148
|
+
releaseDate: "2026-01-01",
|
|
149
|
+
driver: { accountId: "d", displayName: "Dana", emailAddress: "dana@example.com" },
|
|
150
|
+
contributors: [
|
|
151
|
+
{ accountId: "c1", displayName: "Cara", emailAddress: "cara@example.com" },
|
|
152
|
+
{ accountId: "c2", displayName: "Cliff", emailAddress: "cliff@example.com" }
|
|
153
|
+
],
|
|
154
|
+
approvers: [
|
|
155
|
+
{
|
|
156
|
+
person: { accountId: "a1", displayName: "Amy", emailAddress: "amy@example.com" },
|
|
157
|
+
status: "APPROVED",
|
|
158
|
+
declineReason: null,
|
|
159
|
+
description: null
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
tickets: [
|
|
163
|
+
{
|
|
164
|
+
key: "PROJ-1",
|
|
165
|
+
summary: "Do thing",
|
|
166
|
+
assignee: { accountId: "t1", displayName: "Tom", emailAddress: "tom@example.com" },
|
|
167
|
+
labels: [],
|
|
168
|
+
customFields: {}
|
|
169
|
+
},
|
|
170
|
+
{ key: "PROJ-2", summary: null, assignee: null, labels: [], customFields: {} }
|
|
171
|
+
],
|
|
172
|
+
url: "https://x/version/10"
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
it("nulls every Person.emailAddress across driver, contributors, approvers and assignees", () => {
|
|
176
|
+
const stripped = stripEmails(versionWithEmails)
|
|
177
|
+
expect(stripped.driver?.emailAddress).toBeNull()
|
|
178
|
+
expect(stripped.contributors.map((c) => c.emailAddress)).toEqual([null, null])
|
|
179
|
+
expect(stripped.approvers.map((a) => a.person.emailAddress)).toEqual([null])
|
|
180
|
+
expect(stripped.tickets.map((t) => t.assignee?.emailAddress ?? null)).toEqual([null, null])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("preserves non-email fields and overall shape", () => {
|
|
184
|
+
const stripped = stripEmails(versionWithEmails)
|
|
185
|
+
expect(stripped.driver?.displayName).toBe("Dana")
|
|
186
|
+
expect(stripped.contributors.map((c) => c.accountId)).toEqual(["c1", "c2"])
|
|
187
|
+
expect(stripped.approvers[0].status).toBe("APPROVED")
|
|
188
|
+
expect(stripped.tickets.map((t) => t.key)).toEqual(["PROJ-1", "PROJ-2"])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("leaves emails intact when callers keep the original (opt-in path)", () => {
|
|
192
|
+
// The command emits the unmodified version when --emails is set; assert the
|
|
193
|
+
// original is untouched (stripEmails returns a copy, never mutating input).
|
|
194
|
+
expect(versionWithEmails.driver?.emailAddress).toBe("dana@example.com")
|
|
195
|
+
expect(versionWithEmails.tickets[0].assignee?.emailAddress).toBe("tom@example.com")
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("handles a null driver and null assignees without throwing", () => {
|
|
199
|
+
const stripped = stripEmails({ ...versionWithEmails, driver: null })
|
|
200
|
+
expect(stripped.driver).toBeNull()
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// released / unreleased + cap filtering (via a stubbed client)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
const versionsFixture = [
|
|
209
|
+
{ id: "1", name: "1.0.0", released: true, self: "https://x/version/1" },
|
|
210
|
+
{ id: "2", name: "2.0.0", released: false, self: "https://x/version/2" },
|
|
211
|
+
{ id: "3", name: "3.0.0", released: true, self: "https://x/version/3" },
|
|
212
|
+
{ id: "4", name: "4.0.0", released: false, self: "https://x/version/4" }
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build a JiraApiClient mock whose `v3.client.GET` routes by path: the version
|
|
217
|
+
* list endpoint returns the fixture; the JQL search endpoint returns no issues
|
|
218
|
+
* (so the contributor scan resolves to empty without further calls).
|
|
219
|
+
*/
|
|
220
|
+
const makeJiraLayer = () =>
|
|
221
|
+
Layer.succeed(JiraApiClient, {
|
|
222
|
+
v3: {
|
|
223
|
+
client: {
|
|
224
|
+
GET: (path: string) =>
|
|
225
|
+
path === "/rest/api/3/project/{projectIdOrKey}/version"
|
|
226
|
+
? Promise.resolve({
|
|
227
|
+
data: { values: versionsFixture, isLast: true },
|
|
228
|
+
response: { ok: true, status: 200 }
|
|
229
|
+
})
|
|
230
|
+
: Promise.resolve({
|
|
231
|
+
data: { issues: [], isLast: true },
|
|
232
|
+
response: { ok: true, status: 200 }
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} as never)
|
|
237
|
+
|
|
238
|
+
describe("listProjectVersions filtering", () => {
|
|
239
|
+
it.effect("returns all versions when neither flag is set", () =>
|
|
240
|
+
Effect.gen(function*() {
|
|
241
|
+
const service = yield* VersionService
|
|
242
|
+
const list = yield* service.listProjectVersions("PROJ")
|
|
243
|
+
expect(list.map((v) => v.id)).toEqual(["1", "2", "3", "4"])
|
|
244
|
+
}).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
|
|
245
|
+
|
|
246
|
+
it.effect("keeps only released versions when released=true", () =>
|
|
247
|
+
Effect.gen(function*() {
|
|
248
|
+
const service = yield* VersionService
|
|
249
|
+
const list = yield* service.listProjectVersions("PROJ", { released: true })
|
|
250
|
+
expect(list.map((v) => v.id)).toEqual(["1", "3"])
|
|
251
|
+
}).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
|
|
252
|
+
|
|
253
|
+
it.effect("keeps only unreleased versions when unreleased=true", () =>
|
|
254
|
+
Effect.gen(function*() {
|
|
255
|
+
const service = yield* VersionService
|
|
256
|
+
const list = yield* service.listProjectVersions("PROJ", { unreleased: true })
|
|
257
|
+
expect(list.map((v) => v.id)).toEqual(["2", "4"])
|
|
258
|
+
}).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
|
|
259
|
+
|
|
260
|
+
it.effect("caps the result count at maxResults", () =>
|
|
261
|
+
Effect.gen(function*() {
|
|
262
|
+
const service = yield* VersionService
|
|
263
|
+
const list = yield* service.listProjectVersions("PROJ", { maxResults: 2 })
|
|
264
|
+
expect(list.map((v) => v.id)).toEqual(["1", "2"])
|
|
265
|
+
}).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
|
|
266
|
+
})
|
package/vitest.config.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { defineConfig } from "vitest/config"
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
+
resolve: {
|
|
5
|
+
alias: {
|
|
6
|
+
"@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
|
|
7
|
+
}
|
|
8
|
+
},
|
|
4
9
|
test: {
|
|
5
10
|
include: ["test/**/*.test.ts"],
|
|
6
11
|
globals: true,
|