@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
@@ -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`)
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Runtime schemas for Jira Markdown Sync local workspace files.
3
+ *
4
+ * @internal
5
+ */
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+ import { FIELD_SHAPES } from "./types.js"
9
+
10
+ const SiteUrl = Schema.String.pipe(
11
+ Schema.check(Schema.isPattern(/^https:\/\/[a-z0-9][a-z0-9-]*\.atlassian\.net$/))
12
+ )
13
+
14
+ const NonEmptyString = Schema.String.pipe(Schema.check(Schema.isMinLength(1)))
15
+
16
+ export const FieldShapeSchema = Schema.Literals(FIELD_SHAPES)
17
+
18
+ export const RequestedCustomFieldSchema = Schema.Struct({
19
+ displayName: NonEmptyString,
20
+ fieldId: Schema.optional(NonEmptyString),
21
+ shape: FieldShapeSchema,
22
+ ordered: Schema.optional(Schema.Boolean)
23
+ })
24
+
25
+ export const WorkspaceConfigSchema = Schema.Struct({
26
+ version: Schema.Literal(1).pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed(1 as const))),
27
+ siteUrl: SiteUrl,
28
+ documentsDir: NonEmptyString.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("issues"))),
29
+ customFields: Schema.Array(RequestedCustomFieldSchema).pipe(
30
+ Schema.withDecodingDefaultTypeKey(Effect.succeed([]))
31
+ )
32
+ })
33
+
34
+ export const ManifestIssueSchema = Schema.Struct({
35
+ issueId: NonEmptyString,
36
+ issueKey: NonEmptyString,
37
+ documentPath: NonEmptyString,
38
+ filenameMode: Schema.Literals(["convention", "custom"] as const)
39
+ })
40
+
41
+ export const SyncManifestSchema = Schema.Struct({
42
+ version: Schema.Literal(1),
43
+ siteUrl: SiteUrl,
44
+ issues: Schema.Array(ManifestIssueSchema)
45
+ })
46
+
47
+ const UserFieldValueSchema = Schema.Struct({
48
+ accountId: NonEmptyString,
49
+ displayName: NonEmptyString
50
+ })
51
+
52
+ const OptionFieldValueSchema = Schema.Struct({
53
+ id: Schema.optional(NonEmptyString),
54
+ value: NonEmptyString
55
+ })
56
+
57
+ const CascadingFieldValueSchema = Schema.Struct({
58
+ parent: OptionFieldValueSchema,
59
+ child: Schema.optional(OptionFieldValueSchema)
60
+ })
61
+
62
+ const SyncFieldValueItemSchema = Schema.Union([
63
+ Schema.String,
64
+ Schema.Number,
65
+ Schema.Boolean,
66
+ UserFieldValueSchema,
67
+ OptionFieldValueSchema
68
+ ])
69
+
70
+ export const SyncFieldValueSchema = Schema.Union([
71
+ Schema.String,
72
+ Schema.Number,
73
+ Schema.Boolean,
74
+ Schema.Null,
75
+ UserFieldValueSchema,
76
+ OptionFieldValueSchema,
77
+ CascadingFieldValueSchema,
78
+ Schema.Array(SyncFieldValueItemSchema)
79
+ ])
80
+
81
+ export const BaselineCustomFieldSchema = Schema.Struct({
82
+ fieldId: NonEmptyString,
83
+ displayName: NonEmptyString,
84
+ shape: FieldShapeSchema,
85
+ value: SyncFieldValueSchema
86
+ })
87
+
88
+ export const SyncBaselineSchema = Schema.Struct({
89
+ version: Schema.Literal(1),
90
+ issueId: NonEmptyString,
91
+ issueKey: NonEmptyString,
92
+ fields: Schema.Struct({
93
+ summary: Schema.String,
94
+ description: Schema.String,
95
+ labels: Schema.Array(Schema.String),
96
+ customFields: Schema.Record(Schema.String, BaselineCustomFieldSchema)
97
+ }),
98
+ comments: Schema.Array(Schema.Struct({ id: NonEmptyString }))
99
+ })
100
+
101
+ export type WorkspaceConfig = Schema.Schema.Type<typeof WorkspaceConfigSchema>
102
+ export type SyncManifest = Schema.Schema.Type<typeof SyncManifestSchema>
103
+ export type SyncBaseline = Schema.Schema.Type<typeof SyncBaselineSchema>
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Core domain types for Jira Markdown Sync local workspace state.
3
+ *
4
+ * @internal
5
+ */
6
+
7
+ export const FIELD_SHAPES = [
8
+ "text",
9
+ "multilineText",
10
+ "number",
11
+ "boolean",
12
+ "date",
13
+ "singleSelect",
14
+ "multiSelect",
15
+ "user",
16
+ "cascadingSelect"
17
+ ] as const
18
+
19
+ export type FieldShape = typeof FIELD_SHAPES[number]
20
+
21
+ export interface RequestedCustomField {
22
+ readonly displayName: string
23
+ readonly fieldId?: string | undefined
24
+ readonly shape: FieldShape
25
+ readonly ordered?: boolean | undefined
26
+ }
27
+
28
+ export interface WorkspaceConfig {
29
+ readonly version: 1
30
+ readonly siteUrl: string
31
+ readonly documentsDir: string
32
+ readonly customFields: ReadonlyArray<RequestedCustomField>
33
+ }
34
+
35
+ export type FilenameMode = "convention" | "custom"
36
+
37
+ export interface ManifestIssue {
38
+ readonly issueId: string
39
+ readonly issueKey: string
40
+ readonly documentPath: string
41
+ readonly filenameMode: FilenameMode
42
+ }
43
+
44
+ export interface SyncManifest {
45
+ readonly version: 1
46
+ readonly siteUrl: string
47
+ readonly issues: ReadonlyArray<ManifestIssue>
48
+ }
49
+
50
+ export interface UserFieldValue {
51
+ readonly accountId: string
52
+ readonly displayName: string
53
+ }
54
+
55
+ export interface OptionFieldValue {
56
+ readonly id?: string | undefined
57
+ readonly value: string
58
+ }
59
+
60
+ export interface CascadingFieldValue {
61
+ readonly parent: OptionFieldValue
62
+ readonly child?: OptionFieldValue | undefined
63
+ }
64
+
65
+ export type SyncFieldScalar = string | number | boolean | null
66
+
67
+ export type SyncFieldValue =
68
+ | SyncFieldScalar
69
+ | UserFieldValue
70
+ | OptionFieldValue
71
+ | CascadingFieldValue
72
+ | ReadonlyArray<string | number | boolean | UserFieldValue | OptionFieldValue>
73
+
74
+ export interface BaselineCustomField {
75
+ readonly fieldId: string
76
+ readonly displayName: string
77
+ readonly shape: FieldShape
78
+ readonly value: SyncFieldValue
79
+ }
80
+
81
+ export interface SyncBaselineFields {
82
+ readonly summary: string
83
+ readonly description: string
84
+ readonly labels: ReadonlyArray<string>
85
+ readonly customFields: Readonly<Record<string, BaselineCustomField>>
86
+ }
87
+
88
+ export interface SyncBaselineComment {
89
+ readonly id: string
90
+ }
91
+
92
+ export interface SyncBaseline {
93
+ readonly version: 1
94
+ readonly issueId: string
95
+ readonly issueKey: string
96
+ readonly fields: SyncBaselineFields
97
+ readonly comments: ReadonlyArray<SyncBaselineComment>
98
+ }
99
+
100
+ export interface IssueDocumentFrontMatter {
101
+ readonly issueId: string
102
+ readonly issueKey: string
103
+ readonly summary: string
104
+ readonly status: string
105
+ readonly issueType: string
106
+ readonly priority: string | null
107
+ readonly assignee: UserFieldValue | null
108
+ readonly reporter: UserFieldValue | null
109
+ readonly labels: ReadonlyArray<string>
110
+ readonly customFields: Readonly<Record<string, SyncFieldValue>>
111
+ }
112
+
113
+ export interface CommentDraft {
114
+ readonly draftId: string
115
+ readonly body: string
116
+ }
117
+
118
+ export interface AcceptedComment {
119
+ readonly id: string
120
+ readonly author: string
121
+ readonly created: string
122
+ readonly body: string
123
+ }
124
+
125
+ export interface AttachmentReference {
126
+ readonly filename: string
127
+ readonly url: string
128
+ }
129
+
130
+ export interface IssueDocument {
131
+ readonly frontMatter: IssueDocumentFrontMatter
132
+ readonly description: string
133
+ readonly multilineCustomFields: Readonly<Record<string, string>>
134
+ readonly commentDrafts: ReadonlyArray<CommentDraft>
135
+ readonly acceptedComments: ReadonlyArray<AcceptedComment>
136
+ readonly attachments: ReadonlyArray<AttachmentReference>
137
+ readonly localNotes: string
138
+ }
139
+
140
+ export type SyncFieldPath =
141
+ | "summary"
142
+ | "description"
143
+ | "labels"
144
+ | `customFields.${string}`
145
+ | `readOnly.${string}`
146
+
147
+ export interface SyncValidationFailure {
148
+ readonly _tag: "ValidationFailure"
149
+ readonly message: string
150
+ readonly issueKey?: string
151
+ readonly field?: SyncFieldPath
152
+ readonly path?: string
153
+ }
154
+
155
+ export interface SyncConflict {
156
+ readonly issueId: string
157
+ readonly issueKey: string
158
+ readonly field: SyncFieldPath
159
+ readonly baselineValue: SyncFieldValue
160
+ readonly jiraValue: SyncFieldValue
161
+ readonly documentValue: SyncFieldValue
162
+ }
163
+
164
+ export type PlannedFieldChange =
165
+ | {
166
+ readonly _tag: "RemoteOnly"
167
+ readonly issueId: string
168
+ readonly issueKey: string
169
+ readonly field: SyncFieldPath
170
+ readonly jiraValue: SyncFieldValue
171
+ }
172
+ | {
173
+ readonly _tag: "LocalOnly"
174
+ readonly issueId: string
175
+ readonly issueKey: string
176
+ readonly field: SyncFieldPath
177
+ readonly documentValue: SyncFieldValue
178
+ }
179
+ | {
180
+ readonly _tag: "Conflict"
181
+ readonly issueId: string
182
+ readonly issueKey: string
183
+ readonly field: SyncFieldPath
184
+ readonly baselineValue: SyncFieldValue
185
+ readonly jiraValue: SyncFieldValue
186
+ readonly documentValue: SyncFieldValue
187
+ }
188
+
189
+ export interface SyncPlan {
190
+ readonly changes: ReadonlyArray<PlannedFieldChange>
191
+ readonly validationFailures: ReadonlyArray<SyncValidationFailure>
192
+ }
@@ -0,0 +1,76 @@
1
+ import * as NodeServices from "@effect/platform-node/NodeServices"
2
+ import { describe, expect, it } from "@effect/vitest"
3
+ import * as Effect from "effect/Effect"
4
+ import * as FileSystem from "effect/FileSystem"
5
+ import * as Layer from "effect/Layer"
6
+ import * as Path from "effect/Path"
7
+ import type { SyncBaseline } from "../src/internal/sync/types.js"
8
+ import { layer as SyncWorkspaceLayer, SyncWorkspace } from "../src/SyncWorkspace.js"
9
+
10
+ const TestLayer = SyncWorkspaceLayer.pipe(
11
+ Layer.provide(NodeServices.layer),
12
+ Layer.provideMerge(NodeServices.layer)
13
+ )
14
+
15
+ const makeTempRoot = Effect.gen(function*() {
16
+ const fs = yield* FileSystem.FileSystem
17
+ return yield* fs.makeTempDirectoryScoped()
18
+ })
19
+
20
+ describe("SyncWorkspace", () => {
21
+ it.effect("initializes workspace metadata and visible documents directory", () =>
22
+ Effect.gen(function*() {
23
+ const root = yield* makeTempRoot
24
+ const workspace = yield* SyncWorkspace
25
+ const paths = yield* workspace.init({ root, siteUrl: "https://example.atlassian.net" })
26
+ const fs = yield* FileSystem.FileSystem
27
+
28
+ expect(yield* fs.exists(paths.documentsDir)).toBe(true)
29
+ expect(yield* fs.exists(paths.configFile)).toBe(true)
30
+ expect(yield* fs.exists(paths.manifestFile)).toBe(true)
31
+ expect(yield* fs.exists(paths.baselinesDir)).toBe(true)
32
+ expect(yield* fs.exists(paths.historyDir)).toBe(true)
33
+
34
+ const config = yield* workspace.readConfig(root)
35
+ const manifest = yield* workspace.readManifest(root)
36
+ expect(config.siteUrl).toBe("https://example.atlassian.net")
37
+ expect(config.documentsDir).toBe("issues")
38
+ expect(manifest.issues).toEqual([])
39
+ }).pipe(Effect.provide(TestLayer), Effect.scoped))
40
+
41
+ it.effect("writes and reads baselines by issue id", () =>
42
+ Effect.gen(function*() {
43
+ const root = yield* makeTempRoot
44
+ const workspace = yield* SyncWorkspace
45
+ yield* workspace.init({ root, siteUrl: "https://example.atlassian.net" })
46
+
47
+ const baseline: SyncBaseline = {
48
+ version: 1,
49
+ issueId: "100123",
50
+ issueKey: "PROJ-123",
51
+ fields: {
52
+ summary: "Fix checkout copy",
53
+ description: "Description",
54
+ labels: ["copy"],
55
+ customFields: {}
56
+ },
57
+ comments: []
58
+ }
59
+
60
+ yield* workspace.writeBaseline(root, baseline)
61
+ expect(yield* workspace.readBaseline(root, "100123")).toEqual(baseline)
62
+ }).pipe(Effect.provide(TestLayer), Effect.scoped))
63
+
64
+ it.effect("uses configured documents directory for convention document paths", () =>
65
+ Effect.gen(function*() {
66
+ const root = yield* makeTempRoot
67
+ const workspace = yield* SyncWorkspace
68
+ const path = yield* Path.Path
69
+ yield* workspace.init({ root, siteUrl: "https://example.atlassian.net" })
70
+ const config = yield* workspace.readConfig(root)
71
+ yield* workspace.writeConfig(root, { ...config, documentsDir: "issue-docs" })
72
+
73
+ const filePath = yield* workspace.conventionDocumentPath(root, "PROJ-123")
74
+ expect(filePath).toBe(path.join(root, "issue-docs", "PROJ-123.md"))
75
+ }).pipe(Effect.provide(TestLayer), Effect.scoped))
76
+ })