@knpkv/jira-cli 1.2.3 → 1.3.1
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 +113 -0
- package/README.md +64 -0
- package/dist/AttachmentService.d.ts +2 -2
- package/dist/AttachmentService.d.ts.map +1 -1
- package/dist/AttachmentService.js +1 -1
- package/dist/AttachmentService.js.map +1 -1
- package/dist/IssueService.d.ts +82 -3
- package/dist/IssueService.d.ts.map +1 -1
- package/dist/IssueService.js +111 -16
- package/dist/IssueService.js.map +1 -1
- package/dist/JiraAuth.d.ts +1 -1
- package/dist/JiraAuth.d.ts.map +1 -1
- package/dist/JiraAuth.js +74 -10
- package/dist/JiraAuth.js.map +1 -1
- package/dist/MarkdownWriter.d.ts +4 -4
- package/dist/MarkdownWriter.d.ts.map +1 -1
- package/dist/MarkdownWriter.js +2 -2
- package/dist/MarkdownWriter.js.map +1 -1
- package/dist/SyncWorkspace.d.ts +2 -2
- package/dist/SyncWorkspace.d.ts.map +1 -1
- package/dist/SyncWorkspace.js.map +1 -1
- package/dist/VersionService.d.ts +81 -9
- package/dist/VersionService.d.ts.map +1 -1
- package/dist/VersionService.js +113 -17
- package/dist/VersionService.js.map +1 -1
- package/dist/commands/auth.d.ts +3 -2
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/edit.d.ts +13 -0
- package/dist/commands/edit.d.ts.map +1 -0
- package/dist/commands/edit.js +49 -0
- package/dist/commands/edit.js.map +1 -0
- package/dist/commands/errorHandler.d.ts.map +1 -1
- package/dist/commands/issue.d.ts +1 -1
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +2 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/layers.d.ts +3 -3
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +5 -2
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/version.d.ts +8 -1
- package/dist/commands/version.d.ts.map +1 -1
- package/dist/commands/version.js +116 -4
- package/dist/commands/version.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/NodeLayers.js +1 -1
- package/dist/internal/NodeLayers.js.map +1 -1
- package/dist/internal/attachmentInsertion.d.ts.map +1 -1
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +2 -1
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/jqlBuilder.d.ts.map +1 -1
- package/dist/internal/oauthServer.d.ts +8 -2
- package/dist/internal/oauthServer.d.ts.map +1 -1
- package/dist/internal/oauthServer.js +10 -6
- package/dist/internal/oauthServer.js.map +1 -1
- package/dist/internal/openBrowser.d.ts.map +1 -1
- package/dist/internal/sync/baseline.d.ts.map +1 -1
- package/dist/internal/sync/baseline.js +2 -2
- package/dist/internal/sync/baseline.js.map +1 -1
- package/dist/internal/sync/changes.d.ts.map +1 -1
- package/dist/internal/sync/config.d.ts.map +1 -1
- package/dist/internal/sync/config.js +1 -1
- package/dist/internal/sync/config.js.map +1 -1
- package/dist/internal/sync/document.d.ts.map +1 -1
- package/dist/internal/sync/document.js +8 -8
- package/dist/internal/sync/document.js.map +1 -1
- package/dist/internal/sync/fieldValues.d.ts +9 -9
- package/dist/internal/sync/fieldValues.d.ts.map +1 -1
- package/dist/internal/sync/fieldValues.js +12 -16
- package/dist/internal/sync/fieldValues.js.map +1 -1
- package/dist/internal/sync/manifest.d.ts.map +1 -1
- package/dist/internal/sync/manifest.js +1 -1
- package/dist/internal/sync/manifest.js.map +1 -1
- package/dist/internal/sync/paths.d.ts.map +1 -1
- package/dist/internal/sync/schemas.d.ts +17 -9
- package/dist/internal/sync/schemas.d.ts.map +1 -1
- package/dist/internal/sync/schemas.js +6 -6
- package/dist/internal/sync/schemas.js.map +1 -1
- package/dist/internal/sync/types.d.ts +4 -4
- package/dist/internal/sync/types.d.ts.map +1 -1
- package/dist/internal/sync/types.js +1 -1
- package/dist/internal/sync/types.js.map +1 -1
- package/package.json +14 -10
- package/skills/jira/SKILL.md +17 -1
- package/src/AttachmentService.ts +6 -6
- package/src/IssueService.ts +214 -27
- package/src/JiraAuth.ts +86 -14
- package/src/MarkdownWriter.ts +6 -6
- package/src/SyncWorkspace.ts +12 -11
- package/src/VersionService.ts +199 -27
- package/src/commands/edit.ts +88 -0
- package/src/commands/issue.ts +2 -1
- package/src/commands/layers.ts +5 -2
- package/src/commands/version.ts +161 -5
- package/src/index.ts +13 -3
- package/src/internal/NodeLayers.ts +1 -1
- package/src/internal/frontmatter.ts +3 -2
- package/src/internal/oauthServer.ts +25 -13
- package/src/internal/sync/baseline.ts +3 -2
- package/src/internal/sync/config.ts +2 -1
- package/src/internal/sync/document.ts +15 -14
- package/src/internal/sync/fieldValues.ts +21 -22
- package/src/internal/sync/manifest.ts +1 -1
- package/src/internal/sync/schemas.ts +6 -6
- package/src/internal/sync/types.ts +4 -4
- package/test/JiraAuthRefresh.test.ts +269 -0
- package/test/commandTree.test.ts +157 -1
- package/test/editIssuePayload.test.ts +103 -0
- package/test/frontmatter.test.ts +4 -2
- package/test/integration.test.ts +5 -3
- package/test/oauthServer.test.ts +75 -3
- package/test/publicApi.test.ts +34 -0
- package/test/relatedWorkSync.test.ts +179 -0
- package/test/remoteWrites.test.ts +220 -0
- package/test/syncChanges.test.ts +2 -2
- 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 * as Layer from "effect/Layer"
|
|
28
|
+
import { TestClock } from "effect/testing"
|
|
29
|
+
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
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
|
+
})
|
package/test/commandTree.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
package/test/frontmatter.test.ts
CHANGED
|
@@ -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,
|
|
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 &&
|
|
16
|
+
return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed)
|
|
15
17
|
? Object.fromEntries(Object.entries(parsed))
|
|
16
18
|
: {}
|
|
17
19
|
}
|
package/test/integration.test.ts
CHANGED
|
@@ -17,7 +17,9 @@ import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
|
17
17
|
import { Config, Effect, Layer, Option } from "effect"
|
|
18
18
|
import * as FileSystem from "effect/FileSystem"
|
|
19
19
|
import * as Path from "effect/Path"
|
|
20
|
+
import * as Predicate from "effect/Predicate"
|
|
20
21
|
import * as Redacted from "effect/Redacted"
|
|
22
|
+
import type * as Schema from "effect/Schema"
|
|
21
23
|
import * as yaml from "js-yaml"
|
|
22
24
|
import { AttachmentService, layer as AttachmentServiceLayer } from "../src/AttachmentService.js"
|
|
23
25
|
import { insertJiraAttachmentReference } from "../src/internal/attachmentInsertion.js"
|
|
@@ -160,10 +162,10 @@ const expectCanonicalIssueIncludesAttachmentMedia = (
|
|
|
160
162
|
expect(markdown).toContain("image/svg+xml")
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
const parseFrontMatter = (markdown: string): Record<string,
|
|
165
|
+
const parseFrontMatter = (markdown: string): Record<string, Schema.Json> => {
|
|
164
166
|
const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
|
|
165
167
|
const parsed = match?.[1] ? yaml.load(match[1]) : {}
|
|
166
|
-
return parsed !== null &&
|
|
168
|
+
return parsed !== null && Predicate.isObjectOrArray(parsed) && !Array.isArray(parsed)
|
|
167
169
|
? Object.fromEntries(Object.entries(parsed))
|
|
168
170
|
: {}
|
|
169
171
|
}
|
|
@@ -191,7 +193,7 @@ const getIssueAsMarkdown = (config: IntegrationConfig, testDir: string) =>
|
|
|
191
193
|
expect(frontMatter.url).toBe(`${config.baseUrl}/browse/${config.issueKey}`)
|
|
192
194
|
expect(frontMatter.id).toBe(issue.id)
|
|
193
195
|
expect(frontMatter.summary).toBe(issue.summary)
|
|
194
|
-
expect(
|
|
196
|
+
expect(Predicate.isString(frontMatter.status)).toBe(true)
|
|
195
197
|
expect(markdown).toContain(`# ${config.issueKey}:`)
|
|
196
198
|
expectCanonicalIssueIncludesAttachmentMedia(config, issue, markdown)
|
|
197
199
|
|