@knpkv/jira-cli 0.1.1 → 1.0.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 +67 -0
- package/README.md +66 -4
- 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/JiraCliError.d.ts +30 -0
- package/dist/JiraCliError.d.ts.map +1 -1
- package/dist/JiraCliError.js +14 -0
- package/dist/JiraCliError.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/SyncWorkspace.d.ts +34 -0
- package/dist/SyncWorkspace.d.ts.map +1 -0
- package/dist/SyncWorkspace.js +105 -0
- package/dist/SyncWorkspace.js.map +1 -0
- 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 +29 -22
- 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 +4 -4
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -2
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/issue.d.ts +8 -0
- package/dist/commands/issue.d.ts.map +1 -0
- package/dist/commands/issue.js +10 -0
- package/dist/commands/issue.js.map +1 -0
- package/dist/commands/layers.d.ts +6 -18
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +35 -24
- 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 +8 -8
- 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/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +14 -1
- package/dist/internal/frontmatter.js.map +1 -1
- 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/dist/internal/sync/baseline.d.ts +11 -0
- package/dist/internal/sync/baseline.d.ts.map +1 -0
- package/dist/internal/sync/baseline.js +18 -0
- package/dist/internal/sync/baseline.js.map +1 -0
- package/dist/internal/sync/changes.d.ts +15 -0
- package/dist/internal/sync/changes.d.ts.map +1 -0
- package/dist/internal/sync/changes.js +72 -0
- package/dist/internal/sync/changes.js.map +1 -0
- package/dist/internal/sync/config.d.ts +12 -0
- package/dist/internal/sync/config.d.ts.map +1 -0
- package/dist/internal/sync/config.js +53 -0
- package/dist/internal/sync/config.js.map +1 -0
- package/dist/internal/sync/document.d.ts +9 -0
- package/dist/internal/sync/document.d.ts.map +1 -0
- package/dist/internal/sync/document.js +173 -0
- package/dist/internal/sync/document.js.map +1 -0
- package/dist/internal/sync/fieldValues.d.ts +30 -0
- package/dist/internal/sync/fieldValues.d.ts.map +1 -0
- package/dist/internal/sync/fieldValues.js +91 -0
- package/dist/internal/sync/fieldValues.js.map +1 -0
- package/dist/internal/sync/manifest.d.ts +12 -0
- package/dist/internal/sync/manifest.d.ts.map +1 -0
- package/dist/internal/sync/manifest.js +23 -0
- package/dist/internal/sync/manifest.js.map +1 -0
- package/dist/internal/sync/paths.d.ts +26 -0
- package/dist/internal/sync/paths.d.ts.map +1 -0
- package/dist/internal/sync/paths.js +22 -0
- package/dist/internal/sync/paths.js.map +1 -0
- package/dist/internal/sync/schemas.d.ts +128 -0
- package/dist/internal/sync/schemas.d.ts.map +1 -0
- package/dist/internal/sync/schemas.js +82 -0
- package/dist/internal/sync/schemas.js.map +1 -0
- package/dist/internal/sync/types.d.ts +144 -0
- package/dist/internal/sync/types.d.ts.map +1 -0
- package/dist/internal/sync/types.js +17 -0
- package/dist/internal/sync/types.js.map +1 -0
- package/package.json +13 -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/JiraCliError.ts +24 -0
- package/src/MarkdownWriter.ts +7 -11
- package/src/SyncWorkspace.ts +185 -0
- package/src/VersionService.ts +647 -0
- package/src/bin.ts +39 -29
- package/src/commands/auth.ts +6 -12
- package/src/commands/get.ts +4 -4
- package/src/commands/index.ts +2 -2
- package/src/commands/issue.ts +13 -0
- package/src/commands/layers.ts +44 -26
- package/src/commands/search.ts +8 -8
- package/src/commands/version.ts +267 -0
- package/src/internal/frontmatter.ts +15 -1
- package/src/internal/oauthServer.ts +43 -70
- package/src/internal/openBrowser.ts +31 -0
- package/src/internal/sync/baseline.ts +27 -0
- package/src/internal/sync/changes.ts +118 -0
- package/src/internal/sync/config.ts +76 -0
- package/src/internal/sync/document.ts +201 -0
- package/src/internal/sync/fieldValues.ts +145 -0
- package/src/internal/sync/manifest.ts +32 -0
- package/src/internal/sync/paths.ts +48 -0
- package/src/internal/sync/schemas.ts +103 -0
- package/src/internal/sync/types.ts +192 -0
- package/test/SyncWorkspace.test.ts +76 -0
- package/test/VersionService.test.ts +266 -0
- package/test/commandTree.test.ts +266 -0
- package/test/frontmatter.test.ts +69 -0
- package/test/integration.test.ts +187 -0
- package/test/syncChanges.test.ts +106 -0
- package/test/syncConfig.test.ts +138 -0
- package/test/syncDocument.test.ts +69 -0
- package/test/syncFieldValues.test.ts +101 -0
- package/vitest.config.integration.ts +17 -0
- package/vitest.config.ts +6 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Layer from "effect/Layer"
|
|
4
|
+
import * as Ref from "effect/Ref"
|
|
5
|
+
import * as Terminal from "effect/Terminal"
|
|
6
|
+
import { Command } from "effect/unstable/cli"
|
|
7
|
+
import { issueCommand } from "../src/commands/issue.js"
|
|
8
|
+
import { versionCommand } from "../src/commands/version.js"
|
|
9
|
+
import { type Issue, IssueService } from "../src/IssueService.js"
|
|
10
|
+
import { MarkdownWriter } from "../src/MarkdownWriter.js"
|
|
11
|
+
import { type RelatedWork, type Version, VersionService } from "../src/VersionService.js"
|
|
12
|
+
|
|
13
|
+
interface CommandCalls {
|
|
14
|
+
readonly issueGet: number
|
|
15
|
+
readonly issueSearch: number
|
|
16
|
+
readonly versionList: number
|
|
17
|
+
readonly versionGet: number
|
|
18
|
+
readonly versionUpdate: number
|
|
19
|
+
readonly relatedWorkList: number
|
|
20
|
+
readonly relatedWorkAdd: number
|
|
21
|
+
readonly writeMulti: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const emptyCalls: CommandCalls = {
|
|
25
|
+
issueGet: 0,
|
|
26
|
+
issueSearch: 0,
|
|
27
|
+
relatedWorkAdd: 0,
|
|
28
|
+
relatedWorkList: 0,
|
|
29
|
+
versionGet: 0,
|
|
30
|
+
versionList: 0,
|
|
31
|
+
versionUpdate: 0,
|
|
32
|
+
writeMulti: 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sampleIssue: Issue = {
|
|
36
|
+
assignee: null,
|
|
37
|
+
attachments: [],
|
|
38
|
+
comments: [],
|
|
39
|
+
components: [],
|
|
40
|
+
created: new Date("2026-01-01T00:00:00.000Z"),
|
|
41
|
+
description: "",
|
|
42
|
+
fixVersions: [],
|
|
43
|
+
id: "10000",
|
|
44
|
+
key: "PROJ-123",
|
|
45
|
+
labels: [],
|
|
46
|
+
priority: null,
|
|
47
|
+
reporter: null,
|
|
48
|
+
status: "Done",
|
|
49
|
+
summary: "Sample issue",
|
|
50
|
+
type: "Task",
|
|
51
|
+
updated: new Date("2026-01-01T00:00:00.000Z"),
|
|
52
|
+
url: "https://example.atlassian.net/browse/PROJ-123"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sampleVersion: Version = {
|
|
56
|
+
approvers: [],
|
|
57
|
+
archived: false,
|
|
58
|
+
contributors: [],
|
|
59
|
+
description: null,
|
|
60
|
+
driver: null,
|
|
61
|
+
id: "10042",
|
|
62
|
+
name: "1.0.0",
|
|
63
|
+
releaseDate: null,
|
|
64
|
+
released: false,
|
|
65
|
+
startDate: null,
|
|
66
|
+
tickets: [],
|
|
67
|
+
url: "https://example.atlassian.net/projects/PROJ/versions/10042"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sampleRelatedWork: RelatedWork = {
|
|
71
|
+
category: "Communication",
|
|
72
|
+
relatedWorkId: "20000",
|
|
73
|
+
title: "Release notes",
|
|
74
|
+
url: "https://example.atlassian.net/wiki/spaces/PROJ/pages/123"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const CaptureTerminalLayer = (stdout: Ref.Ref<string>) =>
|
|
78
|
+
Layer.succeed(
|
|
79
|
+
Terminal.Terminal,
|
|
80
|
+
Terminal.Terminal.of({
|
|
81
|
+
columns: Effect.succeed(100),
|
|
82
|
+
rows: Effect.succeed(24),
|
|
83
|
+
readInput: Effect.die("readInput should not be called"),
|
|
84
|
+
readLine: Effect.die("readLine should not be called"),
|
|
85
|
+
display: (text) => Ref.update(stdout, (output) => output + text)
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const CommandServicesLayer = (calls: Ref.Ref<CommandCalls>) =>
|
|
90
|
+
Layer.mergeAll(
|
|
91
|
+
Layer.succeed(
|
|
92
|
+
IssueService,
|
|
93
|
+
IssueService.of({
|
|
94
|
+
getByKey: () =>
|
|
95
|
+
Ref.update(calls, (state) => ({ ...state, issueGet: state.issueGet + 1 })).pipe(
|
|
96
|
+
Effect.as(sampleIssue)
|
|
97
|
+
),
|
|
98
|
+
search: () => Effect.die("IssueService.search should not be called"),
|
|
99
|
+
searchAll: () =>
|
|
100
|
+
Ref.update(calls, (state) => ({ ...state, issueSearch: state.issueSearch + 1 })).pipe(
|
|
101
|
+
Effect.as([sampleIssue])
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
),
|
|
105
|
+
Layer.succeed(
|
|
106
|
+
MarkdownWriter,
|
|
107
|
+
MarkdownWriter.of({
|
|
108
|
+
writeMulti: () => Ref.update(calls, (state) => ({ ...state, writeMulti: state.writeMulti + 1 })),
|
|
109
|
+
writeSingle: () => Effect.die("MarkdownWriter.writeSingle should not be called")
|
|
110
|
+
})
|
|
111
|
+
),
|
|
112
|
+
Layer.succeed(
|
|
113
|
+
VersionService,
|
|
114
|
+
VersionService.of({
|
|
115
|
+
addRelatedWork: () =>
|
|
116
|
+
Ref.update(calls, (state) => ({ ...state, relatedWorkAdd: state.relatedWorkAdd + 1 })).pipe(
|
|
117
|
+
Effect.as(sampleRelatedWork)
|
|
118
|
+
),
|
|
119
|
+
getVersion: () =>
|
|
120
|
+
Ref.update(calls, (state) => ({ ...state, versionGet: state.versionGet + 1 })).pipe(
|
|
121
|
+
Effect.as(sampleVersion)
|
|
122
|
+
),
|
|
123
|
+
listProjectVersions: () =>
|
|
124
|
+
Ref.update(calls, (state) => ({ ...state, versionList: state.versionList + 1 })).pipe(
|
|
125
|
+
Effect.as([sampleVersion])
|
|
126
|
+
),
|
|
127
|
+
listRelatedWork: () =>
|
|
128
|
+
Ref.update(calls, (state) => ({ ...state, relatedWorkList: state.relatedWorkList + 1 })).pipe(
|
|
129
|
+
Effect.as([sampleRelatedWork])
|
|
130
|
+
),
|
|
131
|
+
updateVersion: () =>
|
|
132
|
+
Ref.update(calls, (state) => ({ ...state, versionUpdate: state.versionUpdate + 1 })).pipe(
|
|
133
|
+
Effect.as(sampleVersion)
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const runJiraCommand = (
|
|
140
|
+
args: ReadonlyArray<string>,
|
|
141
|
+
calls: Ref.Ref<CommandCalls>,
|
|
142
|
+
stdout: Ref.Ref<string>
|
|
143
|
+
) => {
|
|
144
|
+
const command = Command.make("jira").pipe(
|
|
145
|
+
Command.withDescription("Jira CLI commands"),
|
|
146
|
+
Command.withSubcommands([issueCommand, versionCommand])
|
|
147
|
+
)
|
|
148
|
+
const cli = Command.runWith(command, { version: "0.0.0-test" })
|
|
149
|
+
return cli(args).pipe(
|
|
150
|
+
Effect.provide(Layer.merge(CommandServicesLayer(calls), CaptureTerminalLayer(stdout))),
|
|
151
|
+
Effect.exit
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("Jira command tree", () => {
|
|
156
|
+
it.effect("exposes canonical issue commands and removes top-level issue aliases", () =>
|
|
157
|
+
Effect.gen(function*() {
|
|
158
|
+
const calls = yield* Ref.make(emptyCalls)
|
|
159
|
+
const getOutput = yield* Ref.make("")
|
|
160
|
+
const searchOutput = yield* Ref.make("")
|
|
161
|
+
const legacyGetOutput = yield* Ref.make("")
|
|
162
|
+
const legacySearchOutput = yield* Ref.make("")
|
|
163
|
+
|
|
164
|
+
const getExit = yield* runJiraCommand(["issue", "get", "PROJ-123"], calls, getOutput)
|
|
165
|
+
const searchExit = yield* runJiraCommand(["issue", "search", "project = PROJ"], calls, searchOutput)
|
|
166
|
+
const legacyGetExit = yield* runJiraCommand(["get", "PROJ-123"], calls, legacyGetOutput)
|
|
167
|
+
const legacySearchExit = yield* runJiraCommand(["search", "project = PROJ"], calls, legacySearchOutput)
|
|
168
|
+
|
|
169
|
+
expect(getExit._tag).toBe("Success")
|
|
170
|
+
expect(searchExit._tag).toBe("Success")
|
|
171
|
+
expect(legacyGetExit._tag).toBe("Failure")
|
|
172
|
+
expect(legacySearchExit._tag).toBe("Failure")
|
|
173
|
+
|
|
174
|
+
expect(yield* Ref.get(calls)).toMatchObject({
|
|
175
|
+
issueGet: 1,
|
|
176
|
+
issueSearch: 1,
|
|
177
|
+
writeMulti: 2
|
|
178
|
+
})
|
|
179
|
+
}))
|
|
180
|
+
|
|
181
|
+
it.effect("exposes canonical version commands and rejects legacy names", () =>
|
|
182
|
+
Effect.gen(function*() {
|
|
183
|
+
const calls = yield* Ref.make(emptyCalls)
|
|
184
|
+
const output = yield* Ref.make("")
|
|
185
|
+
const legacyViewOutput = yield* Ref.make("")
|
|
186
|
+
const legacySetOutput = yield* Ref.make("")
|
|
187
|
+
const legacyRelatedWorkOutput = yield* Ref.make("")
|
|
188
|
+
|
|
189
|
+
const listExit = yield* runJiraCommand(["version", "list", "--project", "PROJ"], calls, output)
|
|
190
|
+
const getExit = yield* runJiraCommand(["version", "get", "10042"], calls, output)
|
|
191
|
+
const updateExit = yield* runJiraCommand(
|
|
192
|
+
[
|
|
193
|
+
"version",
|
|
194
|
+
"update",
|
|
195
|
+
"10042",
|
|
196
|
+
"--description",
|
|
197
|
+
"Q3 release"
|
|
198
|
+
],
|
|
199
|
+
calls,
|
|
200
|
+
output
|
|
201
|
+
)
|
|
202
|
+
const relatedWorkListExit = yield* runJiraCommand(
|
|
203
|
+
[
|
|
204
|
+
"version",
|
|
205
|
+
"related-work",
|
|
206
|
+
"list",
|
|
207
|
+
"10042"
|
|
208
|
+
],
|
|
209
|
+
calls,
|
|
210
|
+
output
|
|
211
|
+
)
|
|
212
|
+
const relatedWorkAddExit = yield* runJiraCommand(
|
|
213
|
+
[
|
|
214
|
+
"version",
|
|
215
|
+
"related-work",
|
|
216
|
+
"add",
|
|
217
|
+
"10042",
|
|
218
|
+
"--title",
|
|
219
|
+
"Release notes",
|
|
220
|
+
"--url",
|
|
221
|
+
"https://example.atlassian.net/wiki/spaces/PROJ/pages/123"
|
|
222
|
+
],
|
|
223
|
+
calls,
|
|
224
|
+
output
|
|
225
|
+
)
|
|
226
|
+
const legacyViewExit = yield* runJiraCommand(["version", "view", "10042"], calls, legacyViewOutput)
|
|
227
|
+
const legacySetExit = yield* runJiraCommand(
|
|
228
|
+
[
|
|
229
|
+
"version",
|
|
230
|
+
"set",
|
|
231
|
+
"10042",
|
|
232
|
+
"--description",
|
|
233
|
+
"Q3 release"
|
|
234
|
+
],
|
|
235
|
+
calls,
|
|
236
|
+
legacySetOutput
|
|
237
|
+
)
|
|
238
|
+
const legacyRelatedWorkExit = yield* runJiraCommand(
|
|
239
|
+
[
|
|
240
|
+
"version",
|
|
241
|
+
"relatedwork",
|
|
242
|
+
"list",
|
|
243
|
+
"10042"
|
|
244
|
+
],
|
|
245
|
+
calls,
|
|
246
|
+
legacyRelatedWorkOutput
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
expect(listExit._tag).toBe("Success")
|
|
250
|
+
expect(getExit._tag).toBe("Success")
|
|
251
|
+
expect(updateExit._tag).toBe("Success")
|
|
252
|
+
expect(relatedWorkListExit._tag).toBe("Success")
|
|
253
|
+
expect(relatedWorkAddExit._tag).toBe("Success")
|
|
254
|
+
expect(legacyViewExit._tag).toBe("Failure")
|
|
255
|
+
expect(legacySetExit._tag).toBe("Failure")
|
|
256
|
+
expect(legacyRelatedWorkExit._tag).toBe("Failure")
|
|
257
|
+
|
|
258
|
+
expect(yield* Ref.get(calls)).toMatchObject({
|
|
259
|
+
relatedWorkAdd: 1,
|
|
260
|
+
relatedWorkList: 1,
|
|
261
|
+
versionGet: 1,
|
|
262
|
+
versionList: 1,
|
|
263
|
+
versionUpdate: 1
|
|
264
|
+
})
|
|
265
|
+
}))
|
|
266
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import * as yaml from "js-yaml"
|
|
3
|
+
import { extractFrontMatter, serializeIssue } from "../src/internal/frontmatter.js"
|
|
4
|
+
import type { Issue } from "../src/IssueService.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse the YAML front-matter block of a serialized issue using js-yaml 4
|
|
8
|
+
* directly. We avoid gray-matter's default parser here because it calls the
|
|
9
|
+
* removed `safeLoad` — the same incompatibility this module works around.
|
|
10
|
+
*/
|
|
11
|
+
const parseFrontMatter = (output: string): Record<string, unknown> => {
|
|
12
|
+
const match = output.match(/^---\n([\s\S]*?)\n---/)
|
|
13
|
+
return match ? (yaml.load(match[1]) as Record<string, unknown>) : {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
|
|
17
|
+
key: "OOB-81",
|
|
18
|
+
id: "10081",
|
|
19
|
+
summary: "Test ticket",
|
|
20
|
+
status: "Done",
|
|
21
|
+
type: "Story",
|
|
22
|
+
priority: "High",
|
|
23
|
+
assignee: "Alice",
|
|
24
|
+
reporter: "Bob",
|
|
25
|
+
created: new Date("2026-01-01T00:00:00.000Z"),
|
|
26
|
+
updated: new Date("2026-02-01T00:00:00.000Z"),
|
|
27
|
+
fixVersions: ["OOB 81"],
|
|
28
|
+
labels: ["a", "b"],
|
|
29
|
+
components: ["x"],
|
|
30
|
+
description: "Hello",
|
|
31
|
+
attachments: [],
|
|
32
|
+
comments: [],
|
|
33
|
+
...overrides
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe("frontmatter", () => {
|
|
37
|
+
describe("serializeIssue", () => {
|
|
38
|
+
// Regression: gray-matter's default YAML engine calls js-yaml's removed
|
|
39
|
+
// `safeDump`, which throws under the workspace's js-yaml 4 override. The
|
|
40
|
+
// custom engine must serialize without throwing.
|
|
41
|
+
it("serializes without throwing under js-yaml 4", () => {
|
|
42
|
+
expect(() => serializeIssue(makeIssue())).not.toThrow()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("produces parseable YAML front-matter round-tripping the data", () => {
|
|
46
|
+
const output = serializeIssue(makeIssue())
|
|
47
|
+
const data = parseFrontMatter(output)
|
|
48
|
+
expect(data.key).toBe("OOB-81")
|
|
49
|
+
expect(data.fixVersions).toEqual(["OOB 81"])
|
|
50
|
+
expect(data.labels).toEqual(["a", "b"])
|
|
51
|
+
expect(output).toContain("# OOB-81: Test ticket")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("emits null for absent optional fields", () => {
|
|
55
|
+
const output = serializeIssue(makeIssue({ priority: null, assignee: null, reporter: null }))
|
|
56
|
+
const data = parseFrontMatter(output)
|
|
57
|
+
expect(data.priority).toBeNull()
|
|
58
|
+
expect(data.assignee).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("extractFrontMatter", () => {
|
|
63
|
+
it("renders dates as ISO strings", () => {
|
|
64
|
+
const fm = extractFrontMatter(makeIssue())
|
|
65
|
+
expect(fm.created).toBe("2026-01-01T00:00:00.000Z")
|
|
66
|
+
expect(fm.updated).toBe("2026-02-01T00:00:00.000Z")
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|