@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.
Files changed (143) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +66 -4
  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/JiraCliError.d.ts +30 -0
  12. package/dist/JiraCliError.d.ts.map +1 -1
  13. package/dist/JiraCliError.js +14 -0
  14. package/dist/JiraCliError.js.map +1 -1
  15. package/dist/MarkdownWriter.d.ts +4 -4
  16. package/dist/MarkdownWriter.d.ts.map +1 -1
  17. package/dist/MarkdownWriter.js +6 -6
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +34 -0
  20. package/dist/SyncWorkspace.d.ts.map +1 -0
  21. package/dist/SyncWorkspace.js +105 -0
  22. package/dist/SyncWorkspace.js.map +1 -0
  23. package/dist/VersionService.d.ts +206 -0
  24. package/dist/VersionService.d.ts.map +1 -0
  25. package/dist/VersionService.js +426 -0
  26. package/dist/VersionService.js.map +1 -0
  27. package/dist/bin.js +29 -22
  28. package/dist/bin.js.map +1 -1
  29. package/dist/commands/auth.d.ts +2 -21
  30. package/dist/commands/auth.d.ts.map +1 -1
  31. package/dist/commands/auth.js +6 -6
  32. package/dist/commands/auth.js.map +1 -1
  33. package/dist/commands/get.d.ts +3 -8
  34. package/dist/commands/get.d.ts.map +1 -1
  35. package/dist/commands/get.js +4 -4
  36. package/dist/commands/get.js.map +1 -1
  37. package/dist/commands/index.d.ts +2 -2
  38. package/dist/commands/index.d.ts.map +1 -1
  39. package/dist/commands/index.js +2 -2
  40. package/dist/commands/index.js.map +1 -1
  41. package/dist/commands/issue.d.ts +8 -0
  42. package/dist/commands/issue.d.ts.map +1 -0
  43. package/dist/commands/issue.js +10 -0
  44. package/dist/commands/issue.js.map +1 -0
  45. package/dist/commands/layers.d.ts +6 -18
  46. package/dist/commands/layers.d.ts.map +1 -1
  47. package/dist/commands/layers.js +35 -24
  48. package/dist/commands/layers.js.map +1 -1
  49. package/dist/commands/search.d.ts +3 -8
  50. package/dist/commands/search.d.ts.map +1 -1
  51. package/dist/commands/search.js +8 -8
  52. package/dist/commands/search.js.map +1 -1
  53. package/dist/commands/version.d.ts +12 -0
  54. package/dist/commands/version.d.ts.map +1 -0
  55. package/dist/commands/version.js +179 -0
  56. package/dist/commands/version.js.map +1 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -1
  58. package/dist/internal/frontmatter.js +14 -1
  59. package/dist/internal/frontmatter.js.map +1 -1
  60. package/dist/internal/oauthServer.d.ts +17 -5
  61. package/dist/internal/oauthServer.d.ts.map +1 -1
  62. package/dist/internal/oauthServer.js +23 -40
  63. package/dist/internal/oauthServer.js.map +1 -1
  64. package/dist/internal/openBrowser.d.ts +10 -0
  65. package/dist/internal/openBrowser.d.ts.map +1 -0
  66. package/dist/internal/openBrowser.js +17 -0
  67. package/dist/internal/openBrowser.js.map +1 -0
  68. package/dist/internal/sync/baseline.d.ts +11 -0
  69. package/dist/internal/sync/baseline.d.ts.map +1 -0
  70. package/dist/internal/sync/baseline.js +18 -0
  71. package/dist/internal/sync/baseline.js.map +1 -0
  72. package/dist/internal/sync/changes.d.ts +15 -0
  73. package/dist/internal/sync/changes.d.ts.map +1 -0
  74. package/dist/internal/sync/changes.js +72 -0
  75. package/dist/internal/sync/changes.js.map +1 -0
  76. package/dist/internal/sync/config.d.ts +12 -0
  77. package/dist/internal/sync/config.d.ts.map +1 -0
  78. package/dist/internal/sync/config.js +53 -0
  79. package/dist/internal/sync/config.js.map +1 -0
  80. package/dist/internal/sync/document.d.ts +9 -0
  81. package/dist/internal/sync/document.d.ts.map +1 -0
  82. package/dist/internal/sync/document.js +173 -0
  83. package/dist/internal/sync/document.js.map +1 -0
  84. package/dist/internal/sync/fieldValues.d.ts +30 -0
  85. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  86. package/dist/internal/sync/fieldValues.js +91 -0
  87. package/dist/internal/sync/fieldValues.js.map +1 -0
  88. package/dist/internal/sync/manifest.d.ts +12 -0
  89. package/dist/internal/sync/manifest.d.ts.map +1 -0
  90. package/dist/internal/sync/manifest.js +23 -0
  91. package/dist/internal/sync/manifest.js.map +1 -0
  92. package/dist/internal/sync/paths.d.ts +26 -0
  93. package/dist/internal/sync/paths.d.ts.map +1 -0
  94. package/dist/internal/sync/paths.js +22 -0
  95. package/dist/internal/sync/paths.js.map +1 -0
  96. package/dist/internal/sync/schemas.d.ts +128 -0
  97. package/dist/internal/sync/schemas.d.ts.map +1 -0
  98. package/dist/internal/sync/schemas.js +82 -0
  99. package/dist/internal/sync/schemas.js.map +1 -0
  100. package/dist/internal/sync/types.d.ts +144 -0
  101. package/dist/internal/sync/types.d.ts.map +1 -0
  102. package/dist/internal/sync/types.js +17 -0
  103. package/dist/internal/sync/types.js.map +1 -0
  104. package/package.json +13 -12
  105. package/skills/jira/SKILL.md +90 -0
  106. package/skills/jira/agents/openai.yaml +4 -0
  107. package/src/IssueService.ts +34 -28
  108. package/src/JiraAuth.ts +53 -39
  109. package/src/JiraCliError.ts +24 -0
  110. package/src/MarkdownWriter.ts +7 -11
  111. package/src/SyncWorkspace.ts +185 -0
  112. package/src/VersionService.ts +647 -0
  113. package/src/bin.ts +39 -29
  114. package/src/commands/auth.ts +6 -12
  115. package/src/commands/get.ts +4 -4
  116. package/src/commands/index.ts +2 -2
  117. package/src/commands/issue.ts +13 -0
  118. package/src/commands/layers.ts +44 -26
  119. package/src/commands/search.ts +8 -8
  120. package/src/commands/version.ts +267 -0
  121. package/src/internal/frontmatter.ts +15 -1
  122. package/src/internal/oauthServer.ts +43 -70
  123. package/src/internal/openBrowser.ts +31 -0
  124. package/src/internal/sync/baseline.ts +27 -0
  125. package/src/internal/sync/changes.ts +118 -0
  126. package/src/internal/sync/config.ts +76 -0
  127. package/src/internal/sync/document.ts +201 -0
  128. package/src/internal/sync/fieldValues.ts +145 -0
  129. package/src/internal/sync/manifest.ts +32 -0
  130. package/src/internal/sync/paths.ts +48 -0
  131. package/src/internal/sync/schemas.ts +103 -0
  132. package/src/internal/sync/types.ts +192 -0
  133. package/test/SyncWorkspace.test.ts +76 -0
  134. package/test/VersionService.test.ts +266 -0
  135. package/test/commandTree.test.ts +266 -0
  136. package/test/frontmatter.test.ts +69 -0
  137. package/test/integration.test.ts +187 -0
  138. package/test/syncChanges.test.ts +106 -0
  139. package/test/syncConfig.test.ts +138 -0
  140. package/test/syncDocument.test.ts +69 -0
  141. package/test/syncFieldValues.test.ts +101 -0
  142. package/vitest.config.integration.ts +17 -0
  143. 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
+ })