@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +113 -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 +1 -1
  12. package/dist/JiraAuth.d.ts.map +1 -1
  13. package/dist/JiraAuth.js +74 -10
  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/commands/auth.d.ts +3 -2
  27. package/dist/commands/auth.d.ts.map +1 -1
  28. package/dist/commands/edit.d.ts +13 -0
  29. package/dist/commands/edit.d.ts.map +1 -0
  30. package/dist/commands/edit.js +49 -0
  31. package/dist/commands/edit.js.map +1 -0
  32. package/dist/commands/errorHandler.d.ts.map +1 -1
  33. package/dist/commands/issue.d.ts +1 -1
  34. package/dist/commands/issue.d.ts.map +1 -1
  35. package/dist/commands/issue.js +2 -1
  36. package/dist/commands/issue.js.map +1 -1
  37. package/dist/commands/layers.d.ts +3 -3
  38. package/dist/commands/layers.d.ts.map +1 -1
  39. package/dist/commands/layers.js +5 -2
  40. package/dist/commands/layers.js.map +1 -1
  41. package/dist/commands/version.d.ts +8 -1
  42. package/dist/commands/version.d.ts.map +1 -1
  43. package/dist/commands/version.js +116 -4
  44. package/dist/commands/version.js.map +1 -1
  45. package/dist/index.d.ts +4 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/NodeLayers.js +1 -1
  50. package/dist/internal/NodeLayers.js.map +1 -1
  51. package/dist/internal/attachmentInsertion.d.ts.map +1 -1
  52. package/dist/internal/frontmatter.d.ts.map +1 -1
  53. package/dist/internal/frontmatter.js +2 -1
  54. package/dist/internal/frontmatter.js.map +1 -1
  55. package/dist/internal/jqlBuilder.d.ts.map +1 -1
  56. package/dist/internal/oauthServer.d.ts +8 -2
  57. package/dist/internal/oauthServer.d.ts.map +1 -1
  58. package/dist/internal/oauthServer.js +10 -6
  59. package/dist/internal/oauthServer.js.map +1 -1
  60. package/dist/internal/openBrowser.d.ts.map +1 -1
  61. package/dist/internal/sync/baseline.d.ts.map +1 -1
  62. package/dist/internal/sync/baseline.js +2 -2
  63. package/dist/internal/sync/baseline.js.map +1 -1
  64. package/dist/internal/sync/changes.d.ts.map +1 -1
  65. package/dist/internal/sync/config.d.ts.map +1 -1
  66. package/dist/internal/sync/config.js +1 -1
  67. package/dist/internal/sync/config.js.map +1 -1
  68. package/dist/internal/sync/document.d.ts.map +1 -1
  69. package/dist/internal/sync/document.js +8 -8
  70. package/dist/internal/sync/document.js.map +1 -1
  71. package/dist/internal/sync/fieldValues.d.ts +9 -9
  72. package/dist/internal/sync/fieldValues.d.ts.map +1 -1
  73. package/dist/internal/sync/fieldValues.js +12 -16
  74. package/dist/internal/sync/fieldValues.js.map +1 -1
  75. package/dist/internal/sync/manifest.d.ts.map +1 -1
  76. package/dist/internal/sync/manifest.js +1 -1
  77. package/dist/internal/sync/manifest.js.map +1 -1
  78. package/dist/internal/sync/paths.d.ts.map +1 -1
  79. package/dist/internal/sync/schemas.d.ts +17 -9
  80. package/dist/internal/sync/schemas.d.ts.map +1 -1
  81. package/dist/internal/sync/schemas.js +6 -6
  82. package/dist/internal/sync/schemas.js.map +1 -1
  83. package/dist/internal/sync/types.d.ts +4 -4
  84. package/dist/internal/sync/types.d.ts.map +1 -1
  85. package/dist/internal/sync/types.js +1 -1
  86. package/dist/internal/sync/types.js.map +1 -1
  87. package/package.json +14 -10
  88. package/skills/jira/SKILL.md +17 -1
  89. package/src/AttachmentService.ts +6 -6
  90. package/src/IssueService.ts +214 -27
  91. package/src/JiraAuth.ts +86 -14
  92. package/src/MarkdownWriter.ts +6 -6
  93. package/src/SyncWorkspace.ts +12 -11
  94. package/src/VersionService.ts +199 -27
  95. package/src/commands/edit.ts +88 -0
  96. package/src/commands/issue.ts +2 -1
  97. package/src/commands/layers.ts +5 -2
  98. package/src/commands/version.ts +161 -5
  99. package/src/index.ts +13 -3
  100. package/src/internal/NodeLayers.ts +1 -1
  101. package/src/internal/frontmatter.ts +3 -2
  102. package/src/internal/oauthServer.ts +25 -13
  103. package/src/internal/sync/baseline.ts +3 -2
  104. package/src/internal/sync/config.ts +2 -1
  105. package/src/internal/sync/document.ts +15 -14
  106. package/src/internal/sync/fieldValues.ts +21 -22
  107. package/src/internal/sync/manifest.ts +1 -1
  108. package/src/internal/sync/schemas.ts +6 -6
  109. package/src/internal/sync/types.ts +4 -4
  110. package/test/JiraAuthRefresh.test.ts +269 -0
  111. package/test/commandTree.test.ts +157 -1
  112. package/test/editIssuePayload.test.ts +103 -0
  113. package/test/frontmatter.test.ts +4 -2
  114. package/test/integration.test.ts +5 -3
  115. package/test/oauthServer.test.ts +75 -3
  116. package/test/publicApi.test.ts +34 -0
  117. package/test/relatedWorkSync.test.ts +179 -0
  118. package/test/remoteWrites.test.ts +220 -0
  119. package/test/syncChanges.test.ts +2 -2
  120. package/test/syncConfig.test.ts +2 -2
@@ -1,15 +1,25 @@
1
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
1
2
  import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
2
3
  import { describe, expect, it } from "@effect/vitest"
3
4
  import * as Effect from "effect/Effect"
5
+ import * as Fiber from "effect/Fiber"
4
6
  import * as Layer from "effect/Layer"
5
7
  import * as Ref from "effect/Ref"
6
- import { HttpServer, HttpServerError } from "effect/unstable/http"
8
+ import { HttpClient, HttpClientRequest, HttpServer, HttpServerError } from "effect/unstable/http"
7
9
  import { createServer } from "node:http"
8
- import { makeHttpServerFactory, startCallbackServer } from "../src/internal/oauthServer.js"
10
+ import { HttpServerFactoryLive } from "../src/internal/NodeLayers.js"
11
+ import {
12
+ callbackServerListenOptions,
13
+ callbackUrl,
14
+ HttpServerFactoryTag,
15
+ makeHttpServerFactory,
16
+ startCallbackServer
17
+ } from "../src/internal/oauthServer.js"
9
18
 
10
19
  const EphemeralHttpServerFactoryLive = makeHttpServerFactory(
11
20
  () => NodeHttpServer.layerServer(createServer, { port: 0 })
12
21
  )
22
+ const HttpClientLive = NodeHttpClient.layerUndici
13
23
  const fakeHttpServer = (port: number): HttpServer.HttpServer["Service"] =>
14
24
  HttpServer.make({
15
25
  address: { _tag: "TcpAddress", hostname: "127.0.0.1", port },
@@ -17,6 +27,23 @@ const fakeHttpServer = (port: number): HttpServer.HttpServer["Service"] =>
17
27
  })
18
28
 
19
29
  describe("oauth callback server lifecycle", () => {
30
+ it.effect("binds the same localhost name advertised by the callback URL", () =>
31
+ Effect.scoped(
32
+ Effect.gen(function*() {
33
+ const listenOptions = callbackServerListenOptions(0)
34
+ const factory = yield* HttpServerFactoryTag
35
+ const server = yield* HttpServer.HttpServer.pipe(
36
+ Effect.provide(factory.createServerLayer(listenOptions))
37
+ )
38
+ const advertisedUrl = new URL(callbackUrl(8585))
39
+ const ipv6OnlyLocalhost = (hostname: string) => (hostname === "localhost" ? "::1" : undefined)
40
+ expect(server.address._tag).toBe("TcpAddress")
41
+ expect(listenOptions.host).toBe(advertisedUrl.hostname)
42
+ expect(ipv6OnlyLocalhost(listenOptions.host)).toBe("::1")
43
+ expect(ipv6OnlyLocalhost(advertisedUrl.hostname)).toBe("::1")
44
+ }).pipe(Effect.provide(HttpServerFactoryLive))
45
+ ))
46
+
20
47
  it.effect("releases the port when authorization exits before receiving a code", () =>
21
48
  Effect.gen(function*() {
22
49
  yield* Effect.scoped(
@@ -51,7 +78,7 @@ describe("oauth callback server lifecycle", () => {
51
78
 
52
79
  it.effect("retries occupied fixed-range ports with the injected factory", () => {
53
80
  const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
54
- const occupiedFactory = makeHttpServerFactory((port) =>
81
+ const occupiedFactory = makeHttpServerFactory(({ port }) =>
55
82
  Layer.effect(
56
83
  HttpServer.HttpServer,
57
84
  Ref.update(attempts, (ports) => [...ports, port]).pipe(
@@ -96,4 +123,49 @@ describe("oauth callback server lifecycle", () => {
96
123
  )
97
124
  )
98
125
  })
126
+
127
+ it.effect("ignores a forged error callback before accepting the legitimate state", () =>
128
+ Effect.scoped(
129
+ Effect.gen(function*() {
130
+ const expectedState = "expected-state"
131
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
132
+ const codeReceiver = yield* Effect.forkChild(codePromise)
133
+ const client = yield* HttpClient.HttpClient
134
+
135
+ const forgedResponse = yield* client.execute(
136
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
137
+ HttpClientRequest.setUrlParam("error", "access_denied")
138
+ )
139
+ )
140
+ expect(forgedResponse.status).toBe(403)
141
+ yield* client.execute(
142
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
143
+ HttpClientRequest.setUrlParam("code", "legitimate-code"),
144
+ HttpClientRequest.setUrlParam("state", expectedState)
145
+ )
146
+ )
147
+
148
+ expect(yield* Fiber.join(codeReceiver)).toBe("legitimate-code")
149
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
150
+ ))
151
+
152
+ it.effect("accepts a provider error only after validating state", () =>
153
+ Effect.scoped(
154
+ Effect.gen(function*() {
155
+ const expectedState = "expected-state"
156
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
157
+ const codeReceiver = yield* Effect.forkChild(codePromise)
158
+ const client = yield* HttpClient.HttpClient
159
+
160
+ yield* client.execute(
161
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
162
+ HttpClientRequest.setUrlParam("error", "access_denied"),
163
+ HttpClientRequest.setUrlParam("state", expectedState)
164
+ )
165
+ )
166
+
167
+ const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
168
+ expect(String(error.cause)).toContain("access_denied")
169
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
170
+ ))
99
171
  })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The root barrel is the published surface.
3
+ *
4
+ * The changeset advertises `planRelatedWorkSync` as the reusable planning step,
5
+ * but nothing re-exported it and `package.json` exposed no `VersionService`
6
+ * subpath, so both `import { planRelatedWorkSync } from "@knpkv/jira-cli"` and
7
+ * the natural subpath import would have failed after publication.
8
+ */
9
+ import { describe, expect, it } from "@effect/vitest"
10
+ import * as Predicate from "effect/Predicate"
11
+ import { readFileSync } from "node:fs"
12
+ import { fileURLToPath } from "node:url"
13
+ import * as api from "../src/index.js"
14
+
15
+ const manifestExports = (): ReadonlyArray<string> => {
16
+ const parsed: unknown = JSON.parse(
17
+ readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")
18
+ )
19
+ if (!Predicate.isObject(parsed)) return []
20
+ const exports = parsed["exports"]
21
+ return Predicate.isObject(exports) ? Object.keys(exports) : []
22
+ }
23
+
24
+ describe("@knpkv/jira-cli public API", () => {
25
+ it("exports the related-work planner the changeset advertises", () => {
26
+ expect(Predicate.isFunction(api.planRelatedWorkSync)).toBe(true)
27
+ expect(api.VersionService).toBeDefined()
28
+ expect(api.VersionServiceLayer).toBeDefined()
29
+ })
30
+
31
+ it("declares a VersionService subpath alongside the other services", () => {
32
+ expect(manifestExports()).toContain("./VersionService")
33
+ })
34
+ })
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import type { RelatedWork } from "../src/VersionService.js"
3
+ import { planRelatedWorkSync } from "../src/VersionService.js"
4
+
5
+ const link = (
6
+ title: string,
7
+ url: string,
8
+ category = "Communication",
9
+ relatedWorkId: string | null = `id-${title}`
10
+ ): RelatedWork => ({ relatedWorkId, title, category, url })
11
+
12
+ const notes = { title: "Release Notes - REL 96", url: "https://example.atlassian.net/wiki/pages/1" }
13
+ const vra = { title: "Verbal Risk Assessment - REL 96", url: "https://example.atlassian.net/wiki/pages/2" }
14
+ const report = { title: "Test Report - REL 96", url: "https://example.atlassian.net/wiki/pages/3" }
15
+
16
+ const options = { category: "Communication", prune: false }
17
+
18
+ describe("planRelatedWorkSync", () => {
19
+ it("adds every link when the version has none", () => {
20
+ const plan = planRelatedWorkSync([], [notes, vra, report], options)
21
+
22
+ expect(plan.toAdd).toEqual([notes, vra, report])
23
+ expect(plan.kept).toEqual([])
24
+ expect(plan.toRemove).toEqual([])
25
+ })
26
+
27
+ // The whole point: scaffolding a release twice must not double the links.
28
+ it("is a no-op on a second run", () => {
29
+ const desired = [notes, vra, report]
30
+ const existing = desired.map((d) => link(d.title, d.url))
31
+
32
+ const plan = planRelatedWorkSync(existing, desired, options)
33
+
34
+ expect(plan.toAdd).toEqual([])
35
+ expect(plan.kept).toEqual([notes.url, vra.url, report.url])
36
+ expect(plan.toRemove).toEqual([])
37
+ })
38
+
39
+ it("adds only what is missing", () => {
40
+ const plan = planRelatedWorkSync([link(notes.title, notes.url)], [notes, vra], options)
41
+
42
+ expect(plan.toAdd).toEqual([vra])
43
+ expect(plan.kept).toEqual([notes.url])
44
+ })
45
+
46
+ // A link retitled in the Jira UI is still the same link; matching on URL
47
+ // keeps the user's edit instead of adding a duplicate under our title.
48
+ it("matches on URL, not title", () => {
49
+ const existing = [link("Renamed by hand", notes.url)]
50
+
51
+ const plan = planRelatedWorkSync(existing, [notes], options)
52
+
53
+ expect(plan.toAdd).toEqual([])
54
+ expect(plan.kept).toEqual([notes.url])
55
+ })
56
+
57
+ it("ignores links in other categories", () => {
58
+ const existing = [link(notes.title, notes.url, "Testing")]
59
+
60
+ const plan = planRelatedWorkSync(existing, [notes], options)
61
+
62
+ expect(plan.toAdd).toEqual([notes])
63
+ })
64
+
65
+ it("leaves extras alone without --prune", () => {
66
+ const existing = [link(notes.title, notes.url), link("Stale", "https://example.atlassian.net/wiki/pages/99")]
67
+
68
+ const plan = planRelatedWorkSync(existing, [notes], options)
69
+
70
+ expect(plan.toRemove).toEqual([])
71
+ })
72
+
73
+ it("removes extras in the category with --prune", () => {
74
+ const existing = [link(notes.title, notes.url), link("Stale", "https://example.atlassian.net/wiki/pages/99")]
75
+
76
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
77
+
78
+ expect(plan.toRemove).toEqual([
79
+ { relatedWorkId: "id-Stale", url: "https://example.atlassian.net/wiki/pages/99" }
80
+ ])
81
+ })
82
+
83
+ it("never prunes across categories", () => {
84
+ const existing = [link("Other team's link", "https://example.atlassian.net/wiki/pages/99", "Testing")]
85
+
86
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
87
+
88
+ expect(plan.toRemove).toEqual([])
89
+ expect(plan.toAdd).toEqual([notes])
90
+ })
91
+
92
+ it("skips prune candidates Jira gave no id for", () => {
93
+ const existing = [link("No id", "https://example.atlassian.net/wiki/pages/99", "Communication", null)]
94
+
95
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
96
+
97
+ expect(plan.toRemove).toEqual([])
98
+ })
99
+
100
+ // The pile-up the reconcile exists to clean up: repeated `related-work add`
101
+ // leaves several links to the same page, all of them wanted URLs.
102
+ it("prunes surplus copies of a desired URL, keeping the first", () => {
103
+ const existing = [
104
+ link("Release Notes", notes.url, "Communication", "id-first"),
105
+ link("Release Notes", notes.url, "Communication", "id-second"),
106
+ link("Release Notes", notes.url, "Communication", "id-third")
107
+ ]
108
+
109
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
110
+
111
+ expect(plan.toAdd).toEqual([])
112
+ expect(plan.kept).toEqual([notes.url])
113
+ expect(plan.toRemove).toEqual([
114
+ { relatedWorkId: "id-second", url: notes.url },
115
+ { relatedWorkId: "id-third", url: notes.url }
116
+ ])
117
+ })
118
+
119
+ it("leaves duplicate copies alone without --prune", () => {
120
+ const existing = [
121
+ link("Release Notes", notes.url, "Communication", "id-first"),
122
+ link("Release Notes", notes.url, "Communication", "id-second")
123
+ ]
124
+
125
+ const plan = planRelatedWorkSync(existing, [notes], options)
126
+
127
+ expect(plan.toRemove).toEqual([])
128
+ })
129
+
130
+ // Two --link flags for one page are one link: URL is the identity on the
131
+ // desired side too, or the reconcile would itself create the duplicates.
132
+ it("collapses repeated desired entries to a single add", () => {
133
+ const plan = planRelatedWorkSync([], [notes, { title: "Same page, other title", url: notes.url }], options)
134
+
135
+ expect(plan.toAdd).toEqual([notes])
136
+ })
137
+
138
+ // Every desired link carries a URL, so a url-less entry can never be one of
139
+ // them. Skipping it left the category unreconciled while --prune reported
140
+ // success.
141
+ it("prunes a url-less entry that Jira gave an id", () => {
142
+ const existing = [
143
+ link("Release Notes", notes.url),
144
+ { relatedWorkId: "id-orphan", title: "Legacy attachment", category: "Communication", url: null }
145
+ ]
146
+
147
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
148
+
149
+ expect(plan.toRemove).toEqual([{ relatedWorkId: "id-orphan", url: null }])
150
+ })
151
+
152
+ // Jira can hand back a desired URL twice with only the second copy
153
+ // deletable. Skipping the undeletable one before designating a keeper let the
154
+ // deletable duplicate claim that role, so both survived a reconcile that
155
+ // reported success.
156
+ it("prunes the deletable duplicate when the keeper has no id", () => {
157
+ const existing = [
158
+ { relatedWorkId: null, title: "Release Notes", category: "Communication", url: notes.url },
159
+ link("Release Notes", notes.url, "Communication", "id-second")
160
+ ]
161
+
162
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
163
+
164
+ expect(plan.toRemove).toEqual([{ relatedWorkId: "id-second", url: notes.url }])
165
+ })
166
+
167
+ // The nearby valid fixture: with no id there is nothing to delete with, so
168
+ // it stays skipped however it is pruned.
169
+ it("leaves a url-less entry alone when Jira gave no id either", () => {
170
+ const existing = [
171
+ link("Release Notes", notes.url),
172
+ { relatedWorkId: null, title: "Legacy attachment", category: "Communication", url: null }
173
+ ]
174
+
175
+ const plan = planRelatedWorkSync(existing, [notes], { category: "Communication", prune: true })
176
+
177
+ expect(plan.toRemove).toEqual([])
178
+ })
179
+ })
@@ -0,0 +1,220 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { JiraApiClient, make } from "@knpkv/jira-api-client"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Redacted from "effect/Redacted"
6
+ import * as Schema from "effect/Schema"
7
+ import * as HttpClient from "effect/unstable/http/HttpClient"
8
+ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
9
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
10
+ import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
11
+ import { layer as VersionServiceLayer, VersionService } from "../src/VersionService.js"
12
+
13
+ /** One recorded outbound call, so a test can assert what actually went to Jira. */
14
+ interface RecordedRequest {
15
+ readonly method: string
16
+ readonly url: string
17
+ readonly body: Schema.Json | undefined
18
+ }
19
+
20
+ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
21
+
22
+ const bodyJson = (request: HttpClientRequest.HttpClientRequest): Schema.Json | undefined => {
23
+ if (request.body._tag !== "Uint8Array") return undefined
24
+ return decodeJson(new TextDecoder().decode(request.body.body))
25
+ }
26
+
27
+ const issueFields = {
28
+ attachment: [],
29
+ components: [],
30
+ created: "2026-01-01T00:00:00.000Z",
31
+ description: "",
32
+ issuetype: { name: "Task" },
33
+ status: { name: "Done" },
34
+ summary: "Sample issue",
35
+ updated: "2026-01-01T00:00:00.000Z"
36
+ }
37
+
38
+ /**
39
+ * Route by method+path and record every call.
40
+ *
41
+ * `respond` returns the web `Response` for a request; anything it does not
42
+ * recognise 404s, so an unexpected extra call fails loudly rather than being
43
+ * absorbed by a permissive catch-all.
44
+ */
45
+ const makeRecordingLayer = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
46
+ const calls: Array<RecordedRequest> = []
47
+ const httpClient = HttpClient.make((request) => {
48
+ calls.push({ method: request.method, url: request.url, body: bodyJson(request) })
49
+ return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request)))
50
+ })
51
+ const api = make(httpClient, {
52
+ baseUrl: "https://jira.test",
53
+ auth: { type: "basic", email: "test@example.com", apiToken: Redacted.make("token") }
54
+ })
55
+ const layer = Layer.succeed(
56
+ JiraApiClient,
57
+ JiraApiClient.of({ ...api, uploadAttachment: () => Effect.die("unused Jira upload mock") })
58
+ )
59
+ return { calls, layer }
60
+ }
61
+
62
+ const json = <UnparsedInput>(body: UnparsedInput, status = 200) =>
63
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } })
64
+
65
+ describe("IssueService.edit", () => {
66
+ /**
67
+ * The payload builder is unit-tested separately; what this covers is that the
68
+ * body it builds actually reaches `PUT /issue/{key}` unaltered, rather than
69
+ * being reshaped or dropped on the way through the generated client.
70
+ */
71
+ it.effect("sends the built payload to PUT /issue/{key} and re-reads the issue", () => {
72
+ const { calls, layer } = makeRecordingLayer((request) =>
73
+ request.method === "PUT"
74
+ // Jira answers a default edit with 204 No Content.
75
+ ? new Response(null, { status: 204 })
76
+ : json({
77
+ id: "10000",
78
+ key: "PROJ-123",
79
+ fields: { ...issueFields, fixVersions: [{ name: "OOB 100" }], labels: ["domain:oob"] },
80
+ renderedFields: {}
81
+ })
82
+ )
83
+
84
+ return Effect.gen(function*() {
85
+ const service = yield* IssueService
86
+ const issue = yield* service.edit("PROJ-123", {
87
+ addFixVersions: ["OOB 100"],
88
+ addLabels: ["domain:oob"]
89
+ })
90
+
91
+ const put = calls.find((call) => call.method === "PUT")
92
+ expect(put?.url).toContain("/rest/api/3/issue/PROJ-123")
93
+ expect(put?.body).toEqual({
94
+ update: {
95
+ fixVersions: [{ add: { name: "OOB 100" } }],
96
+ labels: [{ add: "domain:oob" }]
97
+ }
98
+ })
99
+
100
+ // The 204 carries no body, so the reported state has to come from a re-read.
101
+ expect(calls.filter((call) => call.method === "GET")).toHaveLength(1)
102
+ expect(issue.fixVersions).toEqual(["OOB 100"])
103
+ expect(issue.labels).toEqual(["domain:oob"])
104
+ }).pipe(
105
+ Effect.provide(IssueServiceLayer),
106
+ Effect.provide(layer),
107
+ Effect.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
108
+ )
109
+ })
110
+
111
+ /**
112
+ * Once the PUT lands the edit is committed. Reporting the read-back failure as
113
+ * a failed edit invites the caller to repeat a change that already happened.
114
+ */
115
+ it.effect("reports a failed read-back as committed-but-unverified, not as a failed edit", () => {
116
+ const { calls, layer } = makeRecordingLayer((request) =>
117
+ request.method === "PUT"
118
+ ? new Response(null, { status: 204 })
119
+ : json({ errorMessages: ["Internal server error"] }, 500)
120
+ )
121
+
122
+ return Effect.gen(function*() {
123
+ const service = yield* IssueService
124
+ const error = yield* Effect.flip(service.edit("PROJ-123", { addFixVersions: ["OOB 100"] }))
125
+
126
+ expect(error.message).toContain("do not repeat it")
127
+ expect(error.message).not.toContain("Failed to edit")
128
+ // The write really did go out — that is why the wording matters.
129
+ expect(calls.filter((call) => call.method === "PUT")).toHaveLength(1)
130
+ }).pipe(
131
+ Effect.provide(IssueServiceLayer),
132
+ Effect.provide(layer),
133
+ Effect.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
134
+ )
135
+ })
136
+
137
+ /** A refused combination must never reach the network. */
138
+ it.effect("fails before calling Jira when the flags contradict each other", () => {
139
+ const { calls, layer } = makeRecordingLayer(() => new Response(null, { status: 204 }))
140
+
141
+ return Effect.gen(function*() {
142
+ const service = yield* IssueService
143
+ const result = yield* Effect.flip(
144
+ service.edit("PROJ-123", { setFixVersions: ["OOB 100"], addFixVersions: ["OOB 99"] })
145
+ )
146
+
147
+ expect(result.message).toContain("--fix-version cannot be combined")
148
+ expect(calls).toHaveLength(0)
149
+ }).pipe(
150
+ Effect.provide(IssueServiceLayer),
151
+ Effect.provide(layer),
152
+ Effect.provide(Layer.succeed(SiteUrl, "https://example.atlassian.net"))
153
+ )
154
+ })
155
+ })
156
+
157
+ describe("VersionService.createVersion", () => {
158
+ /**
159
+ * The create endpoint wants a numeric `projectId`, but `/project/{key}`
160
+ * serialises the id as a string — so the coercion is the whole reason the
161
+ * project lookup exists, and sending the string form is a 400 from Jira.
162
+ */
163
+ it.effect("resolves the project key to a numeric projectId", () => {
164
+ const { calls, layer } = makeRecordingLayer((request) =>
165
+ request.url.includes("/rest/api/3/project/")
166
+ ? json({ id: "10001", key: "PROJ" })
167
+ : json({ id: "10042", name: "OOB 100", released: false, archived: false }, 201)
168
+ )
169
+
170
+ return Effect.gen(function*() {
171
+ const service = yield* VersionService
172
+ const version = yield* service.createVersion({
173
+ projectKey: "PROJ",
174
+ name: "OOB 100",
175
+ description: "Q3 release"
176
+ })
177
+
178
+ const post = calls.find((call) => call.method === "POST")
179
+ expect(post?.url).toContain("/rest/api/3/version")
180
+ expect(post?.body).toEqual({ name: "OOB 100", projectId: 10001, description: "Q3 release" })
181
+ expect(version.id).toBe("10042")
182
+ expect(version.released).toBe(false)
183
+ }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(layer))
184
+ })
185
+
186
+ /** Omitted optional fields must be absent, not sent as `undefined`/null. */
187
+ it.effect("omits the optional date and description fields when not given", () => {
188
+ const { calls, layer } = makeRecordingLayer((request) =>
189
+ request.url.includes("/rest/api/3/project/")
190
+ ? json({ id: "10001", key: "PROJ" })
191
+ : json({ id: "10042", name: "OOB 100", released: false, archived: false }, 201)
192
+ )
193
+
194
+ return Effect.gen(function*() {
195
+ const service = yield* VersionService
196
+ yield* service.createVersion({ projectKey: "PROJ", name: "OOB 100" })
197
+
198
+ expect(calls.find((call) => call.method === "POST")?.body).toEqual({
199
+ name: "OOB 100",
200
+ projectId: 10001
201
+ })
202
+ }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(layer))
203
+ })
204
+
205
+ /**
206
+ * Without a usable id there is nothing to create the version against, and
207
+ * Jira's own error for a missing projectId does not say so.
208
+ */
209
+ it.effect("fails with a named error when the project returns no numeric id", () => {
210
+ const { calls, layer } = makeRecordingLayer(() => json({ key: "PROJ" }))
211
+
212
+ return Effect.gen(function*() {
213
+ const service = yield* VersionService
214
+ const error = yield* Effect.flip(service.createVersion({ projectKey: "PROJ", name: "OOB 100" }))
215
+
216
+ expect(error.message).toContain("no usable numeric id")
217
+ expect(calls.filter((call) => call.method === "POST")).toHaveLength(0)
218
+ }).pipe(Effect.provide(VersionServiceLayer), Effect.provide(layer))
219
+ })
220
+ })
@@ -10,7 +10,7 @@ const fields = (overrides: Partial<SyncBaselineFields> = {}): SyncBaselineFields
10
10
  Risk: {
11
11
  fieldId: "customfield_1",
12
12
  displayName: "Risk",
13
- shape: "singleSelect",
13
+ form: "singleSelect",
14
14
  value: { id: "1", value: "Low" }
15
15
  }
16
16
  },
@@ -80,7 +80,7 @@ describe("compareIssueFields", () => {
80
80
  Risk: {
81
81
  fieldId: "customfield_1",
82
82
  displayName: "Risk",
83
- shape: "singleSelect",
83
+ form: "singleSelect",
84
84
  value: { id: "2", value: "High" }
85
85
  }
86
86
  }
@@ -29,7 +29,7 @@ customFields:
29
29
  expect(result.customFields[1]).toMatchObject({
30
30
  displayName: "Reviewer",
31
31
  fieldId: "customfield_12345",
32
- shape: "user"
32
+ form: "user"
33
33
  })
34
34
  })
35
35
 
@@ -117,7 +117,7 @@ customFields: []
117
117
  "Security & Compliance Impact": {
118
118
  fieldId: "customfield_10001",
119
119
  displayName: "Security & Compliance Impact",
120
- shape: "singleSelect",
120
+ form: "singleSelect",
121
121
  value: { id: "10423", value: "Low" }
122
122
  }
123
123
  }