@knpkv/jira-cli 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -9
  3. package/dist/JiraCliError.d.ts +30 -0
  4. package/dist/JiraCliError.d.ts.map +1 -1
  5. package/dist/JiraCliError.js +14 -0
  6. package/dist/JiraCliError.js.map +1 -1
  7. package/dist/SyncWorkspace.d.ts +34 -0
  8. package/dist/SyncWorkspace.d.ts.map +1 -0
  9. package/dist/SyncWorkspace.js +105 -0
  10. package/dist/SyncWorkspace.js.map +1 -0
  11. package/dist/bin.js +4 -5
  12. package/dist/bin.js.map +1 -1
  13. package/dist/commands/get.d.ts.map +1 -1
  14. package/dist/commands/get.js +2 -2
  15. package/dist/commands/get.js.map +1 -1
  16. package/dist/commands/index.d.ts +1 -2
  17. package/dist/commands/index.d.ts.map +1 -1
  18. package/dist/commands/index.js +1 -2
  19. package/dist/commands/index.js.map +1 -1
  20. package/dist/commands/issue.d.ts +8 -0
  21. package/dist/commands/issue.d.ts.map +1 -0
  22. package/dist/commands/issue.js +10 -0
  23. package/dist/commands/issue.js.map +1 -0
  24. package/dist/commands/layers.d.ts.map +1 -1
  25. package/dist/commands/layers.js +4 -1
  26. package/dist/commands/layers.js.map +1 -1
  27. package/dist/commands/search.d.ts.map +1 -1
  28. package/dist/commands/search.js +4 -4
  29. package/dist/commands/search.js.map +1 -1
  30. package/dist/commands/version.js +13 -13
  31. package/dist/commands/version.js.map +1 -1
  32. package/dist/internal/frontmatter.d.ts.map +1 -1
  33. package/dist/internal/frontmatter.js +14 -1
  34. package/dist/internal/frontmatter.js.map +1 -1
  35. package/dist/internal/sync/baseline.d.ts +11 -0
  36. package/dist/internal/sync/baseline.d.ts.map +1 -0
  37. package/dist/internal/sync/baseline.js +18 -0
  38. package/dist/internal/sync/baseline.js.map +1 -0
  39. package/dist/internal/sync/changes.d.ts +15 -0
  40. package/dist/internal/sync/changes.d.ts.map +1 -0
  41. package/dist/internal/sync/changes.js +72 -0
  42. package/dist/internal/sync/changes.js.map +1 -0
  43. package/dist/internal/sync/config.d.ts +12 -0
  44. package/dist/internal/sync/config.d.ts.map +1 -0
  45. package/dist/internal/sync/config.js +53 -0
  46. package/dist/internal/sync/config.js.map +1 -0
  47. package/dist/internal/sync/document.d.ts +9 -0
  48. package/dist/internal/sync/document.d.ts.map +1 -0
  49. package/dist/internal/sync/document.js +173 -0
  50. package/dist/internal/sync/document.js.map +1 -0
  51. package/dist/internal/sync/fieldValues.d.ts +30 -0
  52. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  53. package/dist/internal/sync/fieldValues.js +91 -0
  54. package/dist/internal/sync/fieldValues.js.map +1 -0
  55. package/dist/internal/sync/manifest.d.ts +12 -0
  56. package/dist/internal/sync/manifest.d.ts.map +1 -0
  57. package/dist/internal/sync/manifest.js +23 -0
  58. package/dist/internal/sync/manifest.js.map +1 -0
  59. package/dist/internal/sync/paths.d.ts +26 -0
  60. package/dist/internal/sync/paths.d.ts.map +1 -0
  61. package/dist/internal/sync/paths.js +22 -0
  62. package/dist/internal/sync/paths.js.map +1 -0
  63. package/dist/internal/sync/schemas.d.ts +128 -0
  64. package/dist/internal/sync/schemas.d.ts.map +1 -0
  65. package/dist/internal/sync/schemas.js +82 -0
  66. package/dist/internal/sync/schemas.js.map +1 -0
  67. package/dist/internal/sync/types.d.ts +144 -0
  68. package/dist/internal/sync/types.d.ts.map +1 -0
  69. package/dist/internal/sync/types.js +17 -0
  70. package/dist/internal/sync/types.js.map +1 -0
  71. package/package.json +5 -2
  72. package/skills/jira/SKILL.md +11 -11
  73. package/src/JiraCliError.ts +24 -0
  74. package/src/SyncWorkspace.ts +185 -0
  75. package/src/bin.ts +4 -6
  76. package/src/commands/get.ts +2 -2
  77. package/src/commands/index.ts +1 -2
  78. package/src/commands/issue.ts +13 -0
  79. package/src/commands/layers.ts +4 -1
  80. package/src/commands/search.ts +4 -4
  81. package/src/commands/version.ts +15 -15
  82. package/src/internal/frontmatter.ts +15 -1
  83. package/src/internal/sync/baseline.ts +27 -0
  84. package/src/internal/sync/changes.ts +118 -0
  85. package/src/internal/sync/config.ts +76 -0
  86. package/src/internal/sync/document.ts +201 -0
  87. package/src/internal/sync/fieldValues.ts +145 -0
  88. package/src/internal/sync/manifest.ts +32 -0
  89. package/src/internal/sync/paths.ts +48 -0
  90. package/src/internal/sync/schemas.ts +103 -0
  91. package/src/internal/sync/types.ts +192 -0
  92. package/test/SyncWorkspace.test.ts +76 -0
  93. package/test/commandTree.test.ts +266 -0
  94. package/test/frontmatter.test.ts +69 -0
  95. package/test/integration.test.ts +187 -0
  96. package/test/syncChanges.test.ts +106 -0
  97. package/test/syncConfig.test.ts +138 -0
  98. package/test/syncDocument.test.ts +69 -0
  99. package/test/syncFieldValues.test.ts +101 -0
  100. package/vitest.config.integration.ts +17 -0
  101. package/vitest.config.ts +1 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Read-only integration tests for Jira issue fetching and Markdown export.
3
+ *
4
+ * Requires:
5
+ * - JIRA_INTEGRATION=1
6
+ * - JIRA_EMAIL
7
+ * - JIRA_API_KEY
8
+ *
9
+ * Optional overrides:
10
+ * - JIRA_BASE_URL defaults to https://knpkv.atlassian.net
11
+ * - JIRA_ISSUE_KEY defaults to KAN-1
12
+ */
13
+ import * as NodeServices from "@effect/platform-node/NodeServices"
14
+ import { describe, expect, it } from "@effect/vitest"
15
+ import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
16
+ import { Config, Effect, Layer, Option } from "effect"
17
+ import * as FileSystem from "effect/FileSystem"
18
+ import * as Path from "effect/Path"
19
+ import * as Redacted from "effect/Redacted"
20
+ import * as yaml from "js-yaml"
21
+ import { IssueService, layer as IssueServiceLayer, SiteUrl } from "../src/IssueService.js"
22
+ import { layer as MarkdownWriterLayer, MarkdownWriter } from "../src/MarkdownWriter.js"
23
+
24
+ const DEFAULT_BASE_URL = "https://knpkv.atlassian.net"
25
+ const DEFAULT_ISSUE_KEY = "KAN-1"
26
+
27
+ interface IntegrationConfig {
28
+ readonly baseUrl: string
29
+ readonly issueKey: string
30
+ readonly email: string
31
+ readonly apiKey: Redacted.Redacted<string>
32
+ }
33
+
34
+ const envFlagEnabled = (value: string): boolean => ["1", "true", "yes"].includes(value.toLowerCase())
35
+
36
+ const nonEmptyOrDefault = (value: Option.Option<string>, fallback: string): string =>
37
+ Option.match(value, {
38
+ onNone: () => fallback,
39
+ onSome: (some) => some.trim().length > 0 ? some : fallback
40
+ })
41
+
42
+ const requireNonEmpty = (name: string, value: string): Effect.Effect<string, Error> =>
43
+ value.trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
44
+
45
+ const requireNonEmptyRedacted = (
46
+ name: string,
47
+ value: Redacted.Redacted<string>
48
+ ): Effect.Effect<Redacted.Redacted<string>, Error> =>
49
+ Redacted.value(value).trim().length > 0 ? Effect.succeed(value) : Effect.fail(new Error(`${name} must be set`))
50
+
51
+ const SHOULD_RUN_INTEGRATION = Effect.runSync(
52
+ Config.option(Config.string("JIRA_INTEGRATION")).pipe(
53
+ Effect.map((enabled) => Option.isSome(enabled) && envFlagEnabled(enabled.value))
54
+ )
55
+ )
56
+
57
+ const readIntegrationConfig = Effect.gen(function*() {
58
+ const baseUrl = yield* Config.option(Config.string("JIRA_BASE_URL"))
59
+ const issueKey = yield* Config.option(Config.string("JIRA_ISSUE_KEY"))
60
+ const email = yield* Config.string("JIRA_EMAIL")
61
+ const apiKey = yield* Config.redacted("JIRA_API_KEY")
62
+
63
+ return {
64
+ baseUrl: nonEmptyOrDefault(baseUrl, DEFAULT_BASE_URL).replace(/\/+$/, ""),
65
+ issueKey: nonEmptyOrDefault(issueKey, DEFAULT_ISSUE_KEY),
66
+ email: yield* requireNonEmpty("JIRA_EMAIL", email),
67
+ apiKey: yield* requireNonEmptyRedacted("JIRA_API_KEY", apiKey)
68
+ } satisfies IntegrationConfig
69
+ })
70
+
71
+ const makeIntegrationLayer = (config: IntegrationConfig) => {
72
+ const configLayer = Layer.succeed(JiraApiConfig, {
73
+ baseUrl: config.baseUrl,
74
+ auth: {
75
+ type: "basic" as const,
76
+ email: config.email,
77
+ apiToken: config.apiKey
78
+ }
79
+ })
80
+
81
+ return MarkdownWriterLayer.pipe(
82
+ Layer.provideMerge(IssueServiceLayer),
83
+ Layer.provideMerge(Layer.succeed(SiteUrl, config.baseUrl)),
84
+ Layer.provideMerge(JiraApiClient.layer),
85
+ Layer.provideMerge(configLayer),
86
+ Layer.provideMerge(NodeServices.layer)
87
+ )
88
+ }
89
+
90
+ const makeTempRoot = Effect.gen(function*() {
91
+ const fs = yield* FileSystem.FileSystem
92
+ return yield* fs.makeTempDirectoryScoped()
93
+ })
94
+
95
+ const joinPath = (...parts: ReadonlyArray<string>) =>
96
+ Path.Path.pipe(
97
+ Effect.map((path) => path.join(...parts))
98
+ )
99
+
100
+ const readText = (filePath: string) =>
101
+ FileSystem.FileSystem.pipe(
102
+ Effect.flatMap((fs) => fs.readFileString(filePath))
103
+ )
104
+
105
+ const pathExists = (filePath: string) =>
106
+ FileSystem.FileSystem.pipe(
107
+ Effect.flatMap((fs) => fs.exists(filePath))
108
+ )
109
+
110
+ const parseFrontMatter = (markdown: string): Record<string, unknown> => {
111
+ const match = /^---\n([\s\S]*?)\n---/.exec(markdown)
112
+ const parsed = match?.[1] ? yaml.load(match[1]) : {}
113
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
114
+ ? parsed as Record<string, unknown>
115
+ : {}
116
+ }
117
+
118
+ const getIssueAsMarkdown = (config: IntegrationConfig, testDir: string) =>
119
+ Effect.gen(function*() {
120
+ const issueService = yield* IssueService
121
+ const writer = yield* MarkdownWriter
122
+ const outputDir = yield* joinPath(testDir, "get-output")
123
+
124
+ const issue = yield* issueService.getByKey(config.issueKey)
125
+ yield* writer.writeMulti([issue], outputDir)
126
+
127
+ expect(issue.key).toBe(config.issueKey)
128
+ expect(issue.url).toBe(`${config.baseUrl}/browse/${config.issueKey}`)
129
+ expect(issue.summary.length).toBeGreaterThan(0)
130
+
131
+ const issueFile = yield* joinPath(outputDir, `${config.issueKey}.md`)
132
+ expect(yield* pathExists(issueFile)).toBe(true)
133
+
134
+ const markdown = yield* readText(issueFile)
135
+ const frontMatter = parseFrontMatter(markdown)
136
+
137
+ expect(frontMatter.key).toBe(config.issueKey)
138
+ expect(frontMatter.url).toBe(`${config.baseUrl}/browse/${config.issueKey}`)
139
+ expect(frontMatter.id).toBe(issue.id)
140
+ expect(frontMatter.summary).toBe(issue.summary)
141
+ expect(typeof frontMatter.status).toBe("string")
142
+ expect(markdown).toContain(`# ${config.issueKey}:`)
143
+
144
+ return { frontMatter, issue }
145
+ })
146
+
147
+ const searchIssueAsSingleMarkdown = (config: IntegrationConfig, testDir: string) =>
148
+ Effect.gen(function*() {
149
+ const issueService = yield* IssueService
150
+ const writer = yield* MarkdownWriter
151
+ const outputDir = yield* joinPath(testDir, "search-output")
152
+ const jql = `issuekey = ${config.issueKey}`
153
+
154
+ const issues = yield* issueService.searchAll(jql, { maxResults: 1 })
155
+ expect(issues.map((issue) => issue.key)).toContain(config.issueKey)
156
+
157
+ yield* writer.writeSingle(issues, outputDir, jql)
158
+
159
+ const exportFile = yield* joinPath(outputDir, "jira-export.md")
160
+ expect(yield* pathExists(exportFile)).toBe(true)
161
+
162
+ const markdown = yield* readText(exportFile)
163
+ expect(markdown).toContain("# Jira Export")
164
+ expect(markdown).toContain(`Query: \`${jql}\``)
165
+ expect(markdown).toContain(`## ${config.issueKey}:`)
166
+ })
167
+
168
+ describe("Jira integration", () => {
169
+ it.effect.skipIf(!SHOULD_RUN_INTEGRATION)(
170
+ "fetches KAN-1 with an API key and exports it as markdown",
171
+ () =>
172
+ Effect.gen(function*() {
173
+ const config = yield* readIntegrationConfig
174
+ return yield* Effect.gen(function*() {
175
+ const testDir = yield* makeTempRoot
176
+
177
+ const { frontMatter, issue } = yield* getIssueAsMarkdown(config, testDir)
178
+ yield* searchIssueAsSingleMarkdown(config, testDir)
179
+ expect(frontMatter.key).toBe(issue.key)
180
+ }).pipe(
181
+ Effect.provide(makeIntegrationLayer(config)),
182
+ Effect.scoped
183
+ )
184
+ }),
185
+ 120000
186
+ )
187
+ })
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { compareIssueFields } from "../src/internal/sync/changes.js"
3
+ import type { SyncBaselineFields } from "../src/internal/sync/types.js"
4
+
5
+ const fields = (overrides: Partial<SyncBaselineFields> = {}): SyncBaselineFields => ({
6
+ summary: "Baseline summary",
7
+ description: "Baseline description",
8
+ labels: ["a", "b"],
9
+ customFields: {
10
+ Risk: {
11
+ fieldId: "customfield_1",
12
+ displayName: "Risk",
13
+ shape: "singleSelect",
14
+ value: { id: "1", value: "Low" }
15
+ }
16
+ },
17
+ ...overrides
18
+ })
19
+
20
+ const compare = (
21
+ jira: SyncBaselineFields,
22
+ document: SyncBaselineFields,
23
+ baseline: SyncBaselineFields = fields()
24
+ ) =>
25
+ compareIssueFields({
26
+ issueId: "100123",
27
+ issueKey: "PROJ-123",
28
+ baseline,
29
+ jira,
30
+ document
31
+ })
32
+
33
+ describe("compareIssueFields", () => {
34
+ it("returns no changes when Jira and document match the baseline", () => {
35
+ const result = compare(fields(), fields())
36
+ expect(result.changes).toEqual([])
37
+ expect(result.validationFailures).toEqual([])
38
+ })
39
+
40
+ it("classifies remote-only changes", () => {
41
+ const result = compare(fields({ summary: "Remote summary" }), fields())
42
+ expect(result.changes).toEqual([{
43
+ _tag: "RemoteOnly",
44
+ issueId: "100123",
45
+ issueKey: "PROJ-123",
46
+ field: "summary",
47
+ jiraValue: "Remote summary"
48
+ }])
49
+ })
50
+
51
+ it("classifies local-only changes", () => {
52
+ const result = compare(fields(), fields({ description: "Local description" }))
53
+ expect(result.changes).toEqual([{
54
+ _tag: "LocalOnly",
55
+ issueId: "100123",
56
+ issueKey: "PROJ-123",
57
+ field: "description",
58
+ documentValue: "Local description"
59
+ }])
60
+ })
61
+
62
+ it("classifies sync conflicts", () => {
63
+ const result = compare(fields({ summary: "Remote summary" }), fields({ summary: "Local summary" }))
64
+ expect(result.changes).toEqual([{
65
+ _tag: "Conflict",
66
+ issueId: "100123",
67
+ issueKey: "PROJ-123",
68
+ field: "summary",
69
+ baselineValue: "Baseline summary",
70
+ jiraValue: "Remote summary",
71
+ documentValue: "Local summary"
72
+ }])
73
+ })
74
+
75
+ it("compares custom fields", () => {
76
+ const result = compare(
77
+ fields(),
78
+ fields({
79
+ customFields: {
80
+ Risk: {
81
+ fieldId: "customfield_1",
82
+ displayName: "Risk",
83
+ shape: "singleSelect",
84
+ value: { id: "2", value: "High" }
85
+ }
86
+ }
87
+ })
88
+ )
89
+
90
+ expect(result.changes[0]).toMatchObject({
91
+ _tag: "LocalOnly",
92
+ field: "customFields.Risk",
93
+ documentValue: { id: "2", value: "High" }
94
+ })
95
+ })
96
+
97
+ it("reports missing custom fields as validation failures", () => {
98
+ const result = compare(fields(), fields({ customFields: {} }))
99
+ expect(result.validationFailures).toEqual([{
100
+ _tag: "ValidationFailure",
101
+ issueKey: "PROJ-123",
102
+ field: "customFields.Risk",
103
+ message: "Missing reconciled custom field \"Risk\""
104
+ }])
105
+ })
106
+ })
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Exit from "effect/Exit"
4
+ import { parseSyncBaseline, serializeSyncBaseline } from "../src/internal/sync/baseline.js"
5
+ import { parseWorkspaceConfig, serializeWorkspaceConfig } from "../src/internal/sync/config.js"
6
+ import { makeEmptyManifest, parseSyncManifest, serializeSyncManifest } from "../src/internal/sync/manifest.js"
7
+ import type { SyncBaseline } from "../src/internal/sync/types.js"
8
+
9
+ const runExit = <A, E>(effect: Effect.Effect<A, E>): Promise<Exit.Exit<A, E>> => Effect.runPromiseExit(effect)
10
+
11
+ describe("Jira Markdown Sync local data", () => {
12
+ describe("WorkspaceConfig", () => {
13
+ it("parses defaults and requested custom fields", async () => {
14
+ const result = await Effect.runPromise(parseWorkspaceConfig(
15
+ "config.yaml",
16
+ `
17
+ siteUrl: https://example.atlassian.net
18
+ customFields:
19
+ - displayName: Security & Compliance Impact
20
+ shape: singleSelect
21
+ - displayName: Reviewer
22
+ fieldId: customfield_12345
23
+ shape: user
24
+ `
25
+ ))
26
+
27
+ expect(result.documentsDir).toBe("issues")
28
+ expect(result.customFields).toHaveLength(2)
29
+ expect(result.customFields[1]).toMatchObject({
30
+ displayName: "Reviewer",
31
+ fieldId: "customfield_12345",
32
+ shape: "user"
33
+ })
34
+ })
35
+
36
+ it("rejects unsupported field shapes", async () => {
37
+ const result = await runExit(parseWorkspaceConfig(
38
+ "config.yaml",
39
+ `
40
+ siteUrl: https://example.atlassian.net
41
+ customFields:
42
+ - displayName: Mystery
43
+ shape: unsupported
44
+ `
45
+ ))
46
+
47
+ expect(Exit.isFailure(result)).toBe(true)
48
+ })
49
+
50
+ it("rejects duplicate display names without field ids", async () => {
51
+ const result = await runExit(parseWorkspaceConfig(
52
+ "config.yaml",
53
+ `
54
+ siteUrl: https://example.atlassian.net
55
+ customFields:
56
+ - displayName: Reviewer
57
+ shape: user
58
+ - displayName: Reviewer
59
+ shape: user
60
+ `
61
+ ))
62
+
63
+ expect(Exit.isFailure(result)).toBe(true)
64
+ })
65
+
66
+ it("allows duplicate display names when field ids disambiguate", async () => {
67
+ const config = await Effect.runPromise(parseWorkspaceConfig(
68
+ "config.yaml",
69
+ `
70
+ siteUrl: https://example.atlassian.net
71
+ customFields:
72
+ - displayName: Reviewer
73
+ fieldId: customfield_1
74
+ shape: user
75
+ - displayName: Reviewer
76
+ fieldId: customfield_2
77
+ shape: user
78
+ `
79
+ ))
80
+
81
+ expect(config.customFields).toHaveLength(2)
82
+ })
83
+
84
+ it("round-trips YAML serialization", async () => {
85
+ const config = await Effect.runPromise(parseWorkspaceConfig(
86
+ "config.yaml",
87
+ `
88
+ siteUrl: https://example.atlassian.net
89
+ documentsDir: issue-docs
90
+ customFields: []
91
+ `
92
+ ))
93
+
94
+ const parsed = await Effect.runPromise(parseWorkspaceConfig("config.yaml", serializeWorkspaceConfig(config)))
95
+ expect(parsed).toEqual(config)
96
+ })
97
+ })
98
+
99
+ describe("SyncManifest", () => {
100
+ it("round-trips an empty manifest", async () => {
101
+ const manifest = makeEmptyManifest("https://example.atlassian.net")
102
+ const parsed = await Effect.runPromise(parseSyncManifest("manifest.json", serializeSyncManifest(manifest)))
103
+ expect(parsed).toEqual(manifest)
104
+ })
105
+ })
106
+
107
+ describe("SyncBaseline", () => {
108
+ const baseline: SyncBaseline = {
109
+ version: 1,
110
+ issueId: "100123",
111
+ issueKey: "PROJ-123",
112
+ fields: {
113
+ summary: "Fix checkout copy",
114
+ description: "Editable description",
115
+ labels: ["checkout", "copy"],
116
+ customFields: {
117
+ "Security & Compliance Impact": {
118
+ fieldId: "customfield_10001",
119
+ displayName: "Security & Compliance Impact",
120
+ shape: "singleSelect",
121
+ value: { id: "10423", value: "Low" }
122
+ }
123
+ }
124
+ },
125
+ comments: [{ id: "20001" }]
126
+ }
127
+
128
+ it("round-trips baseline JSON", async () => {
129
+ const parsed = await Effect.runPromise(parseSyncBaseline("100123.json", serializeSyncBaseline(baseline)))
130
+ expect(parsed).toEqual(baseline)
131
+ })
132
+
133
+ it("rejects corrupt baseline JSON", async () => {
134
+ const result = await runExit(parseSyncBaseline("100123.json", "{nope"))
135
+ expect(Exit.isFailure(result)).toBe(true)
136
+ })
137
+ })
138
+ })
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { parseIssueDocument, serializeIssueDocument } from "../src/internal/sync/document.js"
3
+ import type { IssueDocument } from "../src/internal/sync/types.js"
4
+ import { SyncValidationError } from "../src/JiraCliError.js"
5
+
6
+ const document: IssueDocument = {
7
+ frontMatter: {
8
+ issueId: "100123",
9
+ issueKey: "PROJ-123",
10
+ summary: "Fix checkout copy",
11
+ status: "In Progress",
12
+ issueType: "Story",
13
+ priority: "Medium",
14
+ assignee: { accountId: "abc123", displayName: "Alice Example" },
15
+ reporter: null,
16
+ labels: ["checkout", "copy"],
17
+ customFields: {
18
+ "Security & Compliance Impact": { id: "10423", value: "Low" }
19
+ }
20
+ },
21
+ description: "Editable description markdown.",
22
+ multilineCustomFields: {
23
+ "Release Notes": "Multiline release notes."
24
+ },
25
+ commentDrafts: [{ draftId: "draft-1", body: "Please review this." }],
26
+ acceptedComments: [{
27
+ id: "20001",
28
+ author: "Alice Example",
29
+ created: "2026-06-27",
30
+ body: "Existing Jira comment."
31
+ }],
32
+ attachments: [{ filename: "evidence.png", url: "https://example.atlassian.net/evidence.png" }],
33
+ localNotes: "Private local note."
34
+ }
35
+
36
+ describe("Issue Document parsing", () => {
37
+ it("round-trips a representative issue document", () => {
38
+ const serialized = serializeIssueDocument(document)
39
+ const parsed = parseIssueDocument("PROJ-123.md", serialized)
40
+ expect(parsed).toEqual(document)
41
+ })
42
+
43
+ it("preserves local notes body text", () => {
44
+ const serialized = serializeIssueDocument({
45
+ ...document,
46
+ localNotes: "line one\n\n- private bullet"
47
+ })
48
+ const parsed = parseIssueDocument("PROJ-123.md", serialized)
49
+ expect(parsed.localNotes).toBe("line one\n\n- private bullet")
50
+ })
51
+
52
+ it("rejects missing description section", () => {
53
+ const serialized = serializeIssueDocument(document).replace(
54
+ /## Description[\s\S]*?## Release Notes/,
55
+ "## Release Notes"
56
+ )
57
+ expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
58
+ })
59
+
60
+ it("rejects duplicate sections", () => {
61
+ const serialized = `${serializeIssueDocument(document)}\n## Description\n\nagain\n`
62
+ expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
63
+ })
64
+
65
+ it("rejects malformed comment draft markers", () => {
66
+ const serialized = serializeIssueDocument(document).replace("<!-- draftId: draft-1 -->", "<!-- draft: draft-1 -->")
67
+ expect(() => parseIssueDocument("PROJ-123.md", serialized)).toThrow(SyncValidationError)
68
+ })
69
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import {
3
+ canonicalizeFieldValue,
4
+ cascadingFieldValue,
5
+ completeListValue,
6
+ explicitClear,
7
+ fieldValuesEqual,
8
+ isCascadingFieldValue,
9
+ isCompleteListValue,
10
+ isExplicitClear,
11
+ isOptionFieldValue,
12
+ isUserFieldValue,
13
+ optionFieldValue,
14
+ userFieldValue
15
+ } from "../src/internal/sync/fieldValues.js"
16
+ import type { SyncFieldValue } from "../src/internal/sync/types.js"
17
+
18
+ describe("Jira Markdown Sync field value helpers", () => {
19
+ it("represents explicit clear as a deliberate null field value", () => {
20
+ const value: SyncFieldValue = explicitClear
21
+
22
+ expect(value).toBeNull()
23
+ expect(isExplicitClear(value)).toBe(true)
24
+ expect(isExplicitClear("")).toBe(false)
25
+ })
26
+
27
+ it("represents user field values with display name and stable account id", () => {
28
+ const value = userFieldValue("account-123", "Jane Doe")
29
+
30
+ expect(value).toEqual({ accountId: "account-123", displayName: "Jane Doe" })
31
+ expect(isUserFieldValue(value)).toBe(true)
32
+ expect(isUserFieldValue({ displayName: "Jane Doe" })).toBe(false)
33
+ })
34
+
35
+ it("represents option field values with readable value and optional Jira option id", () => {
36
+ expect(optionFieldValue("High")).toEqual({ value: "High" })
37
+ expect(optionFieldValue("High", "10001")).toEqual({ id: "10001", value: "High" })
38
+ expect(isOptionFieldValue(optionFieldValue("High", "10001"))).toBe(true)
39
+ expect(isOptionFieldValue({ id: "10001" })).toBe(false)
40
+ })
41
+
42
+ it("represents cascading field values as parent and optional child options", () => {
43
+ const parentOnly = cascadingFieldValue(optionFieldValue("Security"))
44
+ const parentAndChild = cascadingFieldValue(
45
+ optionFieldValue("Security", "10"),
46
+ optionFieldValue("Customer Data", "11")
47
+ )
48
+
49
+ expect(parentOnly).toEqual({ parent: { value: "Security" } })
50
+ expect(parentAndChild).toEqual({
51
+ parent: { id: "10", value: "Security" },
52
+ child: { id: "11", value: "Customer Data" }
53
+ })
54
+ expect(isCascadingFieldValue(parentAndChild)).toBe(true)
55
+ expect(isCascadingFieldValue({ parent: { id: "10" } })).toBe(false)
56
+ })
57
+
58
+ it("represents list values as complete field values, not patches", () => {
59
+ const values = completeListValue(["backend", "api", "backend"])
60
+
61
+ expect(values).toEqual(["api", "backend", "backend"])
62
+ expect(isCompleteListValue(values)).toBe(true)
63
+ expect(isCompleteListValue([{ parent: optionFieldValue("A") }])).toBe(false)
64
+ })
65
+
66
+ it("applies canonical field order to unordered list-like values", () => {
67
+ const first = completeListValue([
68
+ optionFieldValue("Beta", "2"),
69
+ userFieldValue("account-2", "Zoe"),
70
+ optionFieldValue("Alpha", "1"),
71
+ userFieldValue("account-1", "Amy")
72
+ ])
73
+
74
+ expect(first).toEqual([
75
+ optionFieldValue("Alpha", "1"),
76
+ optionFieldValue("Beta", "2"),
77
+ userFieldValue("account-1", "Amy"),
78
+ userFieldValue("account-2", "Zoe")
79
+ ])
80
+ })
81
+
82
+ it("can preserve configured field order for ordered list-like values", () => {
83
+ const value = completeListValue(["third", "first", "second"], { ordered: true })
84
+
85
+ expect(value).toEqual(["third", "first", "second"])
86
+ expect(canonicalizeFieldValue(value, { ordered: true })).toEqual(["third", "first", "second"])
87
+ })
88
+
89
+ it("compares unordered complete list values by canonical value", () => {
90
+ const left: SyncFieldValue = ["frontend", "backend"]
91
+ const right: SyncFieldValue = ["backend", "frontend"]
92
+
93
+ expect(fieldValuesEqual(left, right)).toBe(true)
94
+ expect(fieldValuesEqual(left, right, { ordered: true })).toBe(false)
95
+ })
96
+
97
+ it("compares explicit clears distinctly from empty complete list values", () => {
98
+ expect(fieldValuesEqual(explicitClear, [])).toBe(false)
99
+ expect(fieldValuesEqual([], [])).toBe(true)
100
+ })
101
+ })
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ resolve: {
5
+ alias: {
6
+ "@knpkv/jira-api-client": new URL("../jira-api-client/src/index.ts", import.meta.url).pathname
7
+ }
8
+ },
9
+ test: {
10
+ include: ["test/integration.test.ts"],
11
+ globals: true,
12
+ environment: "node",
13
+ testTimeout: 120000,
14
+ hookTimeout: 60000,
15
+ teardownTimeout: 60000
16
+ }
17
+ })
package/vitest.config.ts CHANGED
@@ -8,6 +8,7 @@ export default defineConfig({
8
8
  },
9
9
  test: {
10
10
  include: ["test/**/*.test.ts"],
11
+ exclude: ["test/integration.test.ts"],
11
12
  globals: true,
12
13
  environment: "node",
13
14
  testTimeout: 30000,