@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +63 -1
  3. package/dist/IssueService.d.ts +2 -2
  4. package/dist/IssueService.d.ts.map +1 -1
  5. package/dist/IssueService.js +3 -3
  6. package/dist/IssueService.js.map +1 -1
  7. package/dist/JiraAuth.d.ts +14 -14
  8. package/dist/JiraAuth.d.ts.map +1 -1
  9. package/dist/JiraAuth.js +18 -10
  10. package/dist/JiraAuth.js.map +1 -1
  11. package/dist/MarkdownWriter.d.ts +4 -4
  12. package/dist/MarkdownWriter.d.ts.map +1 -1
  13. package/dist/MarkdownWriter.js +6 -6
  14. package/dist/MarkdownWriter.js.map +1 -1
  15. package/dist/VersionService.d.ts +206 -0
  16. package/dist/VersionService.d.ts.map +1 -0
  17. package/dist/VersionService.js +426 -0
  18. package/dist/VersionService.js.map +1 -0
  19. package/dist/bin.js +28 -20
  20. package/dist/bin.js.map +1 -1
  21. package/dist/commands/auth.d.ts +2 -21
  22. package/dist/commands/auth.d.ts.map +1 -1
  23. package/dist/commands/auth.js +6 -6
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/get.d.ts +3 -8
  26. package/dist/commands/get.d.ts.map +1 -1
  27. package/dist/commands/get.js +2 -2
  28. package/dist/commands/get.js.map +1 -1
  29. package/dist/commands/index.d.ts +1 -0
  30. package/dist/commands/index.d.ts.map +1 -1
  31. package/dist/commands/index.js +1 -0
  32. package/dist/commands/index.js.map +1 -1
  33. package/dist/commands/layers.d.ts +6 -18
  34. package/dist/commands/layers.d.ts.map +1 -1
  35. package/dist/commands/layers.js +31 -23
  36. package/dist/commands/layers.js.map +1 -1
  37. package/dist/commands/search.d.ts +3 -8
  38. package/dist/commands/search.d.ts.map +1 -1
  39. package/dist/commands/search.js +4 -4
  40. package/dist/commands/search.js.map +1 -1
  41. package/dist/commands/version.d.ts +12 -0
  42. package/dist/commands/version.d.ts.map +1 -0
  43. package/dist/commands/version.js +179 -0
  44. package/dist/commands/version.js.map +1 -0
  45. package/dist/internal/oauthServer.d.ts +17 -5
  46. package/dist/internal/oauthServer.d.ts.map +1 -1
  47. package/dist/internal/oauthServer.js +23 -40
  48. package/dist/internal/oauthServer.js.map +1 -1
  49. package/dist/internal/openBrowser.d.ts +10 -0
  50. package/dist/internal/openBrowser.d.ts.map +1 -0
  51. package/dist/internal/openBrowser.js +17 -0
  52. package/dist/internal/openBrowser.js.map +1 -0
  53. package/package.json +10 -12
  54. package/skills/jira/SKILL.md +90 -0
  55. package/skills/jira/agents/openai.yaml +4 -0
  56. package/src/IssueService.ts +34 -28
  57. package/src/JiraAuth.ts +53 -39
  58. package/src/MarkdownWriter.ts +7 -11
  59. package/src/VersionService.ts +647 -0
  60. package/src/bin.ts +38 -26
  61. package/src/commands/auth.ts +6 -12
  62. package/src/commands/get.ts +2 -2
  63. package/src/commands/index.ts +1 -0
  64. package/src/commands/layers.ts +40 -25
  65. package/src/commands/search.ts +4 -4
  66. package/src/commands/version.ts +267 -0
  67. package/src/internal/oauthServer.ts +43 -70
  68. package/src/internal/openBrowser.ts +31 -0
  69. package/test/VersionService.test.ts +266 -0
  70. 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 MAX_PORT_ATTEMPTS = 10
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.Tag("@knpkv/jira-cli/HttpServerFactory")<
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 readyDeferred = yield* Deferred.make<void, OAuthError>()
134
-
135
- const app = HttpRouter.empty.pipe(
136
- HttpRouter.get(
137
- "/callback",
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 serverLayer = factory.createServerLayer(port)
185
-
186
- const serverFiber = yield* HttpServer.serve(app).pipe(
187
- Layer.provide(serverLayer),
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: Fiber.interrupt(serverFiber).pipe(Effect.asVoid),
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,