@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.
- package/CHANGELOG.md +20 -0
- package/README.md +9 -9
- package/dist/JiraCliError.d.ts +30 -0
- package/dist/JiraCliError.d.ts.map +1 -1
- package/dist/JiraCliError.js +14 -0
- package/dist/JiraCliError.js.map +1 -1
- package/dist/SyncWorkspace.d.ts +34 -0
- package/dist/SyncWorkspace.d.ts.map +1 -0
- package/dist/SyncWorkspace.js +105 -0
- package/dist/SyncWorkspace.js.map +1 -0
- package/dist/bin.js +4 -5
- package/dist/bin.js.map +1 -1
- package/dist/commands/get.d.ts.map +1 -1
- package/dist/commands/get.js +2 -2
- package/dist/commands/get.js.map +1 -1
- package/dist/commands/index.d.ts +1 -2
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +1 -2
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/issue.d.ts +8 -0
- package/dist/commands/issue.d.ts.map +1 -0
- package/dist/commands/issue.js +10 -0
- package/dist/commands/issue.js.map +1 -0
- package/dist/commands/layers.d.ts.map +1 -1
- package/dist/commands/layers.js +4 -1
- package/dist/commands/layers.js.map +1 -1
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +4 -4
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/version.js +13 -13
- package/dist/commands/version.js.map +1 -1
- package/dist/internal/frontmatter.d.ts.map +1 -1
- package/dist/internal/frontmatter.js +14 -1
- package/dist/internal/frontmatter.js.map +1 -1
- package/dist/internal/sync/baseline.d.ts +11 -0
- package/dist/internal/sync/baseline.d.ts.map +1 -0
- package/dist/internal/sync/baseline.js +18 -0
- package/dist/internal/sync/baseline.js.map +1 -0
- package/dist/internal/sync/changes.d.ts +15 -0
- package/dist/internal/sync/changes.d.ts.map +1 -0
- package/dist/internal/sync/changes.js +72 -0
- package/dist/internal/sync/changes.js.map +1 -0
- package/dist/internal/sync/config.d.ts +12 -0
- package/dist/internal/sync/config.d.ts.map +1 -0
- package/dist/internal/sync/config.js +53 -0
- package/dist/internal/sync/config.js.map +1 -0
- package/dist/internal/sync/document.d.ts +9 -0
- package/dist/internal/sync/document.d.ts.map +1 -0
- package/dist/internal/sync/document.js +173 -0
- package/dist/internal/sync/document.js.map +1 -0
- package/dist/internal/sync/fieldValues.d.ts +30 -0
- package/dist/internal/sync/fieldValues.d.ts.map +1 -0
- package/dist/internal/sync/fieldValues.js +91 -0
- package/dist/internal/sync/fieldValues.js.map +1 -0
- package/dist/internal/sync/manifest.d.ts +12 -0
- package/dist/internal/sync/manifest.d.ts.map +1 -0
- package/dist/internal/sync/manifest.js +23 -0
- package/dist/internal/sync/manifest.js.map +1 -0
- package/dist/internal/sync/paths.d.ts +26 -0
- package/dist/internal/sync/paths.d.ts.map +1 -0
- package/dist/internal/sync/paths.js +22 -0
- package/dist/internal/sync/paths.js.map +1 -0
- package/dist/internal/sync/schemas.d.ts +128 -0
- package/dist/internal/sync/schemas.d.ts.map +1 -0
- package/dist/internal/sync/schemas.js +82 -0
- package/dist/internal/sync/schemas.js.map +1 -0
- package/dist/internal/sync/types.d.ts +144 -0
- package/dist/internal/sync/types.d.ts.map +1 -0
- package/dist/internal/sync/types.js +17 -0
- package/dist/internal/sync/types.js.map +1 -0
- package/package.json +5 -2
- package/skills/jira/SKILL.md +11 -11
- package/src/JiraCliError.ts +24 -0
- package/src/SyncWorkspace.ts +185 -0
- package/src/bin.ts +4 -6
- package/src/commands/get.ts +2 -2
- package/src/commands/index.ts +1 -2
- package/src/commands/issue.ts +13 -0
- package/src/commands/layers.ts +4 -1
- package/src/commands/search.ts +4 -4
- package/src/commands/version.ts +15 -15
- package/src/internal/frontmatter.ts +15 -1
- package/src/internal/sync/baseline.ts +27 -0
- package/src/internal/sync/changes.ts +118 -0
- package/src/internal/sync/config.ts +76 -0
- package/src/internal/sync/document.ts +201 -0
- package/src/internal/sync/fieldValues.ts +145 -0
- package/src/internal/sync/manifest.ts +32 -0
- package/src/internal/sync/paths.ts +48 -0
- package/src/internal/sync/schemas.ts +103 -0
- package/src/internal/sync/types.ts +192 -0
- package/test/SyncWorkspace.test.ts +76 -0
- package/test/commandTree.test.ts +266 -0
- package/test/frontmatter.test.ts +69 -0
- package/test/integration.test.ts +187 -0
- package/test/syncChanges.test.ts +106 -0
- package/test/syncConfig.test.ts +138 -0
- package/test/syncDocument.test.ts +69 -0
- package/test/syncFieldValues.test.ts +101 -0
- package/vitest.config.integration.ts +17 -0
- 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
|
+
})
|