@knpkv/jira-cli 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +66 -4
  3. package/dist/IssueService.d.ts +2 -2
  4. package/dist/IssueService.d.ts.map +1 -1
  5. package/dist/IssueService.js +3 -3
  6. package/dist/IssueService.js.map +1 -1
  7. package/dist/JiraAuth.d.ts +14 -14
  8. package/dist/JiraAuth.d.ts.map +1 -1
  9. package/dist/JiraAuth.js +18 -10
  10. package/dist/JiraAuth.js.map +1 -1
  11. package/dist/JiraCliError.d.ts +30 -0
  12. package/dist/JiraCliError.d.ts.map +1 -1
  13. package/dist/JiraCliError.js +14 -0
  14. package/dist/JiraCliError.js.map +1 -1
  15. package/dist/MarkdownWriter.d.ts +4 -4
  16. package/dist/MarkdownWriter.d.ts.map +1 -1
  17. package/dist/MarkdownWriter.js +6 -6
  18. package/dist/MarkdownWriter.js.map +1 -1
  19. package/dist/SyncWorkspace.d.ts +34 -0
  20. package/dist/SyncWorkspace.d.ts.map +1 -0
  21. package/dist/SyncWorkspace.js +105 -0
  22. package/dist/SyncWorkspace.js.map +1 -0
  23. package/dist/VersionService.d.ts +206 -0
  24. package/dist/VersionService.d.ts.map +1 -0
  25. package/dist/VersionService.js +426 -0
  26. package/dist/VersionService.js.map +1 -0
  27. package/dist/bin.js +29 -22
  28. package/dist/bin.js.map +1 -1
  29. package/dist/commands/auth.d.ts +2 -21
  30. package/dist/commands/auth.d.ts.map +1 -1
  31. package/dist/commands/auth.js +6 -6
  32. package/dist/commands/auth.js.map +1 -1
  33. package/dist/commands/get.d.ts +3 -8
  34. package/dist/commands/get.d.ts.map +1 -1
  35. package/dist/commands/get.js +4 -4
  36. package/dist/commands/get.js.map +1 -1
  37. package/dist/commands/index.d.ts +2 -2
  38. package/dist/commands/index.d.ts.map +1 -1
  39. package/dist/commands/index.js +2 -2
  40. package/dist/commands/index.js.map +1 -1
  41. package/dist/commands/issue.d.ts +8 -0
  42. package/dist/commands/issue.d.ts.map +1 -0
  43. package/dist/commands/issue.js +10 -0
  44. package/dist/commands/issue.js.map +1 -0
  45. package/dist/commands/layers.d.ts +6 -18
  46. package/dist/commands/layers.d.ts.map +1 -1
  47. package/dist/commands/layers.js +35 -24
  48. package/dist/commands/layers.js.map +1 -1
  49. package/dist/commands/search.d.ts +3 -8
  50. package/dist/commands/search.d.ts.map +1 -1
  51. package/dist/commands/search.js +8 -8
  52. package/dist/commands/search.js.map +1 -1
  53. package/dist/commands/version.d.ts +12 -0
  54. package/dist/commands/version.d.ts.map +1 -0
  55. package/dist/commands/version.js +179 -0
  56. package/dist/commands/version.js.map +1 -0
  57. package/dist/internal/frontmatter.d.ts.map +1 -1
  58. package/dist/internal/frontmatter.js +14 -1
  59. package/dist/internal/frontmatter.js.map +1 -1
  60. package/dist/internal/oauthServer.d.ts +17 -5
  61. package/dist/internal/oauthServer.d.ts.map +1 -1
  62. package/dist/internal/oauthServer.js +23 -40
  63. package/dist/internal/oauthServer.js.map +1 -1
  64. package/dist/internal/openBrowser.d.ts +10 -0
  65. package/dist/internal/openBrowser.d.ts.map +1 -0
  66. package/dist/internal/openBrowser.js +17 -0
  67. package/dist/internal/openBrowser.js.map +1 -0
  68. package/dist/internal/sync/baseline.d.ts +11 -0
  69. package/dist/internal/sync/baseline.d.ts.map +1 -0
  70. package/dist/internal/sync/baseline.js +18 -0
  71. package/dist/internal/sync/baseline.js.map +1 -0
  72. package/dist/internal/sync/changes.d.ts +15 -0
  73. package/dist/internal/sync/changes.d.ts.map +1 -0
  74. package/dist/internal/sync/changes.js +72 -0
  75. package/dist/internal/sync/changes.js.map +1 -0
  76. package/dist/internal/sync/config.d.ts +12 -0
  77. package/dist/internal/sync/config.d.ts.map +1 -0
  78. package/dist/internal/sync/config.js +53 -0
  79. package/dist/internal/sync/config.js.map +1 -0
  80. package/dist/internal/sync/document.d.ts +9 -0
  81. package/dist/internal/sync/document.d.ts.map +1 -0
  82. package/dist/internal/sync/document.js +173 -0
  83. package/dist/internal/sync/document.js.map +1 -0
  84. package/dist/internal/sync/fieldValues.d.ts +30 -0
  85. package/dist/internal/sync/fieldValues.d.ts.map +1 -0
  86. package/dist/internal/sync/fieldValues.js +91 -0
  87. package/dist/internal/sync/fieldValues.js.map +1 -0
  88. package/dist/internal/sync/manifest.d.ts +12 -0
  89. package/dist/internal/sync/manifest.d.ts.map +1 -0
  90. package/dist/internal/sync/manifest.js +23 -0
  91. package/dist/internal/sync/manifest.js.map +1 -0
  92. package/dist/internal/sync/paths.d.ts +26 -0
  93. package/dist/internal/sync/paths.d.ts.map +1 -0
  94. package/dist/internal/sync/paths.js +22 -0
  95. package/dist/internal/sync/paths.js.map +1 -0
  96. package/dist/internal/sync/schemas.d.ts +128 -0
  97. package/dist/internal/sync/schemas.d.ts.map +1 -0
  98. package/dist/internal/sync/schemas.js +82 -0
  99. package/dist/internal/sync/schemas.js.map +1 -0
  100. package/dist/internal/sync/types.d.ts +144 -0
  101. package/dist/internal/sync/types.d.ts.map +1 -0
  102. package/dist/internal/sync/types.js +17 -0
  103. package/dist/internal/sync/types.js.map +1 -0
  104. package/package.json +13 -12
  105. package/skills/jira/SKILL.md +90 -0
  106. package/skills/jira/agents/openai.yaml +4 -0
  107. package/src/IssueService.ts +34 -28
  108. package/src/JiraAuth.ts +53 -39
  109. package/src/JiraCliError.ts +24 -0
  110. package/src/MarkdownWriter.ts +7 -11
  111. package/src/SyncWorkspace.ts +185 -0
  112. package/src/VersionService.ts +647 -0
  113. package/src/bin.ts +39 -29
  114. package/src/commands/auth.ts +6 -12
  115. package/src/commands/get.ts +4 -4
  116. package/src/commands/index.ts +2 -2
  117. package/src/commands/issue.ts +13 -0
  118. package/src/commands/layers.ts +44 -26
  119. package/src/commands/search.ts +8 -8
  120. package/src/commands/version.ts +267 -0
  121. package/src/internal/frontmatter.ts +15 -1
  122. package/src/internal/oauthServer.ts +43 -70
  123. package/src/internal/openBrowser.ts +31 -0
  124. package/src/internal/sync/baseline.ts +27 -0
  125. package/src/internal/sync/changes.ts +118 -0
  126. package/src/internal/sync/config.ts +76 -0
  127. package/src/internal/sync/document.ts +201 -0
  128. package/src/internal/sync/fieldValues.ts +145 -0
  129. package/src/internal/sync/manifest.ts +32 -0
  130. package/src/internal/sync/paths.ts +48 -0
  131. package/src/internal/sync/schemas.ts +103 -0
  132. package/src/internal/sync/types.ts +192 -0
  133. package/test/SyncWorkspace.test.ts +76 -0
  134. package/test/VersionService.test.ts +266 -0
  135. package/test/commandTree.test.ts +266 -0
  136. package/test/frontmatter.test.ts +69 -0
  137. package/test/integration.test.ts +187 -0
  138. package/test/syncChanges.test.ts +106 -0
  139. package/test/syncConfig.test.ts +138 -0
  140. package/test/syncDocument.test.ts +69 -0
  141. package/test/syncFieldValues.test.ts +101 -0
  142. package/vitest.config.integration.ts +17 -0
  143. package/vitest.config.ts +6 -0
@@ -9,8 +9,22 @@
9
9
  * @internal
10
10
  */
11
11
  import matter from "gray-matter"
12
+ import * as yaml from "js-yaml"
12
13
  import type { Issue } from "../IssueService.js"
13
14
 
15
+ /**
16
+ * `gray-matter` ships a default YAML engine that calls `js-yaml`'s `safeDump`/
17
+ * `safeLoad`, both removed in `js-yaml` 4. The workspace pins `js-yaml` to 4.x
18
+ * (security override), so we supply an engine backed by the 4.x `dump`/`load`
19
+ * API, which is safe by default.
20
+ *
21
+ * @internal
22
+ */
23
+ const yamlEngine = {
24
+ parse: (str: string): object => (yaml.load(str) as object) ?? {},
25
+ stringify: (data: object): string => yaml.dump(data)
26
+ }
27
+
14
28
  /**
15
29
  * Front-matter data for a Jira issue.
16
30
  *
@@ -69,7 +83,7 @@ export const extractFrontMatter = (issue: Issue): IssueFrontMatter => ({
69
83
  export const serializeIssue = (issue: Issue): string => {
70
84
  const frontMatter = extractFrontMatter(issue)
71
85
  const content = buildMarkdownContent(issue)
72
- return matter.stringify(content, frontMatter)
86
+ return matter.stringify(content, frontMatter, { engines: { yaml: yamlEngine } })
73
87
  }
74
88
 
75
89
  /**
@@ -10,21 +10,20 @@
10
10
  *
11
11
  * @internal
12
12
  */
13
- import * as HttpRouter from "@effect/platform/HttpRouter"
14
- import * as HttpServer from "@effect/platform/HttpServer"
15
- import type { ServeError } from "@effect/platform/HttpServerError"
16
- import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
17
- import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
18
13
  import { OAuthError } from "@knpkv/atlassian-common/auth"
19
14
  import * as Context from "effect/Context"
20
15
  import * as Deferred from "effect/Deferred"
21
16
  import * as Effect from "effect/Effect"
17
+ import * as Exit from "effect/Exit"
22
18
  import * as Fiber from "effect/Fiber"
23
19
  import * as Layer from "effect/Layer"
20
+ import * as Scope from "effect/Scope"
21
+ import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
22
+ import type * as HttpServerError from "effect/unstable/http/HttpServerError"
24
23
 
25
24
  const DEFAULT_PORT = 8585
26
- const MAX_PORT_ATTEMPTS = 10
27
-
25
+ const MAX_PORT = 8594
26
+ type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
28
27
  /**
29
28
  * Factory service for creating HTTP servers.
30
29
  * This allows mocking the server creation in tests.
@@ -34,7 +33,7 @@ const MAX_PORT_ATTEMPTS = 10
34
33
  export interface HttpServerFactory {
35
34
  readonly createServerLayer: (port: number) => Layer.Layer<
36
35
  HttpServer.HttpServer,
37
- ServeError,
36
+ HttpServerError.ServeError,
38
37
  never
39
38
  >
40
39
  }
@@ -44,10 +43,10 @@ export interface HttpServerFactory {
44
43
  *
45
44
  * @category Services
46
45
  */
47
- export class HttpServerFactoryTag extends Context.Tag("@knpkv/jira-cli/HttpServerFactory")<
46
+ export class HttpServerFactoryTag extends Context.Service<
48
47
  HttpServerFactoryTag,
49
48
  HttpServerFactory
50
- >() {}
49
+ >()("@knpkv/jira-cli/HttpServerFactory") {}
51
50
 
52
51
  /**
53
52
  * Create a HttpServerFactory layer from a layer factory function.
@@ -59,50 +58,12 @@ export class HttpServerFactoryTag extends Context.Tag("@knpkv/jira-cli/HttpServe
59
58
  * @category Layers
60
59
  */
61
60
  export const makeHttpServerFactory = (
62
- createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, ServeError, never>
61
+ createLayerFn: (port: number) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
63
62
  ): Layer.Layer<HttpServerFactoryTag> =>
64
63
  Layer.succeed(HttpServerFactoryTag, {
65
64
  createServerLayer: createLayerFn
66
65
  })
67
66
 
68
- /**
69
- * Check if a port is available by attempting to start a server.
70
- */
71
- const isPortAvailable = (port: number): Effect.Effect<boolean, never, HttpServerFactoryTag> =>
72
- Effect.gen(function*() {
73
- const factory = yield* HttpServerFactoryTag
74
- const serverLayer = factory.createServerLayer(port)
75
-
76
- const result = yield* Layer.build(serverLayer).pipe(
77
- Effect.scoped,
78
- Effect.as(true),
79
- Effect.catchAll(() => Effect.succeed(false))
80
- )
81
- return result
82
- })
83
-
84
- /**
85
- * Find an available port starting from the default.
86
- */
87
- const findAvailablePort = (): Effect.Effect<number, OAuthError, HttpServerFactoryTag> =>
88
- Effect.gen(function*() {
89
- for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
90
- const port = DEFAULT_PORT + attempt
91
- const available = yield* isPortAvailable(port)
92
- if (available) {
93
- return port
94
- }
95
- }
96
- return yield* Effect.fail(
97
- new OAuthError({
98
- step: "authorize",
99
- cause: `Could not find available port (tried ${DEFAULT_PORT}-${
100
- DEFAULT_PORT + MAX_PORT_ATTEMPTS - 1
101
- }). Close other applications using these ports.`
102
- })
103
- )
104
- })
105
-
106
67
  /**
107
68
  * Result from the OAuth callback server.
108
69
  */
@@ -128,15 +89,32 @@ export const startCallbackServer = (
128
89
  ): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag> =>
129
90
  Effect.gen(function*() {
130
91
  const factory = yield* HttpServerFactoryTag
131
- const port = yield* findAvailablePort()
132
92
  const deferred = yield* Deferred.make<string, OAuthError>()
133
- const readyDeferred = yield* Deferred.make<void, OAuthError>()
134
-
135
- const app = HttpRouter.empty.pipe(
136
- HttpRouter.get(
137
- "/callback",
93
+ const serverScope = yield* Scope.make()
94
+ const buildServerContext = (port: number): Effect.Effect<
95
+ { readonly context: Context.Context<HttpServer.HttpServer>; readonly port: number },
96
+ OAuthError
97
+ > =>
98
+ Layer.buildWithScope(factory.createServerLayer(port), serverScope).pipe(
99
+ Effect.map((context) => ({ context, port })),
100
+ Effect.catchCause((cause) =>
101
+ port < MAX_PORT
102
+ ? buildServerContext(port + 1)
103
+ : Effect.fail(new OAuthError({ step: "authorize", cause }))
104
+ )
105
+ )
106
+ const { context: serverContext } = yield* buildServerContext(DEFAULT_PORT)
107
+ const server: HttpServerInstance = Context.get(serverContext, HttpServer.HttpServer)
108
+ const port = yield* (server.address._tag === "TcpAddress"
109
+ ? Effect.succeed(server.address.port)
110
+ : Effect.fail(new OAuthError({ step: "authorize", cause: "OAuth callback server must listen on a TCP port" })))
111
+
112
+ const router = yield* HttpRouter.make
113
+ yield* router.add(
114
+ "GET",
115
+ "/callback",
116
+ (req) =>
138
117
  Effect.gen(function*() {
139
- const req = yield* HttpServerRequest.HttpServerRequest
140
118
  const url = new URL(req.url, `http://localhost:${port}`)
141
119
  const code = url.searchParams.get("code")
142
120
  const state = url.searchParams.get("state")
@@ -178,26 +156,21 @@ export const startCallbackServer = (
178
156
  "<html><body><h1>Success!</h1><p>You can close this window and return to the terminal.</p></body></html>"
179
157
  )
180
158
  })
181
- )
182
159
  )
160
+ const app = router.asHttpEffect()
183
161
 
184
- const serverLayer = factory.createServerLayer(port)
185
-
186
- const serverFiber = yield* HttpServer.serve(app).pipe(
187
- Layer.provide(serverLayer),
188
- Layer.build,
189
- Effect.tap(() => Deferred.succeed(readyDeferred, undefined)),
190
- Effect.tapError((err) => Deferred.fail(readyDeferred, new OAuthError({ step: "authorize", cause: err }))),
191
- Effect.flatMap(() => Effect.never),
192
- Effect.scoped,
193
- Effect.fork
162
+ const serverFiber = yield* HttpServer.serveEffect(app).pipe(
163
+ Effect.provide(serverContext),
164
+ Effect.provideService(Scope.Scope, serverScope),
165
+ Effect.forkIn(serverScope)
194
166
  )
195
167
 
196
- yield* Deferred.await(readyDeferred)
197
-
198
168
  return {
199
169
  codePromise: Deferred.await(deferred),
200
- shutdown: Fiber.interrupt(serverFiber).pipe(Effect.asVoid),
170
+ shutdown: Effect.gen(function*() {
171
+ yield* Fiber.interrupt(serverFiber)
172
+ yield* Scope.close(serverScope, Exit.void)
173
+ }),
201
174
  port
202
175
  }
203
176
  })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cross-platform browser launcher backed by Effect v4 child process services.
3
+ *
4
+ * @internal
5
+ */
6
+ import * as Effect from "effect/Effect"
7
+ import type * as PlatformError from "effect/PlatformError"
8
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
9
+
10
+ const run = (
11
+ command: string,
12
+ args: ReadonlyArray<string>
13
+ ): Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
14
+ Effect.gen(function*() {
15
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
16
+ yield* spawner.exitCode(
17
+ ChildProcess.make(command, args, {
18
+ stdin: "ignore",
19
+ stdout: "ignore",
20
+ stderr: "ignore"
21
+ })
22
+ )
23
+ })
24
+
25
+ export const openBrowser = (
26
+ url: string
27
+ ): Effect.Effect<void, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
28
+ run("open", [url]).pipe(
29
+ Effect.catch(() => run("xdg-open", [url])),
30
+ Effect.catch(() => run("rundll32.exe", ["url.dll,FileProtocolHandler", url]))
31
+ )
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Sync Baseline helpers.
3
+ *
4
+ * @internal
5
+ */
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+ import { SyncValidationError, SyncWorkspaceError } from "../../JiraCliError.js"
9
+ import { SyncBaselineSchema } from "./schemas.js"
10
+ import type { SyncBaseline } from "./types.js"
11
+
12
+ export const parseSyncBaseline = (
13
+ path: string,
14
+ content: string
15
+ ): Effect.Effect<SyncBaseline, SyncWorkspaceError | SyncValidationError> =>
16
+ Effect.gen(function*() {
17
+ const raw = yield* Effect.try({
18
+ try: () => JSON.parse(content) as unknown,
19
+ catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Baseline JSON", path, cause })
20
+ })
21
+ return yield* Schema.decodeUnknownEffect(SyncBaselineSchema)(raw).pipe(
22
+ Effect.map((baseline) => baseline as SyncBaseline),
23
+ Effect.mapError((cause) => new SyncValidationError({ message: "Invalid Sync Baseline", path, cause }))
24
+ )
25
+ })
26
+
27
+ export const serializeSyncBaseline = (baseline: SyncBaseline): string => `${JSON.stringify(baseline, null, 2)}\n`
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Baseline comparison for Jira Markdown Sync planning.
3
+ *
4
+ * @internal
5
+ */
6
+ import { fieldValuesEqual } from "./fieldValues.js"
7
+ import type {
8
+ PlannedFieldChange,
9
+ SyncBaselineFields,
10
+ SyncFieldPath,
11
+ SyncFieldValue,
12
+ SyncValidationFailure
13
+ } from "./types.js"
14
+
15
+ export interface CompareIssueFieldsInput {
16
+ readonly issueId: string
17
+ readonly issueKey: string
18
+ readonly baseline: SyncBaselineFields
19
+ readonly jira: SyncBaselineFields
20
+ readonly document: SyncBaselineFields
21
+ }
22
+
23
+ export interface CompareIssueFieldsResult {
24
+ readonly changes: ReadonlyArray<PlannedFieldChange>
25
+ readonly validationFailures: ReadonlyArray<SyncValidationFailure>
26
+ }
27
+
28
+ export const compareIssueFields = (input: CompareIssueFieldsInput): CompareIssueFieldsResult => {
29
+ const changes: Array<PlannedFieldChange> = []
30
+ const validationFailures: Array<SyncValidationFailure> = []
31
+
32
+ compareField(input, "summary", input.baseline.summary, input.jira.summary, input.document.summary, changes)
33
+ compareField(
34
+ input,
35
+ "description",
36
+ input.baseline.description,
37
+ input.jira.description,
38
+ input.document.description,
39
+ changes
40
+ )
41
+ compareField(input, "labels", input.baseline.labels, input.jira.labels, input.document.labels, changes)
42
+
43
+ const customFieldNames = new Set([
44
+ ...Object.keys(input.baseline.customFields),
45
+ ...Object.keys(input.jira.customFields),
46
+ ...Object.keys(input.document.customFields)
47
+ ])
48
+
49
+ for (const name of customFieldNames) {
50
+ const field = `customFields.${name}` as const
51
+ const baseline = input.baseline.customFields[name]?.value
52
+ const jira = input.jira.customFields[name]?.value
53
+ const document = input.document.customFields[name]?.value
54
+
55
+ if (baseline === undefined || jira === undefined || document === undefined) {
56
+ validationFailures.push({
57
+ _tag: "ValidationFailure",
58
+ issueKey: input.issueKey,
59
+ field,
60
+ message: `Missing reconciled custom field "${name}"`
61
+ })
62
+ continue
63
+ }
64
+
65
+ compareField(input, field, baseline, jira, document, changes)
66
+ }
67
+
68
+ return { changes, validationFailures }
69
+ }
70
+
71
+ const compareField = (
72
+ input: Pick<CompareIssueFieldsInput, "issueId" | "issueKey">,
73
+ field: SyncFieldPath,
74
+ baselineValue: SyncFieldValue,
75
+ jiraValue: SyncFieldValue,
76
+ documentValue: SyncFieldValue,
77
+ changes: Array<PlannedFieldChange>
78
+ ) => {
79
+ const jiraChanged = !syncFieldValueEquals(jiraValue, baselineValue)
80
+ const documentChanged = !syncFieldValueEquals(documentValue, baselineValue)
81
+
82
+ if (!jiraChanged && !documentChanged) return
83
+
84
+ if (jiraChanged && !documentChanged) {
85
+ changes.push({
86
+ _tag: "RemoteOnly",
87
+ issueId: input.issueId,
88
+ issueKey: input.issueKey,
89
+ field,
90
+ jiraValue
91
+ })
92
+ return
93
+ }
94
+
95
+ if (!jiraChanged && documentChanged) {
96
+ changes.push({
97
+ _tag: "LocalOnly",
98
+ issueId: input.issueId,
99
+ issueKey: input.issueKey,
100
+ field,
101
+ documentValue
102
+ })
103
+ return
104
+ }
105
+
106
+ changes.push({
107
+ _tag: "Conflict",
108
+ issueId: input.issueId,
109
+ issueKey: input.issueKey,
110
+ field,
111
+ baselineValue,
112
+ jiraValue,
113
+ documentValue
114
+ })
115
+ }
116
+
117
+ export const syncFieldValueEquals = (left: SyncFieldValue, right: SyncFieldValue): boolean =>
118
+ fieldValuesEqual(left, right)
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Workspace Config parsing and serialization.
3
+ *
4
+ * @internal
5
+ */
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+ import * as yaml from "js-yaml"
9
+ import { SyncValidationError, SyncWorkspaceError } from "../../JiraCliError.js"
10
+ import { WorkspaceConfigSchema } from "./schemas.js"
11
+ import type { WorkspaceConfig } from "./types.js"
12
+
13
+ export const parseWorkspaceConfig = (
14
+ path: string,
15
+ content: string
16
+ ): Effect.Effect<WorkspaceConfig, SyncWorkspaceError | SyncValidationError> =>
17
+ Effect.gen(function*() {
18
+ const raw = yield* Effect.try({
19
+ try: () => yaml.load(content) ?? {},
20
+ catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse workspace config YAML", path, cause })
21
+ })
22
+ const config = yield* Schema.decodeUnknownEffect(WorkspaceConfigSchema)(raw).pipe(
23
+ Effect.map((config) => config as WorkspaceConfig),
24
+ Effect.mapError((cause) =>
25
+ new SyncValidationError({ message: "Invalid Jira Markdown Sync workspace config", path, cause })
26
+ )
27
+ )
28
+ yield* validateCustomFieldDeclarations(path, config)
29
+ return config
30
+ })
31
+
32
+ export const serializeWorkspaceConfig = (config: WorkspaceConfig): string => yaml.dump(config, { lineWidth: 100 })
33
+
34
+ export const makeDefaultWorkspaceConfig = (siteUrl: string): WorkspaceConfig => ({
35
+ version: 1,
36
+ siteUrl,
37
+ documentsDir: "issues",
38
+ customFields: []
39
+ })
40
+
41
+ const validateCustomFieldDeclarations = (
42
+ path: string,
43
+ config: WorkspaceConfig
44
+ ): Effect.Effect<void, SyncValidationError> =>
45
+ Effect.gen(function*() {
46
+ const displayNames = new Map<string, number>()
47
+ const fieldIds = new Set<string>()
48
+
49
+ for (const field of config.customFields) {
50
+ displayNames.set(field.displayName, (displayNames.get(field.displayName) ?? 0) + 1)
51
+ if (field.fieldId) {
52
+ if (fieldIds.has(field.fieldId)) {
53
+ return yield* Effect.fail(
54
+ new SyncValidationError({
55
+ message: `Duplicate Requested Custom Field id "${field.fieldId}"`,
56
+ field: field.displayName,
57
+ path
58
+ })
59
+ )
60
+ }
61
+ fieldIds.add(field.fieldId)
62
+ }
63
+ }
64
+
65
+ for (const field of config.customFields) {
66
+ if ((displayNames.get(field.displayName) ?? 0) > 1 && !field.fieldId) {
67
+ return yield* Effect.fail(
68
+ new SyncValidationError({
69
+ message: `Duplicate Requested Custom Field "${field.displayName}" must specify fieldId`,
70
+ field: field.displayName,
71
+ path
72
+ })
73
+ )
74
+ }
75
+ }
76
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Parser and serializer for strict Jira Markdown Sync Issue Documents.
3
+ *
4
+ * @internal
5
+ */
6
+ import matter from "gray-matter"
7
+ import * as yaml from "js-yaml"
8
+ import { SyncValidationError } from "../../JiraCliError.js"
9
+ import type {
10
+ AcceptedComment,
11
+ AttachmentReference,
12
+ CommentDraft,
13
+ IssueDocument,
14
+ IssueDocumentFrontMatter
15
+ } from "./types.js"
16
+
17
+ const yamlEngine = {
18
+ parse: (str: string): object => (yaml.load(str) as object) ?? {},
19
+ stringify: (data: object): string => yaml.dump(data)
20
+ }
21
+
22
+ export const DESCRIPTION_SECTION = "Description"
23
+ export const NEW_COMMENTS_SECTION = "New Comments"
24
+ export const COMMENTS_SECTION = "Comments"
25
+ export const ATTACHMENTS_SECTION = "Attachments"
26
+ export const LOCAL_NOTES_SECTION = "Local Notes"
27
+
28
+ const BUILT_IN_SECTIONS = new Set([
29
+ DESCRIPTION_SECTION,
30
+ NEW_COMMENTS_SECTION,
31
+ COMMENTS_SECTION,
32
+ ATTACHMENTS_SECTION,
33
+ LOCAL_NOTES_SECTION
34
+ ])
35
+
36
+ export const serializeIssueDocument = (document: IssueDocument): string => {
37
+ const body = [
38
+ `# ${document.frontMatter.issueKey}: ${document.frontMatter.summary}`,
39
+ "",
40
+ section(DESCRIPTION_SECTION, document.description),
41
+ ...Object.entries(document.multilineCustomFields).map(([name, content]) => section(name, content)),
42
+ section(NEW_COMMENTS_SECTION, serializeCommentDrafts(document.commentDrafts)),
43
+ section(COMMENTS_SECTION, serializeAcceptedComments(document.acceptedComments)),
44
+ section(ATTACHMENTS_SECTION, serializeAttachments(document.attachments)),
45
+ section(LOCAL_NOTES_SECTION, document.localNotes)
46
+ ].filter((part) => part.length > 0).join("\n")
47
+
48
+ return matter.stringify(body, document.frontMatter, { engines: { yaml: yamlEngine } })
49
+ }
50
+
51
+ export const parseIssueDocument = (path: string, content: string): IssueDocument => {
52
+ const parsed = matter(content, { engines: { yaml: yamlEngine } })
53
+ const frontMatter = parseFrontMatter(path, parsed.data)
54
+ const sections = parseSections(path, parsed.content)
55
+
56
+ const description = sections.get(DESCRIPTION_SECTION) ?? fail(path, `Missing ${DESCRIPTION_SECTION} section`)
57
+ const localNotes = sections.get(LOCAL_NOTES_SECTION) ?? ""
58
+ const multilineCustomFields = Object.fromEntries(
59
+ [...sections.entries()].filter(([name]) => !BUILT_IN_SECTIONS.has(name))
60
+ )
61
+
62
+ return {
63
+ frontMatter,
64
+ description,
65
+ multilineCustomFields,
66
+ commentDrafts: parseCommentDrafts(path, sections.get(NEW_COMMENTS_SECTION) ?? ""),
67
+ acceptedComments: parseAcceptedComments(sections.get(COMMENTS_SECTION) ?? ""),
68
+ attachments: parseAttachments(sections.get(ATTACHMENTS_SECTION) ?? ""),
69
+ localNotes
70
+ }
71
+ }
72
+
73
+ const section = (name: string, content: string): string => {
74
+ const normalized = content.trim()
75
+ return normalized.length > 0 ? `## ${name}\n\n${normalized}\n` : `## ${name}\n`
76
+ }
77
+
78
+ const parseFrontMatter = (path: string, data: Record<string, unknown>): IssueDocumentFrontMatter => {
79
+ const requiredString = (key: string): string => {
80
+ const value = data[key]
81
+ if (typeof value !== "string") fail(path, `Missing or invalid front matter field "${key}"`)
82
+ return value as string
83
+ }
84
+
85
+ const nullableString = (key: string): string | null => {
86
+ const value = data[key]
87
+ if (value === null || value === undefined) return null
88
+ if (typeof value !== "string") fail(path, `Invalid front matter field "${key}"`)
89
+ return value as string
90
+ }
91
+
92
+ const userValue = (key: string) => {
93
+ const value = data[key]
94
+ if (value === null || value === undefined) return null
95
+ if (typeof value !== "object") fail(path, `Invalid user field "${key}"`)
96
+ const record = value as Record<string, unknown>
97
+ if (typeof record["accountId"] !== "string" || typeof record["displayName"] !== "string") {
98
+ fail(path, `Invalid user field "${key}"`)
99
+ }
100
+ return {
101
+ accountId: record["accountId"] as string,
102
+ displayName: record["displayName"] as string
103
+ }
104
+ }
105
+
106
+ const labels = data["labels"]
107
+ if (!Array.isArray(labels) || labels.some((label) => typeof label !== "string")) {
108
+ fail(path, `Missing or invalid front matter field "labels"`)
109
+ }
110
+
111
+ const customFields = data["customFields"]
112
+ if (customFields === null || typeof customFields !== "object" || Array.isArray(customFields)) {
113
+ fail(path, `Missing or invalid front matter field "customFields"`)
114
+ }
115
+
116
+ return {
117
+ issueId: requiredString("issueId"),
118
+ issueKey: requiredString("issueKey"),
119
+ summary: requiredString("summary"),
120
+ status: requiredString("status"),
121
+ issueType: requiredString("issueType"),
122
+ priority: nullableString("priority"),
123
+ assignee: userValue("assignee"),
124
+ reporter: userValue("reporter"),
125
+ labels: labels as ReadonlyArray<string>,
126
+ customFields: customFields as IssueDocumentFrontMatter["customFields"]
127
+ }
128
+ }
129
+
130
+ const parseSections = (path: string, content: string): Map<string, string> => {
131
+ const lines = content.split(/\r?\n/)
132
+ const sections = new Map<string, Array<string>>()
133
+ let current: string | null = null
134
+
135
+ for (const line of lines) {
136
+ const match = /^## (.+)$/.exec(line)
137
+ if (match?.[1]) {
138
+ current = match[1].trim()
139
+ if (sections.has(current)) fail(path, `Duplicate section "${current}"`)
140
+ sections.set(current, [])
141
+ } else if (current) {
142
+ sections.get(current)?.push(line)
143
+ }
144
+ }
145
+
146
+ return new Map([...sections.entries()].map(([name, body]) => [name, trimOuterBlankLines(body).join("\n")]))
147
+ }
148
+
149
+ const serializeCommentDrafts = (drafts: ReadonlyArray<CommentDraft>): string =>
150
+ drafts.map((draft) => `<!-- draftId: ${draft.draftId} -->\n${draft.body.trim()}`).join("\n\n")
151
+
152
+ const parseCommentDrafts = (path: string, content: string): ReadonlyArray<CommentDraft> => {
153
+ if (content.trim().length === 0) return []
154
+ const parts = content.split(/(?=<!-- draftId: )/g).filter((part) => part.trim().length > 0)
155
+ return parts.map((part) => {
156
+ const match = /^<!-- draftId: ([^ ]+) -->\n?([\s\S]*)$/.exec(part.trim())
157
+ const draftId = match?.[1] ?? fail(path, "Malformed comment draft marker")
158
+ return { draftId, body: match?.[2]?.trim() ?? "" }
159
+ })
160
+ }
161
+
162
+ const serializeAcceptedComments = (comments: ReadonlyArray<AcceptedComment>): string =>
163
+ comments.map((comment) =>
164
+ `### ${comment.author} - ${comment.created}\n<!-- jiraCommentId: ${comment.id} -->\n\n${comment.body.trim()}`
165
+ ).join("\n\n")
166
+
167
+ const parseAcceptedComments = (content: string): ReadonlyArray<AcceptedComment> => {
168
+ if (content.trim().length === 0) return []
169
+ const parts = content.split(/(?=^### )/gm).filter((part) => part.trim().length > 0)
170
+ return parts.flatMap((part) => {
171
+ const match = /^### (.+) - (.+)\n<!-- jiraCommentId: ([^ ]+) -->\n?([\s\S]*)$/.exec(part.trim())
172
+ if (!match?.[1] || !match[2] || !match[3]) return []
173
+ return [{
174
+ author: match[1],
175
+ created: match[2],
176
+ id: match[3],
177
+ body: match[4]?.trim() ?? ""
178
+ }]
179
+ })
180
+ }
181
+
182
+ const serializeAttachments = (attachments: ReadonlyArray<AttachmentReference>): string =>
183
+ attachments.map((attachment) => `- [${attachment.filename}](${attachment.url})`).join("\n")
184
+
185
+ const parseAttachments = (content: string): ReadonlyArray<AttachmentReference> =>
186
+ content.split(/\r?\n/).flatMap((line) => {
187
+ const match = /^- \[(.+)]\((.+)\)$/.exec(line.trim())
188
+ return match?.[1] && match[2] ? [{ filename: match[1], url: match[2] }] : []
189
+ })
190
+
191
+ const trimOuterBlankLines = (lines: ReadonlyArray<string>): ReadonlyArray<string> => {
192
+ let start = 0
193
+ let end = lines.length
194
+ while (start < end && lines[start]?.trim() === "") start++
195
+ while (end > start && lines[end - 1]?.trim() === "") end--
196
+ return lines.slice(start, end)
197
+ }
198
+
199
+ const fail = (path: string, message: string): never => {
200
+ throw new SyncValidationError({ message, path })
201
+ }