@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,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
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Field value helpers for Jira Markdown Sync reconciliation.
3
+ *
4
+ * @internal
5
+ */
6
+ import type { CascadingFieldValue, OptionFieldValue, SyncFieldValue, UserFieldValue } from "./types.js"
7
+
8
+ export type CompleteListItem = string | number | boolean | UserFieldValue | OptionFieldValue
9
+
10
+ export type CompleteListValue = ReadonlyArray<CompleteListItem>
11
+
12
+ export interface CanonicalFieldValueOptions {
13
+ readonly ordered?: boolean | undefined
14
+ }
15
+
16
+ export const explicitClear = null
17
+
18
+ export const isExplicitClear = (value: unknown): value is null => value === null
19
+
20
+ export const userFieldValue = (accountId: string, displayName: string): UserFieldValue => ({
21
+ accountId,
22
+ displayName
23
+ })
24
+
25
+ export const makeUserFieldValue = userFieldValue
26
+
27
+ export const optionFieldValue = (value: string, id?: string): OptionFieldValue =>
28
+ id === undefined ? { value } : { id, value }
29
+
30
+ export const makeOptionFieldValue = optionFieldValue
31
+
32
+ export const cascadingFieldValue = (
33
+ parent: OptionFieldValue,
34
+ child?: OptionFieldValue
35
+ ): CascadingFieldValue => child === undefined ? { parent } : { parent, child }
36
+
37
+ export const makeCascadingFieldValue = cascadingFieldValue
38
+
39
+ export const completeListValue = (
40
+ items: Iterable<CompleteListItem>,
41
+ options: CanonicalFieldValueOptions = {}
42
+ ): CompleteListValue => canonicalFieldOrder(Array.from(items), options)
43
+
44
+ export const makeCompleteListValue = completeListValue
45
+
46
+ export const isUserFieldValue = (value: unknown): value is UserFieldValue => {
47
+ if (!isRecord(value)) return false
48
+ return typeof value["accountId"] === "string" && typeof value["displayName"] === "string"
49
+ }
50
+
51
+ export const isOptionFieldValue = (value: unknown): value is OptionFieldValue => {
52
+ if (!isRecord(value) || typeof value["value"] !== "string") return false
53
+ const id = value["id"]
54
+ return id === undefined || typeof id === "string"
55
+ }
56
+
57
+ export const isCascadingFieldValue = (value: unknown): value is CascadingFieldValue => {
58
+ if (!isRecord(value) || !isOptionFieldValue(value["parent"])) return false
59
+ const child = value["child"]
60
+ return child === undefined || isOptionFieldValue(child)
61
+ }
62
+
63
+ export const isCompleteListValue = (value: unknown): value is CompleteListValue =>
64
+ Array.isArray(value) && value.every(isCompleteListItem)
65
+
66
+ export const canonicalFieldOrder = <A extends CompleteListItem>(
67
+ items: ReadonlyArray<A>,
68
+ options: CanonicalFieldValueOptions = {}
69
+ ): ReadonlyArray<A> => options.ordered === true ? [...items] : [...items].sort(compareCompleteListItems)
70
+
71
+ export const canonicalizeFieldValue = (
72
+ value: SyncFieldValue,
73
+ options: CanonicalFieldValueOptions = {}
74
+ ): SyncFieldValue => {
75
+ if (Array.isArray(value)) return canonicalFieldOrder(value, options)
76
+ if (isCascadingFieldValue(value)) {
77
+ return value.child === undefined
78
+ ? { parent: canonicalizeOption(value.parent) }
79
+ : { parent: canonicalizeOption(value.parent), child: canonicalizeOption(value.child) }
80
+ }
81
+ if (isUserFieldValue(value)) return { accountId: value.accountId, displayName: value.displayName }
82
+ if (isOptionFieldValue(value)) return canonicalizeOption(value)
83
+ return value
84
+ }
85
+
86
+ export const fieldValuesEqual = (
87
+ left: SyncFieldValue,
88
+ right: SyncFieldValue,
89
+ options: CanonicalFieldValueOptions = {}
90
+ ): boolean => canonicalFieldValueKey(left, options) === canonicalFieldValueKey(right, options)
91
+
92
+ export const compareCompleteListItems = (left: CompleteListItem, right: CompleteListItem): number => {
93
+ const leftKey = completeListItemOrderKey(left)
94
+ const rightKey = completeListItemOrderKey(right)
95
+ return leftKey.localeCompare(rightKey, "en", { numeric: true })
96
+ }
97
+
98
+ const canonicalizeOption = (value: OptionFieldValue): OptionFieldValue =>
99
+ value.id === undefined ? { value: value.value } : { id: value.id, value: value.value }
100
+
101
+ const canonicalFieldValueKey = (
102
+ value: SyncFieldValue,
103
+ options: CanonicalFieldValueOptions
104
+ ): string => {
105
+ if (value === null) return "clear:"
106
+ if (Array.isArray(value)) {
107
+ return `list:${canonicalFieldOrder(value, options).map(completeListItemOrderKey).join("\u0000")}`
108
+ }
109
+ if (isCascadingFieldValue(value)) {
110
+ return `cascading:${optionOrderKey(value.parent)}\u0000${
111
+ value.child === undefined ? "" : optionOrderKey(value.child)
112
+ }`
113
+ }
114
+ if (isUserFieldValue(value)) return userOrderKey(value)
115
+ if (isOptionFieldValue(value)) return optionOrderKey(value)
116
+ return `${typeof value}:${String(value)}`
117
+ }
118
+
119
+ const completeListItemOrderKey = (value: CompleteListItem): string => {
120
+ if (isUserFieldValue(value)) return userOrderKey(value)
121
+ if (isOptionFieldValue(value)) return optionOrderKey(value)
122
+ return `${typeof value}:${String(value)}`
123
+ }
124
+
125
+ const userOrderKey = (value: UserFieldValue): string =>
126
+ `user:${value.displayName.toLocaleLowerCase("en")}\u0000${value.accountId}`
127
+
128
+ const optionOrderKey = (value: OptionFieldValue): string =>
129
+ `option:${value.value.toLocaleLowerCase("en")}\u0000${value.id ?? ""}`
130
+
131
+ const isCompleteListItem = (value: unknown): value is CompleteListItem => {
132
+ switch (typeof value) {
133
+ case "string":
134
+ case "number":
135
+ case "boolean":
136
+ return true
137
+ case "object":
138
+ return isUserFieldValue(value) || isOptionFieldValue(value)
139
+ default:
140
+ return false
141
+ }
142
+ }
143
+
144
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
145
+ value !== null && typeof value === "object" && !Array.isArray(value)
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Sync Manifest 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 { SyncManifestSchema } from "./schemas.js"
10
+ import type { SyncManifest } from "./types.js"
11
+
12
+ export const makeEmptyManifest = (siteUrl: string): SyncManifest => ({
13
+ version: 1,
14
+ siteUrl,
15
+ issues: []
16
+ })
17
+
18
+ export const parseSyncManifest = (
19
+ path: string,
20
+ content: string
21
+ ): Effect.Effect<SyncManifest, SyncWorkspaceError | SyncValidationError> =>
22
+ Effect.gen(function*() {
23
+ const raw = yield* Effect.try({
24
+ try: () => JSON.parse(content) as unknown,
25
+ catch: (cause) => new SyncWorkspaceError({ message: "Failed to parse Sync Manifest JSON", path, cause })
26
+ })
27
+ return yield* Schema.decodeUnknownEffect(SyncManifestSchema)(raw).pipe(
28
+ Effect.mapError((cause) => new SyncValidationError({ message: "Invalid Sync Manifest", path, cause }))
29
+ )
30
+ })
31
+
32
+ export const serializeSyncManifest = (manifest: SyncManifest): string => `${JSON.stringify(manifest, null, 2)}\n`
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Path helpers for Jira Markdown Sync workspace files.
3
+ *
4
+ * @internal
5
+ */
6
+ import type * as Path from "effect/Path"
7
+ import type { WorkspaceConfig } from "./types.js"
8
+
9
+ export const METADATA_DIR = ".jira-md"
10
+ export const DEFAULT_DOCUMENTS_DIR = "issues"
11
+ export const CONFIG_FILE = "config.yaml"
12
+ export const MANIFEST_FILE = "manifest.json"
13
+ export const BASELINES_DIR = "baselines"
14
+ export const HISTORY_DIR = "history"
15
+
16
+ export interface SyncWorkspacePaths {
17
+ readonly root: string
18
+ readonly documentsDir: string
19
+ readonly metadataDir: string
20
+ readonly configFile: string
21
+ readonly manifestFile: string
22
+ readonly baselinesDir: string
23
+ readonly historyDir: string
24
+ }
25
+
26
+ export const resolveWorkspacePaths = (
27
+ path: Path.Path,
28
+ root: string,
29
+ config?: Pick<WorkspaceConfig, "documentsDir">
30
+ ): SyncWorkspacePaths => {
31
+ const documentsDir = path.resolve(root, config?.documentsDir ?? DEFAULT_DOCUMENTS_DIR)
32
+ const metadataDir = path.resolve(root, METADATA_DIR)
33
+ return {
34
+ root: path.resolve(root),
35
+ documentsDir,
36
+ metadataDir,
37
+ configFile: path.join(metadataDir, CONFIG_FILE),
38
+ manifestFile: path.join(metadataDir, MANIFEST_FILE),
39
+ baselinesDir: path.join(metadataDir, BASELINES_DIR),
40
+ historyDir: path.join(metadataDir, HISTORY_DIR)
41
+ }
42
+ }
43
+
44
+ export const baselineFilePath = (path: Path.Path, paths: SyncWorkspacePaths, issueId: string): string =>
45
+ path.join(paths.baselinesDir, `${issueId}.json`)
46
+
47
+ export const conventionDocumentPath = (path: Path.Path, paths: SyncWorkspacePaths, issueKey: string): string =>
48
+ path.join(paths.documentsDir, `${issueKey}.md`)