@knpkv/jira-cli 1.6.1 → 1.6.2

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @knpkv/jira-cli
2
2
 
3
+ ## 1.6.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#579](https://github.com/knpkv/npm/pull/579) [`61c930e`](https://github.com/knpkv/npm/commit/61c930e88f4a9d735522d6c7b052b72d13450a6f) Thanks [@konopkov](https://github.com/konopkov)! - Tests typecheck as part of `check`. `externalizeAdfMetadata` returns the sidecar type `hydrateAdfMetadata` takes.
8
+ - Updated dependencies [[`61c930e`](https://github.com/knpkv/npm/commit/61c930e88f4a9d735522d6c7b052b72d13450a6f)]:
9
+ - @knpkv/atlassian-common@1.9.1
10
+
3
11
  ## 1.6.1
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-cli",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "CLI tool to fetch Jira tickets and export to markdown",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -61,8 +61,8 @@
61
61
  "gray-matter": "^4.0.3",
62
62
  "js-yaml": "5.4.2",
63
63
  "@knpkv/agent-skills": "^0.3.4",
64
- "@knpkv/atlassian-common": "1.9.0",
65
- "@knpkv/jira-api-client": "2.1.0"
64
+ "@knpkv/jira-api-client": "2.1.0",
65
+ "@knpkv/atlassian-common": "1.9.1"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@effect/vitest": "4.0.0",
@@ -90,6 +90,6 @@
90
90
  "test:watch": "vitest",
91
91
  "lint": "eslint src",
92
92
  "lint:fix": "eslint src --fix",
93
- "check": "tsc --noEmit"
93
+ "check": "tsc --noEmit && tsc -p test/tsconfig.json --noEmit"
94
94
  }
95
95
  }
@@ -187,7 +187,7 @@ describe("stripEmails", () => {
187
187
  const stripped = stripEmails(versionWithEmails)
188
188
  expect(stripped.driver?.displayName).toBe("Dana")
189
189
  expect(stripped.contributors.map((c) => c.accountId)).toEqual(["c1", "c2"])
190
- expect(stripped.approvers[0].status).toBe("APPROVED")
190
+ expect(stripped.approvers[0]?.status).toBe("APPROVED")
191
191
  expect(stripped.tickets.map((t) => t.key)).toEqual(["PROJ-1", "PROJ-2"])
192
192
  })
193
193
 
@@ -195,7 +195,7 @@ describe("stripEmails", () => {
195
195
  // The command emits the unmodified version when --emails is set; assert the
196
196
  // original is untouched (stripEmails returns a copy, never mutating input).
197
197
  expect(versionWithEmails.driver?.emailAddress).toBe("dana@example.com")
198
- expect(versionWithEmails.tickets[0].assignee?.emailAddress).toBe("tom@example.com")
198
+ expect(versionWithEmails.tickets[0]?.assignee?.emailAddress).toBe("tom@example.com")
199
199
  })
200
200
 
201
201
  it("handles a null driver and null assignees without throwing", () => {
@@ -249,39 +249,40 @@ const makeJiraLayer = () => {
249
249
  )
250
250
  }
251
251
 
252
- describe("listProjectVersions filtering", () => {
252
+ // One stateless Jira stub for the block; the layer is built once per block, not per test.
253
+ it.layer(VersionServiceLayer.pipe(Layer.provideMerge(makeJiraLayer())))("listProjectVersions filtering", (it) => {
253
254
  it.effect("returns all versions when neither flag is set", () =>
254
255
  Effect.gen(function*() {
255
256
  const service = yield* VersionService
256
257
  const list = yield* service.listProjectVersions("PROJ")
257
258
  expect(list.map((v) => v.id)).toEqual(["1", "2", "3", "4"])
258
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
259
+ }))
259
260
 
260
261
  it.effect("keeps only released versions when released=true", () =>
261
262
  Effect.gen(function*() {
262
263
  const service = yield* VersionService
263
264
  const list = yield* service.listProjectVersions("PROJ", { released: true })
264
265
  expect(list.map((v) => v.id)).toEqual(["1", "3"])
265
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
266
+ }))
266
267
 
267
268
  it.effect("keeps only unreleased versions when unreleased=true", () =>
268
269
  Effect.gen(function*() {
269
270
  const service = yield* VersionService
270
271
  const list = yield* service.listProjectVersions("PROJ", { unreleased: true })
271
272
  expect(list.map((v) => v.id)).toEqual(["2", "4"])
272
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
273
+ }))
273
274
 
274
275
  it.effect("caps the result count at maxResults", () =>
275
276
  Effect.gen(function*() {
276
277
  const service = yield* VersionService
277
278
  const list = yield* service.listProjectVersions("PROJ", { maxResults: 2 })
278
279
  expect(list.map((v) => v.id)).toEqual(["1", "2"])
279
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
280
+ }))
280
281
 
281
282
  it.effect("preserves Jira Premium contributors decoded from the generated Version schema", () =>
282
283
  Effect.gen(function*() {
283
284
  const service = yield* VersionService
284
285
  const list = yield* service.listProjectVersions("PROJ", { maxResults: 1 })
285
286
  expect(list[0]?.contributors.map((person) => person.accountId)).toEqual(["account-1", "account-2"])
286
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(makeJiraLayer())))
287
+ }))
287
288
  })
@@ -25,7 +25,6 @@ const browserOpens = ChildProcessSpawner.make(() =>
25
25
  isRunning: Effect.succeed(false),
26
26
  kill: () => Effect.void,
27
27
  pid: ChildProcessSpawner.ProcessId(1),
28
- reref: Effect.void,
29
28
  stderr: Stream.empty,
30
29
  stdin: Sink.drain,
31
30
  stdout: Stream.empty,
@@ -1,3 +1,4 @@
1
+ import { NodeServices } from "@effect/platform-node"
1
2
  import { describe, expect, it } from "@effect/vitest"
2
3
  import { Command } from "effect/cli"
3
4
  import * as Effect from "effect/Effect"
@@ -95,7 +96,7 @@ const sampleRelatedWork: RelatedWork = {
95
96
  const CaptureTerminalLayer = (stdout: Ref.Ref<string>) =>
96
97
  Layer.succeed(
97
98
  Terminal.Terminal,
98
- Terminal.Terminal.of({
99
+ Terminal.make({
99
100
  columns: Effect.succeed(100),
100
101
  rows: Effect.succeed(24),
101
102
  readInput: Effect.die("readInput should not be called"),
@@ -174,6 +175,15 @@ const CommandServicesLayer = (calls: Ref.Ref<CommandCalls>) =>
174
175
  )
175
176
  )
176
177
 
178
+ /** Run `effect` with `layer`'s services, built in the effect's own scope. */
179
+ const runWithLayer = <A, E, R>(effect: Effect.Effect<A, E, R>, layer: Layer.Layer<R>): Effect.Effect<A, E> =>
180
+ Effect.scoped(
181
+ Effect.gen(function*() {
182
+ const context = yield* Layer.build(layer)
183
+ return yield* effect.pipe(Effect.provideContext(context))
184
+ })
185
+ )
186
+
177
187
  const runJiraCommand = (
178
188
  args: ReadonlyArray<string>,
179
189
  calls: Ref.Ref<CommandCalls>,
@@ -184,10 +194,11 @@ const runJiraCommand = (
184
194
  Command.withSubcommands([issueCommand, versionCommand])
185
195
  )
186
196
  const cli = Command.runWith(command, { version: "0.0.0-test" })
187
- return cli(args).pipe(
188
- Effect.provide(Layer.merge(CommandServicesLayer(calls), CaptureTerminalLayer(stdout))),
189
- Effect.exit
190
- )
197
+ return runWithLayer(
198
+ cli(args),
199
+ // The platform services the CLI runner requires; these commands never touch them.
200
+ Layer.mergeAll(NodeServices.layer, CommandServicesLayer(calls), CaptureTerminalLayer(stdout))
201
+ ).pipe(Effect.exit)
191
202
  }
192
203
 
193
204
  describe("Jira command tree", () => {
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
- import * as Predicate from "effect/Predicate"
3
- import type * as Schema from "effect/Schema"
2
+ import * as Schema from "effect/Schema"
4
3
  import * as yaml from "js-yaml"
5
4
  import { extractFrontMatter, serializeIssue } from "../src/internal/frontmatter.js"
6
5
  import type { Issue } from "../src/IssueService.js"
@@ -10,17 +9,17 @@ import type { Issue } from "../src/IssueService.js"
10
9
  * directly. We avoid gray-matter's default parser here because it calls the
11
10
  * removed `safeLoad` — the same incompatibility this module works around.
12
11
  */
12
+ const FrontMatter = Schema.Record(Schema.String, Schema.Json)
13
+
13
14
  const parseFrontMatter = (output: string): Record<string, Schema.Json> => {
14
- const match = output.match(/^---\n([\s\S]*?)\n---/)
15
- const parsed = match ? yaml.load(match[1]) : {}
16
- return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed)
17
- ? Object.fromEntries(Object.entries(parsed))
18
- : {}
15
+ const match = /^---\n([\s\S]*?)\n---/.exec(output)
16
+ return Schema.decodeUnknownSync(FrontMatter)(match?.[1] === undefined ? {} : yaml.load(match[1]))
19
17
  }
20
18
 
21
19
  const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
22
20
  key: "OOB-81",
23
21
  id: "10081",
22
+ url: "https://example.atlassian.net/browse/OOB-81",
24
23
  summary: "Test ticket",
25
24
  status: "Done",
26
25
  type: "Story",
@@ -70,6 +69,7 @@ describe("frontmatter", () => {
70
69
  filename: "diagram.svg",
71
70
  url: "https://example.atlassian.net/rest/api/3/attachment/content/10001",
72
71
  mediaType: "application/octet-stream",
72
+ mimeType: "image/svg+xml",
73
73
  size: 42
74
74
  }]
75
75
  }))
@@ -14,12 +14,12 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
14
14
  import * as NodeServices from "@effect/platform-node/NodeServices"
15
15
  import { describe, expect, it } from "@effect/vitest"
16
16
  import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
17
- import { Config, Effect, Layer, Option } from "effect"
17
+ import { Config, Data, Effect, Layer, Option } from "effect"
18
18
  import * as FileSystem from "effect/FileSystem"
19
19
  import * as Path from "effect/Path"
20
20
  import * as Predicate from "effect/Predicate"
21
21
  import * as Redacted from "effect/Redacted"
22
- import type * as Schema from "effect/Schema"
22
+ import * as Schema from "effect/Schema"
23
23
  import * as yaml from "js-yaml"
24
24
  import { AttachmentService, layer as AttachmentServiceLayer } from "../src/AttachmentService.js"
25
25
  import { insertJiraAttachmentReference } from "../src/internal/attachmentInsertion.js"
@@ -45,14 +45,21 @@ const nonEmptyOrDefault = (value: Option.Option<string>, fallback: string): stri
45
45
  onSome: (some) => some.trim().length > 0 ? some : fallback
46
46
  })
47
47
 
48
- const requireNonEmpty = (name: string, value: string): Effect.Effect<string, Error> =>
49
- value.trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
48
+ /** An integration setting that is set but empty. */
49
+ class EmptySetting extends Data.TaggedError("EmptySetting")<{ readonly name: string }> {
50
+ override get message() {
51
+ return `${this.name} must be set`
52
+ }
53
+ }
54
+
55
+ const requireNonEmpty = (name: string, value: string): Effect.Effect<string, EmptySetting> =>
56
+ value.trim().length > 0 ? Effect.succeed(value) : Effect.fail(new EmptySetting({ name }))
50
57
 
51
58
  const requireNonEmptyRedacted = (
52
59
  name: string,
53
60
  value: Redacted.Redacted<string>
54
- ): Effect.Effect<Redacted.Redacted<string>, Error> =>
55
- Redacted.value(value).trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
61
+ ): Effect.Effect<Redacted.Redacted<string>, EmptySetting> =>
62
+ Redacted.value(value).trim().length > 0 ? Effect.succeed(value) : Effect.fail(new EmptySetting({ name }))
56
63
 
57
64
  const SHOULD_RUN_INTEGRATION = Effect.runSync(
58
65
  Config.option(Config.String("JIRA_INTEGRATION")).pipe(
@@ -164,10 +171,9 @@ const expectCanonicalIssueIncludesAttachmentMedia = (
164
171
 
165
172
  const parseFrontMatter = (markdown: string): Record<string, Schema.Json> => {
166
173
  const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
167
- const parsed = match?.[1] ? yaml.load(match[1]) : {}
168
- return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed)
169
- ? Object.fromEntries(Object.entries(parsed))
170
- : {}
174
+ return Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(
175
+ match?.[1] === undefined ? {} : yaml.load(match[1])
176
+ )
171
177
  }
172
178
 
173
179
  const getIssueAsMarkdown = (config: IntegrationConfig, testDir: string) =>
@@ -61,6 +61,14 @@ const makeRecordingLayer = (respond: (request: HttpClientRequest.HttpClientReque
61
61
 
62
62
  const json = <UnparsedInput>(body: UnparsedInput, status = 200) =>
63
63
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } })
64
+ /** Run `effect` with `layer`'s services, built in the effect's own scope. */
65
+ const runWithLayer = <A, E, R>(effect: Effect.Effect<A, E, R>, layer: Layer.Layer<R>): Effect.Effect<A, E> =>
66
+ Effect.scoped(
67
+ Effect.gen(function*() {
68
+ const context = yield* Layer.build(layer)
69
+ return yield* effect.pipe(Effect.provideContext(context))
70
+ })
71
+ )
64
72
 
65
73
  describe("IssueService.edit", () => {
66
74
  /**
@@ -102,9 +110,13 @@ describe("IssueService.edit", () => {
102
110
  expect(issue.fixVersions).toEqual(["OOB 100"])
103
111
  expect(issue.labels).toEqual(["domain:oob"])
104
112
  }).pipe(
105
- Effect.provide(IssueServiceLayer),
106
- Effect.provide(layer),
107
- Effect.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
113
+ (effect) =>
114
+ runWithLayer(
115
+ effect,
116
+ IssueServiceLayer.pipe(
117
+ Layer.provideMerge(Layer.merge(layer, Layer.succeed(SiteUrl, "https://example.atlassian.net")))
118
+ )
119
+ )
108
120
  )
109
121
  })
110
122
 
@@ -128,9 +140,13 @@ describe("IssueService.edit", () => {
128
140
  // The write really did go out — that is why the wording matters.
129
141
  expect(calls.filter((call) => call.method === "PUT")).toHaveLength(1)
130
142
  }).pipe(
131
- Effect.provide(IssueServiceLayer),
132
- Effect.provide(layer),
133
- Effect.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
143
+ (effect) =>
144
+ runWithLayer(
145
+ effect,
146
+ IssueServiceLayer.pipe(
147
+ Layer.provideMerge(Layer.merge(layer, Layer.succeed(SiteUrl, "https://example.atlassian.net")))
148
+ )
149
+ )
134
150
  )
135
151
  })
136
152
 
@@ -147,9 +163,13 @@ describe("IssueService.edit", () => {
147
163
  expect(result.message).toContain("--fix-version cannot be combined")
148
164
  expect(calls).toHaveLength(0)
149
165
  }).pipe(
150
- Effect.provide(IssueServiceLayer),
151
- Effect.provide(layer),
152
- Effect.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
166
+ (effect) =>
167
+ runWithLayer(
168
+ effect,
169
+ IssueServiceLayer.pipe(
170
+ Layer.provideMerge(Layer.merge(layer, Layer.succeed(SiteUrl, "https://example.atlassian.net")))
171
+ )
172
+ )
153
173
  )
154
174
  })
155
175
  })
@@ -180,7 +200,7 @@ describe("VersionService.createVersion", () => {
180
200
  expect(post?.body).toEqual({ name: "OOB 100", projectId: 10001, description: "Q3 release" })
181
201
  expect(version.id).toBe("10042")
182
202
  expect(version.released).toBe(false)
183
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(layer))
203
+ }).pipe((effect) => runWithLayer(effect, VersionServiceLayer.pipe(Layer.provideMerge(layer))))
184
204
  })
185
205
 
186
206
  /** Omitted optional fields must be absent, not sent as `undefined`/null. */
@@ -199,7 +219,7 @@ describe("VersionService.createVersion", () => {
199
219
  name: "OOB 100",
200
220
  projectId: 10001
201
221
  })
202
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(layer))
222
+ }).pipe((effect) => runWithLayer(effect, VersionServiceLayer.pipe(Layer.provideMerge(layer))))
203
223
  })
204
224
 
205
225
  /**
@@ -215,6 +235,6 @@ describe("VersionService.createVersion", () => {
215
235
 
216
236
  expect(error.message).toContain("no usable numeric id")
217
237
  expect(calls.filter((call) => call.method === "POST")).toHaveLength(0)
218
- }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(layer))
238
+ }).pipe((effect) => runWithLayer(effect, VersionServiceLayer.pipe(Layer.provideMerge(layer))))
219
239
  })
220
240
  })
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "composite": false,
6
+ "declaration": false,
7
+ "declarationMap": false,
8
+ "rootDir": "..",
9
+ "outDir": null,
10
+ "tsBuildInfoFile": null
11
+ },
12
+ "include": ["../src", "."],
13
+ "exclude": []
14
+ }