@knpkv/jira-cli 1.2.3 → 1.4.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 (147) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +64 -0
  3. package/dist/AttachmentService.d.ts +2 -2
  4. package/dist/AttachmentService.d.ts.map +1 -1
  5. package/dist/AttachmentService.js +1 -1
  6. package/dist/AttachmentService.js.map +1 -1
  7. package/dist/IssueService.d.ts +82 -3
  8. package/dist/IssueService.d.ts.map +1 -1
  9. package/dist/IssueService.js +111 -16
  10. package/dist/IssueService.js.map +1 -1
  11. package/dist/JiraAuth.d.ts +3 -3
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js +87 -23
  14. package/dist/JiraAuth.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 +2 -2
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +2 -2
  20. package/dist/SyncWorkspace.d.ts.map +1 -1
  21. package/dist/SyncWorkspace.js.map +1 -1
  22. package/dist/VersionService.d.ts +81 -9
  23. package/dist/VersionService.d.ts.map +1 -1
  24. package/dist/VersionService.js +113 -17
  25. package/dist/VersionService.js.map +1 -1
  26. package/dist/bin.js +1 -1
  27. package/dist/bin.js.map +1 -1
  28. package/dist/commands/auth.d.ts +13 -2
  29. package/dist/commands/auth.d.ts.map +1 -1
  30. package/dist/commands/auth.js +7 -7
  31. package/dist/commands/auth.js.map +1 -1
  32. package/dist/commands/edit.d.ts +22 -0
  33. package/dist/commands/edit.d.ts.map +1 -0
  34. package/dist/commands/edit.js +49 -0
  35. package/dist/commands/edit.js.map +1 -0
  36. package/dist/commands/errorHandler.d.ts.map +1 -1
  37. package/dist/commands/get.d.ts +6 -1
  38. package/dist/commands/get.d.ts.map +1 -1
  39. package/dist/commands/get.js +3 -3
  40. package/dist/commands/get.js.map +1 -1
  41. package/dist/commands/issue.d.ts +2 -2
  42. package/dist/commands/issue.d.ts.map +1 -1
  43. package/dist/commands/issue.js +3 -2
  44. package/dist/commands/issue.js.map +1 -1
  45. package/dist/commands/issueAttachment.d.ts +1 -1
  46. package/dist/commands/issueAttachment.d.ts.map +1 -1
  47. package/dist/commands/issueAttachment.js +13 -11
  48. package/dist/commands/issueAttachment.js.map +1 -1
  49. package/dist/commands/layers.d.ts +3 -3
  50. package/dist/commands/layers.d.ts.map +1 -1
  51. package/dist/commands/layers.js +5 -2
  52. package/dist/commands/layers.js.map +1 -1
  53. package/dist/commands/search.d.ts +6 -1
  54. package/dist/commands/search.d.ts.map +1 -1
  55. package/dist/commands/search.js +7 -7
  56. package/dist/commands/search.js.map +1 -1
  57. package/dist/commands/version.d.ts +17 -2
  58. package/dist/commands/version.d.ts.map +1 -1
  59. package/dist/commands/version.js +131 -19
  60. package/dist/commands/version.js.map +1 -1
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/internal/NodeLayers.js +1 -1
  66. package/dist/internal/NodeLayers.js.map +1 -1
  67. package/dist/internal/attachmentInsertion.d.ts.map +1 -1
  68. package/dist/internal/frontmatter.d.ts.map +1 -1
  69. package/dist/internal/frontmatter.js +2 -1
  70. package/dist/internal/frontmatter.js.map +1 -1
  71. package/dist/internal/jqlBuilder.d.ts.map +1 -1
  72. package/dist/internal/oauthServer.d.ts +10 -4
  73. package/dist/internal/oauthServer.d.ts.map +1 -1
  74. package/dist/internal/oauthServer.js +13 -8
  75. package/dist/internal/oauthServer.js.map +1 -1
  76. package/dist/internal/openBrowser.d.ts +1 -1
  77. package/dist/internal/openBrowser.d.ts.map +1 -1
  78. package/dist/internal/openBrowser.js +1 -1
  79. package/dist/internal/openBrowser.js.map +1 -1
  80. package/dist/internal/sync/baseline.d.ts.map +1 -1
  81. package/dist/internal/sync/baseline.js +2 -2
  82. package/dist/internal/sync/baseline.js.map +1 -1
  83. package/dist/internal/sync/changes.d.ts.map +1 -1
  84. package/dist/internal/sync/config.d.ts.map +1 -1
  85. package/dist/internal/sync/config.js +1 -1
  86. package/dist/internal/sync/config.js.map +1 -1
  87. package/dist/internal/sync/document.d.ts.map +1 -1
  88. package/dist/internal/sync/document.js +8 -8
  89. package/dist/internal/sync/document.js.map +1 -1
  90. package/dist/internal/sync/fieldValues.d.ts +9 -9
  91. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  92. package/dist/internal/sync/fieldValues.js +12 -16
  93. package/dist/internal/sync/fieldValues.js.map +1 -1
  94. package/dist/internal/sync/manifest.d.ts.map +1 -1
  95. package/dist/internal/sync/manifest.js +1 -1
  96. package/dist/internal/sync/manifest.js.map +1 -1
  97. package/dist/internal/sync/paths.d.ts.map +1 -1
  98. package/dist/internal/sync/schemas.d.ts +17 -9
  99. package/dist/internal/sync/schemas.d.ts.map +1 -1
  100. package/dist/internal/sync/schemas.js +6 -6
  101. package/dist/internal/sync/schemas.js.map +1 -1
  102. package/dist/internal/sync/types.d.ts +4 -4
  103. package/dist/internal/sync/types.d.ts.map +1 -1
  104. package/dist/internal/sync/types.js +1 -1
  105. package/dist/internal/sync/types.js.map +1 -1
  106. package/package.json +15 -11
  107. package/skills/jira/SKILL.md +17 -1
  108. package/src/AttachmentService.ts +6 -6
  109. package/src/IssueService.ts +214 -27
  110. package/src/JiraAuth.ts +106 -42
  111. package/src/MarkdownWriter.ts +6 -6
  112. package/src/SyncWorkspace.ts +12 -11
  113. package/src/VersionService.ts +199 -27
  114. package/src/bin.ts +1 -1
  115. package/src/commands/auth.ts +7 -7
  116. package/src/commands/edit.ts +88 -0
  117. package/src/commands/get.ts +3 -3
  118. package/src/commands/issue.ts +3 -2
  119. package/src/commands/issueAttachment.ts +21 -20
  120. package/src/commands/layers.ts +5 -2
  121. package/src/commands/search.ts +7 -7
  122. package/src/commands/version.ts +176 -23
  123. package/src/index.ts +13 -3
  124. package/src/internal/NodeLayers.ts +1 -1
  125. package/src/internal/frontmatter.ts +3 -2
  126. package/src/internal/oauthServer.ts +29 -16
  127. package/src/internal/openBrowser.ts +1 -1
  128. package/src/internal/sync/baseline.ts +3 -2
  129. package/src/internal/sync/config.ts +2 -1
  130. package/src/internal/sync/document.ts +15 -14
  131. package/src/internal/sync/fieldValues.ts +21 -22
  132. package/src/internal/sync/manifest.ts +1 -1
  133. package/src/internal/sync/schemas.ts +6 -6
  134. package/src/internal/sync/types.ts +4 -4
  135. package/test/IssueService.test.ts +2 -2
  136. package/test/JiraAuthRefresh.test.ts +269 -0
  137. package/test/VersionService.test.ts +2 -2
  138. package/test/commandTree.test.ts +158 -2
  139. package/test/editIssuePayload.test.ts +103 -0
  140. package/test/frontmatter.test.ts +4 -2
  141. package/test/integration.test.ts +12 -10
  142. package/test/oauthServer.test.ts +77 -4
  143. package/test/publicApi.test.ts +34 -0
  144. package/test/relatedWorkSync.test.ts +179 -0
  145. package/test/remoteWrites.test.ts +220 -0
  146. package/test/syncChanges.test.ts +2 -2
  147. package/test/syncConfig.test.ts +2 -2
@@ -0,0 +1,269 @@
1
+ /**
2
+ * What a failed token refresh is allowed to do to the stored credential.
3
+ *
4
+ * Atlassian rotates refresh tokens, so a refresh that does not complete leaves
5
+ * the client unable to tell whether the grant landed. Deleting the profile in
6
+ * that state is unrecoverable and unattended — `JiraApiConfigLive` builds on
7
+ * every CLI invocation, and jcf's statusline runs one every 30 seconds — so a
8
+ * bad network window could silently log the user out with nobody watching.
9
+ *
10
+ * The rule: discard the stored token only when Atlassian actually rejected it
11
+ * (a 4xx). Anything else — timeout, transport failure — keeps the credential so
12
+ * the next run can retry.
13
+ */
14
+ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
15
+ import { describe, expect, it } from "@effect/vitest"
16
+ import {
17
+ HomeDirectoryLive,
18
+ loadActiveProfileToken,
19
+ saveOAuthConfig,
20
+ saveProfileToken
21
+ } from "@knpkv/atlassian-common/config"
22
+ import { ConfigProvider } from "effect"
23
+ import * as Deferred from "effect/Deferred"
24
+ import * as Effect from "effect/Effect"
25
+ import * as Fiber from "effect/Fiber"
26
+ import * as FileSystem from "effect/FileSystem"
27
+ import { HttpClient, HttpClientResponse } from "effect/http"
28
+ import * as Layer from "effect/Layer"
29
+ import { TestClock } from "effect/testing"
30
+ import { JiraAuth, layer as jiraAuthLayer } from "../src/JiraAuth.js"
31
+
32
+ const TOOL = "jira-cli"
33
+
34
+ const expiredToken = {
35
+ access_token: "expired-access",
36
+ refresh_token: "rotating-refresh",
37
+ expires_at: 0,
38
+ token_type: "Bearer",
39
+ scope: "offline_access",
40
+ cloud_id: "cloud-1",
41
+ site_url: "https://example.atlassian.net"
42
+ }
43
+
44
+ const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
45
+
46
+ // JiraAuth also needs a spawner (browser launch during login) and Crypto (PKCE).
47
+ // Without them the layer dies as a defect, which no `catchTag` would see — and
48
+ // the assertions below would then pass without the refresh ever running.
49
+ const authLayerWith = (client: Layer.Layer<HttpClient.HttpClient>) =>
50
+ jiraAuthLayer.pipe(Layer.provide(Layer.mergeAll(client, NodeServices.layer)))
51
+
52
+ // A client that never answers — the stall case, driven by TestClock. It reports
53
+ // when the request has actually been issued so the test can advance the clock at
54
+ // a point where the refresh deadline is registered; adjusting before that would
55
+ // move past the deadline before it exists and hang.
56
+ const stalledClient = (issued: Deferred.Deferred<void>) =>
57
+ Layer.succeed(
58
+ HttpClient.HttpClient,
59
+ HttpClient.make(() => Effect.flatMap(Deferred.succeed(issued, undefined), () => Effect.never))
60
+ )
61
+
62
+ const respondingClient = (status: number, body: string) =>
63
+ Layer.succeed(
64
+ HttpClient.HttpClient,
65
+ HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))))
66
+ )
67
+
68
+ // A client that answers with the one thing that does prove the token is dead.
69
+ const rejectingClient = respondingClient(400, JSON.stringify({ error: "invalid_grant" }))
70
+
71
+ // A rotation that only completes once the test says so, so interruption can be
72
+ // aimed at the exact window between the grant and the token being persisted.
73
+ const gatedClient = (issued: Deferred.Deferred<void>, release: Deferred.Deferred<void>) =>
74
+ Layer.succeed(
75
+ HttpClient.HttpClient,
76
+ HttpClient.make((request) =>
77
+ Effect.gen(function*() {
78
+ yield* Deferred.succeed(issued, undefined)
79
+ yield* Deferred.await(release)
80
+ return HttpClientResponse.fromWeb(
81
+ request,
82
+ new Response(
83
+ JSON.stringify({
84
+ access_token: "fresh-access",
85
+ refresh_token: "rotated-refresh",
86
+ expires_in: 3600,
87
+ scope: "offline_access",
88
+ token_type: "Bearer"
89
+ }),
90
+ { status: 200, headers: { "content-type": "application/json" } }
91
+ )
92
+ )
93
+ })
94
+ )
95
+ )
96
+
97
+ // `HomeDirectoryLive` resolves HOME through the ambient ConfigProvider, so the
98
+ // test home can be injected rather than set on `process.env` — a global that
99
+ // parallel tests would race each other to overwrite and restore. The directory
100
+ // is scoped, so it is removed when the effect finishes either way.
101
+ const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
102
+ Effect.gen(function*() {
103
+ const fs = yield* FileSystem.FileSystem
104
+ const home = yield* fs.makeTempDirectoryScoped({ prefix: "jcf-auth-" })
105
+ return yield* effect.pipe(
106
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
107
+ )
108
+ }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
109
+
110
+ const storedToken = loadActiveProfileToken(TOOL).pipe(Effect.provide(storage))
111
+
112
+ // Both the token and the client config have to be on disk, or `getConfig`
113
+ // short-circuits the refresh before any request is made and the assertions
114
+ // below would pass without exercising anything.
115
+ const seedExpiredToken = Effect.gen(function*() {
116
+ yield* saveOAuthConfig(TOOL, { clientId: "client-1", clientSecret: "secret-1" })
117
+ yield* saveProfileToken(TOOL, expiredToken)
118
+ }).pipe(Effect.provide(storage))
119
+
120
+ describe("JiraAuth token refresh", () => {
121
+ it.effect("keeps the stored credential when the refresh never answers", () =>
122
+ withHome(Effect.gen(function*() {
123
+ yield* seedExpiredToken
124
+
125
+ const issued = yield* Deferred.make<void>()
126
+ const attempt = JiraAuth.pipe(
127
+ Effect.flatMap((auth) => auth.getAccessToken()),
128
+ Effect.provide(authLayerWith(stalledClient(issued))),
129
+ Effect.exit
130
+ )
131
+ const fiber = yield* attempt.pipe(Effect.forkChild({ startImmediately: true }))
132
+
133
+ yield* Deferred.await(issued)
134
+ yield* TestClock.adjust("31 seconds")
135
+ const exit = yield* Fiber.join(fiber)
136
+
137
+ expect(exit._tag).toBe("Failure")
138
+ // The point of the test: a stall is not evidence, so the token survives.
139
+ const after = yield* storedToken
140
+ expect(after?.refresh_token).toBe("rotating-refresh")
141
+ })))
142
+
143
+ // Rate limiting is the case this most needs to survive: several `jira`/`jcf`
144
+ // processes on one expired token hit the endpoint together, one wins the
145
+ // rotation and the rest get 429. Treating that as a verdict on the token
146
+ // would log the user out precisely when things are busiest.
147
+ it.effect("keeps the stored credential when the provider rate-limits", () =>
148
+ withHome(Effect.gen(function*() {
149
+ yield* seedExpiredToken
150
+
151
+ const exit = yield* JiraAuth.pipe(
152
+ Effect.flatMap((auth) => auth.getAccessToken()),
153
+ Effect.provide(authLayerWith(respondingClient(429, "Too Many Requests"))),
154
+ Effect.exit
155
+ )
156
+
157
+ expect(exit._tag).toBe("Failure")
158
+ const after = yield* storedToken
159
+ expect(after?.refresh_token).toBe("rotating-refresh")
160
+ })))
161
+
162
+ // `refreshToken` sends the client credentials in the body, so a wrong or
163
+ // rotated secret comes back as `400 invalid_client` — a config problem, not a
164
+ // dead token. Deleting here would destroy a working credential and force a
165
+ // re-login that fails identically until `jira auth configure` is run.
166
+ it.effect("keeps the stored credential when the client credentials are rejected", () =>
167
+ withHome(Effect.gen(function*() {
168
+ yield* seedExpiredToken
169
+
170
+ const exit = yield* JiraAuth.pipe(
171
+ Effect.flatMap((auth) => auth.getAccessToken()),
172
+ Effect.provide(authLayerWith(respondingClient(400, JSON.stringify({ error: "invalid_client" })))),
173
+ Effect.exit
174
+ )
175
+
176
+ expect(exit._tag).toBe("Failure")
177
+ const after = yield* storedToken
178
+ expect(after?.refresh_token).toBe("rotating-refresh")
179
+ })))
180
+
181
+ // A bare 403 is as likely to be a proxy or WAF as Atlassian revoking
182
+ // anything, so the code has to say so explicitly.
183
+ it.effect("keeps the stored credential on a 403 that does not say invalid_grant", () =>
184
+ withHome(Effect.gen(function*() {
185
+ yield* seedExpiredToken
186
+
187
+ const exit = yield* JiraAuth.pipe(
188
+ Effect.flatMap((auth) => auth.getAccessToken()),
189
+ Effect.provide(authLayerWith(respondingClient(403, "<html>Forbidden</html>"))),
190
+ Effect.exit
191
+ )
192
+
193
+ expect(exit._tag).toBe("Failure")
194
+ const after = yield* storedToken
195
+ expect(after?.refresh_token).toBe("rotating-refresh")
196
+ })))
197
+
198
+ it.effect("discards the stored credential on a 403 revocation", () =>
199
+ withHome(Effect.gen(function*() {
200
+ yield* seedExpiredToken
201
+
202
+ const exit = yield* JiraAuth.pipe(
203
+ Effect.flatMap((auth) => auth.getAccessToken()),
204
+ Effect.provide(authLayerWith(respondingClient(403, JSON.stringify({ error: "invalid_grant" })))),
205
+ Effect.exit
206
+ )
207
+
208
+ expect(exit._tag).toBe("Failure")
209
+ const after = yield* storedToken
210
+ expect(after).toBeNull()
211
+ })))
212
+
213
+ // An unparseable body is no verdict at all, so the conservative reading wins.
214
+ it.effect("keeps the stored credential when the error body is unparseable", () =>
215
+ withHome(Effect.gen(function*() {
216
+ yield* seedExpiredToken
217
+
218
+ const exit = yield* JiraAuth.pipe(
219
+ Effect.flatMap((auth) => auth.getAccessToken()),
220
+ Effect.provide(authLayerWith(respondingClient(400, "not json at all"))),
221
+ Effect.exit
222
+ )
223
+
224
+ expect(exit._tag).toBe("Failure")
225
+ const after = yield* storedToken
226
+ expect(after?.refresh_token).toBe("rotating-refresh")
227
+ })))
228
+
229
+ // The atomicity half: once the grant is out, the replacement must reach disk
230
+ // even though the caller has already given up on the fiber.
231
+ it.effect("persists the rotated token even when interrupted mid-rotation", () =>
232
+ withHome(Effect.gen(function*() {
233
+ yield* seedExpiredToken
234
+ const issued = yield* Deferred.make<void>()
235
+ const release = yield* Deferred.make<void>()
236
+
237
+ const fiber = yield* JiraAuth.pipe(
238
+ Effect.flatMap((auth) => auth.getAccessToken()),
239
+ Effect.provide(authLayerWith(gatedClient(issued, release))),
240
+ Effect.exit,
241
+ Effect.forkChild({ startImmediately: true })
242
+ )
243
+
244
+ yield* Deferred.await(issued) // the grant is in flight
245
+ const interrupting = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild({ startImmediately: true }))
246
+ yield* Deferred.succeed(release, undefined) // now let it answer
247
+ yield* Fiber.join(interrupting)
248
+
249
+ // The interrupt could not tear the rotation in half, so the replacement
250
+ // is on disk rather than lost with the token it consumed.
251
+ const after = yield* storedToken
252
+ expect(after?.refresh_token).toBe("rotated-refresh")
253
+ })))
254
+
255
+ it.effect("discards the stored credential only when the provider rejects it", () =>
256
+ withHome(Effect.gen(function*() {
257
+ yield* seedExpiredToken
258
+
259
+ const exit = yield* JiraAuth.pipe(
260
+ Effect.flatMap((auth) => auth.getAccessToken()),
261
+ Effect.provide(authLayerWith(rejectingClient)),
262
+ Effect.exit
263
+ )
264
+
265
+ expect(exit._tag).toBe("Failure")
266
+ const after = yield* storedToken
267
+ expect(after).toBeNull()
268
+ })))
269
+ })
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { JiraApiClient, make } from "@knpkv/jira-api-client"
3
3
  import * as Effect from "effect/Effect"
4
+ import * as HttpClient from "effect/http/HttpClient"
5
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
4
6
  import * as Layer from "effect/Layer"
5
7
  import * as Redacted from "effect/Redacted"
6
- import * as HttpClient from "effect/unstable/http/HttpClient"
7
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
8
8
  import { stripEmails } from "../src/commands/version.js"
9
9
  import type { Version } from "../src/VersionService.js"
10
10
  import {
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
+ import { Command } from "effect/cli"
2
3
  import * as Effect from "effect/Effect"
3
4
  import * as Layer from "effect/Layer"
4
5
  import * as Ref from "effect/Ref"
5
6
  import * as Terminal from "effect/Terminal"
6
- import { Command } from "effect/unstable/cli"
7
7
  import { AttachmentService } from "../src/AttachmentService.js"
8
8
  import { issueCommand } from "../src/commands/issue.js"
9
9
  import { versionCommand } from "../src/commands/version.js"
@@ -20,6 +20,9 @@ interface CommandCalls {
20
20
  readonly versionUpdate: number
21
21
  readonly relatedWorkList: number
22
22
  readonly relatedWorkAdd: number
23
+ readonly relatedWorkDelete: number
24
+ readonly issueEdit: number
25
+ readonly versionCreate: number
23
26
  readonly writeMulti: number
24
27
  }
25
28
 
@@ -28,10 +31,13 @@ const emptyCalls: CommandCalls = {
28
31
  issueAttachmentUpload: 0,
29
32
  issueSearch: 0,
30
33
  relatedWorkAdd: 0,
34
+ relatedWorkDelete: 0,
31
35
  relatedWorkList: 0,
32
36
  versionGet: 0,
33
37
  versionList: 0,
34
38
  versionUpdate: 0,
39
+ issueEdit: 0,
40
+ versionCreate: 0,
35
41
  writeMulti: 0
36
42
  }
37
43
 
@@ -117,6 +123,10 @@ const CommandServicesLayer = (calls: Ref.Ref<CommandCalls>) =>
117
123
  Ref.update(calls, (state) => ({ ...state, issueGet: state.issueGet + 1 })).pipe(
118
124
  Effect.as(sampleIssue)
119
125
  ),
126
+ edit: () =>
127
+ Ref.update(calls, (state) => ({ ...state, issueEdit: state.issueEdit + 1 })).pipe(
128
+ Effect.as(sampleIssue)
129
+ ),
120
130
  search: () => Effect.die("IssueService.search should not be called"),
121
131
  searchAll: () =>
122
132
  Ref.update(calls, (state) => ({ ...state, issueSearch: state.issueSearch + 1 })).pipe(
@@ -138,6 +148,12 @@ const CommandServicesLayer = (calls: Ref.Ref<CommandCalls>) =>
138
148
  Ref.update(calls, (state) => ({ ...state, relatedWorkAdd: state.relatedWorkAdd + 1 })).pipe(
139
149
  Effect.as(sampleRelatedWork)
140
150
  ),
151
+ createVersion: () =>
152
+ Ref.update(calls, (state) => ({ ...state, versionCreate: state.versionCreate + 1 })).pipe(
153
+ Effect.as(sampleVersion)
154
+ ),
155
+ deleteRelatedWork: () =>
156
+ Ref.update(calls, (state) => ({ ...state, relatedWorkDelete: state.relatedWorkDelete + 1 })),
141
157
  getVersion: () =>
142
158
  Ref.update(calls, (state) => ({ ...state, versionGet: state.versionGet + 1 })).pipe(
143
159
  Effect.as(sampleVersion)
@@ -261,6 +277,21 @@ describe("Jira command tree", () => {
261
277
  calls,
262
278
  output
263
279
  )
280
+ // The sample related work already links this URL, so the reconcile keeps
281
+ // it and adds nothing — what this covers is that the command resolves and
282
+ // the service's new deleteRelatedWork member is actually wired up.
283
+ const relatedWorkSyncExit = yield* runJiraCommand(
284
+ [
285
+ "version",
286
+ "related-work",
287
+ "sync",
288
+ "10042",
289
+ "--link",
290
+ `Release notes=${sampleRelatedWork.url}`
291
+ ],
292
+ calls,
293
+ output
294
+ )
264
295
  const legacyViewExit = yield* runJiraCommand(["version", "view", "10042"], calls, legacyViewOutput)
265
296
  const legacySetExit = yield* runJiraCommand(
266
297
  [
@@ -289,16 +320,141 @@ describe("Jira command tree", () => {
289
320
  expect(updateExit._tag).toBe("Success")
290
321
  expect(relatedWorkListExit._tag).toBe("Success")
291
322
  expect(relatedWorkAddExit._tag).toBe("Success")
323
+ expect(relatedWorkSyncExit._tag).toBe("Success")
292
324
  expect(legacyViewExit._tag).toBe("Failure")
293
325
  expect(legacySetExit._tag).toBe("Failure")
294
326
  expect(legacyRelatedWorkExit._tag).toBe("Failure")
295
327
 
296
328
  expect(yield* Ref.get(calls)).toMatchObject({
297
329
  relatedWorkAdd: 1,
298
- relatedWorkList: 1,
330
+ // The desired link already exists, so sync neither adds nor deletes.
331
+ relatedWorkDelete: 0,
332
+ relatedWorkList: 2,
299
333
  versionGet: 1,
300
334
  versionList: 1,
301
335
  versionUpdate: 1
302
336
  })
303
337
  }))
338
+
339
+ // `--link " =url"` and `--link "title= "` both have a non-empty span either
340
+ // side of the `=`, so a check on the separator position alone lets them
341
+ // through and Jira receives a link with an empty title or url.
342
+ it.effect("rejects a --link whose title or url is only whitespace", () =>
343
+ Effect.gen(function*() {
344
+ const calls = yield* Ref.make(emptyCalls)
345
+ const output = yield* Ref.make("")
346
+
347
+ const blankTitle = yield* runJiraCommand(
348
+ ["version", "related-work", "sync", "10042", "--link", " =https://example.atlassian.net/wiki/pages/1"],
349
+ calls,
350
+ output
351
+ )
352
+ const blankUrl = yield* runJiraCommand(
353
+ ["version", "related-work", "sync", "10042", "--link", "Release notes= "],
354
+ calls,
355
+ output
356
+ )
357
+
358
+ expect(blankTitle._tag).toBe("Failure")
359
+ expect(blankUrl._tag).toBe("Failure")
360
+ // Rejected before the remote write, not after it.
361
+ expect(yield* Ref.get(calls)).toMatchObject({
362
+ relatedWorkAdd: 0,
363
+ relatedWorkDelete: 0,
364
+ relatedWorkList: 0
365
+ })
366
+ }))
367
+
368
+ // The nearby valid fixture: surrounding whitespace is normalized, not
369
+ // rejected.
370
+ it.effect("normalizes a --link padded with whitespace", () =>
371
+ Effect.gen(function*() {
372
+ const calls = yield* Ref.make(emptyCalls)
373
+ const output = yield* Ref.make("")
374
+
375
+ const exit = yield* runJiraCommand(
376
+ ["version", "related-work", "sync", "10042", "--link", ` Release notes = ${sampleRelatedWork.url} `],
377
+ calls,
378
+ output
379
+ )
380
+
381
+ expect(exit._tag).toBe("Success")
382
+ // Matched the existing link by URL, so nothing was added or removed.
383
+ expect(yield* Ref.get(calls)).toMatchObject({
384
+ relatedWorkAdd: 0,
385
+ relatedWorkDelete: 0,
386
+ relatedWorkList: 1
387
+ })
388
+ }))
389
+
390
+ it.effect("wires up issue edit and reaches IssueService.edit", () =>
391
+ Effect.gen(function*() {
392
+ const calls = yield* Ref.make(emptyCalls)
393
+ const output = yield* Ref.make("")
394
+
395
+ const exit = yield* runJiraCommand(
396
+ ["issue", "edit", "PROJ-123", "--add-fix-version", "OOB 100", "--add-label", "domain:oob"],
397
+ calls,
398
+ output
399
+ )
400
+
401
+ expect(exit._tag).toBe("Success")
402
+ expect(yield* Ref.get(calls)).toMatchObject({ issueEdit: 1 })
403
+ }))
404
+
405
+ // `Options.atLeast(0)` reports an absent repeatable flag as `[]`, which
406
+ // `buildEditIssuePayload` reads as "not supplied". Pin that here: with only
407
+ // one flag passed the command must still resolve, rather than the other five
408
+ // empty arrays being taken as replacements that clear their fields.
409
+ it.effect("treats the repeatable flags a caller omits as absent", () =>
410
+ Effect.gen(function*() {
411
+ const calls = yield* Ref.make(emptyCalls)
412
+ const output = yield* Ref.make("")
413
+
414
+ const exit = yield* runJiraCommand(
415
+ ["issue", "edit", "PROJ-123", "--add-label", "domain:oob"],
416
+ calls,
417
+ output
418
+ )
419
+
420
+ expect(exit._tag).toBe("Success")
421
+ expect(yield* Ref.get(calls)).toMatchObject({ issueEdit: 1 })
422
+ }))
423
+
424
+ // Note: `issue edit PROJ-123` with no flags parses fine and reaches the
425
+ // service — the "nothing to edit" refusal lives in `buildEditIssuePayload`
426
+ // inside `IssueService.edit`, which this suite stubs out. That path is covered
427
+ // by editIssuePayload.test.ts and remoteWrites.test.ts.
428
+
429
+ it.effect("wires up version create and reaches VersionService.createVersion", () =>
430
+ Effect.gen(function*() {
431
+ const calls = yield* Ref.make(emptyCalls)
432
+ const output = yield* Ref.make("")
433
+
434
+ const exit = yield* runJiraCommand(
435
+ ["version", "create", "--project", "PROJ", "--name", "OOB 100"],
436
+ calls,
437
+ output
438
+ )
439
+
440
+ expect(exit._tag).toBe("Success")
441
+ expect(yield* Ref.get(calls)).toMatchObject({ versionCreate: 1 })
442
+ }))
443
+
444
+ // Shape-only date validation would let this through and Jira would answer
445
+ // with an unattributed 400 — after the version was already created.
446
+ it.effect("rejects a non-existent --start-date before the remote write", () =>
447
+ Effect.gen(function*() {
448
+ const calls = yield* Ref.make(emptyCalls)
449
+ const output = yield* Ref.make("")
450
+
451
+ const exit = yield* runJiraCommand(
452
+ ["version", "create", "--project", "PROJ", "--name", "OOB 100", "--start-date", "2026-02-30"],
453
+ calls,
454
+ output
455
+ )
456
+
457
+ expect(exit._tag).toBe("Failure")
458
+ expect(yield* Ref.get(calls)).toMatchObject({ versionCreate: 0 })
459
+ }))
304
460
  })
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { isIsoDate } from "../src/commands/version.js"
3
+ import { buildEditIssuePayload } from "../src/IssueService.js"
4
+
5
+ const payloadOf = (result: ReturnType<typeof buildEditIssuePayload>) => {
6
+ if (result._tag === "Invalid") throw new Error(`expected a payload, got invalid: ${result.reason}`)
7
+ return result.payload
8
+ }
9
+
10
+ const errorOf = (result: ReturnType<typeof buildEditIssuePayload>): string => {
11
+ if (result._tag === "Payload") throw new Error("expected an invalid result, got a payload")
12
+ return result.reason
13
+ }
14
+
15
+ describe("buildEditIssuePayload", () => {
16
+ // The reason this helper exists: attaching a ticket to a release must not
17
+ // disturb the releases it is already in, so an add goes through `update`
18
+ // rather than replacing `fields.fixVersions`.
19
+ it("sends an added fix version as an incremental update, not a replacement", () => {
20
+ const payload = payloadOf(buildEditIssuePayload({ addFixVersions: ["OOB 100"] }))
21
+
22
+ expect(payload.update).toEqual({ fixVersions: [{ add: { name: "OOB 100" } }] })
23
+ expect(payload.fields).toBeUndefined()
24
+ })
25
+
26
+ it("wraps fix versions as objects and labels as bare strings", () => {
27
+ const payload = payloadOf(
28
+ buildEditIssuePayload({ addFixVersions: ["OOB 100"], addLabels: ["domain:oob"] })
29
+ )
30
+
31
+ expect(payload.update).toEqual({
32
+ fixVersions: [{ add: { name: "OOB 100" } }],
33
+ labels: [{ add: "domain:oob" }]
34
+ })
35
+ })
36
+
37
+ it("combines adds and removes for the same field in one call", () => {
38
+ const payload = payloadOf(
39
+ buildEditIssuePayload({ addFixVersions: ["OOB 100"], removeFixVersions: ["OOB 99"] })
40
+ )
41
+
42
+ expect(payload.update).toEqual({
43
+ fixVersions: [{ add: { name: "OOB 100" } }, { remove: { name: "OOB 99" } }]
44
+ })
45
+ })
46
+
47
+ it("sends a replacement through fields", () => {
48
+ const payload = payloadOf(buildEditIssuePayload({ setFixVersions: ["OOB 100"] }))
49
+
50
+ expect(payload.fields).toEqual({ fixVersions: [{ name: "OOB 100" }] })
51
+ expect(payload.update).toBeUndefined()
52
+ })
53
+
54
+ // Jira rejects a field named in both `fields` and `update`, and its error does
55
+ // not say which field — so the combination is refused here instead.
56
+ it("refuses a replacement combined with an increment on the same field", () => {
57
+ const message = errorOf(
58
+ buildEditIssuePayload({ setFixVersions: ["OOB 100"], addFixVersions: ["OOB 99"] })
59
+ )
60
+
61
+ expect(message).toContain("--fix-version cannot be combined")
62
+ })
63
+
64
+ it("allows replacing one field while incrementing the other", () => {
65
+ const payload = payloadOf(
66
+ buildEditIssuePayload({ setFixVersions: ["OOB 100"], addLabels: ["domain:oob"] })
67
+ )
68
+
69
+ expect(payload.fields).toEqual({ fixVersions: [{ name: "OOB 100" }] })
70
+ expect(payload.update).toEqual({ labels: [{ add: "domain:oob" }] })
71
+ })
72
+
73
+ it("refuses an edit that would send nothing", () => {
74
+ expect(errorOf(buildEditIssuePayload({}))).toContain("Nothing to edit")
75
+ })
76
+
77
+ // Empty repeatable flags arrive as `[]`, which must read as "not passed"
78
+ // rather than "replace with nothing" — the latter would clear the field.
79
+ it("treats empty flag arrays as absent rather than as a clear", () => {
80
+ expect(errorOf(buildEditIssuePayload({ setFixVersions: [], addLabels: [] }))).toContain("Nothing to edit")
81
+ })
82
+ })
83
+
84
+ describe("isIsoDate", () => {
85
+ it("accepts an ISO date", () => {
86
+ expect(isIsoDate("2026-08-12")).toBe(true)
87
+ })
88
+
89
+ it.each([
90
+ ["12-08-2026", "day first"],
91
+ ["2026-8-12", "unpadded month"],
92
+ ["2026-08-12T00:00:00Z", "timestamp"],
93
+ ["", "empty"]
94
+ ])("rejects %s (%s)", (value) => {
95
+ expect(isIsoDate(value)).toBe(false)
96
+ })
97
+
98
+ // Shape-only validation would let these through and Jira would answer with an
99
+ // unattributed 400.
100
+ it.each(["2026-02-30", "2026-13-01", "2026-00-10"])("rejects the non-existent date %s", (value) => {
101
+ expect(isIsoDate(value)).toBe(false)
102
+ })
103
+ })
@@ -1,4 +1,6 @@
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
4
  import * as yaml from "js-yaml"
3
5
  import { extractFrontMatter, serializeIssue } from "../src/internal/frontmatter.js"
4
6
  import type { Issue } from "../src/IssueService.js"
@@ -8,10 +10,10 @@ import type { Issue } from "../src/IssueService.js"
8
10
  * directly. We avoid gray-matter's default parser here because it calls the
9
11
  * removed `safeLoad` — the same incompatibility this module works around.
10
12
  */
11
- const parseFrontMatter = (output: string): Record<string, unknown> => {
13
+ const parseFrontMatter = (output: string): Record<string, Schema.Json> => {
12
14
  const match = output.match(/^---\n([\s\S]*?)\n---/)
13
15
  const parsed = match ? yaml.load(match[1]) : {}
14
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
16
+ return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed)
15
17
  ? Object.fromEntries(Object.entries(parsed))
16
18
  : {}
17
19
  }