@knpkv/jira-cli 1.6.0 → 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 +16 -0
- package/package.json +5 -5
- package/test/VersionService.test.ts +9 -8
- package/test/authCommand.test.ts +0 -1
- package/test/commandTree.test.ts +16 -5
- package/test/frontmatter.test.ts +7 -7
- package/test/integration.test.ts +16 -10
- package/test/remoteWrites.test.ts +32 -12
- package/test/tsconfig.json +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
11
|
+
## 1.6.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [[`0573403`](https://github.com/knpkv/npm/commit/057340343fcc727fd0c56329d840a668170376ad), [`d76d2d8`](https://github.com/knpkv/npm/commit/d76d2d8af1e266a88414a36e0d41ad27fa143b7d)]:
|
|
16
|
+
- @knpkv/jira-api-client@2.1.0
|
|
17
|
+
- @knpkv/agent-skills@0.3.4
|
|
18
|
+
|
|
3
19
|
## 1.6.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knpkv/jira-cli",
|
|
3
|
-
"version": "1.6.
|
|
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",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"@effect/platform-node": "4.0.0",
|
|
61
61
|
"gray-matter": "^4.0.3",
|
|
62
62
|
"js-yaml": "5.4.2",
|
|
63
|
-
"@knpkv/agent-skills": "^0.3.
|
|
64
|
-
"@knpkv/
|
|
65
|
-
"@knpkv/
|
|
63
|
+
"@knpkv/agent-skills": "^0.3.4",
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
})
|
|
287
|
+
}))
|
|
287
288
|
})
|
package/test/authCommand.test.ts
CHANGED
package/test/commandTree.test.ts
CHANGED
|
@@ -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.
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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", () => {
|
package/test/frontmatter.test.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import * as
|
|
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 =
|
|
15
|
-
|
|
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
|
}))
|
package/test/integration.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
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>,
|
|
55
|
-
Redacted.value(value).trim().length > 0 ? Effect.succeed(value) : Effect.fail(new
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|