@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,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
+ })
@@ -0,0 +1,266 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Layer from "effect/Layer"
4
+ import * as Ref from "effect/Ref"
5
+ import * as Terminal from "effect/Terminal"
6
+ import { Command } from "effect/unstable/cli"
7
+ import { issueCommand } from "../src/commands/issue.js"
8
+ import { versionCommand } from "../src/commands/version.js"
9
+ import { type Issue, IssueService } from "../src/IssueService.js"
10
+ import { MarkdownWriter } from "../src/MarkdownWriter.js"
11
+ import { type RelatedWork, type Version, VersionService } from "../src/VersionService.js"
12
+
13
+ interface CommandCalls {
14
+ readonly issueGet: number
15
+ readonly issueSearch: number
16
+ readonly versionList: number
17
+ readonly versionGet: number
18
+ readonly versionUpdate: number
19
+ readonly relatedWorkList: number
20
+ readonly relatedWorkAdd: number
21
+ readonly writeMulti: number
22
+ }
23
+
24
+ const emptyCalls: CommandCalls = {
25
+ issueGet: 0,
26
+ issueSearch: 0,
27
+ relatedWorkAdd: 0,
28
+ relatedWorkList: 0,
29
+ versionGet: 0,
30
+ versionList: 0,
31
+ versionUpdate: 0,
32
+ writeMulti: 0
33
+ }
34
+
35
+ const sampleIssue: Issue = {
36
+ assignee: null,
37
+ attachments: [],
38
+ comments: [],
39
+ components: [],
40
+ created: new Date("2026-01-01T00:00:00.000Z"),
41
+ description: "",
42
+ fixVersions: [],
43
+ id: "10000",
44
+ key: "PROJ-123",
45
+ labels: [],
46
+ priority: null,
47
+ reporter: null,
48
+ status: "Done",
49
+ summary: "Sample issue",
50
+ type: "Task",
51
+ updated: new Date("2026-01-01T00:00:00.000Z"),
52
+ url: "https://example.atlassian.net/browse/PROJ-123"
53
+ }
54
+
55
+ const sampleVersion: Version = {
56
+ approvers: [],
57
+ archived: false,
58
+ contributors: [],
59
+ description: null,
60
+ driver: null,
61
+ id: "10042",
62
+ name: "1.0.0",
63
+ releaseDate: null,
64
+ released: false,
65
+ startDate: null,
66
+ tickets: [],
67
+ url: "https://example.atlassian.net/projects/PROJ/versions/10042"
68
+ }
69
+
70
+ const sampleRelatedWork: RelatedWork = {
71
+ category: "Communication",
72
+ relatedWorkId: "20000",
73
+ title: "Release notes",
74
+ url: "https://example.atlassian.net/wiki/spaces/PROJ/pages/123"
75
+ }
76
+
77
+ const CaptureTerminalLayer = (stdout: Ref.Ref<string>) =>
78
+ Layer.succeed(
79
+ Terminal.Terminal,
80
+ Terminal.Terminal.of({
81
+ columns: Effect.succeed(100),
82
+ rows: Effect.succeed(24),
83
+ readInput: Effect.die("readInput should not be called"),
84
+ readLine: Effect.die("readLine should not be called"),
85
+ display: (text) => Ref.update(stdout, (output) => output + text)
86
+ })
87
+ )
88
+
89
+ const CommandServicesLayer = (calls: Ref.Ref<CommandCalls>) =>
90
+ Layer.mergeAll(
91
+ Layer.succeed(
92
+ IssueService,
93
+ IssueService.of({
94
+ getByKey: () =>
95
+ Ref.update(calls, (state) => ({ ...state, issueGet: state.issueGet + 1 })).pipe(
96
+ Effect.as(sampleIssue)
97
+ ),
98
+ search: () => Effect.die("IssueService.search should not be called"),
99
+ searchAll: () =>
100
+ Ref.update(calls, (state) => ({ ...state, issueSearch: state.issueSearch + 1 })).pipe(
101
+ Effect.as([sampleIssue])
102
+ )
103
+ })
104
+ ),
105
+ Layer.succeed(
106
+ MarkdownWriter,
107
+ MarkdownWriter.of({
108
+ writeMulti: () => Ref.update(calls, (state) => ({ ...state, writeMulti: state.writeMulti + 1 })),
109
+ writeSingle: () => Effect.die("MarkdownWriter.writeSingle should not be called")
110
+ })
111
+ ),
112
+ Layer.succeed(
113
+ VersionService,
114
+ VersionService.of({
115
+ addRelatedWork: () =>
116
+ Ref.update(calls, (state) => ({ ...state, relatedWorkAdd: state.relatedWorkAdd + 1 })).pipe(
117
+ Effect.as(sampleRelatedWork)
118
+ ),
119
+ getVersion: () =>
120
+ Ref.update(calls, (state) => ({ ...state, versionGet: state.versionGet + 1 })).pipe(
121
+ Effect.as(sampleVersion)
122
+ ),
123
+ listProjectVersions: () =>
124
+ Ref.update(calls, (state) => ({ ...state, versionList: state.versionList + 1 })).pipe(
125
+ Effect.as([sampleVersion])
126
+ ),
127
+ listRelatedWork: () =>
128
+ Ref.update(calls, (state) => ({ ...state, relatedWorkList: state.relatedWorkList + 1 })).pipe(
129
+ Effect.as([sampleRelatedWork])
130
+ ),
131
+ updateVersion: () =>
132
+ Ref.update(calls, (state) => ({ ...state, versionUpdate: state.versionUpdate + 1 })).pipe(
133
+ Effect.as(sampleVersion)
134
+ )
135
+ })
136
+ )
137
+ )
138
+
139
+ const runJiraCommand = (
140
+ args: ReadonlyArray<string>,
141
+ calls: Ref.Ref<CommandCalls>,
142
+ stdout: Ref.Ref<string>
143
+ ) => {
144
+ const command = Command.make("jira").pipe(
145
+ Command.withDescription("Jira CLI commands"),
146
+ Command.withSubcommands([issueCommand, versionCommand])
147
+ )
148
+ const cli = Command.runWith(command, { version: "0.0.0-test" })
149
+ return cli(args).pipe(
150
+ Effect.provide(Layer.merge(CommandServicesLayer(calls), CaptureTerminalLayer(stdout))),
151
+ Effect.exit
152
+ )
153
+ }
154
+
155
+ describe("Jira command tree", () => {
156
+ it.effect("exposes canonical issue commands and removes top-level issue aliases", () =>
157
+ Effect.gen(function*() {
158
+ const calls = yield* Ref.make(emptyCalls)
159
+ const getOutput = yield* Ref.make("")
160
+ const searchOutput = yield* Ref.make("")
161
+ const legacyGetOutput = yield* Ref.make("")
162
+ const legacySearchOutput = yield* Ref.make("")
163
+
164
+ const getExit = yield* runJiraCommand(["issue", "get", "PROJ-123"], calls, getOutput)
165
+ const searchExit = yield* runJiraCommand(["issue", "search", "project = PROJ"], calls, searchOutput)
166
+ const legacyGetExit = yield* runJiraCommand(["get", "PROJ-123"], calls, legacyGetOutput)
167
+ const legacySearchExit = yield* runJiraCommand(["search", "project = PROJ"], calls, legacySearchOutput)
168
+
169
+ expect(getExit._tag).toBe("Success")
170
+ expect(searchExit._tag).toBe("Success")
171
+ expect(legacyGetExit._tag).toBe("Failure")
172
+ expect(legacySearchExit._tag).toBe("Failure")
173
+
174
+ expect(yield* Ref.get(calls)).toMatchObject({
175
+ issueGet: 1,
176
+ issueSearch: 1,
177
+ writeMulti: 2
178
+ })
179
+ }))
180
+
181
+ it.effect("exposes canonical version commands and rejects legacy names", () =>
182
+ Effect.gen(function*() {
183
+ const calls = yield* Ref.make(emptyCalls)
184
+ const output = yield* Ref.make("")
185
+ const legacyViewOutput = yield* Ref.make("")
186
+ const legacySetOutput = yield* Ref.make("")
187
+ const legacyRelatedWorkOutput = yield* Ref.make("")
188
+
189
+ const listExit = yield* runJiraCommand(["version", "list", "--project", "PROJ"], calls, output)
190
+ const getExit = yield* runJiraCommand(["version", "get", "10042"], calls, output)
191
+ const updateExit = yield* runJiraCommand(
192
+ [
193
+ "version",
194
+ "update",
195
+ "10042",
196
+ "--description",
197
+ "Q3 release"
198
+ ],
199
+ calls,
200
+ output
201
+ )
202
+ const relatedWorkListExit = yield* runJiraCommand(
203
+ [
204
+ "version",
205
+ "related-work",
206
+ "list",
207
+ "10042"
208
+ ],
209
+ calls,
210
+ output
211
+ )
212
+ const relatedWorkAddExit = yield* runJiraCommand(
213
+ [
214
+ "version",
215
+ "related-work",
216
+ "add",
217
+ "10042",
218
+ "--title",
219
+ "Release notes",
220
+ "--url",
221
+ "https://example.atlassian.net/wiki/spaces/PROJ/pages/123"
222
+ ],
223
+ calls,
224
+ output
225
+ )
226
+ const legacyViewExit = yield* runJiraCommand(["version", "view", "10042"], calls, legacyViewOutput)
227
+ const legacySetExit = yield* runJiraCommand(
228
+ [
229
+ "version",
230
+ "set",
231
+ "10042",
232
+ "--description",
233
+ "Q3 release"
234
+ ],
235
+ calls,
236
+ legacySetOutput
237
+ )
238
+ const legacyRelatedWorkExit = yield* runJiraCommand(
239
+ [
240
+ "version",
241
+ "relatedwork",
242
+ "list",
243
+ "10042"
244
+ ],
245
+ calls,
246
+ legacyRelatedWorkOutput
247
+ )
248
+
249
+ expect(listExit._tag).toBe("Success")
250
+ expect(getExit._tag).toBe("Success")
251
+ expect(updateExit._tag).toBe("Success")
252
+ expect(relatedWorkListExit._tag).toBe("Success")
253
+ expect(relatedWorkAddExit._tag).toBe("Success")
254
+ expect(legacyViewExit._tag).toBe("Failure")
255
+ expect(legacySetExit._tag).toBe("Failure")
256
+ expect(legacyRelatedWorkExit._tag).toBe("Failure")
257
+
258
+ expect(yield* Ref.get(calls)).toMatchObject({
259
+ relatedWorkAdd: 1,
260
+ relatedWorkList: 1,
261
+ versionGet: 1,
262
+ versionList: 1,
263
+ versionUpdate: 1
264
+ })
265
+ }))
266
+ })
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as yaml from "js-yaml"
3
+ import { extractFrontMatter, serializeIssue } from "../src/internal/frontmatter.js"
4
+ import type { Issue } from "../src/IssueService.js"
5
+
6
+ /**
7
+ * Parse the YAML front-matter block of a serialized issue using js-yaml 4
8
+ * directly. We avoid gray-matter's default parser here because it calls the
9
+ * removed `safeLoad` — the same incompatibility this module works around.
10
+ */
11
+ const parseFrontMatter = (output: string): Record<string, unknown> => {
12
+ const match = output.match(/^---\n([\s\S]*?)\n---/)
13
+ return match ? (yaml.load(match[1]) as Record<string, unknown>) : {}
14
+ }
15
+
16
+ const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
17
+ key: "OOB-81",
18
+ id: "10081",
19
+ summary: "Test ticket",
20
+ status: "Done",
21
+ type: "Story",
22
+ priority: "High",
23
+ assignee: "Alice",
24
+ reporter: "Bob",
25
+ created: new Date("2026-01-01T00:00:00.000Z"),
26
+ updated: new Date("2026-02-01T00:00:00.000Z"),
27
+ fixVersions: ["OOB 81"],
28
+ labels: ["a", "b"],
29
+ components: ["x"],
30
+ description: "Hello",
31
+ attachments: [],
32
+ comments: [],
33
+ ...overrides
34
+ })
35
+
36
+ describe("frontmatter", () => {
37
+ describe("serializeIssue", () => {
38
+ // Regression: gray-matter's default YAML engine calls js-yaml's removed
39
+ // `safeDump`, which throws under the workspace's js-yaml 4 override. The
40
+ // custom engine must serialize without throwing.
41
+ it("serializes without throwing under js-yaml 4", () => {
42
+ expect(() => serializeIssue(makeIssue())).not.toThrow()
43
+ })
44
+
45
+ it("produces parseable YAML front-matter round-tripping the data", () => {
46
+ const output = serializeIssue(makeIssue())
47
+ const data = parseFrontMatter(output)
48
+ expect(data.key).toBe("OOB-81")
49
+ expect(data.fixVersions).toEqual(["OOB 81"])
50
+ expect(data.labels).toEqual(["a", "b"])
51
+ expect(output).toContain("# OOB-81: Test ticket")
52
+ })
53
+
54
+ it("emits null for absent optional fields", () => {
55
+ const output = serializeIssue(makeIssue({ priority: null, assignee: null, reporter: null }))
56
+ const data = parseFrontMatter(output)
57
+ expect(data.priority).toBeNull()
58
+ expect(data.assignee).toBeNull()
59
+ })
60
+ })
61
+
62
+ describe("extractFrontMatter", () => {
63
+ it("renders dates as ISO strings", () => {
64
+ const fm = extractFrontMatter(makeIssue())
65
+ expect(fm.created).toBe("2026-01-01T00:00:00.000Z")
66
+ expect(fm.updated).toBe("2026-02-01T00:00:00.000Z")
67
+ })
68
+ })
69
+ })